A studio in a box on your own Cloudflare: set up a studio (one repository for games, music, videos and posts) with a setup status of your accounts and tools…

The Homie plugin for Claude Code, Codex and Grok: a studio in a box for your AI. It makes games, music and video, and publishes them from a studio that runs on your own Cloudflare account, on the free plan.
skills/): studio-setup, plan, parallel, game, port, publish, office, servers, shop, sound, music, art, style, models, video, playtest, perf, lab, parts and standalone (a game as a desktop app or a phone app: the files Steam and the app stores accept for upload)..mcp.json): the Homie MCP server at https://homie.rocks/mcp, which has creator tools only. Nothing in this folder runs a command on install.hooks/, mod/): a Claude Code mod (Claude Code 2.1.287 or later, the CLI and the desktop app's Code tab). Below: what it adds, and everything it does.hooks/codex.json, hooks/codex.mjs): the mod's holds, refusals and secret redaction as Codex lifecycle hooks, decided by the same module (hooks/lib/holds.mjs). See "Homie's holds in Codex" below for what Codex can and cannot do.hooks/grok.json, hooks/grok.mjs): the same holds, decided by the same module (hooks/lib/holds.mjs, through hooks/codex.mjs), so Claude Code, Codex and Grok cannot drift. Grok Build answers allow or deny; a hold denies the call until the person says proceed <code>. Grok runs them once the plugin is trusted. It reads the hooks file the plugin's ROOT plugin.json names ("hooks": "./hooks/grok.json"), and with none named it loads hooks/hooks.json, which here is the Claude Code mod's file and holds no hooks for Grok. Before the root manifest named it, Grok registered nothing from this plugin (total_hooks=0 in its log) and a deploy ran unheld. 0.30.2 read that as "Grok runs no plugin's hooks", which was wrong: it was this plugin's layout. PostToolUse replaces what Grok reads (updatedToolOutput), so a secret is out of the model's copy and still on the person's screen. homie-studio setup status --client grok reads the hooks' own mark and says whether they ran just now; while it says off, the studio-setup skill tells Grok to ask the person itself. In Grok Build the plugin installs with grok plugin install homie-rocks/homie#plugins/homie (checked on 1.0.41; Grok asks whether to trust it, or takes --trust, and loads its skills, MCP server and hooks only once you do). Grok Bot installs Homie itself when told to read https://homie.rocks/install.md (checked 2026-10-04, as far as the Cloudflare approval); Grok chat with only the connector is not tested by us.homie_feedback, the mod's /feedback): a short note to the people who make Homie, which the person sees word for word and sends only with their yes. See "Tell Homie" below.providers.json): see below..claude-plugin/plugin.json (Claude Code), .codex-plugin/plugin.json (Codex), .grok-plugin/plugin.json (Grok Build: the same skills, the Homie MCP server and hooks/grok.json), and plugin.json (the agent-plugins standard). They say the same thing, and test/manifests.test.mjs checks that they do. Codex reads the skills, the MCP server and Homie's hooks from .codex-plugin/plugin.json, and ignores the mod. plugin.json declares no $schema: Codex (0.156.1 to 0.160.0, tested) reads a root plugin.json only when it declares the Agent Plugins schema, and then runs none of the plugin's hooks. Its top-level "hooks" is for Grok Build, which takes a plugin's hooks file from the root plugin.json and from nowhere else (above).Install it, and read what a studio is and what it costs, in the repository's README. In short:
/plugin install homie --marketplace homie-rocks/homie. It asks you to confirm the marketplace, then opens the plugin's details, where you pick a scope. From a terminal: claude plugin marketplace add homie-rocks/homie, then claude plugin install homie@homie (the terminal's install takes no --marketplace).codex plugin marketplace add homie-rocks/homie, then codex plugin add homie@homie, a new session, and /hooks to trust Homie's three hooks.grok plugin install homie-rocks/homie#plugins/homie, then a new session.https://homie.rocks/mcp.Report a vulnerability as SECURITY.md says.
Homie works through each provider's own CLI, plugin, MCP server and skills, on the creator's own account, and keeps only its own layer on top: budgets and receipts, the kids rules, secrets never in the chat, the owner's one-tap asks, phone budgets, and the rights and licence notes. Nothing here installs by itself. providers.json lists each provider's tools (checked 2026-10-03), the skills that use them, and what stays Homie's; plugin.json points at it (extensions["rocks.homie"].providers). Each skill names its providers in its own frontmatter (metadata.providers, and compatibility in words), and a skill that uses a provider's MCP server declares it for Codex in agents/openai.yaml, so Codex can wire it when the skill is used. A skill offers a provider's tool only when the person wants what it unlocks; the person approves every install and signs in on the provider's own page.
| Provider | What Homie's skills use | Offered when the person wants it |
|---|---|---|
| Cloudflare | Wrangler, pinned in the studio (login, --device where no browser opens; deploy, D1, R2, secrets, ai models list) | Cloudflare's plugin (cloudflare/skills: its skills and API MCP server), its docs MCP server |
| ElevenLabs | ElevenLabs' CLI (elevenlabs auth login; music, stems, speech to text, the subscription) | ElevenLabs' plugin (elevenlabs/plugin: its skills and hosted MCP server), npx skills add elevenlabs/skills |
| fal | fal's MCP server to find models and read schemas and prices; the skills' scripts for paid runs (priced, capped, receipted, resumable) | fal's CLI (fal auth login, fal keys create) to make the key |
| Tripo | Tripo's models on fal (tripo3d/...) | none: Tripo's own CLI and MCP server bill a separate account |
| GitHub | the GitHub CLI (gh auth login --web, gh pr create) | GitHub's MCP server (github@claude-plugins-official) |
| Stripe | Stripe's MCP server (the shop's catalog, tax settings and sales, as the owner signed it in, a sandbox first); the shop's key only through homie-studio shop connect | Stripe's agent plugin (stripe agent setup: its MCP server and skills) once a studio sells; Stripe Projects (setup --via stripe-projects) as an option for Cloudflare and ElevenLabs |
| Ollama | Clef on the person's own computer, when Ollama already has clef-flash: dev's AI guides, chat review and game decisions, and agents sit --brain local, free (detection reads /api/version and /api/tags, loopback only) | ollama pull clef-flash (about 11 GB), only after the person's yes to that size |
Stripe is the shop skill's, with its own rules for Stripe's tools: never a webhook or an API key through the MCP (the mod refuses a write that would hand a signing secret back), Stripe's own confirmation link for a refund it holds, and live mode only when the owner says so.
The AI guides think with Cloudflare's Clef decision model on the studio's own Workers AI (the servers skill), and a game may ask it for its own decisions (net.decide, the game skill). Under dev, Clef can run on the person's own computer through Ollama instead; nothing downloads it by itself, and the mod holds a pull until the person says yes.
Inside a studio (a folder with studio.json at or above where Claude Code runs):
◆ Night Owls · Owl Rush · Checks ▰▰▰▰▱▱ 62% · 4/5 checks · 3 playing now · ▶ Play. One row; parts drop from the end on a narrow terminal. Nothing outside a studio./studio). It opens by itself when a build starts, where the terminal is wide enough for a pane nobody asked for (Claude Code places one from 144 columns, 110 once you have opened it yourself); otherwise a toast says /studio shows it.o) lists who is in each room, with Mute, Kick and Announce through the studio's own back office (homie-studio office). A kick or a mute is only asked for: the office answers with a one-time link, the pane shows it under "Waiting for your tap", and nothing happens until you confirm in your own browser. The mod cannot confirm an ask; no key or command can.lab skill, studio 0.20.0 and later): whether its page is running on this computer, with its link, and each game's last lab check: the take, New's phases beside Today's, whether each build replays the same frames, and the game's JavaScript per frame in both. Read from .studio/lab/; nothing is run.style and models skills), per game, newest first: the phase strip (Style ✓ → Cast 3/7 → Rigs → Animations → In game), the look line, the style decisions with their state (· auto, ~ steered, ● pinned by use, ■ locked) and who set them, a palette's colours, the cast (route, licence, state, cost, STALE when made under an older decision), the scene budgets as bars (draw calls, triangles, picture memory, shipped payload: the asset check's inventory estimate; red when over), the spend against the art budget, and licence problems with their fix. Lock on a decision is your word (homie-studio style lock); Unlock first asks, with what goes stale and what remaking it costs (style blast), and unlocks only on Proceed. Its Characters section lists each rigged character with its skeleton family, bones and clips (how many retargeted onto it, which verbs it lacks) and a skinning bar: a full room's skinned vertices a frame on a phone. Read from .studio/art/<game>/latest.json, which the studio's toolkit writes after every style, assets and anim command; the tab rereads it while it is open./parts): when Claude builds in parallel (the parallel skill), each agent with its time, tool calls, files changed and last step, and the build feed's checks for each part. It opens by itself when two agents run at once./arcade [game]): play a real Homie game while Claude works, in a public room with strangers and bots: Homie Arcade's games, or your studio's own. Click the pad line, then the arrow keys or WASD; space acts; Esc gives the keys back to Claude. See "The game bridge"./studio [tab], /play [game], /watch [room|game], /rooms, /build, /codex [game], /deploy-status, /perf-numbers [game] (the perf skill owns /perf), /parts, /arcade [game], and for art direction /look [game] (the look and the style decisions; it opens the Art tab, since the style skill owns /style), /lock <decision> [game] (your typed words lock it), /assets [game] (the cast, the spend, licence problems), /cast [game] (the characters: skeleton, bones, source, clips, and what skinning a full room costs on a phone), /clips [game] (each character's clips against the verbs the game needs, and the command that adds what is missing), /lineup [game] (the last lineup's flags and where its pictures are; it never renders one) and /rights [game] (licence problems with their fixes, and the game's RIGHTS.md), and /feedback [your words | send | cancel] (Tell Homie, below)./feedback, or Tell Homie in the Studio pane's header, t): a pane with a note to the people who make Homie, exactly as it would go: its kind, the words (your own, after keys, paths, emails and code are taken out), and what goes with it (the step, the studio's and the plugin's versions, the app). Change the kind, the words or add a reply address, then Send (s) or Don't send (n). /feedback alone opens it to write one, or Ask Claude to draft it from this session puts that ask in the prompt box for you to send. Where no pane draws, /feedback <words> prints the note and /feedback send (typed by you; never by Claude) sends it. It posts only to homie.rocks/api/feedback/tell (or the studio's own directory), and only on your Send.studio.json "protect" lists, with its diff;homie-studio deploy, npm run deploy, wrangler deploy, studio_deploy, a song or video publish), with where it goes, what it creates, the commits and files since the last deploy, uncommitted changes, new games, the last checks and who is playing;gen, render and stems with --yes, the models skill's prop and mood with --yes, a request straight at their APIs, a generating command of the provider's own CLI (elevenlabs music compose, elevenlabs text-to-speech, fal api, genmedia run, tripo make and the like; their help, --dry-run, sign-in, pricing and listings are free), a generating tool of their own MCP server or a connector (a run or a job; finding a model, its schema or its price, and an ElevenLabs estimate_only, are free)) that would pass studio.json "budget", the build's budget or the job's cap (a game's models share art/<game>-models/budget.json), or whose cost cannot be read first, with the estimate from the skill's own --dry-run;secret put or bulk (to Cloudflare, a secret put is a deployment), a version rolled out or rolled back by hand, a migration applied or SQL that writes, on the live database (--remote); and the same through Cloudflare's own MCP servers or a claude.ai Cloudflare connector (the API server's execute sending anything but a GET or a GraphQL read; a tool that deletes, updates, edits, puts, deploys or rolls back; a database query that writes). The studio's deploy records what it creates in studio.json and never touches what it did not create; these go around that record. The hold names the studio's own Worker, database or bucket when the change does. Creating something new and reading anything are not held, and --local never is;ollama pull clef-flash, about 11 GB; clef, the 27B, about 18 GB; a request at Ollama's /api/pull naming one), with its size, anywhere: Homie never downloads a model by itself. ollama run of a Clef model is held only when Ollama's own list on this computer (/api/tags) does not have it yet. Other models and Ollama's other commands are not held.homie_feedback with action: "send", local or remote): asked with Send / Don't send and the note's exact words (all of them in the Hold pane). A draft is not held (it sends nothing); the mod fills in the studio's and the plugin's versions and the app, and tells Claude that Claude Code asks. Once you have sent or declined a note in a session, another offer from Claude is refused (you can still ask for one). This hold has no switch. Cancel, a dismissed question, and a run with nobody to ask (claude -p) all refuse the call, with a reason Claude can act on. The guards hold even in bypass-permissions mode. What each guard holds or refuses, and its words, are decided in hooks/lib/holds.mjs, which Homie's hooks for Codex share (see "Homie's holds in Codex").games/<id>/codex/decisions.json that changes the value or the state of a decision the person locked, or leaves the file unreadable while one is locked: a locked decision changes only through homie-studio style set … --unlock --reason, after the person saw what goes stale (style blast);assets/manifest.json entry has no licence, a kind the studio does not know, TurboSquid's licence, or CC BY without an attribution line. When every asset is licensed, the deploy's hold says so;git add or git commit that would put a file over 5 MB under games/ into git (what is staged is read from git itself), naming each file and its size: big files go to the studio's R2, raw models stay in art/<slug>/raw/;stripe_api_write, from Stripe's plugin, claude mcp add or the Claude app's connector) that makes a webhook endpoint, or an event destination with its signing secret included: Stripe answers the secret in that call, so it would land in the conversation. homie-studio shop connect makes the shop's webhook instead, and the secret goes straight to the Worker. Reads, the catalog's writes and turning an endpoint off go through.hsk_), progress keys (hbk_), agent passes (hap_…, whose public id stays), Cloudflare tokens and keys, fal, ElevenLabs, Anthropic, OpenAI, GitHub, npm, Stripe keys and webhook signing secrets, AWS and Google keys, bearer tokens, private keys, and any NAME=value whose name says key, token or secret and whose value looks like one. A one-time owner link goes to the person in the Studio pane (Rooms, "Links for you"); Claude reads that it is there.{
"protect": ["games/*/game.json", "site/theme.json"],
"budget": { "usd": 10, "credits": 2000 }
}
protect: globs, relative to the studio (* within a folder, ** across folders, a folder for everything under it).budget: the most the studio's media jobs spend in all, per unit: dollars (fal) and credits (ElevenLabs), counted from every job's budget.json. Each job's own cap still applies; the skills refuse past it./config, or pluginConfigs in settings.json)| Option | Default | What it turns on |
|---|---|---|
paneAutoOpen | on | The Studio pane when a build starts, the parts pane for two agents |
band | on | The band above the prompt |
guardFiles | on | Holding edits to protected files; refusing changes to locked art decisions and files over 5 MB under games/ into git |
guardDeploys | on | Holding production deploys, and changes to the studio's Cloudflare account outside its deploy (deletes, secrets, hand rollouts, writes to the live database, by Wrangler or Cloudflare's MCP); refusing a deploy that ships an asset with no allowed licence |
guardSpend | on | Holding paid media calls past the budget (the models skill's too), and the ones whose cost cannot be read first (a provider's own CLI, MCP server or API); and holding a Clef model download through Ollama (about 11 GB) |
redactSecrets | on | Taking secrets out of tool output |
renderResults | on | Homie's results and command rows drawn natively |
arcade | on | /arcade and the live Watch views |
pictures | blocks | blocks: frames as coloured cells (any truecolor terminal); image: real pixels where the terminal draws images (kitty, Ghostty, iTerm2, WezTerm) |
| Where | What you get |
|---|---|
claude in a terminal | Everything. Pictures are coloured cells (▀, two pixels a cell), or real pixels with pictures: image |
| The desktop app's Code tab | Everything; pictures are drawn as images (Svg), refreshed up to four times a second |
VS Code's chat panel, claude -p, the Agent SDK, cloud sessions | The guards and secret redaction run; nothing draws. The commands print text instead of opening panes, and a held call is refused when nobody can be asked |
claude plugin validate plugins/homie lists what Claude Code reads from the mod (Claude Code 2.1.287):
❯ ./homie.mjs hooks: session.start, session.end, command.run{command=studio}, command.run{command=build},
command.run{command=rooms}, command.run{command=play}, command.run{command=watch}, command.run{command=codex},
command.run{command=deploy-status}, command.run{command=perf-numbers}, command.run{command=parts},
command.run{command=arcade}, command.run{command=look}, command.run{command=lock}, command.run{command=assets},
command.run{command=cast}, command.run{command=clips}, command.run{command=lineup}, command.run{command=rights},
command.run{command=feedback}, tool.call, tool.call{tool=Edit|Write|MultiEdit|NotebookEdit}, tool.call{tool=Bash},
tool.call{tool=/"^mcp__.+__studio_deploy$"/}, tool.call{tool=/"^mcp__.*homie.*__homie_feedback$"/},
tool.call{tool=/"^mcp__.*stripe.*__stripe_api_write$"/i}, tool.call{tool=/"^mcp__.*(?:fal|eleven|tripo).*__"/i},
tool.call{tool=/"^mcp__.*cloudflare.*__"/i}, turn.complete,
ui.render{component=AbovePrompt}, ui.render{component=Pane}, ui.render{component=AskUserQuestion},
ui.render{component=ToolUse}, ui.render{component=ToolResult}, ui.render{component=ToolGroup}, ui.message, ui.close
❯ ./homie.mjs calls: $.agent.list, $.clock.every, $.command.register, $.fs.exists, $.fs.list, $.fs.read, $.fs.stat,
$.http.fetch, $.process.run, $.process.spawn, $.prompt.fill, $.session.cwd, $.session.surfaces, $.store.get, $.store.set,
$.ui.ask, $.ui.blit, $.ui.close, $.ui.invalidate, $.ui.log, $.ui.open, $.ui.panes, $.ui.resolve, $.ui.tohooks/homie.mjs 1634 lines1/**
2 * THE HOMIE STUDIO MOD (Claude Code 2.1.287 and later; the CLI and the desktop app's Code tab).
3 *
4 * Inside a Homie studio (a folder with studio.json at or above the session's), it draws:
5 * - the band above the prompt: studio · game · build step and % · ▶ Play · N playing now;
6 * - the Studio pane (/studio): Build (the progress feed, stages going green, the latest check frame, a live Watch),
7 * Rooms (live rooms with players and AI, Watch/Join links, Announce, Kick and Mute through the studio's own back
8 * office, which only ASKS for a kick or a mute: the owner confirms each with one tap in their own browser),
9 * Games (launch state, asked for the same way), Stats, Codex, Lab, Parts (the parallel skill's
10 * agents) and Art (art direction: the phase strip, the look, each decision with Lock and Unlock, the cast, the scene
11 * budgets, the spend and the licences); it opens by itself when a build starts, where the terminal is wide enough
12 * for a pane nobody asked for;
13 * - the arcade (/arcade): a real seat in a public room of a live Homie game, played in the pane while Claude works;
14 * - Homie's tool results as checklists, check rows, verdicts and live links (and the commands' rows in words).
15 * Instant commands, no Claude turn: /studio /play /watch /rooms /build /codex /deploy-status /perf-numbers /parts
16 * /arcade /look /lock /assets /lineup /rights /feedback.
17 * Tell Homie: /feedback (and the Studio pane's Tell Homie) shows a note to the people who make Homie exactly as it would
18 * go, with Send and Don't send, and sends it from here only on Send; a homie_feedback send from Claude waits for the
19 * person's own Send in Claude Code's question dialog, with the note's exact words.
20 * Guards on tool calls: an edit to a file studio.json "protect" lists, a production deploy, a paid media call past
21 * the studio's budget, and a Clef model download through Ollama (about 11 GB) are held with what would change and
22 * Proceed / Cancel. Refused outright: a change to a
23 * decision the person locked (games/<id>/codex/decisions.json), a deploy that ships an asset with no allowed licence
24 * in a public game, a `git add` or `git commit` that would put a file over 5 MB under games/ into git, and a write
25 * through Stripe's MCP whose answer would carry a webhook's signing secret into the conversation. Secrets are taken
26 * out of every tool result before Claude reads it.
27 *
28 * WHAT IT REACHES. Files: the studio's own (studio.json, .studio/, games/*, budgets, CODEX.md, .perf/), the file a
29 * held edit names, and the size of a file a `git add` or `git commit` would stage. Network ($.http.fetch): only the
30 * studio's own site (its live address or this computer's dev site), *.homie.rocks (the Homie Arcade game list, and a
31 * note the person pressed Send on, to homie.rocks/api/feedback/tell), its
32 * own game bridge over a private Unix socket, and Ollama's list of models on this computer (loopback, before a Clef
33 * model would be downloaded). Processes: `git` (read-only), the studio's own pinned
34 * `homie-studio` (office, stats, codex link, progress stop, style lock / unlock / blast; each with --json), a media
35 * skill's own `--dry-run` price, and the plugin's game bridge (mod/bridge.mjs: a headless Chrome seat, only while the
36 * arcade or a live Watch is open). It never reads a key file, the keychain or the environment, never approves a
37 * permission, never opens a browser, and never sends anything to a model.
38 *
39 * Mods have strict rules for the mods API (the README's "What the Homie mod does"): every call is spelled
40 * `$.namespace.method(...)` here, and a helper that takes `$` is a function declared at the top of this file.
41 */
42import { GAME_ID, artFor, artSummaryOf, castText, charactersText, clipsText, lineupText, lookText, rightsText, usd } from './lib/art.mjs';
43import { summarizeCodex } from './lib/codex.mjs';
44import { studioCalls } from './lib/commands.mjs';
45import { ago, feedOf, summarize } from './lib/feed.mjs';
46import { claudeEditOf, cloudflareMcpDecision, deployFacts, editDecision, feedbackDecision, holdText, liveSite, mcpDeployDecision, paidMcpDecision, shellDecision, stripeDecision } from './lib/holds.mjs';
47import { redact } from './lib/redact.mjs';
48import { cleanNote, draftId, feedbackUrl, noteBody, withLine } from './lib/feedback.mjs';
49import { readResult } from './lib/results.mjs';
50import {
51 arcadeView, artTab, band, buildCard, buildTab, checksCard, codexTab, deployCard, feedbackCard, gamesTab, guardPanel, holdPane, labTab, partsView, linkable,
52 paneFrame, roomsTab, setupCard, statsTab, studioCard, tellPane, toolUseRow,
53} from './lib/views.mjs';
54
55const PANE = 'homie-studio';
56const PARTS = 'homie-parts';
57const ARCADE = 'homie-arcade';
58const HOLD = 'homie-hold';
59const TELL = 'homie-tell';
60const ARCADE_HOME = 'https://arcade.homie.rocks';
61const TICK_MS = 2000;
62const RECENT_MS = 10 * 60_000;
63
64const DEFAULTS = Object.freeze({
65 paneAutoOpen: true, band: true, guardFiles: true, guardDeploys: true, guardSpend: true, redactSecrets: true,
66 renderResults: true, arcade: true, pictures: 'blocks',
67});
68let OPT = { ...DEFAULTS };
69
70const LABELS = {
71 'setup status': 'Setup status', doctor: 'Setup status', 'setup attach': 'Attach the setup card', check: 'Two-browser check',
72 'port check': 'Port check', 'port plan': 'Port plan', 'port import': 'Port import', deploy: 'Deploy', 'wrangler deploy': 'Deploy (Wrangler)',
73 build: 'Build the site', dev: 'Dev site', publish: 'List in the directory', 'progress start': 'Start a build', 'progress attach': 'Take the build',
74 'progress stage': 'Build stage', 'progress check': 'Build check', 'progress end': 'End the build', 'progress preview': 'Build preview',
75 'progress spend': 'Spend on the build', 'progress show': 'The build', 'progress stop': 'Stop the build', office: 'Back office',
76 'office announce': 'Announce', 'office kick': 'Kick (asks the owner)', 'office mute': 'Mute (asks the owner)', 'office close': 'Close a room (asks the owner)',
77 'office launch': 'Launch state (asks the owner)', 'office invite': 'Invites', 'office link': 'Owner link', stats: 'Stats', codex: 'Game Codex',
78 'codex new': 'New Game Codex', 'codex link': 'Codex link', perf: 'Performance run', 'perf compare': 'Performance compare', 'perf sizes': 'Download sizes',
79 'game new': 'New game', games: 'Games', status: 'Studio status', upgrade: 'Upgrade the studio', look: 'Look at the site',
80 servers: 'Servers', 'servers new': 'New server', 'agents pass': 'Agent pass', demo: 'A working game', new: 'New studio',
81 style: 'Art direction', assets: 'Game assets',
82};
83
84const idleBridge = () => ({ state: 'idle', sock: null, status: null, pic: null, why: null, fps: 0, frames: [], n: 0 });
85
86/** Everything the mod knows, rebuilt from the studio's files on a timer; module variables (a reload starts afresh). */
87const S = {
88 surfaces: [], interactive: false, cwd: null,
89 root: null, studio: null, name: null, local: {}, toolkit: false, games: [], live: null, dev: null,
90 feedId: null, feed: null, feedMtime: 0, last: null, autoOpened: new Set(),
91 preview: null, rooms: { live: null, dev: null, games: null, at: 0 }, office: null, stats: null,
92 busy: {}, why: {}, asks: [], forYou: [], codexes: [], codexLinks: {}, lab: { url: null, checks: [] }, art: [],
93 tab: 'build', tickN: 0, ticking: false, drawn: '',
94 calls: new Map(), parts: new Map(), partsAutoOpened: false, agentsAt: 0,
95 guards: new Map(), guardN: 0, held: null,
96 arcade: { ...idleBridge(), pick: null, game: null }, watch: idleBridge(), arcadeGames: [], arcadeGamesAt: 0,
97 // Tell Homie: the note in the Tell Homie pane, the drafts Claude's homie_feedback showed (by id), and whether the
98 // person already answered a note this session (sent or said no), after which nothing is offered again.
99 tell: { state: null, note: null, kind: 'idea', answered: false, offers: 0, seen: new Map() },
100};
101
102/* ================================================================== register */
103
104export function register(on, options) {
105 OPT = optionsOf(options);
106
107 on('session.start', async ($, e, next) => {
108 const started = await next(e);
109 S.interactive = Boolean(e.isInteractive);
110 S.cwd = e.cwd;
111 try { S.surfaces = [...(await $.session.surfaces())]; } catch { S.surfaces = []; }
112 await findStudio($);
113 if (S.root) { await readStudio($); await readFeed($); }
114 for (const [name, description, argumentHint] of COMMANDS) {
115 try { await $.command.register({ name, description, ...(argumentHint ? { argumentHint } : {}), immediate: true }); } catch (error) { $.ui.log(`/${name} is taken here (${String(error?.message ?? error).slice(0, 80)})`, { to: 'debug' }); }
116 }
117 $.clock.every(TICK_MS, () => tick($));
118 return started;
119 });
120
121 on('session.end', async ($, e, next) => {
122 stopBridge($, 'arcade');
123 stopBridge($, 'watch');
124 return next(e);
125 });
126
127 /* ------------------------------------------------------------ commands */
128
129 on('command.run', { command: 'studio' }, async ($, e) => {
130 const tab = String(e.args ?? '').trim().toLowerCase();
131 if (['build', 'rooms', 'games', 'stats', 'codex', 'lab', 'parts', 'art'].includes(tab)) S.tab = tab;
132 if (!S.root) return { text: 'Not inside a Homie studio (no studio.json here or above). /arcade plays a Homie game meanwhile; ask Claude to set up a studio to get the rest.' };
133 await tick($, { force: true });
134 if (S.tab === 'rooms') void loadRooms($);
135 if (S.tab === 'lab') void readLab($);
136 if (S.tab === 'art') await readArt($);
137 if (!(await openPane($, PANE, `◆ ${S.name}`))) return { text: S.tab === 'art' ? artText('look', '') : studioText() };
138 return {};
139 });
140
141 on('command.run', { command: 'build' }, async ($) => {
142 if (!S.root) return { text: 'Not inside a Homie studio.' };
143 S.tab = 'build';
144 await tick($, { force: true });
145 await openPane($, PANE, `◆ ${S.name}`);
146 return { text: buildText() };
147 });
148
149 on('command.run', { command: 'rooms' }, async ($) => {
150 if (!S.root) return { text: 'Not inside a Homie studio. /arcade lists Homie Arcade\'s rooms.' };
151 S.tab = 'rooms';
152 await loadRooms($);
153 await openPane($, PANE, `◆ ${S.name}`);
154 return { text: roomsText() };
155 });
156
157 on('command.run', { command: 'play' }, async ($, e) => {
158 if (!S.root) {
159 await loadArcadeGames($);
160 return { text: ['Not inside a studio. Homie Arcade, live now (you open these):', ...S.arcadeGames.filter((g) => g.studio === 'Homie Arcade').map((g) => ` ▶ ${g.name}: ${g.base}/${g.id}/play`)].join('\n') };
161 }
162 const want = String(e.args ?? '').trim();
163 const games = want ? S.games.filter((g) => g.id === want || String(g.name ?? '').toLowerCase() === want.toLowerCase()) : S.games;
164 if (!games.length) return { text: want ? `No game "${want}" in ${S.name} (games: ${S.games.map((g) => g.id).join(', ') || 'none yet'}).` : `${S.name} has no game yet.` };
165 const lines = [`${S.name}: Play (open these yourself; nothing is opened for you)`];
166 for (const g of games) {
167 const at = [S.live ? `${S.live}/${g.id}/play` : null, S.dev ? `${linkable(S.dev)}/${g.id}/play (this computer)` : null].filter(Boolean);
168 lines.push(` ▶ ${g.name ?? g.id}: ${at.join(' · ') || 'not running anywhere yet: deploy it, or start the dev site'}`);
169 }
170 return { text: lines.join('\n') };
171 });
172
173 on('command.run', { command: 'watch' }, async ($, e) => {
174 if (!S.root) return { text: 'Not inside a Homie studio. /arcade watches or plays a Homie Arcade room in a pane.' };
175 await loadRooms($);
176 const want = String(e.args ?? '').trim().toLowerCase();
177 const all = roomList().filter((r) => !want || r.room.toLowerCase() === want || r.room.toLowerCase() === `pub-${want}` || r.game === want || r.label.toLowerCase() === want);
178 if (!all.length) return { text: want ? `No live room "${want}" right now.${S.games.length ? ' A room exists while someone plays: open a game\'s Play page, and it can be watched from then on.' : ''}` : 'No live rooms right now. A room exists while someone plays; /play gives the Play links.' };
179 return { text: ['Watch (open these yourself):', ...all.filter((r) => r.watch).map((r) => ` ◉ ${r.name} · ${r.label}: ${linkable(new URL(r.watch, r.base).href)} (${r.players}/${r.max} players${r.ai ? `, ${r.ai} AI` : ''})`), 'Or watch one in the Studio pane: /rooms, then "Watch in the pane".'].join('\n') };
180 });
181
182 on('command.run', { command: 'codex' }, async ($, e) => {
183 if (!S.root) return { text: 'Not inside a Homie studio.' };
184 await readCodexes($);
185 S.tab = 'codex';
186 const want = String(e.args ?? '').trim();
187 await openPane($, PANE, `◆ ${S.name}`);
188 const list = want ? S.codexes.filter((c) => c.id === want) : S.codexes;
189 if (!list.length) return { text: want ? `games/${want}/CODEX.md does not exist yet: ask Claude to plan ${want}.` : 'No Game Codex yet: ask Claude to plan a game; a short interview becomes games/<id>/CODEX.md.' };
190 return { text: list.map((c) => `${c.title ?? c.id} (games/${c.id}/CODEX.md): ${c.sections.filter((x) => x.filled && x.key).length} sections filled${c.missing.length ? `, not decided yet: ${c.missing.join(', ')}` : ''}; ${c.openQuestions} open question${c.openQuestions === 1 ? '' : 's'}. The page: .studio/codex/${c.id}.html (npx --no-install homie-studio codex ${c.id})${S.codexLinks[c.id] ? '; a private link is in the Studio pane' : ''}.`).join('\n') };
191 });
192
193 on('command.run', { command: 'deploy-status' }, async ($) => {
194 if (!S.root) return { text: 'Not inside a Homie studio.' };
195 await readStudio($);
196 await readFeed($);
197 await loadRooms($);
198 const d = await deployFacts(ioOf($), S.root, { studio: S.studio, local: S.local, feed: S.feed, last: S.last, ...(await deployKnown($, S.root)) });
199 return { text: deployText(d) };
200 });
201
202 on('command.run', { command: 'perf-numbers' }, async ($, e) => {
203 if (!S.root) return { text: 'Not inside a Homie studio.' };
204 return { text: await perfText($, String(e.args ?? '').trim()) };
205 });
206
207 on('command.run', { command: 'parts' }, async ($) => {
208 await readParts($, { force: true });
209 if (!(await openPane($, PARTS, 'Parts'))) return { text: partsText() };
210 return {};
211 });
212
213 on('command.run', { command: 'arcade' }, async ($, e) => {
214 if (!OPT.arcade) return { text: 'The arcade is turned off in this plugin\'s settings (/config: Homie, arcade).' };
215 await loadArcadeGames($);
216 const want = String(e.args ?? '').trim().toLowerCase();
217 if (want) {
218 const g = S.arcadeGames.find((x) => x.id === want || x.key === want || x.name.toLowerCase() === want);
219 if (g) S.arcade.pick = g.key;
220 }
221 const opened = await openPane($, ARCADE, 'Arcade', { focus: true, rows: 30 });
222 if (want && S.arcade.pick && opened) await startArcade($);
223 if (!opened) return { text: ['The arcade draws in a pane, which this app does not show. Play in a browser instead (you open these):', ...S.arcadeGames.slice(0, 8).map((g) => ` ▶ ${g.name} · ${g.studio}: ${g.base}/${g.id}/play`)].join('\n') };
224 return {};
225 });
226
227 // Art direction (the style and models skills; .studio/art/<game>/latest.json). /style is the style skill's own
228 // (plugin skills answer to their bare names), so the Art tab's command is /look.
229 on('command.run', { command: 'look' }, async ($, e) => {
230 if (!S.root) return { text: 'Not inside a Homie studio.' };
231 await readArt($);
232 S.tab = 'art';
233 await openPane($, PANE, `◆ ${S.name}`);
234 return { text: artText('look', String(e.args ?? '').trim()) };
235 });
236
237 on('command.run', { command: 'lock' }, async ($, e) => {
238 if (!S.root) return { text: 'Not inside a Homie studio.' };
239 return { text: await lockCommand($, String(e.args ?? '').trim()) };
240 });
241
242 on('command.run', { command: 'assets' }, async ($, e) => {
243 if (!S.root) return { text: 'Not inside a Homie studio.' };
244 await readArt($);
245 return { text: artText('assets', String(e.args ?? '').trim()) };
246 });
247
248 on('command.run', { command: 'cast' }, async ($, e) => {
249 if (!S.root) return { text: 'Not inside a Homie studio.' };
250 await readArt($);
251 // The characters are in the Art tab: it opens, as /look opens it.
252 S.tab = 'art';
253 await openPane($, PANE, `◆ ${S.name}`);
254 return { text: artText('cast', String(e.args ?? '').trim()) };
255 });
256
257 on('command.run', { command: 'clips' }, async ($, e) => {
258 if (!S.root) return { text: 'Not inside a Homie studio.' };
259 await readArt($);
260 return { text: artText('clips', String(e.args ?? '').trim()) };
261 });
262
263 on('command.run', { command: 'lineup' }, async ($, e) => {
264 if (!S.root) return { text: 'Not inside a Homie studio.' };
265 await readArt($);
266 return { text: artText('lineup', String(e.args ?? '').trim()) };
267 });
268
269 on('command.run', { command: 'rights' }, async ($, e) => {
270 if (!S.root) return { text: 'Not inside a Homie studio.' };
271 await readArt($);
272 const picked = artFor(S.art, String(e.args ?? '').trim());
273 if (!picked.list) return { text: picked.why };
274 const out = [];
275 for (const a of picked.list) out.push(rightsText(a, gameName(a.game), await $.fs.exists(`${S.root}/games/${a.game}/assets/RIGHTS.md`)));
276 return { text: out.join('\n') };
277 });
278
279 // Tell Homie. `/feedback <words>` shows the person's own note exactly as it would go, in the Tell Homie pane (or as
280 // text where no pane draws, with `/feedback send`); `/feedback` alone opens the pane to write one, or to let Claude
281 // draft one. Only the person's own Send (a press, or `/feedback send` they typed) sends anything.
282 on('command.run', { command: 'feedback' }, async ($, e) => {
283 const words = String(e.args ?? '').trim();
284 const person = ['composer', 'bridge'].includes(e.origin?.kind);
285 if (/^send$/i.test(words)) {
286 if (!person) return { text: 'Only the person sends a note: /feedback send is theirs to type.' };
287 if (S.tell.state !== 'draft' || !S.tell.note) return { text: 'There is no note waiting. /feedback <your words> shows one first.' };
288 await tellSend($, 'chat');
289 return { text: S.tell.state === 'sent' ? `Sent to Homie. Thank you (ref ${String(S.tell.reference).slice(0, 8)}).` : `Not sent: ${S.tell.why}` };
290 }
291 if (/^(cancel|no|don.?t send)$/i.test(words)) {
292 if (S.tell.state === 'draft') S.tell = { ...S.tell, state: 'declined', answered: true };
293 return { text: 'Not sent. Nothing left this computer.' };
294 }
295 if (words) {
296 const ok = await tellDraft($, { kind: guessKind(words), text: words });
297 if (!ok) return { text: `Not shown: ${S.tell.why}` };
298 } else if (S.tell.state !== 'draft') S.tell = { ...S.tell, state: null, note: null, why: null };
299 if (await openPane($, TELL, 'Tell Homie')) return {};
300 if (!S.tell.note) return { text: 'Tell Homie: /feedback <what happened, in your words> shows the note before anything is sent. Or ask Claude to draft one.' };
301 return { text: `Not sent yet. This is exactly what would go to the people who make Homie:\n${tellText(S.tell)}\nType /feedback send to send it, or /feedback cancel.` };
302 });
303
304 /* ------------------------------------------------------------ tool calls */
305
306 // Outermost: every tool call's result has its secrets taken out (after the guards below have decided), and the
307 // Studio pane learns what Homie command ran and which part ran it.
308 on('tool.call', async ($, e, next) => {
309 noteCall(e);
310 const result = await next(e);
311 afterCall($, e, result);
312 if (!OPT.redactSecrets) return result;
313 return redactResult(result);
314 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod could not check this result for secrets, so it was withheld. Run it again, or turn off the mod\'s secret redaction (/config).' } : next(e)));
315
316 // The guards: what each one means is decided in lib/holds.mjs (Codex's hooks share it); `settle` asks the person.
317 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
318 if (!OPT.guardFiles) return next(e);
319 // A decision the person locked is refused outright (nobody is asked: the person's lock is the answer); a file the
320 // studio protects is held with its diff.
321 const edit = { ...claudeEditOf(e.tool, e), by: e.agentId ? 'a subagent' : 'Claude', byLong: e.agentId ? `a subagent (${partName(e.agentId)})` : 'Claude' };
322 const held = await settle($, await editDecision(ioOf($), holdCtx(), edit));
323 return held ?? next(e);
324 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this edit ran.' } : { deny: 'The Homie mod could not check this edit against the studio\'s protected files (studio.json "protect") and locked art decisions, so it was not made. Ask the person, or try again.' }));
325
326 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
327 const { decision, deploy } = await shellDecision(ioOf($), holdCtx(), e.command, (root) => deployKnown($, root));
328 const held = await settle($, decision);
329 if (held) return held;
330 const result = await next(e);
331 if (deploy) await afterDeploy($, deploy.root, result);
332 return result;
333 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this command ran.' } : { deny: 'The Homie mod could not check this command (a deploy, a Cloudflare change, a paid media call, a model download, or big files into git), so it was not run. Ask the person, or try again.' }));
334
335 on('tool.call', { tool: /^mcp__.+__studio_deploy$/ }, async ($, e, next) => {
336 const decision = await mcpDeployDecision(ioOf($), holdCtx(), S.root ? await deployKnown($, S.root) : {});
337 if (!decision) return next(e);
338 const held = await settle($, decision);
339 if (held) return held;
340 const result = await next(e);
341 await afterDeploy($, S.root, result);
342 return result;
343 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this deploy ran.' } : { deny: 'The Homie mod could not summarise this deploy, so it was not run. Ask the person, or try again.' }));
344
345 // Tell Homie, from Claude (the Homie MCP's homie_feedback, local or remote): a draft gets this studio's facts and a word
346 // that Claude Code will ask; a send waits for the person's own Send in the question dialog, with the note's exact
347 // words; an offer after the person already answered one this session is refused. No switch turns this off.
348 on('tool.call', { tool: /^mcp__.*homie.*__homie_feedback$/ }, async ($, e, next) => {
349 const action = ['send', 'decline'].includes(e.action) ? e.action : 'draft';
350 if (action === 'decline') { S.tell.answered = true; return next(e); }
351 const facts = await noteFacts($);
352 const extra = {};
353 if (!e.studioVersion && facts.studioVersion) extra.studioVersion = facts.studioVersion;
354 if (!e.pluginVersion && facts.pluginVersion) extra.pluginVersion = facts.pluginVersion;
355 if (!e.app) extra.app = 'claude-code';
356 if (action === 'draft') {
357 if (e.offered === true && S.tell.answered) return { deny: 'The person already answered a note to Homie in this session (sent or said no). Homie offers at most once a session: do not offer again. If they ask to tell Homie something themselves, draft it with offered: false.' };
358 // An offer nobody answered may be reworded a few times; it is not asked over and over.
359 if (e.offered === true && S.tell.offers >= 3) return { deny: 'A note was offered several times in this session and the person has not said yes. Do not offer again. If they ask to tell Homie something themselves, draft it with offered: false.' };
360 if (e.offered === true) S.tell.offers += 1;
361 const result = await next({ ...e, ...extra });
362 const d = result?.result?.structuredContent ?? result?.result ?? null;
363 if (d?.kind === 'feedback' && d.draft && d.note) { S.tell.seen.set(d.draft, d); if (S.tell.seen.size > 20) S.tell.seen.delete(S.tell.seen.keys().next().value); }
364 if (!result || result.deny || result.result === undefined) return result;
365 return { ...result, context: [...(result.context ?? []), 'The Homie mod is here: Claude Code itself asks the person before a note leaves. Show them the note and, unless they already said no, call homie_feedback with action "send", this draft and the same fields: Claude Code shows them the exact note with Send and Don\'t send, and only their Send sends it.'] };
366 }
367 // send: held in Claude Code's own question with the note's exact words (lib/holds.mjs, as Codex holds it).
368 const send = { ...e, ...extra };
369 const d = feedbackDecision(e.tool, send, { seen: e.draft ? S.tell.seen.get(String(e.draft)) : null });
370 if (!d) return next(send);
371 if (d.deny) return { deny: d.deny };
372 const answer = await ask($, d.hold);
373 if (answer === d.hold.yes) { S.tell.answered = true; return next({ ...send, by: 'dialog' }); }
374 if (answer === null) return { deny: d.hold.nobody };
375 S.tell.answered = true;
376 if (d.hold.options.includes(answer)) return { deny: d.hold.no };
377 return { deny: `The person did not choose Send; they wrote: "${String(answer).replace(/\s+/g, ' ').slice(0, 300)}". Nothing was sent. If they want it worded differently, draft it again in their words (offered: false), and they are asked again.` };
378 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this note was sent.' } : { deny: 'The Homie mod could not ask the person about this note, so nothing was sent. Ask them in the chat, and try again.' }));
379
380 // Stripe's MCP: a write whose answer would carry a webhook's signing secret into the conversation is refused, always
381 // (the shop's own page makes the webhook, and the secret goes straight to the Worker).
382 on('tool.call', { tool: /^mcp__.*stripe.*__stripe_api_write$/i }, async ($, e, next) => {
383 const refused = stripeDecision(e.tool, e);
384 return refused ?? next(e);
385 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this Stripe call ran.' } : { deny: 'The Homie mod could not check this Stripe write for a secret in its answer, so it was not made. Ask the person, or make it in Stripe\'s Dashboard.' }));
386
387 on('tool.call', { tool: /^mcp__.*(?:fal|eleven|tripo).*__/i }, async ($, e, next) => {
388 const held = await settle($, await paidMcpDecision(ioOf($), holdCtx(), e.tool, e));
389 return held ?? next(e);
390 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this call ran.' } : { deny: 'The Homie mod could not check this paid call against the studio\'s budget, so it was not made. Ask the person.' }));
391
392 // Cloudflare's own MCP servers (and a claude.ai Cloudflare connector), inside a studio: a tool that deletes or changes
393 // something on the account goes around the studio's deploy and its record of what it created, so it is held.
394 on('tool.call', { tool: /^mcp__.*cloudflare.*__/i }, async ($, e, next) => {
395 const held = await settle($, await cloudflareMcpDecision(ioOf($), holdCtx(), e.tool, e));
396 return held ?? next(e);
397 }).catch(async ($, e, next) => (next.called ? { deny: 'The Homie mod failed after this call ran.' } : { deny: 'The Homie mod could not check this change to the Cloudflare account, so it was not made. Ask the person.' }));
398
399 on('turn.complete', async ($, e, next) => {
400 const result = await next(e);
401 if (e.agentId && S.parts.has(e.agentId)) { const l = S.parts.get(e.agentId); l.endedAt = Date.now(); l.status = e.isAborted ? 'killed' : 'completed'; redraw($); }
402 else if (!e.agentId) void tick($, { force: true });
403 return result;
404 });
405
406 /* ------------------------------------------------------------ drawing */
407
408 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
409 if (!OPT.band || !S.root || e.props.hasSurvey) return next(e);
410 const t = $.ui.resolve(e);
411 const ours = band(t, bandData(), e.props.bodyColumns);
412 if (!ours) return next(e);
413 const theirs = await next(e);
414 return t.Box({ flexDirection: 'column', children: [ours, theirs] });
415 });
416
417 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
418 if (e.requestId !== PANE && e.requestId !== PARTS && e.requestId !== ARCADE && e.requestId !== HOLD && e.requestId !== TELL) return next(e);
419 const t = $.ui.resolve(e);
420 const columns = Math.max(30, e.props.bodyColumns ?? 80);
421 const now = Date.now();
422 if (e.requestId === HOLD) return holdPane(t, S.held);
423 if (e.requestId === TELL) {
424 return tellPane(t, {
425 tell: S.tell, columns,
426 on: {
427 kind: (v) => { if (S.tell.note) return tellDraft($, { ...S.tell.note, kind: v }); S.tell.kind = v; redraw($); return undefined; },
428 words: (v) => (String(v ?? '').trim() ? tellDraft($, { ...(S.tell.note ?? {}), kind: S.tell.note?.kind ?? S.tell.kind ?? guessKind(v), text: v }) : undefined),
429 email: (v) => (S.tell.note ? tellDraft($, { ...S.tell.note, email: String(v ?? '').trim() || null }) : undefined),
430 send: () => tellSend($, 'pane'),
431 decline: () => { S.tell = { ...S.tell, state: 'declined', answered: true }; redraw($); },
432 // The ask goes into the prompt box as the person's draft: they send it to Claude themselves, with Enter.
433 draft: async () => { await $.prompt.fill({ text: 'Please draft a short note to the people who make Homie about this session: what was confusing, where I got stuck, or what was good, in plain words. Use homie_feedback with offered: false, and send nothing until I say yes.' }); await $.ui.close({ id: TELL }); },
434 close: () => { void $.ui.close({ id: TELL }); if (S.tell.state !== 'draft') S.tell = { ...S.tell, state: null, note: null, why: null }; },
435 },
436 });
437 }
438 if (e.requestId === PARTS) return partsView(t, { parts: partList(), checks: S.feed ? summarize(S.feed).checks : [], columns, now });
439 if (e.requestId === ARCADE) {
440 const size = arcadeSize(columns, e.props.scroll?.bodyRows ?? 30);
441 if (S.arcade.state !== 'idle' && S.arcade.sock && (size.cols !== S.arcade.cols || size.rows !== S.arcade.rows)) { S.arcade.cols = size.cols; S.arcade.rows = size.rows; void bridgePost($, S.arcade, '/size', size); }
442 return arcadeView(t, {
443 surface: e.surface, a: arcadeData(), games: S.arcadeGames, columns,
444 on: {
445 pick: (v) => { S.arcade.pick = v; $.ui.invalidate('ui.render'); },
446 play: () => startArcade($),
447 leave: () => { stopBridge($, 'arcade'); $.ui.invalidate('ui.render'); },
448 key: (k) => arcadeKey($, k),
449 },
450 });
451 }
452 if (!S.root) return t.Text({ dimColor: true, children: ['Not inside a Homie studio any more.'] });
453 const s = paneStudio();
454 const tabs = {
455 build: () => {
456 const b = S.feed ? summarize(S.feed) : null;
457 const watchSize = { cols: Math.min(columns - 2, 72), rows: Math.max(6, Math.round((Math.min(columns - 2, 72) * 9) / 32)) };
458 if (S.watch.state !== 'idle' && S.watch.sock && (watchSize.cols !== S.watch.cols || watchSize.rows !== S.watch.rows)) { S.watch.cols = watchSize.cols; S.watch.rows = watchSize.rows; void bridgePost($, S.watch, '/size', watchSize); }
459 return buildTab(t, {
460 surface: e.surface, s, b, last: S.last ? summarize(S.last) : null, columns, now, pic: previewPicture(e.surface, columns, b),
461 watch: watchData(b),
462 on: { watch: () => startWatch($, b?.id ?? null), unwatch: () => { stopBridge($, 'watch'); $.ui.invalidate('ui.render'); }, stop: () => stopBuild($) },
463 });
464 },
465 rooms: () => roomsTab(t, {
466 s, rooms: S.rooms, office: S.office, asks: S.asks, forYou: S.forYou, columns, now, busy: S.busy.rooms, why: S.why.rooms,
467 on: {
468 refresh: () => loadRooms($, { force: true }),
469 owner: () => loadOffice($),
470 watchHere: (r) => watchRoom($, r),
471 kick: (r, c) => officeAct($, 'kick', r, c),
472 mute: (r, c) => officeAct($, c.muted ? 'unmute' : 'mute', r, c),
473 announce: (r, text) => announce($, r, text),
474 },
475 }),
476 games: () => gamesTab(t, { s, rooms: S.rooms, office: S.office, columns, on: { launch: (g, v) => launchState($, g, v) } }),
477 stats: () => statsTab(t, { s, stats: S.stats, columns, now, busy: S.busy.stats, why: S.why.stats, on: { refresh: () => loadStats($) } }),
478 codex: () => codexTab(t, { s, codexes: S.codexes, links: S.codexLinks, columns, busy: S.busy.codex, on: { link: (c) => codexLink($, c) } }),
479 lab: () => labTab(t, { lab: S.lab, games: S.games, columns, now }),
480 parts: () => partsView(t, { parts: partList(), checks: S.feed ? summarize(S.feed).checks : [], columns, now }),
481 art: () => artTab(t, { art: S.art, games: S.games, columns, now, busy: S.busy.art, why: S.why.art, on: { lock: (game, d) => artLock($, game, d), unlock: (game, d) => artUnlock($, game, d) } }),
482 };
483 return paneFrame(t, {
484 s, tab: S.tab, columns, onTell: () => { void openPane($, TELL, 'Tell Homie'); },
485 onTab: (id) => { S.tab = id; if (id === 'rooms') void loadRooms($); if (id === 'codex') void readCodexes($); if (id === 'lab') void readLab($); if (id === 'art') void readArt($); $.ui.invalidate('ui.render'); },
486 body: (tabs[S.tab] ?? tabs.build)(),
487 });
488 });
489
490 // A held tool call's question: what would change, drawn above Claude Code's own dialog (the dialog stays whole).
491 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
492 const q = e.props.questions?.[0]?.question;
493 const g = typeof q === 'string' ? S.guards.get(q) : null;
494 if (!g) return next(e);
495 const t = $.ui.resolve(e);
496 const theirs = await next(e);
497 return t.Box({ flexDirection: 'column', children: [guardPanel(t, g), theirs] });
498 });
499
500 // A Homie command's row: what it is in words (the command itself beside it, dim) and, once it has answered, its
501 // result as a checklist, rows or live links, drawn right under it, whether the row stands alone or sits in a group
502 // Claude Code has unfolded (below). The standalone result block then draws nothing of its own.
503 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
504 if (!OPT.renderResults || e.props.tool !== 'Bash') return next(e);
505 const call = S.calls.get(e.requestId) ?? callOf(e.props.input?.command);
506 if (!call?.homie || !call.label) return next(e);
507 const t = $.ui.resolve(e);
508 const row = toolUseRow(t, { label: call.label, command: String(e.props.input?.command ?? '').replace(/\s+/g, ' ').slice(0, 300), state: e.props.isRunning ? 'running' : e.props.isErrored ? 'error' : 'done' });
509 const card = !e.props.isRunning && !e.props.isErrored && e.props.output !== undefined ? resultCard(t, e.props.tool, call, e.props.output, e.viewport) : null;
510 return card ? t.Box({ flexDirection: 'column', children: [row, t.Box({ paddingLeft: 2, children: [card] })] }) : row;
511 });
512
513 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
514 if (!OPT.renderResults || e.props.isErrored) return next(e);
515 const tool = e.props.tool;
516 if (tool !== 'Bash' && !/^mcp__.*homie.*__/i.test(tool)) return next(e);
517 const call = S.calls.get(e.requestId) ?? null;
518 // A command this session saw that was not Homie's stays Claude Code's; one from before a reload is read by its text.
519 if (tool === 'Bash' && call && !call.homie) return next(e);
520 const t = $.ui.resolve(e);
521 const card = resultCard(t, tool, call, e.props.output, e.viewport);
522 if (!card) return next(e);
523 // The command's own row (ToolUse, above) already drew this card.
524 if (tool === 'Bash' && call?.homie && call.label) return t.Text({ children: [''] });
525 return card;
526 });
527
528 // A group of calls Claude Code folds into one line ("Ran 3 shell commands") is unfolded when a Homie command is in
529 // it, so its result shows.
530 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
531 if (!OPT.renderResults || e.props.isExpanded) return next(e);
532 const homie = (e.props.calls ?? []).some((c) => c.tool === 'Bash' && callOf(c.input?.command)?.homie);
533 return homie ? next({ ...e, props: { ...e.props, isExpanded: true } }) : next(e);
534 });
535
536 // The arcade's pad (a Client region) posts the keys it took.
537 on('ui.message', async ($, e, next) => {
538 const d = e.data ?? {};
539 if (typeof d.key === 'string') await arcadeKey($, d.key);
540 return next(e);
541 });
542
543 on('ui.close', async ($, e, next) => {
544 if (e.id === ARCADE) stopBridge($, 'arcade');
545 if (e.id === PANE) stopBridge($, 'watch');
546 return next(e);
547 });
548}
549
550/* ================================================================== options and commands */
551
552const COMMANDS = [
553 ['studio', 'Homie: the Studio pane (build, rooms, games, stats, codex, lab, parts, art)', '[build|rooms|games|stats|codex|lab|parts|art]'],
554 ['play', 'Homie: Play links for this studio\'s games', '[game]'],
555 ['watch', 'Homie: Watch links for the rooms playing now', '[room|game]'],
556 ['rooms', 'Homie: live rooms with players and AI, and the back office', null],
557 ['build', 'Homie: the current build, step by step', null],
558 ['codex', 'Homie: the Game Codex, at a glance', '[game]'],
559 ['deploy-status', 'Homie: what is live, and what changed since the last deploy', null],
560 // /perf is the perf skill's own (plugin skills answer to their bare names), so the numbers are /perf-numbers.
561 ['perf-numbers', 'Homie: the latest performance run\'s numbers', '[game]'],
562 ['parts', 'Homie: the parallel agents building now', null],
563 ['arcade', 'Homie: play a live Homie game with strangers while Claude works', '[game]'],
564 // /style is the style skill's own, so the art direction at a glance is /look.
565 ['look', 'Homie: the game\'s look: its art direction, decision by decision (the Studio pane\'s Art tab)', '[game]'],
566 ['lock', 'Homie: lock one art decision, in your own words (unlocking is asked for in the Art tab)', '<decision> [game]'],
567 ['assets', 'Homie: the game\'s cast: routes, licences, costs, and what is stale', '[game]'],
568 ['cast', 'Homie: the game\'s characters: skeleton, bones, source and clips', '[game]'],
569 ['clips', 'Homie: each character\'s clips against the verbs the game needs', '[game]'],
570 ['lineup', 'Homie: the last asset lineup: what it flagged, and where its pictures are', '[game]'],
571 ['rights', 'Homie: licence problems with their fixes, and the game\'s RIGHTS.md', '[game]'],
572 ['feedback', 'Homie: tell the people who make Homie something (you see the exact note, and only your Send sends it)', '[your words | send | cancel]'],
573];
574
575/** The plugin's userConfig values, with defaults for anything unset. */
576function optionsOf(options) {
577 const o = { ...DEFAULTS };
578 for (const [k, v] of Object.entries(options ?? {})) {
579 if (!(k in DEFAULTS)) continue;
580 if (typeof DEFAULTS[k] === 'boolean') o[k] = v === true || v === 'true';
581 else if (k === 'pictures') o[k] = v === 'image' ? 'image' : 'blocks';
582 }
583 return o;
584}
585
586/* ================================================================== the studio, from its files */
587
588async function findStudio($) {
589 let cwd = S.cwd;
590 try { cwd = await $.session.cwd(); } catch { /* keep the last */ }
591 S.cwd = cwd;
592 let at = String(cwd ?? '').replace(/\/+$/, '');
593 const before = S.root;
594 S.root = null;
595 for (let i = 0; i < 24 && at; i++) {
596 if (await $.fs.exists(`${at}/studio.json`)) { S.root = at; break; }
597 const up = at.slice(0, at.lastIndexOf('/'));
598 if (up === at) break;
599 at = up;
600 }
601 if (S.root !== before) { S.studio = null; S.feed = null; S.feedId = null; S.last = null; S.rooms = { live: null, dev: null, games: null, at: 0 }; S.office = null; S.stats = null; S.codexes = []; S.lab = { url: null, checks: [] }; S.art = []; }
602}
603
604async function readJsonFile($, path) {
605 try { return JSON.parse(await $.fs.read(path)); } catch { return null; }
606}
607
608async function readStudio($) {
609 const root = S.root;
610 if (!root) return;
611 const studio = (await readJsonFile($, `${root}/studio.json`)) ?? {};
612 S.studio = studio;
613 S.name = String(studio.name ?? root.split('/').pop()).slice(0, 60);
614 S.local = (await readJsonFile($, `${root}/.studio/local.json`)) ?? {};
615 S.toolkit = await $.fs.exists(`${root}/node_modules/@homie-rocks/studio/bin/homie-studio.mjs`);
616 S.live = liveSite(studio, S.local);
617 // The dev site (`homie-studio dev`) records its port next to the Worker's config.
618 S.dev = null;
619 for (const dir of [root, `${root}/site`]) {
620 const rec = await readJsonFile($, `${dir}/.wrangler/homie-dev.json`);
621 if (rec && Number(rec.port) > 0) {
622 const url = `http://127.0.0.1:${Number(rec.port)}`;
623 const r = await fetchJson($, `${url}/api/games`, { timeoutMs: 1500 });
624 if (r) { S.dev = url; S.rooms.games = S.rooms.games ?? r.games ?? null; }
625 break;
626 }
627 }
628 const games = [];
629 try {
630 for (const d of await $.fs.list(`${root}/games`)) {
631 if (d.kind !== 'directory' && d.kind !== 'dir' && !(d.kind === 'other' && d.isLink)) continue;
632 const meta = await readJsonFile($, `${root}/games/${d.name}/game.json`);
633 if (meta) games.push({ id: meta.id ?? d.name, name: meta.name ?? d.name, blurb: meta.blurb ?? '', launch: meta.launch ?? null, hasCodex: await $.fs.exists(`${root}/games/${d.name}/CODEX.md`) });
634 else if (await $.fs.exists(`${root}/games/${d.name}/CODEX.md`)) games.push({ id: d.name, name: d.name, blurb: '(planned: its Game Codex, no game yet)', planned: true, hasCodex: true });
635 }
636 } catch { /* no games folder */ }
637 S.games = games.sort((a, b) => a.id.localeCompare(b.id));
638}
639
640/** Only these addresses: the studio's own live site, this computer's dev site, and Homie's own *.homie.rocks. */
641function allowedUrl(url) {
642 let u;
643 try { u = new URL(url); } catch { return false; }
644 if (u.protocol === 'http:') return u.hostname === '127.0.0.1' || u.hostname === 'localhost';
645 if (u.protocol !== 'https:') return false;
646 if (/(^|\.)homie\.rocks$/.test(u.hostname)) return true;
647 return Boolean(S.live && new URL(S.live).host === u.host);
648}
649
650async function fetchJson($, url, { timeoutMs = 6000 } = {}) {
651 if (!allowedUrl(url)) return null;
652 try {
653 const res = await $.http.fetch(url, { headers: { accept: 'application/json', 'user-agent': 'homie-claude-code-mod' } });
654 if (!res.ok) return null;
655 return JSON.parse(res.text);
656 } catch { return null; }
657}
658
659/* ------------------------------------------------------------------ the progress feed */
660
661async function readFeed($) {
662 const dir = `${S.root}/.studio/progress`;
663 let id = null;
664 try { id = String(await $.fs.read(`${dir}/current`)).trim(); } catch { id = null; }
665 if (id && !/^[a-z0-9][a-z0-9-]{5,63}$/.test(id)) id = null;
666 let changed = false;
667 if (id) {
668 let mtime = 0;
669 try { mtime = (await $.fs.stat(`${dir}/${id}.json`)).mtimeMs; } catch { mtime = 0; }
670 if (id !== S.feedId || mtime !== S.feedMtime) {
671 const doc = feedOf(await readText($, `${dir}/${id}.json`));
672 if (doc && doc.state === 'running') {
673 const fresh = id !== S.feedId;
674 S.feed = doc; S.feedId = id; S.feedMtime = mtime; changed = true;
675 if (fresh) await autoOpen($, id);
676 await readPreview($, dir, doc);
677 } else if (doc) { S.last = doc; S.feed = null; S.feedId = null; changed = true; }
678 }
679 } else if (S.feedId) {
680 // The open build ended: its feed is the last build now.
681 const doc = feedOf(await readText($, `${dir}/${S.feedId}.json`));
682 S.last = doc ?? S.feed; S.feed = null; S.feedId = null; changed = true;
683 } else if (!S.last && S.tickN % 15 === 3) {
684 S.last = await newestFeed($, dir);
685 if (S.last) changed = true;
686 }
687 return changed;
688}
689
690async function readText($, path) {
691 try { return await $.fs.read(path); } catch { return null; }
692}
693
694async function newestFeed($, dir) {
695 let best = null;
696 try {
697 const files = (await $.fs.list(dir)).filter((f) => f.name.endsWith('.json')).sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0)).slice(0, 3);
698 for (const f of files) { const d = feedOf(await readText($, `${dir}/${f.name}`)); if (d && (!best || String(d.updatedAt) > String(best.updatedAt))) best = d; }
699 } catch { /* no feeds */ }
700 return best;
701}
702
703/**
704 * The latest check frame. @homie-rocks/studio 0.21.0 keeps a small raw RGB copy beside the feed
705 * (`<build>.preview.<w>x<h>.rgb`) for this pane; the feed itself carries the JPEG the Claude app's card shows.
706 */
707async function readPreview($, dir, doc) {
708 const at = doc.preview?.at ?? null;
709 if (!doc.preview || (S.preview && S.preview.build === doc.build && S.preview.at === at)) return;
710 let rgb = null;
711 try {
712 const f = (await $.fs.list(dir)).find((x) => x.name.startsWith(`${doc.build}.preview.`) && x.name.endsWith('.rgb'));
713 const m = f ? /\.preview\.(\d+)x(\d+)\.rgb$/.exec(f.name) : null;
714 if (m) {
715 const { base64 } = await $.fs.read(`${dir}/${f.name}`, { as: 'bytes' });
716 rgb = { w: Number(m[1]), h: Number(m[2]), data: bytesOf(base64), file: `${dir}/${f.name}` };
717 if (rgb.data.length < rgb.w * rgb.h * 3) rgb = null;
718 }
719 } catch { rgb = null; }
720 S.preview = { build: doc.build, at, rgb, jpeg: typeof doc.preview.image === 'string' ? doc.preview.image : null, memo: null };
721}
722
723function bytesOf(base64) {
724 if (typeof Uint8Array.fromBase64 === 'function') return Uint8Array.fromBase64(base64);
725 const bin = atob(base64);
726 const out = new Uint8Array(bin.length);
727 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
728 return out;
729}
730
731/** The preview as the surface draws it: Raster cells (or an Image) in the terminal, an Svg on the desktop. */
732function previewPicture(surface, columns, b) {
733 const p = S.preview;
734 if (!p || !b || p.build !== b.build) return null;
735 if (surface === 'terminal') {
736 if (!p.rgb) return null;
737 // At most 14 rows: a glance at the frame, not the whole pane.
738 let cols = Math.max(16, Math.min(columns - 2, 64));
739 let rows = Math.max(4, Math.round((cols * p.rgb.h) / p.rgb.w / 2));
740 if (rows > 14) { rows = 14; cols = Math.max(16, Math.round((rows * 2 * p.rgb.w) / p.rgb.h)); }
741 if (OPT.pictures === 'image') return { key: 'preview', image: { file: p.rgb.file, format: 'rgb', width: p.rgb.w, height: p.rgb.h, generation: Date.parse(p.at ?? '') || 0 }, columns: cols, rows };
742 if (!p.memo || p.memo.cols !== cols) p.memo = { cols, rows, cells: cellsFromRgb(p.rgb, cols, rows) };
743 return { key: 'preview', cells: p.memo.cells, columns: cols, rows };
744 }
745 if (!p.jpeg || p.jpeg.length > 120_000) return null;
746 const w = p.rgb?.w ?? 480; const h = p.rgb?.h ?? 300;
747 return { svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"><image href="${p.jpeg}" width="${w}" height="${h}"/></svg>`, width: Math.min(480, columns * 7), height: Math.round((Math.min(480, columns * 7) * h) / w) };
748}
749
750/** RGB pixels as Raster cells: '▀' per cell, the averaged colour of the pixels above (fg) and below (bg). */
751function cellsFromRgb(img, cols, rows) {
752 const nums = new Uint32Array(cols * rows * 3);
753 const avg = (x0, y0, x1, y1) => {
754 let r = 0; let g = 0; let b = 0; let n = 0;
755 for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { const i = (y * img.w + x) * 3; r += img.data[i]; g += img.data[i + 1]; b += img.data[i + 2]; n++; }
756 return n ? ((Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)) >>> 0 : 0;
757 };
758 for (let cy = 0; cy < rows; cy++) {
759 for (let cx = 0; cx < cols; cx++) {
760 const x0 = Math.floor((cx * img.w) / cols); const x1 = Math.max(x0 + 1, Math.floor(((cx + 1) * img.w) / cols));
761 const ya = Math.floor((cy * 2 * img.h) / (rows * 2)); const yb = Math.max(ya + 1, Math.floor(((cy * 2 + 1) * img.h) / (rows * 2))); const yc = Math.max(yb + 1, Math.floor(((cy * 2 + 2) * img.h) / (rows * 2)));
762 const k = (cy * cols + cx) * 3;
763 nums[k] = 0x2580; nums[k + 1] = avg(x0, ya, x1, Math.min(img.h, yb)); nums[k + 2] = avg(x0, Math.min(img.h - 1, yb), x1, Math.min(img.h, yc));
764 }
765 }
766 return new Uint8Array(nums.buffer).toBase64();
767}
768
769/** A build that starts opens the Studio pane, where the terminal is wide enough for a pane nobody asked for. */
770async function autoOpen($, id) {
771 if (!OPT.paneAutoOpen || !S.interactive || S.autoOpened.has(id) || !S.surfaces.length) return;
772 S.autoOpened.add(id);
773 S.tab = 'build';
774 try {
775 const r = await $.ui.open({ id: PANE, title: `◆ ${S.name}` });
776 if (!r.isPlaced) $.ui.toast(`${S.name}: a build started. /studio shows it (the pane waits for a wider terminal).`);
777 } catch { /* the pane is up already */ }
778}
779
780/* ------------------------------------------------------------------ rooms, office, stats */
781
782function roomList() {
783 return [...(S.rooms.live?.rooms ?? []).map((r) => ({ ...r, where: 'live', base: S.live })), ...(S.rooms.dev?.rooms ?? []).map((r) => ({ ...r, where: 'here', base: S.dev }))];
784}
785
786async function loadRooms($, { force = false } = {}) {
787 if (!S.root) return;
788 if (!force && Date.now() - S.rooms.at < 10_000) return;
789 S.busy.rooms = force ? 'reading the rooms' : null;
790 const [live, dev, games] = await Promise.all([
791 S.live ? fetchJson($, `${S.live}/api/rooms`) : null,
792 S.dev ? fetchJson($, `${S.dev}/api/rooms`, { timeoutMs: 2000 }) : null,
793 S.live ? fetchJson($, `${S.live}/api/games`) : null,
794 ]);
795 S.rooms = { live, dev, games: games?.games ?? S.rooms.games ?? null, at: Date.now() };
796 S.busy.rooms = null;
797 redraw($);
798}
799
800/**
801 * The studio's own CLI, from its pinned toolkit, with --json. The owner's actions go through it so they keep the
802 * office's rules: its key is minted with the studio's own Cloudflare login and dropped after; kick, mute, close and
803 * launch only ASK, and the owner confirms in their own browser. Nothing here can confirm an ask.
804 */
805async function studioCli($, args, { timeoutMs = 120_000 } = {}) {
806 if (!S.root || !S.toolkit) return { ok: false, why: 'the studio\'s toolkit is not installed here: npm install in the studio folder first' };
807 try {
808 // A studio that is not online yet runs its office on this computer's dev site (its local database).
809 const site = !S.live && S.dev && /^(office|stats|codex link|players)/.test(args.join(' ')) ? ['--url', S.dev] : [];
810 const r = await $.process.run(['node', `${S.root}/node_modules/@homie-rocks/studio/bin/homie-studio.mjs`, ...args, ...site, '--json'], { cwd: S.root, timeoutMs });
811 const text = String(r.stdout ?? '').trim();
812 try { return JSON.parse(text.slice(text.indexOf('{'))); } catch { return { ok: false, why: (r.stderr || text || `exited ${r.exitCode}`).trim().split('\n').slice(-2).join(' ').slice(0, 300) }; }
813 } catch (error) {
814 return { ok: false, why: `could not run homie-studio: ${String(error?.message ?? error).slice(0, 200)}` };
815 }
816}
817
818async function loadOffice($) {
819 if (S.busy.rooms) return;
820 S.busy.rooms = 'reading the back office (a 10-minute key, minted with the studio\'s own Cloudflare login, dropped after)';
821 S.why.rooms = null;
822 redraw($);
823 const r = await studioCli($, ['office']);
824 S.busy.rooms = null;
825 if (r.ok) S.office = { at: Date.now(), data: r };
826 else S.why.rooms = `The back office did not answer: ${r.why ?? r.message ?? 'no reason given'}`;
827 redraw($);
828}
829
830/** Kick, mute and unmute through the office: ASKED, never done here; the person gets the owner's one-tap link. */
831async function officeAct($, op, room, client) {
832 if (S.busy.rooms) return;
833 const seat = String(client.seat + 1);
834 const args = op === 'kick' ? ['office', 'kick', room.game, room.room, seat] : ['office', 'mute', room.game, room.room, seat, ...(op === 'unmute' ? ['--off'] : [])];
835 S.busy.rooms = `${op === 'kick' ? 'asking to kick' : op === 'mute' ? 'asking to mute' : 'asking to unmute'} ${client.name}`;
836 redraw($);
837 const r = await studioCli($, args);
838 S.busy.rooms = null;
839 noteAsk($, r, `${op} ${client.name} in ${room.name} · ${room.label}`);
840 void loadOffice($);
841}
842
843function noteAsk($, r, fallback) {
844 if (!r.ok) { S.why.rooms = `Not asked: ${r.why ?? r.message ?? 'no reason given'}`; redraw($); return; }
845 if (r.asked) {
846 S.asks.push({ what: r.what ?? fallback, link: r.link ?? null, at: Date.now() });
847 S.asks = S.asks.slice(-8);
848 $.ui.toast(`Waiting for your tap: ${r.what ?? fallback}. The link is in the Studio pane (Rooms).`, { timeoutMs: 8000 });
849 } else $.ui.toast(r.message ?? 'Done.');
850 redraw($);
851}
852
853async function announce($, room, text) {
854 const line = String(text ?? '').trim();
855 if (!line) return;
856 S.busy.rooms = 'announcing';
857 redraw($);
858 const r = await studioCli($, ['office', 'announce', line, '--game', room.game, '--room', room.room]);
859 S.busy.rooms = null;
860 if (r.ok) $.ui.toast(`Announced in ${room.name} · ${room.label}${r.people !== undefined ? ` to ${r.people} ${r.people === 1 ? 'person' : 'people'}` : ''}.`);
861 else S.why.rooms = `Not announced: ${r.why ?? r.message}`;
862 redraw($);
863}
864
865async function launchState($, game, launch) {
866 S.busy.rooms = 'asking the office';
867 redraw($);
868 const r = await studioCli($, ['office', 'launch', game.id, launch]);
869 S.busy.rooms = null;
870 noteAsk($, r, `${game.name}: launch ${launch}`);
871}
872
873async function loadStats($) {
874 if (S.busy.stats) return;
875 S.busy.stats = 'reading the studio\'s numbers (a 10-minute key, dropped after)';
876 S.why.stats = null;
877 redraw($);
878 const r = await studioCli($, ['stats', '--range', '7d']);
879 S.busy.stats = null;
880 if (r.ok !== false && r.totals) S.stats = { at: Date.now(), data: r };
881 else S.why.stats = `No numbers: ${r.why ?? r.message ?? 'the site did not answer'}`;
882 redraw($);
883}
884
885async function readCodexes($) {
886 if (!S.root) return;
887 const out = [];
888 for (const g of S.games.filter((x) => x.hasCodex)) {
889 const text = await readText($, `${S.root}/games/${g.id}/CODEX.md`);
890 if (text) out.push({ id: g.id, ...summarizeCodex(text) });
891 }
892 S.codexes = out;
893 redraw($);
894}
895
896/**
897 * The Game Lab's files (studio 0.20.0 and later): .studio/lab/server.json says which port its page is on (it counts
898 * as running only when its /_lab/health answers), and .studio/lab/<game>/latest.json holds each game's last lab check.
899 */
900async function readLab($) {
901 if (!S.root) return;
902 const dir = `${S.root}/.studio/lab`;
903 const server = await readJsonFile($, `${dir}/server.json`);
904 let url = null;
905 if (Number.isInteger(server?.port) && server.port > 0 && server.port < 65536) {
906 const base = `http://127.0.0.1:${server.port}`;
907 if ((await fetchJson($, `${base}/_lab/health`, { timeoutMs: 1500 }))?.ok === true) url = base;
908 }
909 let names = [];
910 try { names = (await $.fs.list(dir)).filter((d) => d.kind !== 'file' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(d.name)).map((d) => d.name); } catch { names = []; }
911 const checks = [];
912 for (const id of names.slice(0, 16)) {
913 const c = labCheckOf(await readJsonFile($, `${dir}/${id}/latest.json`), id);
914 if (c) checks.push(c);
915 }
916 S.lab = { url, checks: checks.sort((a, b) => String(b.at).localeCompare(String(a.at))) };
917 redraw($);
918}
919
920/** One latest.json as the Lab tab shows it, its fields checked (a file the mod did not write). */
921function labCheckOf(l, id) {
922 if (!l || l.v !== 1 || typeof l.at !== 'string') return null;
923 const num = (v) => (Number.isFinite(v) ? v : null);
924 const str = (v, n = 120) => (typeof v === 'string' ? v.slice(0, n) : null);
925 const phases = (ps) => (Array.isArray(ps) ? ps.slice(0, 12).filter((p) => typeof p?.name === 'string' && Number.isFinite(p.from) && Number.isFinite(p.to)).map((p) => ({ name: p.name.slice(0, 24), from: p.from, to: p.to })) : null);
926 const mean = (c) => num(c?.mean);
927 const drift = (v) => (Number.isInteger(v) ? v : null);
928 return {
929 game: id, at: l.at, take: str(l.take, 40), frames: num(l.frames), fps: num(l.fps), device: str(l.device, 16),
930 today: str(l.today, 16), report: str(l.report, 200),
931 timeline: { new: phases(l.timeline?.new) ?? [], today: phases(l.timeline?.today) },
932 deterministic: { new: drift(l.deterministic?.new), today: drift(l.deterministic?.today) },
933 cost: { new: mean(l.cost?.new), today: mean(l.cost?.today) },
934 };
935}
936
937/**
938 * Art direction (the style and models skills): .studio/art/<game>/latest.json, which the studio toolkit writes after
939 * every `style` and `assets` command. Read with every field checked (lib/art.mjs); newest first.
940 */
941async function readArt($) {
942 if (!S.root) return;
943 const dir = `${S.root}/.studio/art`;
944 let names = [];
945 try { names = (await $.fs.list(dir)).filter((d) => d.kind !== 'file' && GAME_ID.test(d.name)).map((d) => d.name); } catch { names = []; }
946 const out = [];
947 for (const id of names.slice(0, 16)) {
948 const a = artSummaryOf(await readJsonFile($, `${dir}/${id}/latest.json`), id);
949 if (a) out.push(a);
950 }
951 S.art = out.sort((a, b) => String(b.at).localeCompare(String(a.at)));
952 redraw($);
953}
954
955function gameName(id) { return S.games.find((g) => g.id === id)?.name ?? id; }
956
957/** Lock, from the Art tab: the person's press, recorded as their words. Only the person locks. */
958async function artLock($, game, d) {
959 if (S.busy.art) return;
960 S.busy.art = `locking ${d.name}`;
961 S.why.art = null;
962 redraw($);
963 try {
964 const r = await studioCli($, ['style', 'lock', game, d.id, '--words', 'pressed Lock in the Studio pane']);
965 if (r.ok) $.ui.toast(r.locked?.length ? `Locked: ${d.name} (${d.label}).` : `${d.name} was locked already.`);
966 else S.why.art = `Not locked: ${r.why ?? r.message ?? 'no reason given'}`;
967 } finally { S.busy.art = null; }
968 await readArt($);
969}
970
971/**
972 * Unlock, from the Art tab: asked first, with the blast radius (`style blast`: the assets that go stale, what remaking
973 * them costs, what is free), and only on Proceed run with the person's press as the reason.
974 */
975async function artUnlock($, game, d) {
976 if (S.busy.art) return;
977 S.busy.art = `reading what unlocking ${d.name} would make stale`;
978 S.why.art = null;
979 redraw($);
980 try {
981 const r = await studioCli($, ['style', 'blast', game, d.id]);
982 const b = r.ok && r.blast && typeof r.blast === 'object' ? r.blast : null;
983 if (!b || !Array.isArray(b.assets)) {
984 S.why.art = `Not unlocked: the blast radius could not be read (${r.why ?? r.message ?? 'no answer'}), and nothing is unlocked without it.`;
985 return;
986 }
987 const assets = b.assets.filter((a) => a && typeof a === 'object').slice(0, 40);
988 const paid = assets.filter((a) => !a.free && Number(a.remake?.usd) > 0);
989 const cost = Number(b.totals?.usd) || 0;
990 S.busy.art = `waiting for your answer: unlock ${d.name}?`;
991 redraw($);
992 const answer = await ask($, {
993 question: `Unlock ${d.name} in ${gameName(game)}?`,
994 title: `Unlock ${d.name}`,
995 lines: [
996 { k: 'Now', v: String(b.from ?? d.label) },
997 { k: 'Stale', v: assets.length ? `${assets.length} asset${assets.length === 1 ? '' : 's'} if it changes` : 'nothing made under it', ...(assets.length ? { style: { color: 'yellow' } } : {}) },
998 { k: 'Remake', v: cost ? `about ${usd(cost)} (${paid.length} paid)` : 'free', ...(cost ? { style: { color: 'yellow', bold: true } } : {}) },
999 ...(Number(b.totals?.free) > 0 ? [{ k: 'Free', v: `${Number(b.totals.free)} by a re-tint or the library` }] : []),
1000 ],
1001 detail: {
1002 lines: [
1003 { k: 'Decision', v: `${d.id} (${d.name}), locked by the person: ${b.from ?? d.label}` },
1004 ...(Array.isArray(b.decisions) && b.decisions.length ? [{ k: 'Also moves', v: b.decisions.map(String).join(', ') }] : []),
1005 ...assets.map((a) => ({ k: String(a.id), v: `${a.kind ?? 'asset'}, ${a.route ?? '?'}: ${a.free ?? `${a.remake?.how ?? 'remade'}${Number(a.remake?.usd) > 0 ? `, about ${usd(a.remake.usd)}` : ''}`}` })),
1006 { k: 'Remaking all', v: cost ? `about ${usd(cost)}, under the game's art budget` : 'costs nothing', style: { bold: true } },
1007 String(b.note ?? 'Nothing is remade by itself.'),
1008 'Proceed unlocks it (your press is recorded as the reason). It stays as it is until you or Claude change it.',
1009 ],
1010 },
1011 });
1012 if (answer !== 'Proceed') { $.ui.toast(`${d.name} stays locked.`); return; }
1013 S.busy.art = `unlocking ${d.name}`;
1014 redraw($);
1015 const u = await studioCli($, ['style', 'unlock', game, d.id, '--reason', 'pressed Unlock in the Studio pane']);
1016 if (u.ok) $.ui.toast(u.already ? `${d.name} was not locked.` : `Unlocked ${d.name} (now steered: Claude may change it with you).`);
1017 else S.why.art = `Not unlocked: ${u.why ?? u.message ?? 'no reason given'}`;
1018 } finally {
1019 S.busy.art = null;
1020 await readArt($);
1021 }
1022}
1023
1024/** `/lock <decision> [game]`: the person typed it, so it is their word. */
1025async function lockCommand($, args) {
1026 const [did, want] = args.split(/\s+/).filter(Boolean);
1027 if (!did || !/^[a-z]+(?:\.[a-z0-9-]{1,40}){0,2}$/.test(did)) return 'Usage: /lock <decision> [game], for example /lock style.palette (or /lock style for the whole style phase). /look lists the decisions.';
1028 if (want && !GAME_ID.test(want)) return `"${want.slice(0, 40)}" is not a game id.`;
1029 await readArt($);
1030 const picked = artFor(S.art, want);
1031 if (!picked.list) return picked.why;
1032 if (picked.list.length > 1) return `Several games have art direction (${picked.list.map((a) => a.game).join(', ')}): /lock ${did} <game>.`;
1033 const game = picked.list[0].game;
1034 const r = await studioCli($, ['style', 'lock', game, did, '--words', `/lock ${did}`]);
1035 await readArt($);
1036 if (!r.ok) return `Not locked: ${r.why ?? r.message ?? 'no reason given'}`;
1037 const all = (S.art.find((a) => a.game === game)?.decisions ?? []).flatMap((p) => p.rows);
1038 const words = (id) => { const x = all.find((y) => y.id === id); return x ? `${x.name} (${x.label})` : id; };
1039 return [
1040 r.locked?.length ? `Locked in ${gameName(game)}: ${r.locked.map(words).join('; ')}.` : `Nothing new to lock in ${gameName(game)}.`,
1041 ...(r.already?.length ? [` already locked: ${r.already.join(', ')}`] : []),
1042 ' Claude cannot change a locked decision; unlocking is asked for in the Studio pane (/look, then Unlock), with what goes stale.',
1043 ].join('\n');
1044}
1045
1046async function codexLink($, c) {
1047 S.busy.codex = `a private link to ${c.title ?? c.id}`;
1048 redraw($);
1049 const r = await studioCli($, ['codex', 'link', c.id]);
1050 S.busy.codex = null;
1051 if (r.ok && r.link) S.codexLinks[c.id] = r.link;
1052 else $.ui.toast(`No link: ${r.why ?? r.message ?? 'the site did not answer'}`);
1053 redraw($);
1054}
1055
1056async function stopBuild($) {
1057 const r = await studioCli($, ['progress', 'stop']);
1058 $.ui.toast(r.ok === false ? `Could not ask the build to stop: ${r.why}` : 'The build stops at its next safe point.');
1059 void tick($, { force: true });
1060}
1061
1062/* ------------------------------------------------------------------ the timer */
1063
1064async function tick($, { force = false } = {}) {
1065 if (S.ticking) return;
1066 S.ticking = true;
1067 let changed = false;
1068 try {
1069 S.tickN++;
1070 if (!S.root || S.tickN % 5 === 0) { const before = S.root; await findStudio($); if (S.root !== before) { changed = true; if (S.root) await readStudio($); } }
1071 if (S.root) {
1072 if (S.tickN % 15 === 1 || force) { await readStudio($); changed = true; }
1073 if (await readFeed($)) changed = true;
1074 if (S.tickN % 15 === 2 || (force && Date.now() - S.rooms.at > 5000)) { await loadRooms($); changed = true; }
1075 if (S.tab === 'codex' && S.tickN % 15 === 4) await readCodexes($);
1076 if (S.tab === 'lab' && S.tickN % 5 === 3) await readLab($);
1077 if (S.tab === 'art' && S.tickN % 5 === 3) await readArt($);
1078 }
1079 if (await readParts($)) changed = true;
1080 await keepBridges($);
1081 } catch (error) {
1082 $.ui.log(`homie tick: ${String(error?.message ?? error).slice(0, 200)}`, { to: 'debug' });
1083 } finally { S.ticking = false; }
1084 if (changed) redraw($);
1085}
1086
1087/** Ask for a redraw only when what is drawn could have changed. */
1088function redraw($) { $.ui.invalidate('ui.render'); }
1089
1090async function openPane($, id, title, extra = {}) {
1091 if (!S.surfaces.length) { try { S.surfaces = [...(await $.session.surfaces())]; } catch { S.surfaces = []; } }
1092 if (!S.surfaces.length) return false;
1093 try {
1094 const r = await $.ui.open({ id, title, focus: true, closeOnEscape: true, ...extra });
1095 redraw($);
1096 return r.isPlaced !== false;
1097 } catch { return false; }
1098}
1099
1100/* ------------------------------------------------------------------ what each tool call was */
1101
1102function callOf(command) {
1103 const c = studioCalls(command)[0];
1104 if (c) return { homie: true, sub: c.sub, label: LABELS[c.sub] ?? `homie-studio ${c.sub}` };
1105 const m = /(?:^|\/)(playtest|art|music|video|sound|perf)\.mjs\s+(\w+)/.exec(String(command ?? ''));
1106 if (m && /skills\//.test(String(command))) return { homie: true, sub: `${m[1]} ${m[2]}`, playtest: m[1] === 'playtest', label: `${m[1][0].toUpperCase()}${m[1].slice(1)} · ${m[2]}` };
1107 return null;
1108}
1109
1110function noteCall(e) {
1111 try {
1112 if (e.tool === 'Bash') {
1113 const c = callOf(e.command);
1114 if (c) { S.calls.set(e.tool_use_id, c); if (S.calls.size > 300) S.calls.delete(S.calls.keys().next().value); }
1115 }
1116 if (e.agentId) {
1117 const l = S.parts.get(e.agentId) ?? { id: e.agentId, description: '', type: '', status: 'running', startedAt: Date.now(), endedAt: null, tools: 0, files: new Set(), last: '' };
1118 l.tools++;
1119 const target = e.file_path ?? e.notebook_path ?? e.path ?? e.pattern ?? e.command ?? e.url ?? '';
1120 const rel = S.root && typeof target === 'string' ? target.replace(`${S.root}/`, '') : String(target);
1121 l.last = `${e.tool}${rel ? ` ${String(rel).replace(/\s+/g, ' ').slice(0, 90)}` : ''}`;
1122 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(e.tool) && typeof target === 'string') l.files.add(rel);
1123 l.lastAt = Date.now();
1124 S.parts.set(e.agentId, l);
1125 }
1126 } catch { /* tracking never stops a call */ }
1127}
1128
1129function afterCall($, e, result) {
1130 try {
1131 if (e.tool === 'Bash') {
1132 const c = S.calls.get(e.tool_use_id);
1133 if (c?.homie && /^(progress|check|port check|build|deploy|dev|game|codex|office|stats)/.test(c.sub)) void tick($, { force: true });
1134 // A style or assets command rewrites .studio/art/<game>/latest.json: the open Art tab shows it now, not in 10 s.
1135 if (c?.homie && /^(style|assets)\b/.test(c.sub) && S.tab === 'art') void readArt($);
1136 }
1137 if (e.tool === 'Agent' || e.tool === 'Task') void readParts($, { force: true });
1138 } catch { /* never */ }
1139}
1140
1141function redactResult(result) {
1142 if (!result || result.deny || result.result === undefined) {
1143 if (result?.isError && typeof result.text === 'string') {
1144 const r = redact(result.text);
1145 if (r.hits.length) { keepLinks(r.links); return { deny: r.value }; }
1146 }
1147 return result;
1148 }
1149 const r = redact(result.result);
1150 if (!r.hits.length) return result;
1151 keepLinks(r.links);
1152 const note = `The Homie mod took ${r.hits.length === 1 ? 'a secret' : 'secrets'} (${r.hits.join(', ')}) out of this output before you read it.${r.links.length ? ' A one-time owner link is in the person\'s Homie Studio pane (Rooms): tell them it is there; you never see it.' : ''} In this app, the studio's own commands (npx --no-install homie-studio office …, stats, agents sit) use their keys themselves.`;
1153 return { result: r.value, context: [...(result.context ?? []), note] };
1154}
1155
1156function keepLinks(links) {
1157 for (const link of links) S.forYou.push({ link, at: Date.now() });
1158 S.forYou = S.forYou.slice(-6);
1159}
1160
1161/* ------------------------------------------------------------------ Tell Homie (lib/feedback.mjs) */
1162
1163/** What a note carries from here: the studio's pinned toolkit, this plugin's version, the app, and where it goes. */
1164async function noteFacts($) {
1165 let studioVersion = null;
1166 if (S.root) {
1167 const pkg = await readJsonFile($, `${S.root}/package.json`);
1168 const spec = pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio'];
1169 studioVersion = /(\d+\.\d+\.\d+)/.exec(String(spec ?? ''))?.[1] ?? (typeof S.studio?.homie?.studio === 'string' ? S.studio.homie.studio : null);
1170 }
1171 let pluginVersion = null;
1172 for (const f of [`${$.plugin.root}/.claude-plugin/plugin.json`, `${$.plugin.root}/plugin.json`]) {
1173 const v = (await readJsonFile($, f))?.version;
1174 if (typeof v === 'string') { pluginVersion = v; break; }
1175 }
1176 const named = String(S.studio?.homie?.directory ?? '').replace(/\/+$/, '');
1177 const directory = named && allowedUrl(`${named}/`) ? named : 'https://homie.rocks';
1178 return { studioVersion, pluginVersion, app: 'claude-code', directory };
1179}
1180
1181/** The kind a person's own words most likely are (they can change it in the pane). */
1182function guessKind(words) {
1183 const w = String(words ?? '').toLowerCase();
1184 if (/\b(bug|broke|broken|crash|error|fails?|failed|wrong)\b/.test(w)) return 'bug';
1185 if (/\b(stuck|can.?t|cannot|won.?t|blocked)\b/.test(w)) return 'stuck';
1186 if (/\b(confus\w*|unclear|don.?t understand|did ?n.?o?t (?:know|understand)|no idea|not sure what|makes? no sense|lost|what does)\b/.test(w)) return 'confusing';
1187 if (/\b(love|great|thanks?|thank you|awesome|nice|lovely|amazing)\b/.test(w)) return 'praise';
1188 return 'idea';
1189}
1190
1191/** The Homie step this session ran last (a command in words), within ten minutes: what a note is about. */
1192function lastStep() {
1193 const recent = [...S.calls.values()].filter((c) => c.homie && c.label).pop();
1194 return recent ? recent.label : null;
1195}
1196
1197/** A note in the Tell Homie pane: drafted from the person's words through the same rules as everywhere. */
1198async function tellDraft($, { kind, text, email = null, step }) {
1199 // While a Send is on its way the note stays as it is: what is on screen is what was sent.
1200 if (S.tell.busy) return false;hooks/lib/art.mjs 265 lines1/**
2 * Art direction, as the mod reads it. The studio toolkit writes `.studio/art/<game>/latest.json` after every
3 * `homie-studio style …` and `assets …` command (lib/art-cli.mjs `writeArtSummary`): the phase strip, the look line,
4 * the decisions, the cast, the scene budgets, the spend and the licence problems in one small file. The mod did not
5 * write it, so every field is checked here before anything is drawn. Also the plain parts of the art guards: which
6 * locked decisions an edit to `decisions.json` would change, and what a public game ships without an allowed
7 * licence. Plain functions: no `$`, no I/O.
8 */
9import { ago } from './feed.mjs';
10
11export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
12const DECISION_ID = /^[a-z]+(?:\.[a-z0-9-]{1,40}){1,2}$/;
13const HEX = /^#[0-9a-fA-F]{6}$/;
14const STATES = ['auto', 'steered', 'pinned', 'locked'];
15const PHASES = ['style', 'cast', 'rigs', 'animations', 'game'];
16const ROUTES = ['procedural', 'library', 'generated', 'imported', 'premium'];
17
18/** The state marks the toolkit prints too (art-cli.mjs `artLines`). */
19export const MARK = { auto: '·', steered: '~', pinned: '●', locked: '■' };
20export const LEGEND = '· auto ~ steered ● pinned by use ■ locked by the person';
21export const BY = { ai: 'AI', person: 'the person', use: 'by use' };
22
23// A string from the file without terminal escapes or control characters (a label would draw them), cut to `n`.
24const str = (v, n = 120) => (typeof v === 'string' ? v.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim().slice(0, n) || null : null);
25const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
26const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
27const list = (v, n) => (Array.isArray(v) ? v.slice(0, n) : []);
28// A studio-relative path to show (never followed): no absolute path, no "..".
29const relPath = (v) => { const s = str(v, 200); return s && !s.startsWith('/') && !s.split('/').includes('..') ? s : null; };
30const pick = (o, keys) => Object.fromEntries(keys.map((k) => [k, num(o?.[k]) !== null && o[k] >= 0 ? o[k] : null]));
31
32const TOTALS = ['assets', 'triangles', 'drawCalls', 'textureMB', 'firstPlayMB'];
33const BUDGETS = ['drawCalls', 'triangles', 'textureMB', 'firstPlayMB'];
34
35/** One latest.json as the Art tab shows it, every field checked; null when it is not one (it is then ignored). */
36export function artSummaryOf(l, id) {
37 if (!l || typeof l !== 'object' || l.v !== 1 || typeof l.at !== 'string' || !Number.isFinite(Date.parse(l.at)) || !GAME_ID.test(String(id))) return null;
38 if (l.game !== undefined && l.game !== id) return null;
39 const phases = list(l.phases, 8).flatMap((p) => {
40 const total = count(p?.total);
41 const settled = count(p?.settled);
42 if (!PHASES.includes(p?.id) || total === null || settled === null || settled > total) return [];
43 return [{ id: p.id, label: str(p.label, 24) ?? p.id, total, settled, locked: Math.min(count(p.locked) ?? 0, total) }];
44 });
45 const decisions = list(l.decisions, 8).flatMap((g) => {
46 if (!PHASES.includes(g?.phase)) return [];
47 const rows = list(g.rows, 40).flatMap((r) => {
48 if (typeof r?.id !== 'string' || !DECISION_ID.test(r.id) || !STATES.includes(r.state)) return [];
49 const colours = Array.isArray(r.colours) ? r.colours.filter((c) => typeof c === 'string' && HEX.test(c)).slice(0, 8) : null;
50 return [{ id: r.id, name: str(r.name, 32) ?? r.id.split('.').pop(), label: str(r.label, 100) ?? '', state: r.state, by: Object.hasOwn(BY, r.by) ? r.by : null, colours: colours?.length ? colours : null }];
51 });
52 return [{ phase: g.phase, label: str(g.label, 24) ?? g.phase, rows }];
53 });
54 const cast = list(l.cast, 80).flatMap((a) => {
55 if (typeof a?.id !== 'string' || !GAME_ID.test(a.id)) return [];
56 return [{ id: a.id, kind: str(a.kind, 16) ?? 'asset', route: ROUTES.includes(a.route) ? a.route : 'unknown', tier: str(a.tier, 16), license: str(a.license, 48), state: str(a.state, 16) ?? 'auto', usd: Math.max(0, num(a.usd) ?? 0) }];
57 });
58 const licence = list(l.licence, 40).flatMap((x) => {
59 const problem = str(x?.problem, 200);
60 if (!problem || !['refuse', 'warn'].includes(x.level)) return [];
61 return [{ asset: str(x.asset, 120) ?? 'an asset', level: x.level, problem, fix: str(x.fix, 200) }];
62 });
63 const sp = l.spend && typeof l.spend === 'object' ? l.spend : {};
64 const spend = {
65 used: Math.max(0, num(sp.used) ?? 0),
66 cap: num(sp.cap) !== null && sp.cap >= 0 ? sp.cap : null,
67 items: list(sp.items, 50).flatMap((x) => (str(x?.what, 80) && num(x.usd) !== null ? [{ what: str(x.what, 80), usd: Math.max(0, x.usd) }] : [])),
68 };
69 const c = l.check;
70 const check = c && typeof c === 'object' && typeof c.ok === 'boolean'
71 ? { ok: c.ok, at: str(c.at, 40), totals: pick(c.totals, TOTALS), budgets: pick(c.budgets, BUDGETS), failing: list(c.failing, 40).filter((f) => typeof f === 'string' && GAME_ID.test(f)) }
72 : null;
73 const u = l.lineup;
74 const lineup = u && typeof u === 'object' && typeof u.at === 'string'
75 ? { at: u.at, flagged: count(u.flagged) ?? 0, images: { front: relPath(u.images?.front), quarter: relPath(u.images?.quarter), silhouettes: relPath(u.images?.silhouettes) } }
76 : null;
77 const b = l.board;
78 const directions = b && typeof b === 'object' ? list(b.directions, 6).flatMap((d) => (/^[a-f]$/.test(d?.id) && str(d.label, 100) ? [{ id: d.id, label: str(d.label, 100) }] : [])) : [];
79 const board = directions.length ? { chosen: /^[a-f]$/.test(b.chosen) ? b.chosen : null, directions } : null;
80 const VERB = /^[a-z][a-z0-9]{1,15}$/;
81 const verbs = (v, n = 24) => list(v, n).filter((x) => typeof x === 'string' && VERB.test(x));
82 const characters = list(l.characters, 40).flatMap((c) => {
83 if (typeof c?.id !== 'string' || !GAME_ID.test(c.id)) return [];
84 return [{ id: c.id, kind: str(c.kind, 16) ?? 'character', route: ROUTES.includes(c.route) ? c.route : 'unknown', family: str(c.family, 16), skeleton: str(c.skeleton, 40), bones: count(c.bones), verbs: verbs(c.verbs), missing: verbs(c.missing), retargeted: count(c.retargeted) ?? 0, animsKB: count(c.animsKB) }];
85 });
86 const k = l.skinning;
87 const skinning = k && typeof k === 'object' && count(k.vertices) !== null ? { players: count(k.players) ?? 0, vertices: k.vertices, bones: count(k.bones) ?? 0, budget: { vertices: count(k.budget?.vertices), bones: count(k.budget?.bones) } } : null;
88 return {
89 game: id, at: l.at, path: ['automatic', 'hands-on'].includes(l.path) ? l.path : null, line: str(l.line, 240),
90 phases, decisions, cast, stale: list(l.stale, 80).filter((s) => typeof s === 'string' && GAME_ID.test(s)),
91 licence, spend, check, lineup, board, need: verbs(l.need), characters, skinning,
92 };
93}
94
95/** The phase strip: "Style ✓ → Cast 3/7 → Rigs → Animations → In game" (✓ once every decision of a phase is settled). */
96export function phaseStrip(phases) {
97 return phases.map((p) => (p.total && p.settled === p.total ? `${p.label} ✓` : p.settled ? `${p.label} ${p.settled}/${p.total}` : p.label)).join(' → ');
98}
99
100export const usd = (n) => `$${Number(n).toFixed(2)}`;
101
102/** The games a command means: the one named, else every game with art direction; or why there is none. */
103export function artFor(arts, want) {
104 const w = String(want ?? '').trim().toLowerCase();
105 if (!arts.length) return { why: 'No art direction yet: ask Claude for a look ("make it cozy and low-poly"); the style skill decides it with you (homie-studio style init <game>).' };
106 if (!w) return { list: arts };
107 const hit = arts.filter((a) => a.game === w);
108 return hit.length ? { list: hit } : { why: `${w} has no art direction yet (games with one: ${arts.map((a) => a.game).join(', ')}).` };
109}
110
111const title = (a, name) => (name && name !== a.game ? `${name} (${a.game})` : a.game);
112
113/** `/look`: the look line, the phase strip and the style phase's decisions, as text. */
114export function lookText(a, name, now) {
115 const style = a.decisions.find((d) => d.phase === 'style');
116 const rest = a.decisions.filter((d) => d.phase !== 'style' && d.rows.length);
117 const more = rest.reduce((n, d) => n + d.rows.length, 0);
118 const width = Math.max(8, ...(style?.rows ?? []).map((r) => r.name.length));
119 return [
120 `${title(a, name)}: ${a.line ?? 'no look line yet'}`,
121 ` ${phaseStrip(a.phases)}${a.path ? ` · ${a.path}` : ''} · ${ago(a.at, now)}`,
122 ...(style ? [` ${style.label}:`, ...style.rows.map((r) => ` ${MARK[r.state]} ${r.name.padEnd(width)} ${r.label}${r.state !== 'auto' && r.by ? ` (${r.state}, ${BY[r.by]})` : ''}`)] : [' no style decisions yet']),
123 ...(more ? [` + ${more} more decision${more === 1 ? '' : 's'} in ${rest.map((d) => d.label).join(', ')}: npx --no-install homie-studio style ${a.game}`] : []),
124 ` ${LEGEND}. /lock <decision> locks one; Unlock in the Studio pane (Art) asks first, with what goes stale.`,
125 ].join('\n');
126}
127
128/** `/assets`: the cast, the spend and the licence problems, as text. */
129export function castText(a, name) {
130 const stale = new Set(a.stale);
131 const width = Math.max(6, ...a.cast.map((c) => c.id.length));
132 const refuse = a.licence.filter((x) => x.level === 'refuse');
133 return [
134 `${title(a, name)}: ${a.cast.length ? `${a.cast.length} asset${a.cast.length === 1 ? '' : 's'}` : 'no assets yet (homie-studio assets find "<words>" searches the free library)'}`,
135 ...a.cast.map((c) => ` ${c.id.padEnd(width)} ${[c.kind, c.route, c.license ?? 'NO LICENCE', c.state, c.usd ? usd(c.usd) : 'free'].join(' · ')}${stale.has(c.id) ? ' STALE (made under an older decision)' : ''}`),
136 ` spent ${usd(a.spend.used)}${a.spend.cap !== null ? ` of ${usd(a.spend.cap)}` : ' (no art budget: free routes only)'}${a.spend.items.length ? ` on ${a.spend.items.length} paid step${a.spend.items.length === 1 ? '' : 's'}` : ''}`,
137 ...(a.check ? [` scene: ${budgetWords(a.check)}`] : []),
138 ...(a.licence.length ? [` licences: ${refuse.length ? `${refuse.length} to fix before a public deploy` : 'warnings only'} (/rights ${a.game})`] : a.cast.length ? [' licences: every asset recorded and allowed'] : []),
139 ].join('\n');
140}
141
142/** `/cast`: the characters, each with its skeleton, bones, source and how many of the game's clips it has. */
143export function charactersText(a, name) {
144 if (!a.characters.length) return `${title(a, name)}: no characters with a rig yet (homie-studio assets find "<words>" --kind character: free, animated, CC0).`;
145 const width = Math.max(6, ...a.characters.map((c) => c.id.length));
146 return [
147 `${title(a, name)}: ${a.characters.length} character${a.characters.length === 1 ? '' : 's'}${a.need.length ? `; the game needs ${a.need.join(', ')}` : ''}`,
148 ...a.characters.map((c) => ` ${c.id.padEnd(width)} ${[c.kind, c.route, `${c.family ?? '?'} skeleton`, `${c.bones ?? '?'} bones`, `${c.verbs.length} clips${c.retargeted ? ` (${c.retargeted} retargeted)` : ''}`].join(' · ')}${c.missing.length ? ` MISSING ${c.missing.join(', ')}` : ''}`),
149 ...(a.skinning ? [` skinning a room of ${a.skinning.players}: ${fmt(a.skinning.vertices)}/${fmt(a.skinning.budget.vertices ?? 0)} vertices, ${fmt(a.skinning.bones)}/${fmt(a.skinning.budget.bones ?? 0)} bones a frame on a phone${a.skinning.budget.vertices !== null && a.skinning.vertices > a.skinning.budget.vertices ? ' (OVER: crowd mode, or lighter characters)' : ''}`] : []),
150 ' /clips <game> lists each one\'s clips; ask Claude for the animation card to see them move.',
151 ].join('\n');
152}
153
154/** `/clips`: every character's clips against the verbs the game needs. */
155export function clipsText(a, name) {
156 if (!a.characters.length) return `${title(a, name)}: no characters with clips yet.`;
157 return [
158 `${title(a, name)}: the game needs ${a.need.length ? a.need.join(', ') : '(no anim.clips decision yet)'}`,
159 ...a.characters.flatMap((c) => [` ${c.id} (${c.skeleton ?? c.family ?? '?'}${c.animsKB !== null ? `, ${c.animsKB} KB of clips` : ''})`, ` ${c.verbs.join(', ') || 'no clips'}${c.missing.length ? ` · missing ${c.missing.join(', ')} (homie-studio anim add ${a.game} ${c.id} --verbs ${c.missing.join(',')})` : ''}`]),
160 ].join('\n');
161}
162
163/** The scene's totals against its budgets in one line: "8/100 draw calls, 898/150,000 triangles, …". */
164export function budgetWords(check) {
165 return budgetRows(check).map((b) => `${b.value === null ? '?' : fmt(b.value)}/${b.budget === null ? '?' : fmt(b.budget)}${b.unit ? ` ${b.unit}` : ''} ${b.label}${b.over ? ' (OVER)' : ''}`).join(', ');
166}
167
168const fmt = (n) => (Number.isInteger(n) ? n.toLocaleString('en-US') : String(+n.toFixed(1)));
169
170/**
171 * The four scene budgets as rows for bars: draw calls, triangles, picture MB, and the shipped payload (every built file
172 * gzipped; the check's key for it is still `firstPlayMB`, its old name, and it never was a measured first-play
173 * download). All four are the asset check's inventory estimate, not a reading of the running game.
174 */
175export function budgetRows(check) {
176 return [['drawCalls', 'draw calls', ''], ['triangles', 'triangles', ''], ['textureMB', 'picture memory', 'MB'], ['firstPlayMB', 'shipped payload', 'MB']].map(([k, label, unit]) => {
177 const value = check.totals[k];
178 const budget = check.budgets[k];
179 return { key: k, label, unit, value, budget, over: value !== null && budget !== null && value > budget, percent: value !== null && budget ? Math.min(100, (value / budget) * 100) : 0 };
180 });
181}
182
183/** `/lineup`: the last lineup's flags and its pictures (never rendered here), or how to get one. */
184export function lineupText(a, name, now) {
185 if (!a.lineup) return `${title(a, name)}: no lineup yet: ask Claude for one (every asset side by side at true scale, silhouettes, palette drift).`;
186 const pics = Object.entries(a.lineup.images).filter(([, p]) => p).map(([k, p]) => ` ${k}: ${p}`);
187 return [
188 `${title(a, name)}: last lineup ${ago(a.lineup.at, now)}: ${a.lineup.flagged ? `${a.lineup.flagged} asset${a.lineup.flagged === 1 ? '' : 's'} flagged` : 'nothing flagged'}.`,
189 ...(pics.length ? [' Pictures (open them yourself):', ...pics] : [' no pictures recorded']),
190 ].join('\n');
191}
192
193/** `/rights`: licence problems with their fixes, and where RIGHTS.md is. */
194export function rightsText(a, name, hasRights) {
195 const rights = `games/${a.game}/assets/RIGHTS.md`;
196 return [
197 `${title(a, name)}: ${a.licence.length ? `${a.licence.length} licence problem${a.licence.length === 1 ? '' : 's'}` : a.cast.length ? `every asset's licence is recorded and allowed (${a.cast.length})` : 'no assets yet'}`,
198 ...a.licence.flatMap((x) => [` ${x.level === 'refuse' ? '✗' : '!'} ${x.asset}: ${x.problem}`, ...(x.fix ? [` → ${x.fix}`] : [])]),
199 ` ${hasRights ? rights : `${rights} is not written yet: npx --no-install homie-studio assets rights ${a.game}`}`,
200 ].join('\n');
201}
202
203/* ------------------------------------------------------------------ the lock guard */
204
205/** The game whose decisions.json a studio-relative path is, or null. */
206export function decisionsFileOf(rel) {
207 const m = /^games\/([a-z0-9][a-z0-9-]{0,63})\/codex\/decisions\.json$/.exec(String(rel ?? ''));
208 return m ? m[1] : null;
209}
210
211// JSON with every object's keys sorted: the same value written in another order or spacing is the same value.
212const canonical = (v) => JSON.stringify(v, (k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((key) => [key, x[key]])) : x));
213
214/**
215 * The decisions locked in `before` (decisions.json's text now) whose value or state `after` (its text after the edit)
216 * changes or drops: [ids]. Null when something is locked and `after` is not JSON: the toolkit writes this file, and an
217 * edit that breaks it cannot be checked. Other edits (anything not locked, a label, a note) change nothing here.
218 */
219export function lockedChanges(before, after) {
220 let doc;
221 try { doc = JSON.parse(before); } catch { return []; }
222 const decisions = doc && typeof doc.decisions === 'object' && doc.decisions ? doc.decisions : {};
223 const locked = Object.keys(decisions).filter((id) => decisions[id]?.state === 'locked');
224 if (!locked.length) return [];
225 let next;
226 try { next = JSON.parse(after); } catch { return null; }
227 const now = next && typeof next.decisions === 'object' && next.decisions ? next.decisions : {};
228 return locked.filter((id) => !now[id] || now[id].state !== 'locked' || canonical(now[id].value) !== canonical(decisions[id].value));
229}
230
231/* ------------------------------------------------------------------ the licence guard */
232
233const KNOWN = new Set(['cc0', 'cc-by-4.0', 'cc-by-3.0', 'own', 'generated', 'qal', 'mixamo', 'other']);
234/**
235 * A public game: game.json `launch` is not private or invite-only. (It used to matter too whether the game's source
236 * was shared, `share.source`; no game's source is shared any more, and that key is ignored.)
237 */
238export function publicGame(meta) {
239 return Boolean(meta && typeof meta === 'object' && !['private', 'invite'].includes(meta.launch));
240}
241
242/**
243 * What a public game's assets/manifest.json ships that a deploy must not: { count, problems: [{ asset, problem }] }.
244 * Refused: no licence, a kind the studio does not know, TurboSquid's EULA (never on the web), and a CC BY asset with
245 * no attribution. A licence that forbids handing the file on (Quaternius, Mixamo, a EULA, a bought asset) is no
246 * problem here: a game serves its files to its players only, and what may be in a shared part is the toolkit's check.
247 */
248export function licenceIssues(manifest) {
249 const assets = manifest && typeof manifest === 'object' && Array.isArray(manifest.assets) ? manifest.assets : null;
250 if (!assets) return { count: 0, problems: [{ asset: 'assets/manifest.json', problem: 'it is not a manifest the toolkit wrote (no "assets" list)' }] };
251 const problems = [];
252 for (const a of assets.slice(0, 2000)) {
253 const id = str(a?.id, 64) ?? '(an asset with no id)';
254 const lic = a?.license && typeof a.license === 'object' ? a.license : {};
255 const kind = typeof lic.kind === 'string' ? lic.kind : '';
256 const eula = /^eula:[a-z0-9-]{1,40}$/.test(kind);
257 const market = /^market:[a-z0-9:._-]{1,80}$/.test(kind);
258 if (!kind) { problems.push({ asset: id, problem: 'no licence recorded' }); continue; }
259 if (kind === 'eula:turbosquid') { problems.push({ asset: id, problem: 'TurboSquid\'s licence never allows a web game to serve the file' }); continue; }
260 if (!KNOWN.has(kind) && !eula && !market) { problems.push({ asset: id, problem: `"${str(kind, 40)}" is not a licence kind the studio knows` }); continue; }
261 if (kind.startsWith('cc-by') && !str(lic.attribution, 300)) problems.push({ asset: id, problem: `${kind} needs credit, and there is no attribution line` });
262 }
263 return { count: assets.length, problems };
264}
265hooks/lib/codex.mjs 63 lines1/**
2 * A Game Codex (`games/<id>/CODEX.md`, the plan skill and @homie-rocks/studio lib/codex.mjs) at a glance, for the
3 * Studio pane: its title and pitch, which sections have something in them, the open questions, the milestones and
4 * the newest lines under Latest.
5 */
6
7const SECTIONS = [
8 ['concept', 'Concept', /^(concept|the game|pitch|overview|core loop)\b/i],
9 ['world', 'World', /^(world|setting|story|lore|zones?|maps?|levels?)\b/i],
10 ['characters', 'Characters', /^(characters|cast|heroes|classes|creatures|monsters|enemies|units|pieces)\b/i],
11 ['art', 'Art direction', /^(art|look|style|visual)/i],
12 ['controls', 'Controls', /^(controls|devices|input)\b/i],
13 ['rooms', 'Rooms and players', /^(rooms|players|multiplayer|netplay)\b/i],
14 ['sound', 'Music and sound', /^(music|sound|audio)\b/i],
15 ['milestones', 'Milestones', /^(milestones?|plan|roadmap|scope|steps)\b/i],
16 ['questions', 'Open questions', /^(open questions|questions|undecided|to decide)\b/i],
17];
18
19const item = /^\s*(?:[-*+]|\d+[.)])\s+(.*\S)\s*$/;
20
21/** { title, pitch, sections: [{ key, title, filled }], missing, questions, milestones: [{ done, text }], latest } */
22export function summarizeCodex(source) {
23 let text = String(source ?? '').replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '');
24 let meta = {};
25 const fm = /^---\n([\s\S]*?)\n---\n?/.exec(text);
26 if (fm) {
27 for (const l of fm[1].split('\n')) { const m = /^([A-Za-z][\w-]*):\s*(.+)$/.exec(l); if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g, '').trim(); }
28 text = text.slice(fm[0].length);
29 }
30 let title = null;
31 const pitch = [];
32 const sections = [];
33 let fence = false;
34 for (const line of text.split('\n')) {
35 if (/^(```|~~~)/.test(line)) fence = !fence;
36 const h1 = !fence && /^#\s+(.+?)\s*#*\s*$/.exec(line);
37 const h2 = !fence && /^##\s+(.+?)\s*#*\s*$/.exec(line);
38 if (h1 && title === null && !sections.length) { title = h1[1]; continue; }
39 if (h2) { sections.push({ title: h2[1].trim(), body: [] }); continue; }
40 if (sections.length) sections[sections.length - 1].body.push(line);
41 else pitch.push(line);
42 }
43 for (const s of sections) {
44 s.key = SECTIONS.find(([, , re]) => re.test(s.title))?.[0] ?? (/^latest|^decisions|^changelog|^news/i.test(s.title) ? 'latest' : null);
45 s.filled = Boolean(s.body.join('\n').trim());
46 }
47 const has = (key) => sections.some((s) => s.key === key && s.filled);
48 const questions = sections.filter((s) => s.key === 'questions').flatMap((s) => s.body.map((l) => item.exec(l)?.[1]).filter(Boolean));
49 const milestones = sections.filter((s) => s.key === 'milestones').flatMap((s) => s.body.map((l) => /^\s*[-*+]\s+\[([ xX])\]\s+(.*\S)/.exec(l)).filter(Boolean).map((m) => ({ done: m[1] !== ' ', text: m[2] })));
50 const latest = sections.filter((s) => s.key === 'latest').flatMap((s) => s.body.map((l) => item.exec(l)?.[1]).filter(Boolean)).slice(0, 3);
51 const firstPara = pitch.join('\n').trim().split(/\n\s*\n/)[0]?.replace(/\s+/g, ' ').trim() ?? '';
52 return {
53 title: title ?? meta.name ?? meta.title ?? null,
54 pitch: firstPara.slice(0, 400),
55 sections: sections.map((s) => ({ key: s.key, title: s.title, filled: s.filled })),
56 missing: SECTIONS.filter(([key]) => key !== 'questions' && !has(key)).map(([, t]) => t),
57 questions: questions.slice(0, 6),
58 openQuestions: questions.length,
59 milestones,
60 latest,
61 };
62}
63hooks/lib/commands.mjs 434 lines1/**
2 * What a shell command Claude is about to run means for a studio: a `homie-studio` command (and which), a production
3 * deploy, a paid media call (fal, ElevenLabs, Tripo; through Homie's skills, the providers' own CLIs or their APIs),
4 * or a change to the Cloudflare account outside the studio's deploy. It reads the command's text only; it never runs
5 * anything.
6 *
7 * Like any reading of shell text it is a net, not a sandbox: `$(...)`, aliases, `eval`, `bash -c "..."` and scripts
8 * that call these commands are not seen. The plugin README says so.
9 */
10
11/** Splits one shell segment into words, honouring quotes. Good enough to read commands, flags and paths. */
12export function tokenize(text) {
13 const words = [];
14 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
15 let m;
16 while ((m = re.exec(String(text))) !== null) words.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]);
17 return words;
18}
19
20/** The segments of a command line, each with the folder a `cd dir &&` before it moved to (null: the session's). */
21export function segments(command) {
22 const out = [];
23 let dir = null;
24 for (const raw of String(command ?? '').split(/&&|\|\||;|\||\n/)) {
25 let words = tokenize(raw.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''));
26 while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1);
27 while (words.length && ['command', 'exec', 'env', 'nohup', 'time', 'sudo'].includes(words[0])) words = words.slice(1);
28 if (!words.length) continue;
29 if (words[0] === 'cd') { dir = words[1] && words[1] !== '-' ? (dir && !words[1].startsWith('/') && !words[1].startsWith('~') ? `${dir}/${words[1]}` : words[1]) : dir; continue; }
30 out.push({ words, dir, text: raw.trim() });
31 }
32 return out;
33}
34
35const FLAG = /^--?[A-Za-z]/;
36
37/** Flags of a word list: Map name → value (true for a bare flag), and the positional words. */
38export function flagsOf(words, bools = []) {
39 const flags = new Map();
40 const pos = [];
41 for (let i = 0; i < words.length; i++) {
42 const w = words[i];
43 if (w.startsWith('--')) {
44 const [k, v] = w.slice(2).split(/=(.*)/s, 2);
45 if (v !== undefined) flags.set(k, v);
46 else if (words[i + 1] !== undefined && !FLAG.test(words[i + 1]) && !bools.includes(k)) flags.set(k, words[++i]);
47 else flags.set(k, true);
48 } else pos.push(w);
49 }
50 return { flags, pos };
51}
52
53const NPM_SCRIPTS = { dev: 'dev', build: 'build', deploy: 'deploy', check: 'check', studio: null };
54/** The two-word commands of homie-studio (bin/homie-studio.mjs). */
55const TWO = {
56 port: ['plan', 'import', 'check'], setup: ['status', 'attach'], office: ['link', 'key', 'announce', 'invite', 'launch', 'kick', 'mute', 'close', 'revoke'],
57 stats: ['key', 'link', 'revoke', 'share'], game: ['new'], codex: ['new', 'link'], perf: ['sizes', 'compare'],
58 progress: ['start', 'stage', 'check', 'preview', 'spend', 'shot', 'song', 'log', 'stop', 'end', 'attach', 'change', 'pr', 'show'],
59 agents: ['pass', 'passes', 'revoke', 'brain', 'sit'], servers: ['new', 'set', 'close', 'level', 'member'], media: ['list', 'move', 'put'],
60 players: ['owner'], storage: ['add'], statusline: [], chrome: ['install'],
61 // A game as an app of its own: it builds on this computer and uploads nothing, so none of these is held.
62 standalone: ['plan', 'build', 'run', 'steam', 'ci'],
63};
64
65/**
66 * A `homie-studio` invocation in a command line: { sub, args, flags, pos, dir, text } where `sub` is the first one or
67 * two words ('check', 'port check', 'setup status', 'office kick', ...). `npm run deploy` reads as `deploy`.
68 */
69export function studioCalls(command) {
70 const calls = [];
71 for (const seg of segments(command)) {
72 const w = seg.words;
73 let at = -1;
74 for (let i = 0; i < w.length; i++) {
75 if (/(^|\/)homie-studio(\.mjs)?$/.test(w[i])) { at = i; break; }
76 }
77 let rest = null;
78 if (at >= 0) rest = w.slice(at + 1);
79 else if (w[0] === 'npm' && w[1] === 'run' && w[2] in NPM_SCRIPTS) {
80 const script = NPM_SCRIPTS[w[2]];
81 const extra = w.slice(3).filter((x) => x !== '--');
82 rest = script ? [script, ...extra] : extra;
83 } else if ((w[0] === 'npx' || w[0] === 'bunx' || w[0] === 'pnpm') && w.some((x) => /^wrangler$/.test(x)) && w.includes('deploy')) {
84 calls.push({ sub: 'wrangler deploy', args: w, flags: new Map(), pos: [], dir: seg.dir, text: seg.text });
85 continue;
86 } else if (/(^|\/)wrangler$/.test(w[0]) && w[1] === 'deploy') {
87 calls.push({ sub: 'wrangler deploy', args: w, flags: new Map(), pos: [], dir: seg.dir, text: seg.text });
88 continue;
89 }
90 if (!rest) continue;
91 const { flags, pos } = flagsOf(rest, ['json', 'plan', 'yes', 'dry-run', 'apply', 'share', 'stop', 'install', 'remove', 'reopen', 'off', 'revoke', 'release', 'device']);
92 const second = TWO[pos[0]];
93 const sub = !pos.length ? 'help' : second && second.includes(pos[1]) ? `${pos[0]} ${pos[1]}` : pos[0];
94 calls.push({ sub, args: rest, flags, pos, dir: seg.dir, text: seg.text });
95 }
96 return calls;
97}
98
99/** A production deploy in this command line: { what, dir } or null (`--plan`, `--help` and `--dry-run` are not). */
100export function deployOf(command) {
101 for (const c of studioCalls(command)) {
102 if (c.flags.has('plan') || c.flags.has('help') || c.flags.has('dry-run')) continue;
103 if (c.sub === 'deploy') return { what: 'homie-studio deploy', dir: c.dir, ci: c.flags.has('ci') };
104 if (c.sub === 'wrangler deploy') return { what: 'wrangler deploy', dir: c.dir };
105 }
106 // The music and video skills publish by rebuilding and redeploying the site.
107 for (const seg of segments(command)) {
108 const i = seg.words.findIndex((x) => /(^|\/)(music|video)\.mjs$/.test(x));
109 if (i < 0 || seg.words[i + 1] !== 'publish') continue;
110 const { flags, pos } = flagsOf(seg.words.slice(i + 2), ['no-deploy', 'json']);
111 if (flags.has('no-deploy')) continue;
112 const kind = /music\.mjs$/.test(seg.words[i]) ? 'song' : 'video';
113 return { what: `publish the ${kind} ${pos[0] ?? ''}`.trim(), dir: seg.dir, media: { kind, slug: pos[0] ?? null } };
114 }
115 return null;
116}
117
118const PAID_HOSTS = [
119 [/\b(?:queue\.)?fal\.run\b|\bfal\.ai\/(?:api|v1)\b|\brest\.alpha\.fal\.ai\b/, 'fal', 'usd'],
120 [/\bapi\.elevenlabs\.io\b|\bapi\.us\.elevenlabs\.io\b/, 'ElevenLabs', 'credits'],
121];
122
123/**
124 * A paid media call in this command line, or null:
125 * { provider: 'fal', unit: 'usd', script: 'art'|'video'|'models', kind: 'art'|'videos', slug, words, i, dir, dryRun: [argv] }
126 * { provider: 'ElevenLabs', unit: 'credits', script: 'music', kind: 'music', slug, ... }
127 * { provider, unit, raw: true } a request straight at the provider: its cost cannot be read first
128 * A skill's call without `--yes` only prices or asks, so it is free and not held. The models skill's `prop`,
129 * `character` and `mood` are one fal call each; every model of a game shares one cap, `art/<game>-models/budget.json`.
130 */
131export function paidOf(command) {
132 for (const seg of segments(command)) {
133 const w = seg.words;
134 const i = w.findIndex((x) => /(^|\/)(art|video|music|models)\.mjs$/.test(x));
135 if (i >= 0) {
136 const script = /(^|\/)art\.mjs$/.test(w[i]) ? 'art' : /video\.mjs$/.test(w[i]) ? 'video' : /models\.mjs$/.test(w[i]) ? 'models' : 'music';
137 const { flags, pos } = flagsOf(w.slice(i + 1), ['yes', 'dry-run', 'json', 'vocals', 'no-deploy', 'mesh', 'concept-again']);
138 const verb = pos[0];
139 const paid = script === 'music' ? ['render', 'stems'].includes(verb) : script === 'models' ? ['prop', 'mood', 'character'].includes(verb) : verb === 'gen';
140 if (!paid || !flags.has('yes') || flags.has('dry-run')) continue;
141 // The same call priced and not made: the skill's own --dry-run (free; it asks the provider's price list).
142 const head = /(^|\/)node$/.test(w[0]) ? w.slice(0, i) : ['node'];
143 const dryRun = [...head, w[i], ...w.slice(i + 1).filter((x) => x !== '--yes' && x !== '--json'), '--dry-run', '--json'];
144 return {
145 provider: script === 'music' ? 'ElevenLabs' : 'fal', unit: script === 'music' ? 'credits' : 'usd', script, verb,
146 kind: script === 'video' ? 'videos' : script === 'music' ? 'music' : 'art',
147 slug: script === 'models' ? (pos[1] ? `${pos[1]}-models` : null) : pos[1] ?? null, model: flags.get('model') ?? null,
148 ...(script === 'models' ? { tool: `models ${verb}` } : {}), dir: seg.dir, dryRun, text: seg.text,
149 };
150 }
151 if (['curl', 'wget', 'http', 'xh'].includes(w[0].split('/').pop())) {
152 for (const [re, provider, unit] of PAID_HOSTS) if (re.test(seg.text)) return { provider, unit, raw: true, dir: seg.dir, text: seg.text };
153 }
154 const cli = providerCliOf(w);
155 if (cli) return { ...cli, raw: true, dir: seg.dir, text: seg.text };
156 }
157 return null;
158}
159
160/*
161 * THE PROVIDERS' OWN CLIs, where a command spends (read from each one's --help on 2026-10-03). A call made with one of
162 * them goes straight at the provider and its cost cannot be read first, so it is held like a request at their API.
163 * Help, a schema, a dry run, a sign-in and a listing are free and never held.
164 * elevenlabs ElevenLabs' CLI (brew elevenlabs/tap/elevenlabs, npm @elevenlabs/cli): its generating groups
165 * fal fal's CLI (pip install fal): `fal api <model>` runs a hosted model, `fal run` runs an app on fal
166 * genmedia fal's genmedia CLI: `genmedia run <model>`
167 * tripo Tripo's CLI (npm tripo-cli): everything but its sign-in, balance, usage, status and docs
168 * stripe Stripe's CLI, its Projects plugin only: `stripe projects upgrade`, `billing add` / `billing update`
169 * (a payment method or a spend limit) and anything with --confirm-paid-service; the rest (status,
170 * catalog, search, a free add, link, env) spends nothing
171 */
172const LAUNCHERS = new Set(['npx', 'bunx', 'pnpx', 'uvx']);
173const CLI_PACKAGES = { '@elevenlabs/cli': 'elevenlabs', 'tripo-cli': 'tripo' };
174const CLI_FREE_FLAGS = ['--help', '-h', '--dry-run', '--schema', '--spec', '--spec-raw', '--version', '-V', '-v'];
175const ELEVEN_SPENDS = new Set(['music', 'text-to-speech', 'text-to-sound-effects', 'text-to-dialogue', 'text-to-voice', 'speech-to-speech',
176 'speech-to-text', 'audio-isolation', 'dubbing', 'forced-alignment', 'flows', 'say', 'studio', 'productions', 'speech-engine']);
177const ELEVEN_FREE_VERB = /^(list|get|delete|help|status|search|show|upload)$/;
178const TRIPO_FREE = new Set(['login', 'logout', 'whoami', 'balance', 'usage', 'status', 'docs', 'mcp', 'help', 'config', 'list', 'get', 'download', 'models', 'version']);
179const CLI_BOOLS = ['dry-run', 'human', 'quiet', 'debug', 'help', 'schema', 'spec', 'spec-raw', 'version', 'json', 'yes', 'no-browser', 'remote', 'local', 'force'];
180
181/** The program a command line runs, past `npx -y`, `bunx`, `pnpm dlx`, `npm exec --` and `uvx`: { prog, args } or null. */
182export function programOf(words) {
183 let i = 0;
184 const bare = (x) => String(x ?? '').replace(/^(@[^/@]+\/[^@]+|[^@]+)@.*$/, '$1');
185 for (let guard = 0; guard < 4 && i < words.length; guard++) {
186 const n = words[i].split('/').pop();
187 if (LAUNCHERS.has(n)) i += 1;
188 else if ((n === 'pnpm' && ['dlx', 'exec'].includes(words[i + 1])) || (n === 'npm' && words[i + 1] === 'exec')) i += 2;
189 else break;
190 while (i < words.length && words[i].startsWith('-')) i += words[i] === '-p' || words[i] === '--package' ? 2 : 1;
191 }
192 if (i >= words.length) return null;
193 const word = bare(words[i]);
194 const prog = CLI_PACKAGES[word] ?? word.split('/').pop();
195 return { prog, args: words.slice(i + 1) };
196}
197
198/** A paid call through a provider's own CLI: { provider, unit, tool } or null. */
199export function providerCliOf(words) {
200 const p = programOf(words);
201 if (!p || !['elevenlabs', 'fal', 'genmedia', 'tripo', 'stripe'].includes(p.prog)) return null;
202 if (p.args.some((a) => CLI_FREE_FLAGS.includes(a))) return null;
203 const { pos } = flagsOf(p.args, CLI_BOOLS);
204 if (!pos.length) return null;
205 const tool = `${p.prog} ${pos.slice(0, p.prog === 'elevenlabs' && pos[0] !== 'say' ? 2 : 1).join(' ')}`;
206 if (p.prog === 'elevenlabs') {
207 if (!ELEVEN_SPENDS.has(pos[0])) return null;
208 if (pos[0] !== 'say' && pos.slice(1).some((x) => ELEVEN_FREE_VERB.test(x))) return null;
209 if (pos[0] !== 'say' && pos.length < 2) return null;
210 return { provider: 'ElevenLabs', unit: 'credits', tool };
211 }
212 if (p.prog === 'fal') return ['api', 'run'].includes(pos[0]) ? { provider: 'fal', unit: 'usd', tool } : null;
213 if (p.prog === 'genmedia') return pos[0] === 'run' ? { provider: 'fal', unit: 'usd', tool } : null;
214 if (p.prog === 'stripe') {
215 if (pos[0] !== 'projects') return null;
216 const paid = pos[1] === 'upgrade' || (pos[1] === 'billing' && ['add', 'update'].includes(pos[2])) || p.args.includes('--confirm-paid-service');
217 return paid ? { provider: 'Stripe Projects', unit: 'usd', tool: `stripe projects ${pos.slice(1, pos[1] === 'billing' ? 3 : 2).join(' ')}` } : null;
218 }
219 return TRIPO_FREE.has(pos[0]) ? null : { provider: 'Tripo', unit: 'usd', tool };
220}
221
222/*
223 * A CLEF MODEL DOWNLOADED BY OLLAMA. @homie-rocks/studio 0.24.4 runs Cloudflare's Clef decision model on the person's
224 * own computer when Ollama has it, and nothing in Homie downloads one by itself: `ollama pull clef-flash` is about
225 * 11 GB (`clef`, the 27B, about 18 GB), so the person says yes to the size first. What is read here:
226 * ollama pull <clef model> always a download (or a check for a newer copy)
227 * ollama run <clef model> a download when Ollama does not have that model yet (the mod asks Ollama's own list)
228 * a request at Ollama's /api/pull naming a Clef model (curl, wget and the like)
229 * Listing, showing, copying or removing a model, `ollama serve`, `--help`, and any other model are not read.
230 */
231const CLEF_MODELS = { 'clef-flash': { size: 'about 11 GB', params: '9B' }, clef: { size: 'about 18 GB', params: '27B' } };
232const OLLAMA_BOOLS = ['help', 'insecure', 'verbose', 'nowordwrap', 'hidethinking', 'think'];
233
234/** A model reference (`clef-flash`, `clef:27b`, `registry.ollama.ai/library/clef-flash:latest`) → { model, tag } or null. */
235function clefRef(ref) {
236 const m = /^(clef(?:-flash)?)(?::([A-Za-z0-9._-]+))?$/.exec(String(ref ?? '').split('/').pop());
237 return m ? { model: m[1], tag: m[2] ?? null } : null;
238}
239
240/**
241 * A Clef model download in this command line, or null:
242 * { verb: 'pull'|'run'|'api', model: 'clef-flash'|'clef', tag, ref, size: 'about 11 GB', params, host, dir, text }
243 * `host` is an OLLAMA_HOST the command sets, as written (the mod asks only a loopback one).
244 */
245export function modelPullOf(command) {
246 for (const seg of segments(command)) {
247 const host = /(?:^|\s)OLLAMA_HOST=("[^"]*"|'[^']*'|\S+)/.exec(seg.text)?.[1]?.replace(/^["']|["']$/g, '') ?? null;
248 const w = seg.words;
249 if (['curl', 'wget', 'http', 'xh'].includes(w[0].split('/').pop())) {
250 if (!/\/api\/pull\b/.test(seg.text)) continue;
251 const named = /\b(?:model|name)\\?["']?\s*[:=]\s*\\?["']?([A-Za-z0-9._:/-]+)/.exec(seg.text);
252 const r = named ? clefRef(named[1]) : null;
253 if (r) return { verb: 'api', ...r, ref: named[1], ...CLEF_MODELS[r.model], host, dir: seg.dir, text: seg.text };
254 continue;
255 }
256 const p = programOf(w);
257 if (!p || p.prog !== 'ollama' || p.args.includes('-h')) continue;
258 const { flags, pos } = flagsOf(p.args, OLLAMA_BOOLS);
259 if (flags.has('help') || !['pull', 'run'].includes(pos[0])) continue;
260 const r = clefRef(pos[1]);
261 if (r) return { verb: pos[0], ...r, ref: pos[1], ...CLEF_MODELS[r.model], host, dir: seg.dir, text: seg.text };
262 }
263 return null;
264}
265
266/**
267 * An MCP tool that spends money at a media provider (a fal, ElevenLabs or Tripo connector's generating tool), or null.
268 * Their own servers' listing, schema, pricing and job-status tools are free, and so is ElevenLabs' `estimate_only`
269 * (it prices a call and makes nothing); its agent-building tools spend nothing by themselves.
270 */
271export function paidMcpOf(tool, input = {}) {
272 const m = /^mcp__(.+?)__(.+)$/.exec(String(tool ?? ''));
273 if (!m) return null;
274 const [, server, full] = m;
275 const provider = /fal/i.test(server) ? 'fal' : /eleven/i.test(server) ? 'ElevenLabs' : /tripo/i.test(server) ? 'Tripo' : null;
276 if (!provider) return null;
277 const name = full.replace(/^creative_/i, '');
278 if (/^(list|get|search|check|status|describe|read|find|voices?|models?|usage|balance|price|pricing|quote|recommend|cancel|upload|estimate|schema|docs?)/i.test(name)) return null;
279 if (provider === 'ElevenLabs' && (input?.estimate_only === true || /agent|knowledge|widget|conversation|webhook|workspace/i.test(name))) return null;
280 return { provider, unit: provider === 'ElevenLabs' ? 'credits' : 'usd', raw: true, tool: full };
281}
282
283/*
284 * A CHANGE TO A CLOUDFLARE ACCOUNT OUTSIDE THE STUDIO'S OWN DEPLOY. `npm run deploy` (homie-studio deploy) records
285 * what it creates in studio.json and never touches what it did not create; Wrangler run by hand, and the tools of
286 * Cloudflare's own MCP servers, go around that record. What is held (inside a studio, with guardDeploys on):
287 * anything deleted (a Worker, a D1 database, an R2 bucket or object, a KV namespace or key, a queue, a secret);
288 * a secret put (Cloudflare: a secret put is itself a deployment), a version rolled out or rolled back by hand;
289 * a migration applied to the live database, and SQL that writes to it;
290 * through an MCP server: a delete, update, edit, put, deploy or rollback tool, a live-database query that writes,
291 * and an `execute` (Cloudflare's API server) whose code sends anything but GET (a GraphQL read is a POST and is free).
292 * Creating something new and reading anything are not held. `--local` never touches the account.
293 */
294const READ_SQL = /^\s*(select|pragma|explain|with\b[\s\S]*\bselect)\b/i;
295export function readOnlySql(sql) {
296 const parts = String(sql ?? '').split(';').map((s) => s.trim()).filter(Boolean);
297 return parts.length > 0 && parts.every((s) => READ_SQL.test(s) && !/\b(insert|update|delete|drop|alter|create|replace)\b/i.test(s));
298}
299
300/** Wrangler, run by hand, changing the account: { what, kind, names, dir, text } or null. */
301export function cloudflareChangeOf(command) {
302 for (const seg of segments(command)) {
303 const p = programOf(seg.words);
304 if (!p || p.prog !== 'wrangler') continue;
305 if (p.args.some((a) => a === '--help' || a === '-h' || a === '--local' || a === '--dry-run')) continue;
306 const { flags, pos } = flagsOf(p.args, CLI_BOOLS);
307 const at = (kind, n) => ({ what: `wrangler ${pos.slice(0, n).join(' ')}`.trim(), kind, names: pos.slice(n), dir: seg.dir, text: seg.text });
308 const del = pos.indexOf('delete');
309 if (del >= 0 && del <= 2) return at('delete', del + 1);
310 if ((pos[0] === 'secret' && ['put', 'bulk'].includes(pos[1])) || (pos[0] === 'versions' && pos[1] === 'secret' && ['put', 'bulk'].includes(pos[2]))) return at('secret', pos[0] === 'versions' ? 3 : 2);
311 if ((pos[0] === 'versions' && pos[1] === 'deploy') || pos[0] === 'rollback' || (pos[0] === 'deployments' && pos[1] === 'rollback')) return at('deploy', pos[0] === 'rollback' ? 1 : 2);
312 if (pos[0] === 'd1' && pos[1] === 'migrations' && pos[2] === 'apply' && flags.has('remote')) return at('schema', 3);
313 if (pos[0] === 'd1' && pos[1] === 'execute' && flags.has('remote') && (flags.has('file') || !readOnlySql(flags.get('command')))) return at('data', 2);
314 }
315 return null;
316}
317
318/** A tool of a Cloudflare MCP server (Cloudflare's own, or a claude.ai connector) changing the account, or null. */
319export function cloudflareMcpChangeOf(tool, input = {}) {
320 const m = /^mcp__(.+?)__(.+)$/.exec(String(tool ?? ''));
321 if (!m || !/cloudflare/i.test(m[1])) return null;
322 const name = m[2];
323 const names = Object.entries(input ?? {}).filter(([k, v]) => typeof v === 'string' && /(^|_)(name|id|bucket|database|script)/i.test(k)).map(([, v]) => v);
324 if (name === 'execute') {
325 const code = String(input?.code ?? '');
326 const methods = [...code.matchAll(/\bmethod\s*:\s*["'`](POST|PUT|PATCH|DELETE)["'`]/gi)].map((x) => x[1].toUpperCase());
327 const writes = [...new Set(methods.filter((x) => x !== 'POST' || !/graphql/i.test(code)))];
328 if (!writes.length) return null;
329 return { what: `Cloudflare's API (${writes.join(', ')}) through its MCP`, kind: writes.includes('DELETE') ? 'delete' : 'change', names: [], code, text: code.slice(0, 300) };
330 }
331 if (/(^|_)(delete|remove|destroy|purge)(_|$)/i.test(name)) return { what: name, kind: 'delete', names, text: name };
332 if (/(^|_)(update|edit|put|deploy|rollback)(_|$)/i.test(name)) return { what: name, kind: 'change', names, text: name };
333 if (/(^|_)query$/i.test(name) && !readOnlySql(input?.sql ?? input?.query)) return { what: name, kind: 'data', names, text: String(input?.sql ?? input?.query ?? '').slice(0, 300) };
334 return null;
335}
336
337/**
338 * A write through Stripe's MCP server whose answer would carry a secret into the conversation, or null. Stripe hands a
339 * new webhook endpoint's signing secret back once, in the create call's answer (and an event destination's, when asked
340 * to include it). The shop's webhook is made on the owner's computer instead (`homie-studio shop connect`), where
341 * the secret goes straight to the studio's Worker. An update of an existing endpoint (we_…) carries no secret.
342 */
343export function stripeSecretWriteOf(tool, input) {
344 if (!/^mcp__.*stripe.*__stripe_api_write$/i.test(String(tool ?? ''))) return null;
345 let text = '';
346 try { text = JSON.stringify(input ?? {}); } catch { text = String(input ?? ''); }
347 // A path (/v1/webhook_endpoints), an operation's name (PostWebhookEndpoints) or a resource word: all the same here.
348 const flat = text.toLowerCase().replace(/[^a-z0-9]/g, '');
349 const newEndpoint = flat.includes('webhookendpoint') && !flat.includes('eventdestination') && !/\bwe_[A-Za-z0-9]{6,}/.test(text);
350 const destinationSecret = flat.includes('eventdestination') && flat.includes('signingsecret');
351 if (!newEndpoint && !destinationSecret) return null;
352 return 'Refused by the Homie mod: Stripe answers a new webhook\'s signing secret in this call, and it would land in the conversation. A studio\'s shop webhook is made on the owner\'s computer instead: run `npx --no-install homie-studio shop connect` for Stripe browser approval and private webhook setup; follow the remaining step it names. Only if the owner chooses the fallback use --manual. (Reading or turning off an existing endpoint through Stripe\'s MCP is fine.)';
353}
354
355// git's own options before the subcommand that take a value, and the subcommands' (a value is never a path).
356const GIT_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']);
357const STAGE_VALUE = {
358 add: new Set(['--chmod', '--pathspec-from-file']),
359 commit: new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '-c', '--reedit-message', '--author', '--date', '-t', '--template', '--fixup', '--squash', '--cleanup', '--pathspec-from-file', '--trailer']),
360};
361
362/**
363 * Each `git add` and `git commit` in a command line: { verb, dir, paths, all } where `dir` is the folder git runs in
364 * (a `cd` before it, or `git -C <dir>`; null: the session's), `paths` the paths it names, and `all` what a flag adds
365 * besides: 'all' (`add -A`), 'tracked' (`add -u`, `commit -a`) or null. What gets staged is read from git itself.
366 */
367export function gitStagesOf(command) {
368 const out = [];
369 for (const seg of segments(command)) {
370 const w = seg.words;
371 if (!/(^|\/)git$/.test(w[0])) continue;
372 let dir = seg.dir;
373 let i = 1;
374 while (i < w.length && w[i].startsWith('-')) {
375 if (w[i] === '-C' && w[i + 1]) { const d = w[i + 1]; dir = d.startsWith('/') || !dir ? d : `${dir}/${d}`; i += 2; }
376 else if (GIT_VALUE.has(w[i])) i += 2;
377 else i++;
378 }
379 const verb = w[i];
380 if (verb !== 'add' && verb !== 'commit') continue;
381 const values = STAGE_VALUE[verb];
382 const paths = [];
383 let all = null;
384 let rest = false;
385 for (let k = i + 1; k < w.length; k++) {
386 const x = w[k];
387 if (rest || x === '-' || !x.startsWith('-')) { paths.push(x); continue; }
388 if (x === '--') { rest = true; continue; }
389 if (x.startsWith('--')) {
390 const name = x.split('=')[0];
391 if (verb === 'add' && ['--all', '--no-ignore-removal'].includes(name)) all = 'all';
392 else if ((verb === 'add' && name === '--update') || (verb === 'commit' && name === '--all')) all = all ?? 'tracked';
393 if (values.has(name) && !x.includes('=')) k++;
394 continue;
395 }
396 // Short flags, perhaps bundled ("-am msg"): a flag that takes a value takes the next word when it ends the bundle.
397 const letters = x.slice(1);
398 if (verb === 'add' && letters.includes('A')) all = 'all';
399 else if ((verb === 'add' && letters.includes('u')) || (verb === 'commit' && /^[^mFCct]*a/.test(letters))) all = all ?? 'tracked';
400 const at = [...letters].findIndex((ch) => values.has(`-${ch}`));
401 if (at === letters.length - 1) k++;
402 }
403 out.push({ verb, dir, paths, all });
404 }
405 return out;
406}
407
408/** A path inside a folder? Both absolute; forward slashes. */
409export function inside(root, path) {
410 const r = String(root).replace(/\/+$/, '');
411 return path === r || String(path).startsWith(`${r}/`);
412}
413
414/** A studio-relative path matched against `protect` globs (`*` within a folder, `**` across, `?` one character). */
415export function globMatch(glob, rel) {
416 const g = String(glob).replace(/^\.?\//, '');
417 let re = '';
418 for (let i = 0; i < g.length; i++) {
419 const ch = g[i];
420 if (ch === '*' && g[i + 1] === '*') { re += g[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += g[i + 2] === '/' ? 2 : 1; }
421 else if (ch === '*') re += '[^/]*';
422 else if (ch === '?') re += '[^/]';
423 else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
424 }
425 // A folder glob ("site/" or "site") protects what is under it too.
426 return new RegExp(`^${re}${g.endsWith('/') ? '.*' : '(?:/.*)?'}$`).test(rel);
427}
428
429/** The first `protect` glob a studio-relative path matches, or null. */
430export function protectedBy(globs, rel) {
431 for (const g of Array.isArray(globs) ? globs : []) if (typeof g === 'string' && g.trim() && globMatch(g.trim(), rel)) return g.trim();
432 return null;
433}
434hooks/lib/feed.mjs 93 lines1/**
2 * A build's progress feed (`.studio/progress/<build>.json`, @homie-rocks/studio lib/progress.mjs), read for a person
3 * at a glance: the same numbers the status line, the Game Codex and the Claude app's card show (lib/feed-summary.mjs
4 * in the studio package; test/mod-lib.test.mjs checks the two agree). Plain functions: no `$`, no I/O.
5 */
6
7const PASSED = new Set(['pass', 'skip']);
8const KIND = 'homie-studio-progress';
9export const BUILD_ID = /^[a-z0-9][a-z0-9-]{5,63}$/;
10
11/** A parsed feed document, or null when it is not one. */
12export function feedOf(text) {
13 try {
14 const d = typeof text === 'string' ? JSON.parse(text) : text;
15 return d && d.kind === KIND && Array.isArray(d.stages) ? d : null;
16 } catch { return null; }
17}
18
19/** One feed as a person reads it: how far along, the stage, the checks, the spend, the preview. */
20export function summarize(doc) {
21 if (!doc || !Array.isArray(doc.stages)) return null;
22 const checks = Array.isArray(doc.checks) ? doc.checks : [];
23 let units = 0;
24 for (const s of doc.stages) {
25 if (s.state === 'done' || s.state === 'skipped') units += 1;
26 else if (s.state === 'running') {
27 const own = checks.filter((c) => c.stage === s.id);
28 units += own.length ? 0.1 + 0.85 * (own.filter((c) => PASSED.has(c.state)).length / own.length) : 0.35;
29 }
30 }
31 const total = Math.max(1, doc.stages.length);
32 const percent = doc.state === 'passed' ? 100 : Math.max(0, Math.min(99, Math.round((units / total) * 100)));
33 const at = doc.stages.find((s) => s.id === doc.stage) ?? doc.stages.find((s) => s.state === 'running') ?? null;
34 const spend = doc.spend ?? { unit: 'usd', used: 0, budget: null };
35 const money = (n) => (spend.unit === 'credits' ? `${Math.round(Number(n))} credits` : `$${Number(n).toFixed(2)}`);
36 const used = Number(spend.used) || 0;
37 const hasBudget = spend.budget !== null && spend.budget !== undefined;
38 return {
39 build: doc.build, what: doc.what, id: doc.id ?? null, title: String(doc.title ?? doc.id ?? 'Build'), studio: doc.studio ?? '',
40 state: doc.state, percent,
41 stage: at ? { id: at.id, label: at.label, state: at.state, note: at.note ?? '' } : null,
42 stages: doc.stages.map((s) => ({ id: s.id, label: s.label, state: doc.state === 'passed' && s.state === 'pending' ? 'skipped' : s.state, note: s.note ?? '' })),
43 checks: checks.map((c) => ({ id: c.id, label: c.label ?? c.id, stage: c.stage ?? null, state: c.state, ms: c.ms ?? null, note: c.note ?? '' })),
44 counts: {
45 total: checks.length,
46 pass: checks.filter((c) => PASSED.has(c.state)).length,
47 fail: checks.filter((c) => c.state === 'fail').length,
48 running: checks.filter((c) => c.state === 'running').length,
49 },
50 spend: { unit: spend.unit, used, budget: hasBudget ? Number(spend.budget) : null, text: used || hasBudget ? `${money(used)}${hasBudget ? ` of ${money(spend.budget)}` : ''}` : '' },
51 preview: doc.preview?.url ?? null,
52 previewImage: doc.preview?.image ?? null,
53 previewCaption: doc.preview?.caption ?? '',
54 previewAt: doc.preview?.at ?? null,
55 stopping: Boolean(doc.stop?.requested) && doc.state === 'running',
56 last: Array.isArray(doc.log) && doc.log.length ? doc.log[doc.log.length - 1].text : '',
57 log: Array.isArray(doc.log) ? doc.log.slice(-6).map((l) => l.text) : [],
58 error: doc.error ?? null,
59 change: doc.change ?? null,
60 site: doc.site ?? null,
61 startedAt: doc.startedAt ?? null, updatedAt: doc.updatedAt ?? null, endedAt: doc.endedAt ?? null,
62 };
63}
64
65/** A bar of `width` cells for a percentage: '▰▰▰▱▱'. */
66export function bar(percent, width) {
67 const full = Math.max(0, Math.min(width, Math.round((percent / 100) * width)));
68 return { done: '▰'.repeat(full), left: '▱'.repeat(width - full) };
69}
70
71/** The mark and colour of a stage or check state. */
72export function markOf(state) {
73 switch (state) {
74 case 'done': case 'pass': case 'passed': return { mark: '✓', color: 'green' };
75 case 'failed': case 'fail': return { mark: '✗', color: 'red' };
76 case 'running': return { mark: '●', color: 'cyan' };
77 case 'skipped': case 'skip': return { mark: '–', color: undefined, dim: true };
78 case 'stopped': return { mark: '■', color: 'yellow' };
79 default: return { mark: '○', color: undefined, dim: true };
80 }
81}
82
83/** How long ago an ISO time was, in a few words ("12 s ago", "3 min ago"). */
84export function ago(iso, now) {
85 const t = Date.parse(String(iso ?? ''));
86 if (!Number.isFinite(t)) return '';
87 const s = Math.max(0, Math.round((now - t) / 1000));
88 if (s < 60) return `${s} s ago`;
89 if (s < 3600) return `${Math.round(s / 60)} min ago`;
90 if (s < 86400 * 2) return `${Math.round(s / 3600)} h ago`;
91 return `${Math.round(s / 86400)} days ago`;
92}
93hooks/lib/holds.mjs 701 lines1/**
2 * THE HOLDS, DECIDED IN ONE PLACE. Claude Code's Homie mod (hooks/homie.mjs), Codex's hooks (hooks/codex.mjs)
3 * and Grok's hooks (hooks/grok.mjs) all ask this module what a tool call means for a studio, so the three apps
4 * cannot drift apart:
5 * null let it through;
6 * { deny } refused outright, nobody is asked (the reason says what to do instead);
7 * { hold } the person says Proceed or Cancel first: { question, title, lines, diff?, more?, detail,
8 * no (the reason when they said no), nobody (the reason when nobody could be asked) };
9 * { note } let it through, with one line for the person (the mod's toast).
10 * Each app shows a hold its own way: the mod in Claude Code's question dialog with the Hold pane, Codex and Grok
11 * by refusing the call with a short code the person answers in their own message (hooks/codex.mjs and hooks/grok.mjs
12 * say how far each app goes).
13 *
14 * Everything here reads through `io`, which each app builds from what it may reach:
15 * io.exists(path) → boolean io.read(path) → text (throws when missing)
16 * io.stat(path, { resolve }) → { kind, size, realPath?, mtimeMs }
17 * io.list(dir) → [{ name, kind }] io.run(argv, { cwd, timeoutMs }) → { exitCode, stdout }
18 * io.fetchJson(url) → value | null (only Ollama's model list on this computer, before a Clef download)
19 * `io.run` is asked only for `git -C <studio>` (read-only) and a media skill's own `--dry-run` (free). Nothing here
20 * writes a file, reads a key, the keychain or the environment, or approves anything.
21 */
22import { decisionsFileOf, GAME_ID, licenceIssues, lockedChanges, publicGame } from './art.mjs';
23import { cloudflareChangeOf, cloudflareMcpChangeOf, deployOf, gitStagesOf, inside, modelPullOf, paidMcpOf, paidOf, protectedBy, stripeSecretWriteOf } from './commands.mjs';
24import { applyEdit, unifiedDiff } from './diff.mjs';
25import { ago, summarize } from './feed.mjs';
26import { KIND_LABEL, cleanNote, withLine } from './feedback.mjs';
27
28/** Which holds are on: the mod's settings of the same names (Codex: all on unless HOMIE_GUARD_* turns one off). */
29export const GUARDS = Object.freeze({ guardFiles: true, guardDeploys: true, guardSpend: true });
30
31export async function readJson(io, path) {
32 try { return JSON.parse(await io.read(path)); } catch { return null; }
33}
34
35async function readText(io, path) {
36 try { return await io.read(path); } catch { return null; }
37}
38
39/** The studio a folder is in: the nearest folder at or above it with a studio.json, or null. */
40export async function studioRootOf(io, dir) {
41 let at = String(dir ?? '').replace(/\/+$/, '');
42 for (let i = 0; i < 24 && at; i++) {
43 if (await io.exists(`${at}/studio.json`)) return at;
44 const up = at.slice(0, at.lastIndexOf('/'));
45 if (up === at) break;
46 at = up;
47 }
48 return null;
49}
50
51/** The studio whose folder a command line's `cd` names (relative to the session's folder), or the session's. */
52export async function studioFor(io, ctx, dir) {
53 if (!dir) return ctx.root;
54 return studioRootOf(io, dir.startsWith('/') ? dir : `${ctx.cwd}/${dir}`);
55}
56
57/** The live site: the studio's custom domain, else this computer's workers.dev address from its last deploy. */
58export function liveSite(studio, local) {
59 const cf = studio?.cloudflare ?? {};
60 const raw = String(cf.domain ?? '').trim();
61 if (raw) { try { const u = new URL(/^https?:\/\//.test(raw) ? raw : `https://${raw}`); if (u.protocol === 'https:') return u.origin; } catch { /* not a domain */ } }
62 for (const u of [cf.url, local?.url]) { try { const x = new URL(String(u ?? '')); if (x.protocol === 'https:') return x.origin; } catch { /* none */ } }
63 return null;
64}
65
66/** A studio's name, as a hold says it. */
67export function studioName(studio, root) {
68 return String(studio?.name ?? String(root ?? '').split('/').pop()).slice(0, 60);
69}
70
71/**
72 * What a hold knows about the session, read from the studio's own files (Codex: once per call; the mod keeps its own,
73 * refreshed every 2 s). { app, cwd, root, studio, local, name, feed, guards }.
74 */
75export async function contextOf(io, cwd, { app = 'codex', guards = GUARDS } = {}) {
76 const root = await studioRootOf(io, cwd);
77 const studio = root ? (await readJson(io, `${root}/studio.json`)) ?? {} : null;
78 const local = root ? (await readJson(io, `${root}/.studio/local.json`)) ?? {} : null;
79 let feed = null;
80 if (root) {
81 const dir = `${root}/.studio/progress`;
82 const id = String((await readText(io, `${dir}/current`)) ?? '').trim();
83 if (/^[a-z0-9][a-z0-9-]{5,63}$/.test(id)) {
84 const doc = await readJson(io, `${dir}/${id}.json`);
85 if (doc && typeof doc === 'object') feed = doc;
86 }
87 }
88 return { app, cwd, root, studio, local, name: root ? studioName(studio, root) : null, feed: feed?.state === 'running' ? feed : null, last: feed?.state === 'running' ? null : feed, guards: { ...GUARDS, ...guards } };
89}
90
91/** A path with its "." and ".." folded, so it compares with the studio's own. */
92export function normalPath(p) {
93 const out = [];
94 for (const part of String(p).split('/')) {
95 if (part === '' || part === '.') continue;
96 if (part === '..') out.pop();
97 else out.push(part);
98 }
99 return `/${out.join('/')}`;
100}
101
102/** A hold's whole story as plain lines: where no pane can show it (a narrow terminal), and Codex's approval text. */
103export function holdText(g, { diffLines = 40 } = {}) {
104 const d = g.detail ?? {};
105 return [`⚠ ${g.title}`, ...(d.lines ?? []).map((l) => (typeof l === 'string' ? ` ${l}` : ` ${l.k}: ${l.v}`)), ...(d.full?.source ? d.full.source.split('\n').slice(0, diffLines).map((l) => ` ${l}`) : [])].join('\n');
106}
107
108/* ------------------------------------------------------------------ edits */
109
110/**
111 * An edit to files in a studio: { tool, by, changes: [{ path, from?, apply(before) → after | null, a?, b? }] }. `apply`
112 * gives the file's text after the edit from its text before (read at `from` for a moved file; null: the edit cannot
113 * apply, and the tool will refuse it); `a`/`b` give the diff straight (a notebook cell). `by` is who asked, in words
114 * ("Claude", "a subagent", "Codex").
115 */
116export async function editDecision(io, ctx, edit) {
117 if (!ctx.guards.guardFiles || !ctx.root) return null;
118 const locked = await lockDecision(io, ctx, edit);
119 if (locked) return locked;
120 return protectDecision(io, ctx, edit);
121}
122
123/** Where a path is in the studio: the path or its real path (a link into the studio counts), or null. */
124async function inStudio(io, root, path) {
125 if (!String(path).startsWith('/')) return null;
126 let real = path;
127 try { real = (await io.stat(path, { resolve: true })).realPath ?? path; } catch { real = path; }
128 return [path, real].find((p) => inside(root, p)) ?? null;
129}
130
131/**
132 * An edit to games/<id>/codex/decisions.json that changes the value or the state of a decision the person locked:
133 * refused, nobody asked (their lock is the answer). The toolkit writes this file; the way to change a locked decision is
134 * its own `style set … --unlock --reason`, after the person saw the blast radius.
135 */
136async function lockDecision(io, ctx, edit) {
137 const root = ctx.root;
138 for (const c of edit.changes) {
139 if (c.notebook) continue;
140 const hit = await inStudio(io, root, c.path);
141 const game = hit ? decisionsFileOf(hit.slice(root.length + 1)) : null;
142 if (!game) continue;
143 const before = await readText(io, c.from ?? c.path);
144 if (before === null) continue;
145 const after = c.apply(before);
146 // An edit that cannot apply is refused by the tool itself.
147 if (after === null) continue;
148 const changed = lockedChanges(before, after);
149 if (changed && !changed.length) continue;
150 const how = (id) => `${id} is locked by the person; change it with homie-studio style set ${game} ${id} <value> --unlock --reason "<what they asked for>" after they saw the blast radius (homie-studio style blast ${game} ${id})`;
151 return { deny: changed === null
152 ? `games/${game}/codex/decisions.json holds decisions the person locked, and this edit would leave it unreadable (not JSON). The studio toolkit writes this file: homie-studio style set / steer / lock / unlock ${game} … changes a decision, and a locked one changes only after the person saw the blast radius (homie-studio style blast).`
153 : `${changed.map(how).join('. ')}. Nothing was changed.` };
154 }
155 return null;
156}
157
158/** An edit to a file the studio protects (studio.json "protect"): held, with its diff, until the person says Proceed. */
159async function protectDecision(io, ctx, edit) {
160 const protect = ctx.studio?.protect;
161 if (!Array.isArray(protect) || !protect.length) return null;
162 const root = ctx.root;
163 const hits = [];
164 for (const c of edit.changes) {
165 const hit = await inStudio(io, root, c.path);
166 if (!hit) continue;
167 const rel = hit.slice(root.length + 1);
168 const glob = protectedBy(protect, rel);
169 if (glob) hits.push({ c, rel, glob });
170 }
171 if (!hits.length) return null;
172 const { c, rel, glob } = hits[0];
173 const before = c.notebook ? '' : (await readText(io, c.from ?? c.path)) ?? '';
174 const [a, b] = c.a !== undefined ? [c.a, c.b] : (() => { const after = c.apply(before); return after === null ? [String(c.old ?? ''), String(c.new ?? '')] : [before, after]; })();
175 // Claude Code's question dialog has room for about five diff rows ("@@" lines count): the first changes, cut short.
176 let diff = unifiedDiff(a, b, { context: 0, maxLines: 4, lineWidth: 28 });
177 for (let n = 3; n >= 1 && diff.rows > 5; n--) diff = unifiedDiff(a, b, { context: 0, maxLines: n, lineWidth: 28 });
178 const full = unifiedDiff(a, b, { maxLines: 400, maxChars: 9500 });
179 const others = hits.slice(1).map((h) => h.rel);
180 const what = others.length ? `${rel} and ${others.length} more protected file${others.length === 1 ? '' : 's'}` : rel;
181 return {
182 hold: {
183 kind: 'file',
184 question: `Change ${what}, which the studio protects?`,
185 title: `Protected: ${rel.split('/').slice(-2).join('/')}${others.length ? ` +${others.length}` : ''}`,
186 lines: [
187 { k: 'Rule', v: glob },
188 { k: edit.tool, v: `+${diff.added} −${diff.removed} lines by ${edit.by === 'a subagent' ? 'a subagent' : edit.by ?? 'Claude'}`, style: { color: 'yellow' } },
189 ],
190 diff,
191 more: diff.shownChanges < full.added + full.removed ? `… ${full.added + full.removed - diff.shownChanges} more changed lines: Hold pane` : 'with its context in the Hold pane',
192 detail: {
193 lines: [
194 { k: 'File', v: rel },
195 { k: 'Rule', v: `studio.json "protect": "${glob}"` },
196 { k: 'Change', v: `${edit.tool}${before ? '' : ' (a new file)'} · +${full.added} −${full.removed} lines`, style: { color: 'yellow' } },
197 ...(others.length ? [{ k: 'Also', v: hits.slice(1).map((h) => `${h.rel} ("${h.glob}")`).join(', '), style: { color: 'yellow' } }] : []),
198 { k: 'By', v: edit.byLong ?? edit.by ?? 'Claude' },
199 ],
200 full,
201 },
202 no: `The person said no to this change to ${what} (studio.json "protect": "${glob}"). Do not retry it unless they ask; say what you wanted to change and why.`,
203 nobody: `${rel} is protected by studio.json ("protect": "${glob}"), and nobody could be asked here, so the change was not made. Ask the person in chat first.`,
204 },
205 };
206}
207
208/** The mod's (and Codex's) reading of a Claude Code edit tool's input, as a change list for editDecision. */
209export function claudeEditOf(tool, input) {
210 const path = String(input?.file_path ?? input?.notebook_path ?? '');
211 if (tool === 'NotebookEdit') return { tool, changes: [{ path, notebook: true, a: '', b: String(input?.new_source ?? ''), apply: () => null }] };
212 return { tool, changes: [{ path, apply: (before) => applyEdit(tool, before, input ?? {}), old: input?.old_string, new: input?.new_string }] };
213}
214
215/* ------------------------------------------------------------------ big files into git */
216
217const BIG_FILE = 5 * 1024 * 1024;
218
219/**
220 * A `git add` or `git commit` in a studio that would put a file over 5 MB under games/ into git: refused, naming each
221 * file and its size. What gets staged is read from git itself (read-only): the index for a commit, `status` for a
222 * folder or `-A`; the sizes from the files.
223 */
224export async function bigFilesDecision(io, ctx, stages) {
225 const big = new Map();
226 for (const st of stages.slice(0, 6)) {
227 const here = ctx.cwd ?? ctx.root;
228 const base = st.dir ? (st.dir.startsWith('/') ? st.dir : `${here}/${st.dir}`) : here;
229 if (!base) continue;
230 const root = await studioRootOf(io, base);
231 if (!root) continue;
232 const git = async (dir, args) => { try { const r = await io.run(['git', '-C', dir, ...args], { timeoutMs: 15_000 }); return r.exitCode === 0 ? String(r.stdout ?? '') : ''; } catch { return ''; } };
233 // git names files from the top of the repository; the studio may sit inside a bigger one.
234 const prefix = (await git(root, ['rev-parse', '--show-prefix'])).trim();
235 const files = new Set();
236 const listed = async (dir, args, mode) => {
237 // `-z` entries: a path (diff), or "XY path" (status; a rename or copy is followed by its old path, which is
238 // skipped). A deleted file has no size; `tracked` leaves out untracked files ("??").
239 const parts = (await git(dir, args)).split('\0');
240 for (let i = 0; i < parts.length; i++) {
241 let p = parts[i];
242 if (!p) continue;
243 if (mode) {
244 const xy = p.slice(0, 2);
245 if (/^[RC]/.test(xy)) i++;
246 if (xy.includes('D') || (mode === 'tracked' && xy === '??')) continue;
247 p = p.slice(3);
248 }
249 if (p.startsWith(prefix)) files.add(`${root}/${p.slice(prefix.length)}`);
250 }
251 };
252 if (st.verb === 'commit') {
253 await listed(root, ['diff', '--cached', '--name-only', '-z'], null);
254 if (st.all) await listed(root, ['diff', '--name-only', '-z'], null);
255 } else if (st.all && !st.paths.length) {
256 await listed(root, ['status', '--porcelain', '-z', '--untracked-files=all', '--', 'games'], st.all);
257 }
258 // A commit names only files git tracks; `add -u` only those too.
259 const mode = st.verb === 'commit' || st.all === 'tracked' ? 'tracked' : 'all';
260 for (const p of st.paths.slice(0, 200)) {
261 const abs = normalPath(p.startsWith('/') ? p : `${base}/${p}`);
262 let kind = null;
263 try { kind = (await io.stat(abs)).kind; } catch { kind = null; }
264 if (kind === 'file') files.add(abs);
265 // A folder, a glob, or a path only git knows (git expands pathspecs itself).
266 else await listed(base, ['status', '--porcelain', '-z', '--untracked-files=all', '--', abs === root ? 'games' : p], mode);
267 }
268 let n = 0;
269 for (const f of files) {
270 const abs = normalPath(f);
271 if (!inside(`${root}/games`, abs) || big.has(abs) || ++n > 2000) continue;
272 let size = 0;
273 try { size = Number((await io.stat(abs)).size) || 0; } catch { size = 0; }
274 if (size > BIG_FILE) big.set(abs, { rel: abs.slice(root.length + 1), size });
275 }
276 }
277 if (!big.size) return null;
278 const named = [...big.values()].map((b) => `${b.rel} (${(b.size / 1024 / 1024).toFixed(1)} MB)`);
279 return { deny: `Not run: ${named.join(', ')} ${big.size === 1 ? 'is' : 'are'} over 5 MB under games/, and a studio keeps big files out of git. Big files go to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise.` };
280}
281
282/* ------------------------------------------------------------------ deploys */
283
284/**
285 * The licences a deploy ships: every public game's assets/manifest.json (game.json `launch` not private or invite).
286 * { count, games, problems: [{ game, asset, problem }] }.
287 */
288export async function licenceFacts(io, root) {
289 const out = { count: 0, games: 0, problems: [] };
290 let dirs = [];
291 try { dirs = await io.list(`${root}/games`); } catch { dirs = []; }
292 for (const d of dirs.slice(0, 200)) {
293 if (d.kind === 'file' || !GAME_ID.test(d.name)) continue;
294 if (!publicGame(await readJson(io, `${root}/games/${d.name}/game.json`))) continue;
295 const path = `${root}/games/${d.name}/assets/manifest.json`;
296 if (!(await io.exists(path))) continue;
297 const r = licenceIssues(await readJson(io, path));
298 out.count += r.count;
299 out.games++;
300 for (const p of r.problems) out.problems.push({ game: d.name, ...p });
301 }
302 return out;
303}
304
305/**
306 * What a deploy would change, from the studio's own files, git and (in the mod) its live site. `known` is what the
307 * caller already holds: { studio, local, live, stored: { commit, at } (the mod's record of its last deploy), liveIds
308 * (games the live site has), games (this studio's), feed, last, playing }.
309 */
310export async function deployFacts(io, root, known = {}) {
311 const studio = known.studio ?? (await readJson(io, `${root}/studio.json`)) ?? {};
312 const local = known.local ?? (await readJson(io, `${root}/.studio/local.json`)) ?? {};
313 const live = known.live !== undefined ? known.live : liveSite(studio, local);
314 const created = Array.isArray(studio.cloudflare?.created) ? studio.cloudflare.created : [];
315 const first = created.length ? null : `on the Cloudflare account Wrangler is signed in to: Worker ${studio.cloudflare?.worker ?? '?'}, D1 ${studio.cloudflare?.d1 ?? '?'}, Durable Objects Table and Lobby (free plan, no payment method)`;
316 const where = live ? live.replace(/^https:\/\//, '') : 'a new workers.dev address (the first deploy makes it)';
317 const git = async (args) => { try { const r = await io.run(['git', '-C', root, ...args], { timeoutMs: 10_000 }); return r.exitCode === 0 ? r.stdout : null; } catch { return null; } };
318 const stored = known.stored ?? null;
319 const since = stored?.commit ?? null;
320 let commits = [];
321 let diffstat = null;
322 if (since) {
323 commits = String((await git(['log', '--oneline', '--no-decorate', `${since}..HEAD`])) ?? '').split('\n').filter(Boolean);
324 diffstat = String((await git(['diff', '--shortstat', since])) ?? '').trim() || null;
325 } else if (local?.deployedAt) {
326 commits = String((await git(['log', '--oneline', '--no-decorate', `--since=${local.deployedAt}`])) ?? '').split('\n').filter(Boolean);
327 }
328 const uncommitted = String((await git(['status', '--porcelain'])) ?? '').split('\n').filter((l) => l.trim() && !/\.studio\//.test(l)).length;
329 const liveIds = new Set(known.liveIds ?? []);
330 const newGames = live && liveIds.size ? (known.games ?? []).filter((g) => !g.planned && !liveIds.has(g.id)).map((g) => g.id) : [];
331 const recent = [known.feed, known.last].filter(Boolean).map((d) => summarize(d)).find((b) => b.counts.total);
332 const checks = recent ? `${recent.title}: ${recent.counts.pass}/${recent.counts.total} passed${recent.counts.fail ? `, ${recent.counts.fail} failing` : ''} (${ago(recent.endedAt ?? recent.updatedAt, Date.now())})` : 'no two-browser check on record here';
333 const checksShort = recent ? `${recent.counts.pass}/${recent.counts.total} passed · ${ago(recent.endedAt ?? recent.updatedAt, Date.now())}` : 'none on record';
334 const stat = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/.exec(diffstat ?? '');
335 const files = stat ? `${stat[1]} changed, +${stat[2] ?? 0} −${stat[3] ?? 0}` : null;
336 const playing = known.playing ?? 0;
337 return {
338 files, checksShort, lastDeployShort: local?.deployedAt ? ago(local.deployedAt, Date.now()) : stored?.at ? ago(stored.at, Date.now()) : created.length ? 'not from here' : 'never',
339 where, first, live, lastDeploy: local?.deployedAt ? `${ago(local.deployedAt, Date.now())} (${local.deployedAt.slice(0, 16).replace('T', ' ')} UTC)` : stored?.at ? ago(stored.at, Date.now()) : created.length ? 'not from this computer' : 'never',
340 commits: commits.map((c) => c.replace(/^[0-9a-f]+ /, '')), commitCount: commits.length, diffstat, uncommitted, newGames, checks,
341 checksOk: Boolean(recent && !recent.counts.fail && recent.counts.pass === recent.counts.total), playing,
342 };
343}
344
345/** A production deploy: refused when a public game ships an unlicensed asset, else held with what will change. */
346export async function deployDecision(io, root, what, known = {}) {
347 // A public game shipping an asset with no allowed licence is refused before anyone is asked.
348 const lic = await licenceFacts(io, root);
349 if (lic.problems.length) {
350 const games = [...new Set(lic.problems.map((p) => p.game))];
351 return { deny: `Not deployed: ${lic.problems.length} asset${lic.problems.length === 1 ? '' : 's'} in a public game ${lic.problems.length === 1 ? 'has' : 'have'} no allowed licence: ${lic.problems.slice(0, 12).map((p) => `${p.game}/${p.asset}: ${p.problem}`).join('; ')}${lic.problems.length > 12 ? '; …' : ''}. Fix each with the studio's toolkit (${games.map((g) => `homie-studio assets check ${g}`).join(', ')} says what is wrong and how to fix it), then deploy again.` };
352 }
353 const licences = lic.count ? `${lic.count} asset${lic.count === 1 ? '' : 's'}, all licensed` : null;
354 const d = await deployFacts(io, root, known);
355 const name = known.name ?? studioName(known.studio ?? (await readJson(io, `${root}/studio.json`)), root);
356 const short = [
357 { k: 'Where', v: d.live ? d.where : 'a new workers.dev address', style: { bold: true } },
358 ...(d.first ? [{ k: 'Creates', v: 'Worker, D1, rooms (free)', style: { color: 'yellow' } }] : []),
359 { k: 'Last', v: d.lastDeployShort },
360 ...(d.commitCount ? [{ k: 'Commits', v: `${d.commitCount} since` }] : []),
361 ...(d.files ? [{ k: 'Files', v: d.files }] : []),
362 ...(d.uncommitted ? [{ k: 'Uncommitted', v: `${d.uncommitted} file${d.uncommitted === 1 ? '' : 's'}`, style: { color: 'yellow' } }] : []),
363 ...(d.newGames.length ? [{ k: 'New', v: d.newGames.join(', '), style: { color: 'green' } }] : []),
364 { k: 'Checks', v: d.checksShort, ...(d.checksOk ? {} : { style: { color: 'yellow' } }) },
365 ...(licences ? [{ k: 'Licences', v: licences, style: { color: 'green' } }] : []),
366 ...(d.playing ? [{ k: 'Playing', v: `${d.playing} ${d.playing === 1 ? 'person' : 'people'} now` }] : []),
367 ];
368 const long = [
369 { k: 'Where', v: d.where, style: { bold: true } },
370 ...(d.first ? [{ k: 'Creates', v: d.first, style: { color: 'yellow' } }] : []),
371 { k: 'Last deploy', v: d.lastDeploy },
372 ...(d.commits.length ? [{ k: 'Commits', v: `${d.commitCount} since then: ${d.commits.slice(0, 6).join(' · ')}${d.commitCount > 6 ? ' …' : ''}` }] : []),
373 ...(d.diffstat ? [{ k: 'Files', v: d.diffstat }] : []),
374 ...(d.uncommitted ? [{ k: 'Uncommitted', v: `${d.uncommitted} file${d.uncommitted === 1 ? '' : 's'} changed and not committed (deployed as they are now)`, style: { color: 'yellow' } }] : []),
375 ...(d.newGames.length ? [{ k: 'New games', v: d.newGames.join(', '), style: { color: 'green' } }] : []),
376 { k: 'Checks', v: d.checks, ...(d.checksOk ? {} : { style: { color: 'yellow' } }) },
377 ...(licences ? [{ k: 'Licences', v: `${licences} (assets/manifest.json of ${lic.games} public game${lic.games === 1 ? '' : 's'})`, style: { color: 'green' } }] : []),
378 ...(d.playing ? [{ k: 'Playing now', v: `${d.playing} ${d.playing === 1 ? 'person' : 'people'} (rooms reconnect and keep their seats)` }] : []),
379 `Run as ${known.by ?? 'Claude'} wrote it: ${what}`,
380 ];
381 return {
382 hold: {
383 kind: 'deploy',
384 question: `Deploy ${name || 'the studio'} to production?`,
385 title: `Deploy ${name || 'the studio'}`,
386 lines: short,
387 detail: { lines: long },
388 no: 'The person cancelled this deploy, so nothing went live. Do not retry it unless they ask; say what is ready and what they would get.',
389 nobody: 'This deploy needs the person\'s go-ahead and nobody could be asked here, so it did not run. Ask them in chat first.',
390 },
391 };
392}
393
394/* ------------------------------------------------------------------ Cloudflare changes outside the deploy */
395
396/**
397 * A change to a studio's Cloudflare account outside its own deploy (lib/commands.mjs says which): held until the person
398 * says Proceed. The studio's deploy records what it creates and never touches what it did not; a delete, a secret, a
399 * hand rollout or a write to the live database made here goes around that record, and what is deleted does not come
400 * back.
401 */
402export async function cloudflareDecision(io, root, c, studio = null) {
403 const s = studio ?? (await readJson(io, `${root}/studio.json`)) ?? {};
404 const cf = s.cloudflare ?? {};
405 const own = [['Worker', cf.worker, 'Worker'], ['D1 database', cf.d1, 'D1'], ['R2 bucket', cf.r2, 'R2']].filter(([, n]) => n);
406 const text = `${(c.names ?? []).join(' ')} ${c.code ?? ''}`;
407 const hits = own.filter(([, n]) => (c.names ?? []).includes(n) || (c.code && text.includes(n)));
408 const name = studioName(s, root);
409 const verb = { delete: 'Delete something on', secret: 'Change a secret on', deploy: 'Roll out a version on', schema: 'Change the live database on', data: 'Write to the live database on' }[c.kind] ?? 'Change something on';
410 return {
411 hold: {
412 kind: 'cloudflare',
413 question: `${verb} Cloudflare for ${name}, outside the studio's deploy?`,
414 title: `Cloudflare: ${String(c.what).slice(0, 60)}`,
415 lines: [
416 { k: 'Change', v: String(c.what).slice(0, 80), style: { color: 'yellow', bold: true } },
417 ...(hits.length ? [{ k: 'Studio', v: `its own ${hits.map(([, n, short]) => `${short} ${n}`).join(', ')}`, style: { color: 'red' } }] : []),
418 ],
419 detail: {
420 lines: [
421 { k: 'Change', v: String(c.what), style: { color: 'yellow', bold: true } },
422 ...(hits.length ? [{ k: 'Studio\'s own', v: hits.map(([k, n]) => `${k} ${n}`).join(', '), style: { color: 'red' } }] : []),
423 { k: 'Account', v: 'the Cloudflare account this computer is signed in to' },
424 { k: 'Call', v: String(c.text ?? '').slice(0, 300) },
425 `The studio's deploy (npm run deploy) records what it creates in studio.json and never touches what it did not create; this goes around that record${c.kind === 'delete' ? ', and what is deleted does not come back' : ''}.`,
426 'Proceed lets this one call through. Cancel stops it here.',
427 ],
428 },
429 no: `The person said no to this Cloudflare change (${c.what}). Do not retry it unless they ask for it.`,
430 nobody: `This changes the Cloudflare account outside the studio's deploy (${c.what}) and nobody could be asked here, so it was not made. Ask the person first; the studio's own Worker, database, storage and secrets change through npm run deploy and homie-studio.`,
431 },
432 };
433}
434
435/* ------------------------------------------------------------------ paid calls */
436
437/** Everything this studio's media jobs have spent in one unit (each job's budget.json `spent`). */
438export async function spentAll(io, root, unit) {
439 let total = 0;
440 for (const kind of ['art', 'videos', 'music']) {
441 let dirs = [];
442 try { dirs = await io.list(`${root}/${kind}`); } catch { dirs = []; }
443 for (const d of dirs.slice(0, 200)) {
444 const b = await readJson(io, `${root}/${kind}/${d.name}/budget.json`);
445 if (b && b.unit === unit) total += Number(b.spent) || 0;
446 }
447 }
448 return Math.round(total * 10000) / 10000;
449}
450
451/**
452 * A paid media call: held when it would take the studio, the open build or the job past its budget, or when its
453 * cost cannot be read first. The price comes from the skill's own --dry-run (free).
454 */
455export async function spendDecision(io, ctx, paid) {
456 const root = ctx.root;
457 const unit = paid.unit;
458 const money = (n) => (unit === 'usd' ? `$${Number(n).toFixed(2)}` : `${Math.round(Number(n))} credits`);
459 let est = null;
460 let basis = '';
461 if (!paid.raw && paid.dryRun && root) {
462 try {
463 const dir = paid.dir ? (paid.dir.startsWith('/') ? paid.dir : `${ctx.cwd}/${paid.dir}`) : ctx.cwd;
464 const r = await io.run(paid.dryRun, { cwd: dir, timeoutMs: 45_000 });
465 const j = JSON.parse(String(r.stdout).slice(String(r.stdout).indexOf('{')));
466 const images = Array.isArray(j?.images) ? j.images.filter((x) => Number.isFinite(Number(x?.price?.usd))) : [];
467 if (j?.price && Number.isFinite(Number(j.price.usd))) { est = Number(j.price.usd); basis = j.price.basis ?? ''; }
468 // The models skill's `mood` prices one image per style-board direction: the call costs their sum.
469 else if (images.length) { est = images.reduce((n, x) => n + Number(x.price.usd), 0); basis = `${images.length} mood image${images.length === 1 ? '' : 's'}`; }
470 else if (j?.quote && Number.isFinite(Number(j.quote.music))) { est = Number(j.quote.music); basis = j.quote.basis ?? ''; }
471 else { const m = /guess of (\d+) credits/.exec(String(j?.why ?? '')); if (m) { est = Number(m[1]); basis = 'a guess from the length'; } }
472 } catch { est = null; }
473 }
474 const budgets = [];
475 if (root) {
476 const cap = Number(ctx.studio?.budget?.[unit]);
477 if (Number.isFinite(cap) && cap >= 0) budgets.push({ what: 'the studio\'s budget (studio.json "budget")', short: 'Studio', cap, spent: await spentAll(io, root, unit) });
478 if (paid.slug) {
479 const job = await readJson(io, `${root}/${paid.kind}/${paid.slug}/budget.json`);
480 if (job && job.unit === unit && Number.isFinite(Number(job.cap))) budgets.push({ what: `this job's cap (${paid.kind}/${paid.slug})`, short: 'Job', cap: Number(job.cap), spent: Number(job.spent) || 0 });
481 }
482 if (ctx.feed) {
483 const b = summarize(ctx.feed);
484 if (b.spend.unit === unit && b.spend.budget !== null) budgets.push({ what: `this build's budget (${b.title})`, short: 'Build', cap: b.spend.budget, spent: b.spend.used });
485 }
486 }
487 const over = est === null ? budgets : budgets.filter((b) => b.spent + est > b.cap + 1e-9);
488 const unknown = est === null;
489 if (!unknown && !over.length) {
490 return budgets.length ? { note: `${paid.provider}: about ${money(est)}${budgets[0] ? ` · ${budgets[0].what.split(' (')[0]}: ${money(budgets[0].spent)} of ${money(budgets[0].cap)} spent` : ''}` } : null;
491 }
492 if (unknown && !budgets.length && !paid.raw) return null;
493 return {
494 hold: {
495 kind: 'spend',
496 question: unknown ? `Make a paid ${paid.provider} call whose cost could not be read first?` : `Spend about ${money(est)} at ${paid.provider}, past the budget?`,
497 title: `Paid: ${paid.provider}${paid.model ? ` ${paid.model.split('/').pop()}` : paid.tool ? ` ${paid.tool}` : ''}`,
498 lines: [
499 { k: 'Cost', v: unknown ? 'unknown (not priced)' : `about ${money(est)}`, style: { color: 'yellow', bold: true } },
500 ...budgets.map((b) => ({ k: b.short, v: `${money(b.spent)}${unknown ? '' : ` + ${money(est)}`} of ${money(b.cap)}`, ...(over.includes(b) ? { style: { color: 'red' } } : {}) })),
501 { k: 'Account', v: `your own ${paid.provider}` },
502 ],
503 detail: {
504 lines: [
505 { k: 'Cost', v: unknown ? (paid.raw ? 'unknown: a request straight at the provider, not priced first' : 'unknown: the skill could not price it') : `about ${money(est)}${basis ? ` (${basis})` : ''}`, style: { color: 'yellow', bold: true } },
506 ...budgets.map((b) => ({ k: 'Budget', v: `${b.what}: ${money(b.spent)} of ${money(b.cap)} spent${!unknown && b.spent + est > b.cap ? ` → ${money(b.spent + est)}, over by ${money(b.spent + est - b.cap)}` : ''}`, ...(over.includes(b) ? { style: { color: 'red' } } : {}) })),
507 { k: 'Account', v: `the person's own ${paid.provider} account` },
508 { k: 'Call', v: String(paid.text ?? paid.tool ?? '').slice(0, 300) },
509 'Proceed lets this one call through (a job\'s own cap still applies: the skill refuses past it). Cancel stops it here.',
510 ],
511 },
512 no: `The person said no to this ${paid.provider} call (${unknown ? 'its cost could not be read first' : `about ${money(est)}, past the budget`}). Do not retry it unless they raise the budget or ask for it.`,
513 nobody: `This ${paid.provider} call would pass the studio's budget, or its cost is unknown, and nobody could be asked here, so it was not made. Ask the person first.`,
514 },
515 };
516}
517
518/* ------------------------------------------------------------------ a Clef model download */
519
520/** Ollama's address on this computer: the default port, or a loopback OLLAMA_HOST the command sets; null for any other host. */
521export function ollamaBase(host) {
522 if (!host) return 'http://127.0.0.1:11434';
523 const m = /^(?:http:\/\/)?(127\.0\.0\.1|localhost)(?::(\d{2,5}))?\/?$/.exec(String(host).trim());
524 return m ? `http://${m[1]}:${m[2] ?? '11434'}` : null;
525}
526
527/**
528 * A Clef model downloaded through Ollama (lib/commands.mjs says which): held until the person says Proceed, with its
529 * size. Homie never downloads a model by itself. `ollama run` downloads only a model Ollama does not have, so it goes
530 * through when Ollama's own list on this computer (GET /api/tags, loopback only) already has that model; a pull is
531 * always held (it fetches a newer copy when there is one).
532 */
533export async function modelPullDecision(io, pull) {
534 const base = ollamaBase(pull.host);
535 const tags = base ? await io.fetchJson(`${base}/api/tags`) : null;
536 const names = Array.isArray(tags?.models) ? tags.models.map((m) => String(m?.name ?? m?.model ?? '')) : null;
537 const want = `${pull.model}:${pull.tag ?? 'latest'}`;
538 const have = names ? names.includes(want) || (!pull.tag && names.includes(pull.model)) : false;
539 if (pull.verb === 'run' && have) return null;
540 const here = have ? 'already on this computer: a pull fetches a newer copy when there is one' : names ? 'not on this computer yet' : 'Ollama did not say (not running, or not on its usual port)';
541 return {
542 hold: {
543 kind: 'model',
544 question: `Download ${pull.model} (${pull.size}) to this computer with Ollama?`,
545 title: `Download ${pull.model}, ${pull.size.replace(/^about /, '~')}`,
546 lines: [
547 { k: 'Size', v: `${pull.size} on this computer's disk`, style: { color: 'yellow', bold: true } },
548 { k: 'Model', v: `${pull.ref} (Clef, ${pull.params})` },
549 { k: 'Here', v: here },
550 ],
551 detail: {
552 lines: [
553 { k: 'Size', v: `${pull.size}, downloaded to this computer's disk`, style: { color: 'yellow', bold: true } },
554 { k: 'Model', v: `${pull.ref}: Cloudflare's Clef decision model, the ${pull.params}, through Ollama` },
555 { k: 'Here', v: here },
556 { k: 'Call', v: String(pull.text ?? '').slice(0, 300) },
557 'Homie never downloads a model by itself: the person says yes to the size first. Without it, AI guides and game decisions under dev play from the game\'s script, and the deployed studio still thinks with its own Workers AI.',
558 'Proceed lets this one download through. Cancel stops it here.',
559 ],
560 },
561 no: `The person said no to downloading ${pull.model} (${pull.size}). Do not retry it unless they ask for it; under dev the guides and game decisions play from the game's script without it.`,
562 nobody: `This downloads ${pull.model} (${pull.size}) to this computer and nobody could be asked here, so it did not run. Ask the person first, and say the size.`,
563 },
564 };
565}
566
567/* ------------------------------------------------------------------ one call, in the order both apps check it */
568
569/**
570 * A shell command line, checked as the mod's Bash guard checks it: big files into git, a production deploy (refused
571 * for an unlicensed asset, else held), a change to the Cloudflare account outside the deploy, a paid call, a Clef
572 * download. { decision, deploy: { root } | null }: `deploy` is set when the command is a studio's deploy (the mod
573 * records the commit after it ran), and then nothing after the deploy is checked. `known` is what the caller knows
574 * about a deploy (deployFacts), or a function of the deploy's studio folder that gives it.
575 */
576export async function shellDecision(io, ctx, command, known = {}) {
577 const g = ctx.guards;
578 const stages = g.guardFiles ? gitStagesOf(command) : [];
579 if (stages.length) {
580 const big = await bigFilesDecision(io, ctx, stages);
581 if (big) return { decision: big, deploy: null };
582 }
583 const deploy = g.guardDeploys ? deployOf(command) : null;
584 if (deploy) {
585 const root = await studioFor(io, ctx, deploy.dir);
586 if (root) {
587 const k = typeof known === 'function' ? await known(root) : known;
588 const own = root === ctx.root ? { studio: ctx.studio, local: ctx.local, name: ctx.name, feed: ctx.feed, last: ctx.last, ...k } : k;
589 return { decision: await deployDecision(io, root, deploy.what, own), deploy: { root } };
590 }
591 }
592 const change = g.guardDeploys ? cloudflareChangeOf(command) : null;
593 if (change) {
594 const root = await studioFor(io, ctx, change.dir);
595 if (root) return { decision: await cloudflareDecision(io, root, change, root === ctx.root ? ctx.studio : null), deploy: null };
596 }
597 const paid = g.guardSpend ? paidOf(command) : null;
598 if (paid) {
599 const d = await spendDecision(io, ctx, paid);
600 if (d) return { decision: d, deploy: null };
601 }
602 const pull = g.guardSpend ? modelPullOf(command) : null;
603 if (pull) {
604 const d = await modelPullDecision(io, pull);
605 if (d) return { decision: d, deploy: null };
606 }
607 return { decision: null, deploy: null };
608}
609
610/**
611 * The MCP tools a hold looks at, by the pattern of their name (`mcp__<server>__<tool>`). The mod's `tool.call` filters
612 * are these same patterns, written out (test/mod-lib.test.mjs checks that they match).
613 */
614export const MCP_TOOLS = Object.freeze({
615 deploy: /^mcp__.+__studio_deploy$/,
616 stripe: /^mcp__.*stripe.*__stripe_api_write$/i,
617 paid: /^mcp__.*(?:fal|eleven|tripo).*__/i,
618 cloudflare: /^mcp__.*cloudflare.*__/i,
619 feedback: /^mcp__.*homie.*__homie_feedback$/,
620});
621
622/**
623 * Tell Homie (lib/feedback.mjs): a homie_feedback send leaves only on the person's own yes, given with the note's exact
624 * words in front of them: Claude Code's question (Send / Don't send), Codex's "proceed <code>". The words are the
625 * call's own, cleaned as the server will clean them, or, for a send that names only its draft, the draft the app saw
626 * (`seen`). A draft or a decline sends nothing and is not held. No switch turns this off.
627 */
628export function feedbackDecision(tool, input, { seen = null } = {}) {
629 if (!MCP_TOOLS.feedback.test(String(tool ?? '')) || input?.action !== 'send') return null;
630 const c = input.text
631 ? cleanNote({ kind: input.kind, text: input.text, step: input.step, email: input.email, offered: input.offered === true, studioVersion: input.studioVersion, pluginVersion: input.pluginVersion, app: input.app })
632 : seen?.note ? { ok: true, note: seen.note } : null;
633 if (!c?.ok) return { deny: c ? `Not sent: ${c.why}.` : 'Not sent: send the note with its kind and text (as drafted), so the person sees exactly what would go.' };
634 // The same words as the draft the app saw: its own facts (the server's versions and app) are what goes with them.
635 if (seen?.note && seen.note.text === c.note.text && seen.note.kind === c.note.kind) c.note = seen.note;
636 const words = c.note.text.split('\n');
637 return {
638 hold: {
639 kind: 'feedback',
640 question: 'Send this note to Homie?',
641 title: 'Tell Homie: send this note?',
642 options: ['Send', 'Don\u2019t send'],
643 yes: 'Send',
644 lines: [{ k: 'Kind', v: KIND_LABEL[c.note.kind] ?? c.note.kind }, ...words.slice(0, 6).map((l, i) => ({ k: i ? ' ' : 'Note', v: l || ' ' }))],
645 more: 'the whole note, and what goes with it, in the Hold pane',
646 detail: { lines: [{ k: 'Kind', v: KIND_LABEL[c.note.kind] ?? c.note.kind }, ...words.map((l) => ` ${l}`), { k: 'With it', v: withLine(c.note) }, 'Only the person\'s yes sends it: privately, to the people who make Homie.'] },
647 no: 'The person chose not to send it. Nothing was sent. Tell them so in a few words, and do not offer to send a note again in this session.',
648 nobody: 'Nothing is sent to Homie without the person\'s own yes, and nobody could be asked here. Nothing was sent.',
649 },
650 };
651}
652
653/** The Homie MCP's studio_deploy: a production deploy of the session's studio. */
654export async function mcpDeployDecision(io, ctx, known = {}) {
655 if (!ctx.guards.guardDeploys || !ctx.root) return null;
656 return deployDecision(io, ctx.root, 'studio_deploy', { studio: ctx.studio, local: ctx.local, name: ctx.name, feed: ctx.feed, last: ctx.last, ...known });
657}
658
659/** A write through Stripe's MCP that would hand back a webhook's signing secret: refused, always. */
660export function stripeDecision(tool, input) {
661 const why = stripeSecretWriteOf(tool, input);
662 return why ? { deny: why } : null;
663}
664
665/** A generating tool of a fal, ElevenLabs or Tripo MCP server: held past the budget, or when it cannot be priced. */
666export async function paidMcpDecision(io, ctx, tool, input) {
667 const paid = ctx.guards.guardSpend ? paidMcpOf(tool, input) : null;
668 return paid ? spendDecision(io, ctx, paid) : null;
669}
670
671/** A Cloudflare MCP tool (Cloudflare's own servers, a claude.ai connector) changing the account, inside a studio. */
672export async function cloudflareMcpDecision(io, ctx, tool, input) {
673 const change = ctx.guards.guardDeploys && ctx.root ? cloudflareMcpChangeOf(tool, input) : null;
674 return change ? cloudflareDecision(io, ctx.root, change, ctx.studio) : null;
675}
676
677/**
678 * An MCP tool call, checked as the mod checks one: the Homie MCP's studio_deploy, a note to Homie (homie_feedback send,
679 * held for the person's own yes), a write through Stripe's MCP that would hand back a webhook's signing secret
680 * (refused), a paid call at fal, ElevenLabs or Tripo, and a change through a Cloudflare MCP server inside a studio. { decision, deploy: { root } | null }; the first that decides wins.
681 */
682export async function mcpDecision(io, ctx, tool, input, known = {}) {
683 const t = String(tool ?? '');
684 if (MCP_TOOLS.deploy.test(t)) {
685 const d = await mcpDeployDecision(io, ctx, typeof known === 'function' ? await known(ctx.root) : known);
686 return { decision: d, deploy: d && ctx.root ? { root: ctx.root } : null };
687 }
688 const checks = [
689 [MCP_TOOLS.feedback, () => feedbackDecision(t, input)],
690 [MCP_TOOLS.stripe, () => stripeDecision(t, input)],
691 [MCP_TOOLS.paid, () => paidMcpDecision(io, ctx, t, input)],
692 [MCP_TOOLS.cloudflare, () => cloudflareMcpDecision(io, ctx, t, input)],
693 ];
694 for (const [re, check] of checks) {
695 if (!re.test(t)) continue;
696 const d = await check();
697 if (d) return { decision: d, deploy: null };
698 }
699 return { decision: null, deploy: null };
700}
701hooks/lib/redact.mjs 100 lines1/**
2 * SECRETS OUT OF TOOL OUTPUT. The Homie mod runs every tool result through `redact` before Claude reads it or the
3 * transcript keeps it. It only matches text: it never opens a key file, the keychain or the environment.
4 *
5 * What it hides: the studio's own one-time and owner keys (office and stats keys `hsk_`, progress write keys `hbk_`,
6 * agent passes `hap_…`, whose public id part stays), Cloudflare API tokens and global keys, provider keys (fal,
7 * ElevenLabs, Anthropic, OpenAI, GitHub, npm, Stripe keys and webhook secrets, AWS, Google), bearer tokens, private key blocks, and the value
8 * of any `NAME=value` whose name says key, token or secret.
9 *
10 * A link that carries one (the owner's one-time sign-in link, a confirm link, a player-owner link) is a link for the
11 * person, not for Claude: it is taken out whole and handed back in `links`, so the mod can show it to the person in
12 * its pane, and Claude reads that it is there.
13 */
14
15const HIDE = (kind) => `[${kind} hidden by Homie]`;
16
17/** Each rule: what it is, the pattern, and how to write what replaces a match (the default hides the whole match). */
18export const RULES = [
19 { kind: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
20 { kind: 'office key', re: /\bhsk_[a-f0-9]{48}\b/g },
21 { kind: 'progress key', re: /\bhbk_[a-f0-9]{48}\b/g },
22 // An agent pass: its id (hap_ and ten hex digits) is public and stays; the secret after it goes.
23 { kind: 'agent pass', re: /\b(hap_[a-f0-9]{10})_[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/g, to: (m, id) => `${id}_${HIDE('agent pass')}` },
24 { kind: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
25 { kind: 'OpenAI key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/g },
26 { kind: 'ElevenLabs key', re: /\bsk_[a-f0-9]{48}\b/g },
27 { kind: 'Stripe key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
28 // A webhook signing secret has no live/test part: whsec_ and the secret itself.
29 { kind: 'Stripe webhook secret', re: /\bwhsec_[A-Za-z0-9+/=_-]{16,}/g },
30 { kind: 'fal key', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\b/g },
31 { kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g },
32 { kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
33 { kind: 'AWS key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
34 { kind: 'Google key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
35 { kind: 'Cloudflare key', re: /(X-Auth-Key["']?\s*[:=]\s*["']?)([a-f0-9]{37})\b/gi, to: (m, head) => `${head}${HIDE('Cloudflare key')}` },
36 { kind: 'token', re: /(\b[Bb]earer\s+)([A-Za-z0-9_\-.~+/]{24,}=*)/g, to: (m, head) => `${head}${HIDE('token')}` },
37 // NAME=value (or NAME: value) where the name says what it is: CLOUDFLARE_API_TOKEN, FAL_KEY, ELEVENLABS_API_KEY ...
38 // Only a value that looks like a key (16+ characters of letters AND digits, no dots or brackets), so source code
39 // that names a key (`const FAL_KEY = process.env.FAL_KEY`) is never changed under Claude's eyes.
40 {
41 kind: 'secret',
42 re: /\b([A-Z][A-Z0-9_]*(?:API_KEY|API_TOKEN|_TOKEN|_SECRET|SECRET_KEY|_KEY|PASSWORD)\b["']?\s*[=:]\s*["']?)([A-Za-z0-9_\-+/=:]{16,})(?![A-Za-z0-9_.(])/g,
43 to: (m, head, value) => (/[A-Za-z]/.test(value) && /[0-9]/.test(value) ? `${head}${HIDE('secret')}` : m),
44 },
45];
46
47const URL_RE = /https?:\/\/[^\s"'<>`)\]]+/g;
48const LINK_SECRET = /\b(?:hsk|hbk)_[a-f0-9]{48}\b|\bhap_[a-f0-9]{10}_[A-Za-z0-9_-]{40}/;
49export const LINK_NOTE = '[one-time owner link: the Homie mod showed it to the person in its Studio pane; Claude never sees it]';
50
51/** Redact one string: { text, hits: [kinds], links: [urls] }. Unchanged text keeps its identity. */
52export function redactText(text) {
53 if (typeof text !== 'string' || text.length < 8) return { text, hits: [], links: [] };
54 const hits = [];
55 const links = [];
56 let out = text.replace(URL_RE, (url) => {
57 if (!LINK_SECRET.test(url)) return url;
58 links.push(url.replace(/[.,;:]+$/, ''));
59 hits.push('owner link');
60 return LINK_NOTE + url.slice(url.replace(/[.,;:]+$/, '').length);
61 });
62 for (const rule of RULES) {
63 rule.re.lastIndex = 0;
64 out = out.replace(rule.re, (...m) => {
65 const replaced = rule.to ? rule.to(...m) : HIDE(rule.kind);
66 if (replaced !== m[0]) hits.push(rule.kind);
67 return replaced;
68 });
69 }
70 return hits.length ? { text: out, hits, links } : { text, hits, links };
71}
72
73/**
74 * Redact every string inside a value (a tool's result record, an MCP result, a string), at most 12 levels deep.
75 * Returns { value, hits, links }; `value` is the same object when nothing was hidden.
76 */
77export function redact(value) {
78 const hits = [];
79 const links = [];
80 const walk = (v, depth) => {
81 if (typeof v === 'string') {
82 const r = redactText(v);
83 if (r.hits.length) { hits.push(...r.hits); links.push(...r.links); }
84 return r.text;
85 }
86 if (depth > 12 || v === null || typeof v !== 'object') return v;
87 if (Array.isArray(v)) {
88 let changed = false;
89 const next = v.map((x) => { const y = walk(x, depth + 1); if (y !== x) changed = true; return y; });
90 return changed ? next : v;
91 }
92 let changed = false;
93 const next = {};
94 for (const [k, x] of Object.entries(v)) { const y = walk(x, depth + 1); if (y !== x) changed = true; next[k] = y; }
95 return changed ? next : v;
96 };
97 const out = walk(value, 0);
98 return { value: out, hits: [...new Set(hits)], links: [...new Set(links)] };
99}
100hooks/lib/feedback.mjs 183 lines1/**
2 * TELL HOMIE (0.28.0): a short note from a creator to the people who make Homie, drafted by Claude in plain words,
3 * shown to the person exactly as it would go, and sent ONLY after they say yes. One module for every road a note
4 * takes from this side: the local MCP's `homie_feedback` (lib/mcp-tools.mjs), its card (mcp/ui/feedback.js), and
5 * the rules the Homie mod and the remote Homie MCP keep the same (homie.rocks has its own copy of these rules and
6 * applies them again when a note arrives).
7 *
8 * What a note is, and nothing more: its kind (stuck, confusing, idea, praise, bug), the words, the step or skill it
9 * is about, the studio and plugin versions, which app it came from, whether Claude offered it or the person asked,
10 * and a reply address only when the person typed one. No file, no log, no code, no key, no studio name, no path.
11 *
12 * Before anything is shown, the words go through the mod's own redaction (lib/redact.mjs, the same rules the mod
13 * uses on tool output: keys, tokens, private keys, owner links) and then a note's own: fenced code, home folders
14 * (a user's home becomes ~), email addresses, a workers.dev address's account name, network addresses and long
15 * opaque strings. What was taken out is listed with the draft, so the person sees it too.
16 *
17 * THE DRAFT IS THE NOTE. A draft's id is a hash of everything that would be sent; a send carries the id and the same
18 * fields, and a send whose fields do not hash to its id is refused. So what goes out is exactly what was shown.
19 */
20import { redactText } from './redact.mjs';
21
22export const FEEDBACK_KINDS = Object.freeze(['stuck', 'confusing', 'idea', 'praise', 'bug']);
23export const FEEDBACK_APPS = Object.freeze(['claude-code', 'codex', 'grok', 'claude-desktop', 'claude-ai', 'other']);
24export const FEEDBACK_LIMITS = Object.freeze({ text: 1500, raw: 6000, lines: 30, step: 80, email: 254, version: 32, perSession: 5, offers: 3 });
25export const KIND_LABEL = Object.freeze({ stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' });
26export const APP_LABEL = Object.freeze({ 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok', 'claude-desktop': 'the Claude desktop app', 'claude-ai': 'Claude on the web or a phone', other: 'another app' });
27
28/** Where a note goes on a Homie directory (homie.rocks, or a local one for tests). */
29export const feedbackUrl = (directory = 'https://homie.rocks') => `${String(directory || 'https://homie.rocks').replace(/\/+$/, '')}/api/feedback/tell`;
30
31const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{2,63}$/;
32const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,31}$/;
33const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
34const HIDDEN = (what) => `[${what} hidden by Homie]`;
35
36/*
37 * A note's own rules, after the mod's. Each: what it is (said to the person), the pattern, and its replacement.
38 * Order matters: code first (whatever is inside a fence goes whole), then links and addresses.
39 */
40const NOTE_RULES = [
41 { kind: 'code', re: /```[\s\S]*?(?:```|$)/g, to: () => '[code left out by Homie]' },
42 // A workers.dev address names the Cloudflare account, often after its owner: the account part goes.
43 // (Labels are bounded, as DNS bounds them: an unbounded one makes this rule quadratic on a long run of hyphens.)
44 { kind: 'a workers.dev account name', re: /\b([a-z0-9-]{1,63}\.)?([a-z0-9-]{1,63})(\.workers\.dev)\b/gi, to: (m, sub, account, tail) => `${sub ?? ''}[account]${tail}` },
45 { kind: 'a home folder', re: /(?:\/Users\/|\/home\/)([^/\s"'`]+)/g, to: () => '~' },
46 { kind: 'a home folder', re: /\b[A-Za-z]:\\Users\\([^\\\s"'`]+)/g, to: () => '~' },
47 { kind: 'an email address', re: /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,63}\b/g, to: () => HIDDEN('an email address') },
48 // A network address that is not this computer's own.
49 { kind: 'a network address', re: /\b(?!127\.0\.0\.1\b)(?!0\.0\.0\.0\b)(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/g, to: () => HIDDEN('a network address') },
50 // A signed token (three dotted parts, the first two JSON in base64): whole, before the rule below takes one part.
51 { kind: 'a token', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, to: () => HIDDEN('a token') },
52 // Anything long and opaque that a rule above did not name (a token of a kind nobody listed, a hash, a blob), after
53 // a dot too.
54 { kind: 'a long code', re: /(?<![A-Za-z0-9_\-+/=])(?=[A-Za-z0-9_\-+/=]*[0-9])(?=[A-Za-z0-9_\-+/=]*[A-Za-z])[A-Za-z0-9_\-+/=]{32,}(?![A-Za-z0-9_\-+/=])/g, to: () => HIDDEN('a long code') },
55];
56
57/** The words of a note as they may go: redacted, plain, and the list of what was taken out. */
58export function redactNote(value) {
59 let text = String(value ?? '').replace(/\r\n?/g, '\n').replace(CONTROL, '');
60 const taken = [];
61 for (const rule of NOTE_RULES.slice(0, 1)) text = applyRule(text, rule, taken);
62 const r = redactText(text);
63 if (r.hits.length) {
64 taken.push(...r.hits.map((h) => (h === 'owner link' ? 'a private link' : `a ${h}`.replace(/^a ([aeiou])/i, 'an $1'))));
65 // The mod's words for a link it showed in its pane are not true of a note: here the link is simply gone.
66 text = r.text.split('[one-time owner link: the Homie mod showed it to the person in its Studio pane; Claude never sees it]').join(HIDDEN('a private link'));
67 }
68 for (const rule of NOTE_RULES.slice(1)) text = applyRule(text, rule, taken);
69 text = text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
70 return { text, taken: [...new Set(taken)] };
71}
72
73function applyRule(text, rule, taken) {
74 rule.re.lastIndex = 0;
75 return text.replace(rule.re, (...m) => {
76 const out = rule.to(...m);
77 if (out !== m[0]) taken.push(rule.kind);
78 return out;
79 });
80}
81
82const oneLine = (value, max) => String(value ?? '').replace(CONTROL, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
83
84/**
85 * A note as it may be shown and sent, or why not: { ok: true, note, taken } | { ok: false, why }.
86 * `input`: { kind, text, step?, studioVersion?, pluginVersion?, app?, email?, offered? }.
87 */
88export function cleanNote(input = {}) {
89 const kind = String(input.kind ?? '').trim().toLowerCase();
90 if (!FEEDBACK_KINDS.includes(kind)) return { ok: false, why: `kind is one of ${FEEDBACK_KINDS.join(', ')}` };
91 const raw = String(input.text ?? '');
92 // Before any rule reads it: a note is short, and a rule's cost grows with what it is given.
93 if (raw.length > FEEDBACK_LIMITS.raw) return { ok: false, why: `the note is ${raw.length.toLocaleString('en-US')} characters: keep it under ${FEEDBACK_LIMITS.text.toLocaleString('en-US')} (a short note, never a log or a file)` };
94 const { text, taken } = redactNote(raw);
95 if (!text) return { ok: false, why: 'the note is empty: write what happened in a sentence or two, in plain words' };
96 if (text.length > FEEDBACK_LIMITS.text) return { ok: false, why: `the note is ${text.length} characters: keep it under ${FEEDBACK_LIMITS.text.toLocaleString('en-US')} (a short note, never a log or a file)` };
97 if (text.split('\n').length > FEEDBACK_LIMITS.lines) return { ok: false, why: `the note has more than ${FEEDBACK_LIMITS.lines} lines: a short note, never a log or a file` };
98 const stepRaw = oneLine(input.step, 400);
99 const step = stepRaw ? oneLine(redactNote(stepRaw).text, FEEDBACK_LIMITS.step) || null : null;
100 const version = (v) => { const s = String(v ?? '').trim().replace(/^v/, ''); return VERSION.test(s) ? s : null; };
101 const app = FEEDBACK_APPS.includes(String(input.app ?? '')) ? String(input.app) : 'other';
102 const emailRaw = String(input.email ?? '').trim();
103 if (emailRaw && (emailRaw.length > FEEDBACK_LIMITS.email || !EMAIL.test(emailRaw))) return { ok: false, why: 'the reply address is not a whole email address: leave it out, or ask the person for theirs' };
104 const note = {
105 kind, text, step,
106 studioVersion: version(input.studioVersion), pluginVersion: version(input.pluginVersion),
107 app, email: emailRaw || null, offered: input.offered === true,
108 };
109 return { ok: true, note, taken };
110}
111
112/** A draft's id: what would be sent, hashed. The same note always has the same id; any change gives another. */
113export async function draftId(note) {
114 const canonical = JSON.stringify([note.kind, note.text, note.step, note.studioVersion, note.pluginVersion, note.app, note.email, note.offered]);
115 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
116 return `fd_${[...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('')}`;
117}
118
119/** What goes with the words, in a line the person reads. */
120export function withLine(note) {
121 const parts = [
122 note.step ? `the step (${note.step})` : null,
123 note.studioVersion ? `studio ${note.studioVersion}` : null,
124 note.pluginVersion ? `plugin ${note.pluginVersion}` : null,
125 APP_LABEL[note.app] ?? null,
126 note.offered ? 'that Claude offered it' : 'that you asked to send it',
127 ].filter(Boolean);
128 return `${parts.join(' · ')}. ${note.email ? `A reply address: ${note.email}.` : 'No reply address.'}`;
129}
130
131/** The note, exactly as it would go, as the text a chat shows (for an app with no card). */
132export function noteBlock(note, taken = []) {
133 return [
134 ` Kind: ${KIND_LABEL[note.kind]}`,
135 ...note.text.split('\n').map((l, i, all) => ` ${i === 0 ? '"' : ' '}${l}${i === all.length - 1 ? '"' : ''}`),
136 ` Sent with it: ${withLine(note)}`,
137 ...(taken.length ? [` Homie took out: ${taken.join(', ')}.`] : []),
138 ].join('\n');
139}
140
141/** The body a Homie directory takes at /api/feedback/tell. `source`: 'plugin' (this toolkit) or 'mod'. */
142export function noteBody(note, { source, consent }) {
143 const body = { kind: note.kind, text: note.text, source, consent, offered: note.offered, app: note.app };
144 for (const k of ['step', 'studioVersion', 'pluginVersion', 'email']) if (note[k]) body[k] = note[k];
145 return body;
146}
147
148/**
149 * Send a note the person said yes to: { ok, reference, repeat } | { ok: false, why, status }. Never retried here:
150 * a second press is the person's to make, and the directory answers a repeat with the same reference.
151 */
152export async function sendNote(note, { directory, source = 'plugin', consent = 'chat', fetchImpl = fetch, timeoutMs = 15_000 } = {}) {
153 let res;
154 try {
155 res = await fetchImpl(feedbackUrl(directory), {
156 method: 'POST',
157 headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': `homie-studio-feedback (${source})` },
158 body: JSON.stringify(noteBody(note, { source, consent })),
159 redirect: 'manual',
160 signal: AbortSignal.timeout(timeoutMs),
161 });
162 } catch (error) {
163 return { ok: false, status: 0, why: `Homie could not be reached (${error?.name === 'TimeoutError' ? 'no answer in 15 s' : error?.cause?.code ?? error?.message ?? 'no connection'}). Nothing was sent; the person can try again later.` };
164 }
165 let body = null;
166 try { body = JSON.parse(await res.text()); } catch { body = null; }
167 if (res.ok && body?.ok === true && typeof body.reference === 'string') return { ok: true, reference: body.reference, repeat: body.repeat === true };
168 const said = typeof body?.message === 'string' ? oneLine(body.message, 300) : null;
169 if (res.status === 429) return { ok: false, status: 429, why: said ?? 'Homie has had enough notes from this network today. Nothing was sent; try again tomorrow.' };
170 if (res.status === 404) return { ok: false, status: 404, why: 'This Homie directory does not take notes yet. Nothing was sent.' };
171 return { ok: false, status: res.status, why: `${said ?? `Homie answered ${res.status}`}. Nothing was sent.` };
172}
173
174/** Which app a client is, from its MCP initialize clientInfo. */
175export function appOf(clientInfo) {
176 const name = String(clientInfo?.name ?? '').toLowerCase();
177 if (/codex/.test(name)) return 'codex';
178 if (/grok/.test(name)) return 'grok';
179 if (/claude[-_ ]?code/.test(name)) return 'claude-code';
180 if (/claude/.test(name)) return 'claude-desktop';
181 return 'other';
182}
183hooks/lib/results.mjs 142 lines1/**
2 * Homie's tool results, read back into data the mod draws natively: the setup status as a checklist, a check's rows,
3 * a playtest's verdicts, a deploy's live links, and the cards the Homie MCP tools answer with (structuredContent
4 * `kind` setup, build or studio). Each reader takes the text the command printed (the same formatter the studio's
5 * CLI uses: test/mod-lib.test.mjs feeds them real output) and returns null when the text is not what it reads, so
6 * the transcript then shows the tool's own output unchanged.
7 */
8
9const MARKS = { '✓': 'ok', '→': 'act', '✗': 'missing', '○': 'optional', '…': 'later', '?': 'unknown' };
10
11/** The text of a tool's result: a Bash record's stdout, an MCP result's text blocks, or a string. */
12export function textOf(output) {
13 if (typeof output === 'string') return output;
14 if (!output || typeof output !== 'object') return '';
15 if (typeof output.stdout === 'string') return output.stdout;
16 const blocks = Array.isArray(output) ? output : Array.isArray(output.content) ? output.content : null;
17 if (blocks) return blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
18 return '';
19}
20
21/** An MCP result's structuredContent (the Homie MCP's cards carry `kind`), or null. */
22export function structuredOf(output) {
23 if (!output || typeof output !== 'object') return null;
24 if (output.structuredContent && typeof output.structuredContent === 'object') return output.structuredContent;
25 if (typeof output.kind === 'string') return output;
26 return null;
27}
28
29/** `homie-studio setup status` (lib/doctor.mjs formatStatus): { title, rows, ready, next, note } or null. */
30export function parseSetupStatus(text) {
31 const lines = String(text ?? '').split('\n');
32 const head = lines.findIndex((l) => /^Setup status( for .+| \(before the studio exists\))\s*$/.test(l));
33 if (head < 0) return null;
34 const title = lines[head].trim();
35 const rows = [];
36 let i = head + 1;
37 for (; i < lines.length; i++) {
38 const l = lines[i];
39 if (/^Ready: /.test(l)) break;
40 const row = /^ {2}([✓→✗○…?]) (\S.*?)(?: {2,}|$)(\((?:optional|recommended)\) )?(.*)$/.exec(l);
41 if (row) { rows.push({ state: MARKS[row[1]], label: row[2].trim(), need: row[3] ? row[3].replace(/[() ]/g, '') : 'required', detail: row[4].trim(), unlocks: '', fix: '' }); continue; }
42 const more = /^ {4,}(unlocks |fix: )?(.*)$/.exec(l);
43 if (more && rows.length) {
44 const last = rows[rows.length - 1];
45 if (more[1] === 'unlocks ') last.unlocks = more[2].trim();
46 else if (more[1] === 'fix: ') last.fix = more[2].trim();
47 else if (more[2].trim()) last.fix = last.fix ? `${last.fix} ${more[2].trim()}` : more[2].trim();
48 }
49 }
50 if (!rows.length) return null;
51 const ready = [];
52 const readyLine = lines.find((l) => /^Ready: /.test(l));
53 if (readyLine) {
54 for (const part of readyLine.slice(7).split(' · ')) {
55 const m = /^(.*?)\s+([✓○…])$/.exec(part.trim());
56 if (m) ready.push({ feature: m[1], state: m[2] === '✓' ? 'ready' : m[2] === '…' ? 'later' : 'optional' });
57 }
58 }
59 const next = [];
60 const now = lines.findIndex((l) => /^Do this now:/.test(l));
61 if (now >= 0) for (let k = now + 1; k < lines.length && /^ {2}→ /.test(lines[k]); k++) next.push(lines[k].replace(/^ {2}→ /, '').trim());
62 return { title, rows, ready, next };
63}
64
65/** `homie-studio check` (two browsers, one room, one round): { ok, summary, rows, why } or null. */
66export function parseCheck(text, { known = false } = {}) {
67 const t = String(text ?? '');
68 const pass = /^PASS: two fresh browsers in room (\S+) finished round (\d+) \((\d+) humans?, (\d+) bots?\) in (\d+) s\.$/m.exec(t);
69 if (pass) {
70 const rows = [
71 { state: 'pass', label: 'Two fresh browsers seated', note: '' },
72 { state: 'pass', label: `Same room (${pass[1]})`, note: '' },
73 { state: 'pass', label: `Round ${pass[2]} finished`, note: `${pass[3]} humans, ${pass[4]} bots` },
74 ];
75 for (const m of t.matchAll(/^ {2}(\w+): seat (\d+) \((\w+)\), seated in ([\d.]+) s$/gm)) rows.push({ state: 'pass', label: `${m[1]} seated`, note: `seat ${m[2]}, ${m[3]}, ${m[4]} s` });
76 for (const m of t.matchAll(/^ {2}(\w+) drew (\S+) fps(?: on (.+))?$/gm)) rows.push({ state: 'info', label: `${m[1]} frame rate`, note: `${m[2]} fps${m[3] ? ` on ${m[3]}` : ''}` });
77 return { ok: true, kind: 'check', summary: `Two browsers finished a round in ${pass[5]} s`, rows };
78 }
79 const fail = /^homie-studio: (.+)$/m.exec(t);
80 if (fail && known) return { ok: false, kind: 'check', summary: 'The two-browser check did not pass', rows: [{ state: 'fail', label: 'Two-browser check', note: fail[1] }], why: fail[1] };
81 return null;
82}
83
84/** `homie-studio port check`: { ok, summary, rows, receipt } or null. */
85export function parsePortCheck(text) {
86 const t = String(text ?? '');
87 const head = /^(PASS|NOT YET): (\S+) at (\S+) \((\d+) s\)$/m.exec(t);
88 if (!head) return null;
89 const rows = [];
90 for (const m of t.matchAll(/^ {2}(ok|FAIL|skip) {1,4}(.+)$/gm)) rows.push({ state: m[1] === 'ok' ? 'pass' : m[1] === 'FAIL' ? 'fail' : 'skip', label: m[2].trim(), note: '' });
91 const receipt = /^Receipt and screenshots: (.+)$/m.exec(t)?.[1] ?? null;
92 return { ok: head[1] === 'PASS', kind: 'port check', summary: `${head[1] === 'PASS' ? 'Passed' : 'Not yet'}: ${head[2]} (${head[4]} s)`, game: head[2], url: head[3], rows, receipt };
93}
94
95/** The playtest skill's `run` / `report` print: { ok, summary, rows, weak, report } or null. */
96export function parsePlaytest(text) {
97 const t = String(text ?? '');
98 const block = /^rows:\n((?: {2}.*\n?)+)/m.exec(t);
99 if (!block) return null;
100 const rows = [];
101 for (const l of block[1].split('\n')) {
102 const m = /^ {2}(PASS|FAIL|WARN|BLOCKED)\s+(.+?)(?::\s+(.+))?$/.exec(l);
103 if (m) rows.push({ state: m[1] === 'PASS' ? 'pass' : m[1] === 'FAIL' ? 'fail' : m[1] === 'WARN' ? 'warn' : 'blocked', label: m[2], note: m[3] ?? '' });
104 }
105 if (!rows.length) return null;
106 const weak = /^weak:\n((?: {2}.*\n?)+)/m.exec(t)?.[1].split('\n').map((l) => l.trim()).filter(Boolean) ?? [];
107 const game = /^game: (.+)$/m.exec(t)?.[1] ?? null;
108 const report = /^report: (.+)$/m.exec(t)?.[1] ?? null;
109 const fails = rows.filter((r) => r.state === 'fail').length;
110 return { ok: fails === 0, kind: 'playtest', game, summary: `${rows.filter((r) => r.state === 'pass').length} pass · ${fails} fail · ${rows.filter((r) => r.state === 'warn').length} warn${rows.some((r) => r.state === 'blocked') ? ' · some could not be judged here' : ''}`, rows, weak, report };
111}
112
113/** `homie-studio deploy`: { ok, url, also, games: [{ id, play }], media, cloudflare, claim } or null. */
114export function parseDeploy(text) {
115 const t = String(text ?? '');
116 const live = /^Live: (\S+)(?: \(commit ([0-9a-f]{7})(?: on (\S+))?\))?$/m.exec(t);
117 if (!live) return null;
118 const games = [];
119 const media = [];
120 for (const m of t.matchAll(/^ {2}([a-z0-9][a-z0-9-]*): (https?:\/\/\S+)$/gm)) games.push({ id: m[1], play: m[2] });
121 for (const m of t.matchAll(/^ {2}(song|video) (\S+): (https?:\/\/\S+)$/gm)) media.push({ kind: m[1], slug: m[2], page: m[3] });
122 const also = /^ {2}also at (\S+)$/m.exec(t)?.[1] ?? null;
123 const cloudflare = /^Cloudflare: (.+)$/m.exec(t)?.[1] ?? null;
124 return { ok: true, kind: 'deploy', url: live[1].replace(/[.,]$/, ''), commit: live[2] ?? null, branch: live[3] ?? null, also, games, media, cloudflare, claim: /claimed itself in the directory/.test(t) };
125}
126
127/** Which reader a result goes to, from what the tool call was (when the mod saw it) or from the text alone. */
128export function readResult({ tool, call, output }) {
129 const s = structuredOf(output);
130 if (s && ['setup', 'build', 'studio', 'feedback'].includes(s.kind)) return { kind: `card:${s.kind}`, data: s };
131 const text = textOf(output);
132 if (!text) return null;
133 const sub = call?.sub ?? null;
134 if (sub === 'setup status' || sub === 'doctor' || /^Setup status/m.test(text)) { const d = parseSetupStatus(text); if (d) return { kind: 'setup', data: d }; }
135 if (sub === 'port check' || /^(PASS|NOT YET): \S+ at \S+ \(\d+ s\)$/m.test(text)) { const d = parsePortCheck(text); if (d) return { kind: 'checks', data: d }; }
136 if (sub === 'check' || /^PASS: two fresh browsers/m.test(text)) { const d = parseCheck(text, { known: sub === 'check' }); if (d) return { kind: 'checks', data: d }; }
137 if (call?.playtest || /^rows:\n {2}(PASS|FAIL|WARN|BLOCKED)/m.test(text)) { const d = parsePlaytest(text); if (d) return { kind: 'checks', data: d }; }
138 if (sub === 'deploy' || /^Live: https?:\/\//m.test(text)) { const d = parseDeploy(text); if (d) return { kind: 'deploy', data: d }; }
139 if (tool && /__(?:setup_status|studio_scaffold|studio_open)$/.test(tool)) { const d = parseSetupStatus(text); if (d) return { kind: 'setup', data: d }; }
140 return null;
141}
142hooks/lib/views.mjs 729 lines1/**
2 * What the Homie mod draws: the band above the prompt, the Studio pane's tabs, the parts pane, the arcade, the guard
3 * panels above Claude Code's question dialog, and Homie's tool results. Every function takes the surface's element
4 * table (`t`, from `$.ui.resolve(e)`) and plain data, and returns a tree; none calls the mods API. Callbacks
5 * (`on.*`) come from the hooks module, which owns every `$` call.
6 *
7 * Drawn for the terminal and the Claude desktop app's Code tab alike: pictures are a `Raster` (or an `Image`) in the
8 * terminal and an `Svg` on the desktop; everything else is Box, Text, Button, Link, Code, Select and Input.
9 */
10import { BY, LEGEND, MARK, budgetRows, usd } from './art.mjs';
11import { ago, bar, markOf } from './feed.mjs';
12
13export const ACCENT = '#ffcf5a';
14const DIM = { dimColor: true };
15
16/** One line of text: a string or inline parts, with Text styles. */
17export function line(t, parts, style = {}) {
18 return t.Text({ ...style, children: (Array.isArray(parts) ? parts : [parts]).filter((p) => p !== null && p !== undefined && p !== false).map((p) => (typeof p === 'string' || typeof p === 'number' ? String(p) : p)) });
19}
20const span = (t, text, style = {}) => t.Text({ ...style, children: [String(text)] });
21const col = (t, children, props = {}) => t.Box({ flexDirection: 'column', ...props, children: children.filter(Boolean) });
22const row = (t, children, props = {}) => t.Box({ flexDirection: 'row', columnGap: 1, ...props, children: children.filter(Boolean) });
23
24/** A link the person opens (`https:` or this computer's dev site); plain text where the address is not one. */
25export function link(t, href, label) {
26 const h = linkable(href);
27 let ok = typeof h === 'string' && (/^https:\/\/[^\s@]+$/.test(h) || /^http:\/\/localhost(?::\d+)?(?:\/[^\s@]*)?$/.test(h));
28 let norm = null;
29 try { norm = ok ? new URL(h).href : null; } catch { ok = false; }
30 return ok && norm.length <= 2048 ? t.Link({ href: norm, label: label ?? href }) : span(t, label ? `${label} ${href ?? ''}` : String(href ?? ''), DIM);
31}
32
33/** A localhost URL as Claude Code's Link takes it (`http://localhost`), for this computer's dev site at 127.0.0.1. */
34export const linkable = (url) => (typeof url === 'string' ? url.replace(/^http:\/\/127\.0\.0\.1(?=[:/]|$)/, 'http://localhost') : url);
35
36const fit = (text, n) => { const s = String(text ?? ''); return s.length <= n ? s : `${s.slice(0, Math.max(1, n - 1))}…`; };
37
38/* ------------------------------------------------------------------ the band */
39
40/**
41 * The band above the prompt: studio · game · build step or % · ▶ Play · N playing now. Parts drop from the end to fit
42 * `columns`. Returns null outside a studio.
43 */
44export function band(t, s, columns) {
45 if (!s || !s.name) return null;
46 // One row, always: inline parts in one Text, dropped from the end to fit, and cut at the edge if still too long.
47 // A link counts its address too (a terminal draws the URL after the label).
48 const parts = [];
49 parts.push({ w: s.name.length + 2, el: span(t, `◆ ${s.name}`, { bold: true, color: ACCENT }) });
50 if (s.game) parts.push({ w: s.game.length, el: span(t, s.game) });
51 if (s.build) {
52 const b = s.build;
53 if (b.state === 'running') {
54 const bars = bar(b.percent, 8);
55 const label = b.stopping ? 'stopping…' : b.stage ?? 'Starting';
56 parts.push({ w: label.length + 14, el: line(t, [span(t, label, { color: 'cyan' }), ' ', span(t, bars.done, { color: 'green' }), span(t, bars.left, DIM), ` ${b.percent}%`]) });
57 if (b.checks) parts.push({ w: b.checks.length, el: span(t, b.checks, b.failing ? { color: 'red' } : DIM) });
58 } else if (b.state === 'passed') parts.push({ w: 8, el: span(t, '✓ built', { color: 'green' }) });
59 else if (b.state === 'failed') parts.push({ w: 10 + (b.stage?.length ?? 0), el: span(t, `✗ failed${b.stage ? ` at ${b.stage}` : ''}`, { color: 'red' }) });
60 else if (b.state === 'stopped') parts.push({ w: 9, el: span(t, '■ stopped', { color: 'yellow' }) });
61 }
62 if (s.playing !== null && s.playing !== undefined) parts.push({ w: 16, el: span(t, s.playing ? `${s.playing} playing now` : 'nobody playing', s.playing ? { color: 'green' } : DIM) });
63 if (s.play) parts.push({ w: 8 + String(s.play).length, el: link(t, s.play, '▶ Play') });
64 const width = Math.max(20, (columns ?? 80) - 4);
65 let used = 0;
66 const shown = [];
67 for (const p of parts) {
68 if (shown.length >= 1 && used + p.w + 3 > width) continue;
69 used += p.w + 3;
70 shown.push(p.el);
71 }
72 const kids = [' '];
73 shown.forEach((el, i) => { if (i) kids.push(span(t, ' · ', DIM)); kids.push(el); });
74 return t.Text({ wrap: 'truncate-end', children: kids });
75}
76
77/* ------------------------------------------------------------------ the pane's frame */
78
79export const TABS = [['build', 'Build', '1'], ['rooms', 'Rooms', '2'], ['games', 'Games', '3'], ['stats', 'Stats', '4'], ['codex', 'Codex', '5'], ['lab', 'Lab', '6'], ['parts', 'Parts', '7'], ['art', 'Art', '8']];
80
81export function paneFrame(t, { s, tab, onTab, body, columns, onTell = null }) {
82 const site = s.live ? ['live', s.live] : s.dev ? ['here', s.dev] : null;
83 const head = t.Box({ flexDirection: 'row', justifyContent: 'space-between', children: [
84 row(t, [
85 span(t, `◆ ${s.name}`, { bold: true, color: ACCENT }),
86 site ? span(t, '·', DIM) : null,
87 site ? link(t, linkable(site[1]), site[0] === 'live' ? 'live' : 'this computer') : span(t, '· not online yet', DIM),
88 ]),
89 // Tell Homie: a short note to the people who make Homie (the Tell Homie pane; nothing is sent without Send).
90 onTell ? t.Button({ key: 'tell-homie', label: 'Tell Homie', hotkey: 't', plain: true, dimColor: true, onPress: onTell }) : null,
91 ].filter(Boolean) });
92 const tabs = t.Box({
93 flexDirection: 'row', columnGap: 2, flexWrap: 'wrap',
94 children: TABS.map(([id, label, key]) => t.Button({ key: `tab-${id}`, label, hotkey: key, plain: true, ...(id === tab ? {} : { dimColor: true }), onPress: () => onTab(id) })),
95 });
96 return col(t, [head, tabs, span(t, '─'.repeat(Math.max(10, Math.min(columns, 200))), DIM), body]);
97}
98
99/* ------------------------------------------------------------------ Build */
100
101function stagesRow(t, stages) {
102 const kids = [];
103 stages.forEach((st, i) => {
104 const m = markOf(st.state);
105 if (i) kids.push(span(t, '→', DIM));
106 kids.push(span(t, `${m.mark} ${st.label}`, { ...(m.color ? { color: m.color } : {}), ...(m.dim ? DIM : {}), ...(st.state === 'running' ? { bold: true } : {}) }));
107 });
108 return t.Box({ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', children: kids });
109}
110
111export function checkRows(t, checks, width) {
112 return checks.map((c) => {
113 const m = c.state === 'info' ? { mark: '·', dim: true } : c.state === 'warn' ? { mark: '!', color: 'yellow' } : c.state === 'blocked' ? { mark: '?', color: 'yellow' } : markOf(c.state);
114 const note = [c.ms ? `${(c.ms / 1000).toFixed(1)} s` : '', c.note ?? ''].filter(Boolean).join(' · ');
115 return line(t, [span(t, ` ${m.mark} `, { ...(m.color ? { color: m.color } : {}), ...(m.dim ? DIM : {}) }), span(t, fit(c.label, 34)), note ? span(t, ` ${fit(note, Math.max(10, width - 40))}`, DIM) : null], { wrap: 'truncate-end' });
116 });
117}
118
119/** The picture of a build or a game: a Raster or an Image in the terminal, an Svg on the desktop, words where none. */
120export function picture(t, surface, pic, { alt, onWatch } = {}) {
121 if (!pic) return null;
122 if (surface === 'terminal' && pic.cells && t.Raster) return t.Raster({ key: pic.key ?? 'frame', columns: pic.columns, rows: pic.rows, cells: pic.cells });
123 if (surface === 'terminal' && pic.image && t.Image) return t.Image({ key: pic.key ?? 'frame', source: pic.image, columns: pic.columns, rows: pic.rows, alt: alt ?? 'the latest frame' });
124 if (pic.svg && t.Svg) return t.Svg({ source: pic.svg, alt: alt ?? 'the latest frame', ...(pic.width ? { width: pic.width } : {}), ...(pic.height ? { height: pic.height } : {}) });
125 return span(t, `[${alt ?? 'picture'}]`, DIM);
126}
127
128export function buildTab(t, { surface, s, b, last, columns, pic, watch, now, on }) {
129 if (!b) {
130 return col(t, [
131 span(t, 'No build is running.', { bold: true }),
132 last ? line(t, [span(t, `${markOf(last.state).mark} `, { color: markOf(last.state).color ?? undefined }), `${last.title} ${last.state} ${ago(last.endedAt ?? last.updatedAt, now)}`], DIM) : null,
133 span(t, 'Ask Claude to build a game and this tab follows it: plan, build, checks, deploy, with the latest frame.', DIM),
134 s.games.length ? line(t, ['Games here: ', s.games.map((g) => g.name ?? g.id).join(', ')], DIM) : null,
135 ]);
136 }
137 const bars = bar(b.percent, Math.max(8, Math.min(20, Math.floor(columns / 6))));
138 const title = row(t, [
139 span(t, fit(b.title, Math.max(12, columns - 40)), { bold: true }),
140 span(t, b.state === 'running' ? (b.stopping ? 'stopping…' : b.stage?.label ?? '') : b.state, { color: b.state === 'running' ? 'cyan' : markOf(b.state).color ?? 'white' }),
141 line(t, [span(t, bars.done, { color: b.state === 'failed' ? 'red' : 'green' }), span(t, bars.left, DIM), ` ${b.percent}%`]),
142 ]);
143 const meta = line(t, [`started ${ago(b.startedAt, now)}`, b.spend.text ? ` · ${b.spend.text}` : '', b.counts.total ? ` · ${b.counts.pass}/${b.counts.total} checks` : ''], DIM);
144 const checks = b.checks.length ? col(t, checkRows(t, b.checks.slice(-10), columns)) : null;
145 const links = row(t, [
146 b.preview ? link(t, linkable(b.preview), '▶ Play') : null,
147 watch?.url ? link(t, linkable(watch.url), '◉ Watch') : null,
148 b.change?.url ? link(t, b.change.url, `PR #${b.change.number ?? ''}`) : null,
149 ], { columnGap: 3 });
150 const live = watch?.live
151 ? col(t, [
152 line(t, [span(t, '● live', { color: 'red', bold: true }), ` ${watch.label ?? ''}`, watch.fps ? ` ${watch.fps} fps` : ''], DIM),
153 picture(t, surface, watch.pic, { alt: 'the live room' }),
154 ])
155 : pic ? col(t, [picture(t, surface, pic, { alt: b.previewCaption || 'the latest check frame' }), line(t, [b.previewCaption || 'latest check frame', b.previewAt ? ` · ${ago(b.previewAt, now)}` : ''], DIM)]) : null;
156 const buttons = row(t, [
157 watch?.room && !watch.live ? t.Button({ key: 'watch-live', label: 'Watch it live', hotkey: 'w', plain: true, onPress: on.watch }) : null,
158 watch?.live ? t.Button({ key: 'watch-stop', label: 'Stop watching', hotkey: 'w', plain: true, onPress: on.unwatch }) : null,
159 b.state === 'running' && !b.stopping ? t.Button({ key: 'stop-build', label: 'Stop the build', hotkey: 'x', plain: true, onPress: on.stop }) : null,
160 ], { columnGap: 3 });
161 return col(t, [
162 title, meta, stagesRow(t, b.stages),
163 b.error ? span(t, `✗ ${fit(b.error, columns * 2)}`, { color: 'red' }) : null,
164 checks, links, live, buttons,
165 b.log.length ? col(t, b.log.slice(-3).map((l) => span(t, fit(l, columns), DIM))) : null,
166 ], { rowGap: 0 });
167}
168
169/* ------------------------------------------------------------------ Rooms */
170
171export function roomsTab(t, { s, rooms, office, asks, forYou, columns, now, busy, why, on }) {
172 const groups = [];
173 const all = [...(rooms.live?.rooms ?? []).map((r) => ({ ...r, where: 'live', base: s.live })), ...(rooms.dev?.rooms ?? []).map((r) => ({ ...r, where: 'here', base: s.dev }))];
174 const playing = (rooms.live?.playing ?? 0) + (rooms.dev?.playing ?? 0);
175 groups.push(row(t, [
176 span(t, all.length ? `${playing} playing in ${all.length} room${all.length === 1 ? '' : 's'}` : 'No live rooms right now', { bold: true }),
177 rooms.at ? span(t, `· ${ago(new Date(rooms.at).toISOString(), now)}`, DIM) : null,
178 t.Button({ key: 'rooms-refresh', label: 'Refresh', hotkey: 'r', plain: true, onPress: on.refresh }),
179 s.toolkit ? t.Button({ key: 'rooms-owner', label: office?.data ? 'Owner view: on' : 'Owner view', hotkey: 'o', plain: true, onPress: on.owner }) : null,
180 ], { columnGap: 2 }));
181 if (busy) groups.push(span(t, `… ${busy}`, { color: 'cyan' }));
182 if (why) groups.push(span(t, fit(why, columns * 3), { color: 'yellow' }));
183 for (const r of all) {
184 const owner = office?.data?.games?.find((g) => g.id === r.game)?.rooms?.find((x) => x.room === r.room) ?? null;
185 const pips = '●'.repeat(Math.min(r.players, 16)) + '○'.repeat(Math.max(0, Math.min(r.max, 16) - Math.min(r.players, 16)));
186 groups.push(col(t, [
187 row(t, [
188 span(t, fit(`${r.name} · ${r.server ? `${r.server.name} · ` : ''}${r.label}`, Math.max(16, columns - 30)), { bold: true }),
189 span(t, `${r.players}/${r.max}`, { color: r.players ? 'green' : undefined }),
190 span(t, pips, { color: 'green' }),
191 r.ai ? span(t, `${r.ai} AI`, { color: 'magenta' }) : null,
192 r.where === 'here' ? span(t, 'this computer', DIM) : null,
193 ]),
194 row(t, [
195 r.watch && r.base ? link(t, linkable(new URL(r.watch, r.base).href), '◉ Watch') : null,
196 r.base ? link(t, linkable(new URL(r.play, r.base).href), '▶ Join') : null,
197 r.watch && r.base ? t.Button({ key: `watch-here-${r.where}-${r.game}-${r.room}`, label: 'Watch in the pane', plain: true, onPress: () => on.watchHere(r) }) : null,
198 ], { columnGap: 3, paddingLeft: 2 }),
199 ...(owner ? owner.clients.map((c) => row(t, [
200 span(t, c.seat !== null && c.seat !== undefined ? `seat ${c.seat + 1}` : 'watching', DIM),
201 span(t, fit(String(c.name ?? 'someone'), 22)),
202 span(t, `${c.device ?? ''}${c.role === 'host' ? ' · host' : ''}${c.muted ? ' · muted' : ''}`, DIM),
203 c.seat !== null && c.seat !== undefined && (r.where === 'live' || !s.live) ? t.Button({ key: `mute-${r.game}-${r.room}-${c.seat}`, label: c.muted ? 'Unmute' : 'Mute', onPress: () => on.mute(r, c) }) : null,
204 c.seat !== null && c.seat !== undefined && (r.where === 'live' || !s.live) ? t.Button({ key: `kick-${r.game}-${r.room}-${c.seat}`, label: 'Kick', onPress: () => on.kick(r, c) }) : null,
205 ], { paddingLeft: 2 })) : []),
206 owner ? t.Input({ key: `announce-${r.where}-${r.game}-${r.room}`, label: ' Announce', placeholder: 'a line every player in this room sees', value: '', submitLabel: 'announce', onSubmit: (text) => on.announce(r, text) }) : null,
207 ], { marginTop: 1 }));
208 }
209 if (office?.data && !all.length) groups.push(span(t, 'The owner view lists who is in each room once someone plays.', DIM));
210 if (asks.length) {
211 groups.push(col(t, [
212 span(t, 'Waiting for your tap (the office asks; nothing happens until you confirm in your own browser):', { color: ACCENT, bold: true }),
213 ...asks.slice(-4).map((a) => row(t, [span(t, fit(a.what, Math.max(20, columns - 16))), a.link ? link(t, a.link, 'Open ↗') : null], { paddingLeft: 2 })),
214 ], { marginTop: 1 }));
215 }
216 if (forYou.length) {
217 groups.push(col(t, [
218 span(t, 'Links for you (kept out of Claude\'s view):', { color: ACCENT, bold: true }),
219 ...forYou.slice(-4).map((a) => row(t, [span(t, `${a.at ? new Date(a.at).toTimeString().slice(0, 5) : ''}`, DIM), link(t, a.link, 'one-time owner link ↗')], { paddingLeft: 2 })),
220 ], { marginTop: 1 }));
221 }
222 if (!s.live && !s.dev) groups.push(span(t, 'This studio is not online yet: deploy it, or run its dev site, and its rooms show here.', DIM));
223 return col(t, groups);
224}
225
226/* ------------------------------------------------------------------ Games */
227
228export function gamesTab(t, { s, rooms, office, columns, on }) {
229 if (!s.games.length) return col(t, [span(t, 'No games yet.', { bold: true }), span(t, 'Ask Claude to plan one: its Game Codex comes first, then the build.', DIM)]);
230 const live = new Map((rooms.games ?? []).map((g) => [g.id, g]));
231 return col(t, s.games.map((g) => {
232 const o = office?.data?.games?.find((x) => x.id === g.id) ?? null;
233 const launch = o?.launch ?? g.launch ?? 'public';
234 const base = s.live ?? s.dev;
235 const playing = (rooms.live?.rooms ?? []).filter((r) => r.game === g.id).reduce((n, r) => n + r.players, 0);
236 return col(t, [
237 row(t, [
238 span(t, fit(g.name ?? g.id, Math.max(10, columns - 40)), { bold: true }),
239 span(t, g.id, DIM),
240 span(t, launch, { color: launch === 'public' ? 'green' : launch === 'invite' ? 'yellow' : 'magenta' }),
241 s.live ? span(t, live.has(g.id) || playing ? 'live' : 'not live yet', live.has(g.id) || playing ? { color: 'green' } : DIM) : null,
242 playing ? span(t, `${playing} playing`, { color: 'green' }) : null,
243 ]),
244 g.blurb ? span(t, fit(g.blurb, columns * 2), DIM) : null,
245 row(t, [
246 base ? link(t, linkable(`${base}/${g.id}/play`), '▶ Play') : null,
247 base ? link(t, linkable(`${base}/${g.id}/`), 'Page') : null,
248 s.toolkit && (s.live || s.dev) ? t.Select({ key: `launch-${g.id}`, label: 'Launch', value: launch, options: [{ value: 'private', label: 'private' }, { value: 'invite', label: 'invite-only beta' }, { value: 'public', label: 'public' }], onSelect: (v) => on.launch(g, v) }) : null,
249 ], { columnGap: 3, paddingLeft: 2 }),
250 ], { marginBottom: 1 });
251 }).concat([s.toolkit && (s.live || s.dev) ? span(t, `A launch change is asked for: you confirm it with one tap in your own browser (Rooms shows the link)${s.live ? '' : '. Not online yet: this computer\'s dev site keeps its own settings'}.`, DIM) : span(t, 'Launch states need the studio\'s site (live, or its dev site here) and its toolkit (npm install).', DIM)]));
252}
253
254/* ------------------------------------------------------------------ Stats */
255
256const SPARK = '▁▂▃▄▅▆▇█';
257export function spark(values) {
258 const top = Math.max(1, ...values);
259 return values.map((v) => SPARK[Math.min(7, Math.floor((v / top) * 7.999))]).join('');
260}
261
262export function statsTab(t, { s, stats, columns, now, busy, why, on }) {
263 const head = row(t, [
264 span(t, 'Last 7 days', { bold: true }),
265 stats?.at ? span(t, `· read ${ago(new Date(stats.at).toISOString(), now)}`, DIM) : null,
266 s.toolkit && (s.live || s.dev) ? t.Button({ key: 'stats-refresh', label: stats?.data ? 'Refresh' : 'Read the numbers', hotkey: 'r', plain: true, onPress: on.refresh }) : null,
267 ], { columnGap: 2 });
268 if (!stats?.data) {
269 return col(t, [head, busy ? span(t, `… ${busy}`, { color: 'cyan' }) : null, why ? span(t, fit(why, columns * 3), { color: 'yellow' }) : null,
270 span(t, s.live || s.dev ? `The studio's own counts (never tracking): visits, plays, rounds, peak players, where people came from${s.live ? '. Reading them mints a 10-minute key with the studio\'s own Cloudflare login, and drops it' : ', from this computer\'s dev site until the studio is online'}.` : 'Stats come from the studio\'s site: deploy it, or run its dev site.', DIM)]);
271 }
272 const d = stats.data;
273 const n = (x) => Number(x || 0).toLocaleString('en-US');
274 const tt = d.totals ?? {};
275 const days = Array.isArray(d.days) ? d.days : [];
276 const tiles = [['visits', tt.visits], ['plays', tt.plays], ['rounds', tt.rounds], ['peak', tt.peakPlayers], ['now', tt.playingNow]];
277 return col(t, [
278 head,
279 busy ? span(t, `… ${busy}`, { color: 'cyan' }) : null,
280 t.Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: tiles.map(([k, v]) => col(t, [span(t, n(v), { bold: true, color: ACCENT }), span(t, k, DIM)])) }),
281 days.length ? line(t, ['plays ', span(t, spark(days.map((x) => x.plays ?? 0)), { color: 'green' }), ' visits ', span(t, spark(days.map((x) => x.visits ?? 0)), { color: 'cyan' })]) : null,
282 ...(d.games ?? []).slice(0, 8).map((g) => line(t, [span(t, fit(g.id, 18)), ` ${n(g.plays)} plays · ${n(g.rounds)} rounds · peak ${n(g.peakPlayers)} · now ${n(g.playingNow)}`], { wrap: 'truncate-end' })),
283 d.crossings ? line(t, [`from homie.rocks ${n(d.crossings.fromHub)} · other studios ${n(d.crossings.fromStudios)} · search ${n(d.crossings.fromSearch)} · the web ${n(d.crossings.fromWeb)}`], DIM) : null,
284 ...(d.referrers ?? []).slice(0, 4).map((r) => span(t, ` ${fit(r.from, 30)} ${n(r.visits)} visits, ${n(r.plays)} plays`, DIM)),
285 d.players ? span(t, `players: ${n(d.players.accounts)} accounts, ${n(d.players.active7d)} played this week`, DIM) : null,
286 ]);
287}
288
289/* ------------------------------------------------------------------ Codex */
290
291export function codexTab(t, { s, codexes, links, columns, busy, on }) {
292 if (!codexes.length) return col(t, [span(t, 'No Game Codex yet.', { bold: true }), span(t, 'Ask Claude to plan your game: a short interview becomes games/<id>/CODEX.md, a page in the game\'s own look.', DIM)]);
293 return col(t, codexes.map((c) => col(t, [
294 row(t, [span(t, fit(c.title ?? c.id, columns - 20), { bold: true, color: ACCENT }), span(t, `games/${c.id}/CODEX.md`, DIM)]),
295 c.pitch ? span(t, fit(c.pitch, columns * 3)) : null,
296 t.Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: c.sections.filter((x) => x.key && x.key !== 'latest').map((x) => span(t, `${x.filled ? '✓' : '○'} ${x.title}`, x.filled ? { color: 'green' } : DIM)) }),
297 c.missing.length ? span(t, `not decided yet: ${c.missing.join(', ')}`, { color: 'yellow' }) : null,
298 c.openQuestions ? col(t, [span(t, `${c.openQuestions} open question${c.openQuestions === 1 ? '' : 's'}:`, DIM), ...c.questions.slice(0, 3).map((q) => span(t, ` ? ${fit(q, columns - 6)}`))]) : null,
299 c.milestones.length ? span(t, `milestones ${c.milestones.filter((m) => m.done).length}/${c.milestones.length}: ${c.milestones.map((m) => (m.done ? '■' : '□')).join('')}`, DIM) : null,
300 ...c.latest.map((l) => span(t, ` · ${fit(l, columns - 6)}`, DIM)),
301 row(t, [
302 links[c.id] ? link(t, links[c.id], 'Open the codex ↗') : s.toolkit && (s.live || s.dev) ? t.Button({ key: `codex-link-${c.id}`, label: 'Get a private link to the codex page', plain: true, onPress: () => on.link(c) }) : span(t, `the page: .studio/codex/${c.id}.html (homie-studio codex ${c.id})`, DIM),
303 ], { paddingLeft: 2 }),
304 ], { marginBottom: 1 })).concat([busy ? span(t, `… ${busy}`, { color: 'cyan' }) : null]));
305}
306
307/* ------------------------------------------------------------------ Lab */
308
309/** A take's phases in one line, as the lab names them: "windup 6f → rise 14f → land 4f". */
310const phasesOf = (ps) => (ps ?? []).map((p) => `${p.name} ${Math.max(0, p.to - p.from + 1)}f`).join(' → ');
311
312/**
313 * The Game Lab (`homie-studio lab`, studio 0.20.0 and later): whether its page is running on this computer, and each
314 * game's last lab check (.studio/lab/<game>/latest.json): the take, New's phases beside Today's, whether a replay
315 * landed on the same frames, and the game's JavaScript per frame in both builds. Read from files; nothing is run.
316 */
317export function labTab(t, { lab, games, columns, now }) {
318 const name = (id) => games.find((g) => g.id === id)?.name ?? id;
319 const head = lab.url
320 ? row(t, [span(t, '● The Game Lab is running', { color: 'green', bold: true }), link(t, `${lab.url}/`, 'Open the lab ↗')])
321 : span(t, 'The Game Lab is not running. Ask Claude to tune how a move feels ("iterate on the jump"): it opens the lab, New beside Today.', DIM);
322 if (!lab.checks.length) return col(t, [head, span(t, 'No lab check yet. A check plays the take in New and in Today, headless, and compares them frame by frame.', DIM)]);
323 const replay = (which, n) => (n === null ? span(t, `✓ ${which} replays the same frames`, { color: 'green' }) : span(t, `✗ ${which} drifts from frame ${n}`, { color: 'red' }));
324 return col(t, [head, ...lab.checks.map((c) => col(t, [
325 row(t, [span(t, fit(name(c.game), Math.max(12, columns - 34)), { bold: true, color: ACCENT }), span(t, c.take ? `take "${fit(c.take, 24)}"` : 'no take yet', DIM), span(t, ago(c.at, now), DIM)]),
326 span(t, `${c.frames} frames at ${c.fps} fps${c.device ? ` · ${c.device}` : ''}${c.today ? ` · Today is ${c.today}` : ''}`, DIM),
327 line(t, [span(t, 'New ', { bold: true }), fit(phasesOf(c.timeline.new) || 'no phases', columns - 8)]),
328 c.timeline.today ? line(t, [span(t, 'Today ', { bold: true }), fit(phasesOf(c.timeline.today) || 'no phases', columns - 8)]) : span(t, 'Today not built', DIM),
329 row(t, [c.cost.new ? replay('New', c.deterministic.new) : null, c.cost.today ? replay('Today', c.deterministic.today) : null], { columnGap: 3, flexWrap: 'wrap' }),
330 c.cost.new ? span(t, `JavaScript per frame: New ${c.cost.new} ms${c.cost.today ? ` · Today ${c.cost.today} ms` : ''} (a hint, not a frame rate)`, DIM) : null,
331 c.report ? span(t, fit(c.report, columns), DIM) : null,
332 lab.url ? link(t, `${lab.url}/${c.game}/`, `Open ${name(c.game)} in the lab ↗`) : null,
333 ], { marginBottom: 1 }))]);
334}
335
336/* ------------------------------------------------------------------ Art */
337
338const STATE_STYLE = { auto: DIM, steered: { color: 'cyan' }, pinned: { color: 'green' }, locked: { color: ACCENT, bold: true } };
339
340/** The phase strip as spans: a settled phase ✓ green, one under way with its count, one not started dim. */
341function phaseRow(t, phases) {
342 const kids = [];
343 phases.forEach((p, i) => {
344 if (i) kids.push(span(t, '→', DIM));
345 const done = p.total > 0 && p.settled === p.total;
346 kids.push(span(t, done ? `${p.label} ✓` : p.settled ? `${p.label} ${p.settled}/${p.total}` : p.label, done ? { color: 'green' } : p.settled ? { color: 'cyan' } : DIM));
347 });
348 return t.Box({ flexDirection: 'row', columnGap: 1, flexWrap: 'wrap', children: kids });
349}
350
351/** One decision: its state mark, name, value (a palette's colours as swatches), who set it, and Lock or Unlock. */
352function decisionRow(t, game, d, columns, on) {
353 const nameW = 16;
354 return row(t, [
355 span(t, MARK[d.state], STATE_STYLE[d.state]),
356 span(t, fit(d.name, nameW).padEnd(nameW), d.state === 'locked' ? { bold: true } : {}),
357 span(t, fit(d.label, Math.max(12, columns - nameW - 36)), d.state === 'auto' ? DIM : {}),
358 d.colours ? line(t, d.colours.map((c) => span(t, '██', { color: c }))) : null,
359 d.state !== 'auto' && d.by ? span(t, BY[d.by], DIM) : null,
360 d.state === 'locked'
361 ? t.Button({ key: `art-unlock-${game}-${d.id}`, label: 'Unlock', plain: true, onPress: () => on.unlock(game, d) })
362 : t.Button({ key: `art-lock-${game}-${d.id}`, label: 'Lock', plain: true, dimColor: true, onPress: () => on.lock(game, d) }),
363 ]);
364}
365
366/** The cast: route, licence, state and cost per asset; STALE when made under an older decision. */
367function castRows(t, a) {
368 if (!a.cast.length) return [span(t, 'No assets yet: Claude finds them in the free starter library, imports yours with their licence, or makes one within the budget.', DIM)];
369 const stale = new Set(a.stale);
370 const idW = Math.min(18, Math.max(6, ...a.cast.map((c) => c.id.length)));
371 return a.cast.slice(0, 24).map((c) => row(t, [
372 span(t, fit(c.id, idW).padEnd(idW)),
373 span(t, `${c.kind} · ${c.route}`, DIM),
374 span(t, c.license ?? 'no licence', c.license ? DIM : { color: 'red', bold: true }),
375 span(t, c.state, c.state === 'approved' ? { color: 'green' } : DIM),
376 span(t, c.usd ? usd(c.usd) : 'free', c.usd ? {} : DIM),
377 stale.has(c.id) ? span(t, 'STALE', { color: 'yellow', bold: true }) : null,
378 ], { paddingLeft: 2 })).concat(a.cast.length > 24 ? [span(t, ` … ${a.cast.length - 24} more (/assets ${a.game})`, DIM)] : []);
379}
380
381/**
382 * The characters (the animate skill): each one's skeleton family and bones, its clips (how many of the game's verbs,
383 * how many retargeted onto it) and what it lacks.
384 */
385function characterRows(t, a, columns) {
386 const idW = Math.min(18, Math.max(6, ...a.characters.map((c) => c.id.length)));
387 const need = a.need.length;
388 return [
389 span(t, `Characters · ${a.characters.length}${need ? ` · the game needs ${need} clip${need === 1 ? '' : 's'}` : ''}`, { bold: true }),
390 ...a.characters.slice(0, 16).map((c) => row(t, [
391 span(t, fit(c.id, idW).padEnd(idW)),
392 span(t, `${c.family ?? '?'} · ${c.bones ?? '?'} bones · ${c.route}`, DIM),
393 span(t, `${c.verbs.length} clips${c.retargeted ? ` (${c.retargeted} retargeted)` : ''}`, c.missing.length ? {} : { color: 'green' }),
394 c.missing.length ? span(t, fit(`missing ${c.missing.join(', ')}`, Math.max(12, columns - idW - 50)), { color: 'yellow' }) : null,
395 ], { paddingLeft: 2 })),
396 a.characters.length > 16 ? span(t, ` … ${a.characters.length - 16} more (/cast ${a.game})`, DIM) : null,
397 ];
398}
399
400/** Skinning on a phone: the room's players times the heaviest character, against the budget. */
401function skinRow(t, k, columns) {
402 const width = Math.max(8, Math.min(20, Math.floor(columns / 6)));
403 const budget = k.budget.vertices ?? 0;
404 const over = budget > 0 && k.vertices > budget;
405 const bars = bar(budget ? Math.min(100, (k.vertices / budget) * 100) : 0, width);
406 return row(t, [
407 span(t, 'skinning'.padEnd(15), DIM),
408 line(t, [span(t, bars.done, { color: over ? 'red' : 'green' }), span(t, bars.left, DIM)]),
409 span(t, `${k.vertices.toLocaleString('en-US')} / ${budget.toLocaleString('en-US')} vertices (a room of ${k.players})`, over ? { color: 'red', bold: true } : {}),
410 ], { paddingLeft: 2 });
411}
412
413/** The scene's budgets as bars (draw calls, triangles, picture memory, shipped payload: the asset check's inventory estimate), red when over. */
414function budgetBars(t, check, columns, now) {
415 const width = Math.max(8, Math.min(20, Math.floor(columns / 6)));
416 const n = (v) => (v === null ? '?' : Number.isInteger(v) ? v.toLocaleString('en-US') : String(+v.toFixed(1)));
417 return [
418 line(t, [span(t, 'Scene budgets', { bold: true }), span(t, ` assets check ${check.at ? ago(check.at, now) : ''}`, DIM), check.ok ? span(t, ' ✓ within', { color: 'green' }) : span(t, ' ✗ over', { color: 'red' })]),
419 ...budgetRows(check).map((b) => {
420 const bars = bar(b.percent, width);
421 return row(t, [
422 span(t, b.label.padEnd(15), DIM),
423 b.value === null ? span(t, '·'.repeat(width), DIM) : line(t, [span(t, bars.done, { color: b.over ? 'red' : 'green' }), span(t, bars.left, DIM)]),
424 span(t, b.value === null ? `not measured / ${n(b.budget)}${b.unit ? ` ${b.unit}` : ''}` : `${n(b.value)} / ${n(b.budget)}${b.unit ? ` ${b.unit}` : ''}`, b.over ? { color: 'red', bold: true } : b.value === null ? DIM : {}),
425 ], { paddingLeft: 2 });
426 }),
427 check.failing.length ? span(t, ` ✗ over its own budget: ${check.failing.join(', ')}`, { color: 'red' }) : null,
428 ];
429}
430
431/**
432 * Art direction (the style and models skills; .studio/art/<game>/latest.json, newest game first): the phase strip,
433 * the look, the style phase's decisions with Lock (the person's word) and Unlock (asked first, with what goes stale),
434 * the cast, the scene budgets, the spend against the cap and the licence problems. Read from files; Lock and Unlock
435 * run the studio's own `homie-studio style`.
436 */
437export function artTab(t, { art, games, columns, now, busy, why, on }) {
438 const name = (id) => games.find((g) => g.id === id)?.name ?? id;
439 if (!art.length) {
440 return col(t, [
441 span(t, 'No art direction yet.', { bold: true }),
442 span(t, 'Ask Claude for a look ("cozy and low-poly, foxes in an autumn wood"): the style skill decides the style with you, and this tab follows every decision, the cast, the scene budgets and the spend.', DIM),
443 ]);
444 }
445 return col(t, [
446 busy ? span(t, `… ${busy}`, { color: 'cyan' }) : null,
447 why ? span(t, fit(why, columns * 3), { color: 'yellow' }) : null,
448 ...art.map((a) => {
449 const style = a.decisions.find((d) => d.phase === 'style');
450 const rest = a.decisions.filter((d) => d.phase !== 'style' && d.rows.length);
451 const more = rest.reduce((n, d) => n + d.rows.length, 0);
452 const refuse = a.licence.filter((x) => x.level === 'refuse');
453 const over = a.spend.cap !== null && a.spend.used > a.spend.cap;
454 return col(t, [
455 row(t, [span(t, fit(name(a.game), Math.max(12, columns - 44)), { bold: true, color: ACCENT }), span(t, a.game, DIM), a.path ? span(t, a.path, DIM) : null, span(t, ago(a.at, now), DIM)]),
456 phaseRow(t, a.phases),
457 a.line ? span(t, fit(a.line, columns * 2)) : null,
458 a.board ? span(t, `style board: ${a.board.chosen ? `${a.board.chosen.toUpperCase()} chosen (${fit(a.board.directions.find((d) => d.id === a.board.chosen)?.label ?? '', columns - 28)})` : `${a.board.directions.length} directions, none chosen yet`}`, DIM) : null,
459 style ? col(t, [span(t, style.label, { bold: true }), ...style.rows.map((d) => decisionRow(t, a.game, d, columns, on))], { marginTop: 1 }) : null,
460 more ? span(t, `+ ${more} more decision${more === 1 ? '' : 's'} in ${rest.map((d) => d.label).join(', ')} (/look ${a.game})`, DIM) : null,
461 col(t, [span(t, `Cast · ${a.cast.length} asset${a.cast.length === 1 ? '' : 's'}${a.stale.length ? ` · ${a.stale.length} stale` : ''}`, { bold: true }), ...castRows(t, a)], { marginTop: 1 }),
462 a.characters.length ? col(t, characterRows(t, a, columns), { marginTop: 1 }) : null,
463 a.check ? col(t, budgetBars(t, a.check, columns, now), { marginTop: 1 }) : span(t, `No scene check yet: homie-studio assets check ${a.game} measures every asset against the phone budgets.`, DIM),
464 a.skinning ? skinRow(t, a.skinning, columns) : null,
465 line(t, [span(t, 'Spend ', { bold: true }), span(t, `${usd(a.spend.used)}${a.spend.cap !== null ? ` of ${usd(a.spend.cap)}` : ''}`, over ? { color: 'red', bold: true } : {}), span(t, a.spend.cap === null ? ' no art budget: free routes only' : a.spend.items.length ? ` ${a.spend.items.length} paid step${a.spend.items.length === 1 ? '' : 's'}, receipts in art/` : ' nothing paid yet', DIM)]),
466 a.lineup ? span(t, `lineup ${ago(a.lineup.at, now)}: ${a.lineup.flagged ? `${a.lineup.flagged} flagged` : 'nothing flagged'} (/lineup ${a.game})`, a.lineup.flagged ? { color: 'yellow' } : DIM) : null,
467 a.licence.length
468 ? col(t, [
469 span(t, `Licences: ${refuse.length ? `${refuse.length} to fix before a public deploy` : 'warnings'}`, { bold: true, color: refuse.length ? 'red' : 'yellow' }),
470 ...a.licence.slice(0, 8).flatMap((x) => [
471 span(t, ` ${x.level === 'refuse' ? '✗' : '!'} ${fit(`${x.asset}: ${x.problem}`, columns * 2)}`, { color: x.level === 'refuse' ? 'red' : 'yellow' }),
472 x.fix ? span(t, ` → ${fit(x.fix, columns * 2)}`, DIM) : null,
473 ]),
474 ])
475 : a.cast.length ? span(t, '✓ Licences: every asset recorded and allowed', { color: 'green' }) : null,
476 ], { marginBottom: 1 });
477 }),
478 span(t, `${LEGEND}. Lock is your word; Unlock asks first, with what goes stale.`, DIM),
479 ]);
480}
481
482/* ------------------------------------------------------------------ Parts */
483
484export function partsView(t, { parts, checks, columns, now }) {
485 if (!parts.length) {
486 return col(t, [span(t, 'No parts yet.', { bold: true }), span(t, 'When Claude builds in parallel (the parallel skill: game logic, art, sound, the landing page), each agent shows here with what it is doing.', DIM)]);
487 }
488 const running = parts.filter((l) => l.status === 'running').length;
489 const done = parts.filter((l) => l.status === 'completed').length;
490 const secs = (ms) => { const s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`; };
491 return col(t, [
492 line(t, [span(t, `${running} running`, { color: running ? 'cyan' : undefined, bold: true }), ` · ${done} done${parts.length - running - done ? ` · ${parts.length - running - done} stopped` : ''}`, running ? span(t, ' · the merge waits for every part', DIM) : null]),
493 ...parts.map((l) => {
494 const m = l.status === 'running' ? markOf('running') : l.status === 'completed' ? markOf('done') : markOf('failed');
495 const elapsed = (l.endedAt ?? now) - (l.startedAt ?? now);
496 return col(t, [
497 row(t, [span(t, m.mark, { color: m.color }), span(t, fit(l.description || l.type || l.id, Math.max(14, columns - 34)), { bold: true }), span(t, secs(elapsed), DIM), span(t, `${l.tools} tool${l.tools === 1 ? '' : 's'}`, DIM), l.edits ? span(t, `${l.edits} file${l.edits === 1 ? '' : 's'}`, { color: 'green' }) : null]),
498 l.last ? span(t, ` ${fit(l.last, columns - 4)}`, DIM) : null,
499 ]);
500 }),
501 checks.length ? line(t, ['feed: ', ...checks.flatMap((c, i) => [i ? ' ' : '', span(t, `${markOf(c.state).mark} ${c.label}`, { color: markOf(c.state).color })])], { wrap: 'truncate-end' }) : null,
502 ]);
503}
504
505/* ------------------------------------------------------------------ the arcade */
506
507export function arcadeView(t, { surface, a, games, columns, on }) {
508 const st = a.status ?? {};
509 const playing = a.state === 'playing' || a.state === 'watching';
510 const head = row(t, [
511 span(t, `◆ ${a.game?.studio ?? 'Homie Arcade'}`, { bold: true, color: ACCENT }),
512 a.game ? span(t, fit(a.game.name, 24), { bold: true }) : null,
513 st.room ? span(t, `room ${String(st.room).replace(/^pub-/, '')}`, DIM) : null,
514 st.seat !== null && st.seat !== undefined ? span(t, `seat ${st.seat + 1}${st.role === 'host' ? ' · host' : ''}`, { color: 'green' }) : a.state === 'watching' ? span(t, 'watching', { color: 'cyan' }) : null,
515 st.players !== null && st.players !== undefined ? span(t, `${st.players} ${st.players === 1 ? 'person' : 'people'}${st.bots ? ` + ${st.bots} bots` : ''}${st.ai ? ` · ${st.ai} AI` : ''}`, DIM) : null,
516 a.fps ? span(t, `${a.fps} fps`, DIM) : null,
517 ]);
518 const kids = [head];
519 if (a.state === 'idle' || a.state === 'ended') {
520 kids.push(span(t, 'Play a real Homie game with strangers while Claude works: a public room on a live studio, rendered here.', DIM));
521 if (a.why) kids.push(span(t, fit(a.why, columns * 2), { color: 'yellow' }));
522 kids.push(row(t, [
523 games.length ? t.Select({ key: 'arcade-game', label: 'Game', value: a.pick ?? games[0].key, options: games.map((g) => ({ value: g.key, label: fit(`${g.name} · ${g.studio}`, 40) })), onSelect: on.pick }) : span(t, 'Looking for games…', DIM),
524 games.length ? t.Button({ key: 'arcade-play', label: 'Play', hotkey: 'p', plain: true, autoFocus: true, onPress: on.play }) : null,
525 ], { columnGap: 3 }));
526 kids.push(span(t, 'One headless Chrome on this computer runs the game for you (lowest priority, paused whenever this pane is hidden).', DIM));
527 return col(t, kids);
528 }
529 if (a.state === 'starting') kids.push(span(t, `Opening ${a.game?.name ?? 'the game'}… (a real browser seat; the first frame takes a few seconds)`, { color: 'cyan' }));
530 if (a.pic) kids.push(picture(t, surface, a.pic, { alt: `${a.game?.name ?? 'the game'}, live` }));
531 if (playing || a.state === 'starting') {
532 if (a.state !== 'watching') {
533 kids.push(t.Client({ key: 'pad', module: './arcade-pad.mjs', props: { focused: Boolean(a.padFocused), hint: a.game?.hint ?? '' }, width: Math.max(20, Math.min(columns, 70)), height: 1 }));
534 kids.push(row(t, [
535 t.Button({ key: 'k-w', label: '↑', hotkey: 'w', plain: true, onPress: () => on.key('up') }),
536 t.Button({ key: 'k-a', label: '←', hotkey: 'a', plain: true, onPress: () => on.key('left') }),
537 t.Button({ key: 'k-s', label: '↓', hotkey: 's', plain: true, onPress: () => on.key('down') }),
538 t.Button({ key: 'k-d', label: '→', hotkey: 'd', plain: true, onPress: () => on.key('right') }),
539 t.Button({ key: 'k-e', label: 'action', hotkey: 'e', plain: true, onPress: () => on.key('space') }),
540 t.Button({ key: 'arcade-leave', label: 'Leave', hotkey: 'x', plain: true, onPress: on.leave }),
541 ], { columnGap: 2 }));
542 } else {
543 kids.push(row(t, [t.Button({ key: 'arcade-leave', label: 'Stop watching', hotkey: 'x', plain: true, onPress: on.leave })]));
544 }
545 }
546 if (a.why) kids.push(span(t, fit(a.why, columns * 2), { color: 'yellow' }));
547 return col(t, kids);
548}
549
550/* ------------------------------------------------------------------ guards, above Claude Code's question dialog */
551
552/**
553 * A held call's panel, drawn above Claude Code's question dialog. That site takes at most 12 rows around the dialog,
554 * and Claude Code counts a row as about 38 cells whatever the terminal's width, so the panel is short and narrow: a
555 * title, a few facts and the first lines of the diff, each cut at 34 cells. Everything else is in the Hold pane.
556 */
557export const GUARD_ROWS = 11;
558export const GUARD_WIDTH = 34;
559export function guardPanel(t, g, { maxRows = GUARD_ROWS, width = GUARD_WIDTH } = {}) {
560 const one = { wrap: 'truncate-end' };
561 let room = maxRows - 2 - 1;
562 const kids = [span(t, fit(`⚠ ${g.title}`, width), { bold: true, color: 'yellow', ...one })];
563 for (const l of (g.lines ?? []).slice(0, Math.max(0, room - (g.diff?.source ? 3 : 0)))) {
564 const k = `${l.k} `;
565 kids.push(line(t, [span(t, k, DIM), span(t, fit(l.v, Math.max(6, width - k.length)), l.style ?? {})], one));
566 room--;
567 }
568 if (g.diff?.source && room >= (g.diff.rows ?? 3)) {
569 // The diff here was cut to fit by the hooks module (complete hunks, short lines); its "@@" line is a row too.
570 kids.push(t.Code({ format: 'diff', source: g.diff.source, wrap: 'truncate-end' }));
571 room -= g.diff.rows ?? 0;
572 }
573 if (g.more && room >= 1) { kids.push(span(t, fit(g.more, width), { ...DIM, ...one })); room--; }
574 return t.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', paddingX: 1, children: kids });
575}
576
577/** The whole held change, in the Hold pane: every fact in full, and the whole diff. */
578export function holdPane(t, g) {
579 if (!g) return span(t, 'Nothing is held.', DIM);
580 const d = g.detail ?? {};
581 return col(t, [
582 span(t, `⚠ ${g.title}`, { bold: true, color: 'yellow' }),
583 ...(d.lines ?? []).map((l) => (typeof l === 'string' ? span(t, l) : line(t, [span(t, `${l.k} `, DIM), span(t, l.v, l.style ?? {})]))),
584 d.full?.source ? t.Code({ format: 'diff', source: d.full.source }) : null,
585 d.full && d.full.total > d.full.shown ? span(t, `… ${d.full.total - d.full.shown} more lines`, DIM) : null,
586 span(t, 'Answer in the dialog under the prompt: Proceed or Cancel.', DIM),
587 ]);
588}
589
590/* ------------------------------------------------------------------ Homie's tool results, drawn natively */
591
592const HEAD = (t, text, ok) => span(t, `${ok === false ? '✗' : ok === true ? '✓' : '◆'} ${text}`, { bold: true, color: ok === false ? 'red' : ok === true ? 'green' : ACCENT });
593
594export function setupCard(t, d, columns) {
595 const markStyle = { ok: ['✓', 'green'], act: ['→', 'yellow'], missing: ['✗', 'red'], optional: ['○', undefined], later: ['…', undefined], unknown: ['?', undefined] };
596 return col(t, [
597 HEAD(t, d.title),
598 ...d.rows.map((r) => {
599 const [m, c] = markStyle[r.state] ?? ['?', undefined];
600 return col(t, [
601 line(t, [span(t, ` ${m} `, { ...(c ? { color: c } : DIM) }), span(t, r.label, { bold: r.state === 'act' || r.state === 'missing' }), r.need !== 'required' ? span(t, ` (${r.need})`, DIM) : null, span(t, ` ${fit(r.detail, Math.max(10, columns - r.label.length - 18))}`, DIM)], { wrap: 'truncate-end' }),
602 r.fix && r.state !== 'ok' ? span(t, ` → ${fit(r.fix, columns - 7)}`, { color: 'yellow', wrap: 'truncate-end' }) : null,
603 ]);
604 }),
605 d.ready.length ? t.Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [span(t, 'Ready:', DIM), ...d.ready.map((f) => span(t, `${f.state === 'ready' ? '✓' : f.state === 'later' ? '…' : '○'} ${f.feature}`, f.state === 'ready' ? { color: 'green' } : DIM))] }) : null,
606 d.next.length ? col(t, [span(t, 'Do this now:', { bold: true }), ...d.next.slice(0, 4).map((n) => span(t, ` → ${fit(n, columns * 2)}`, { color: 'yellow' }))]) : null,
607 // Homie's own updates by email (0.29.0): the person signs up there, double opt-in; nothing here asks for an address.
608 d.updates ? line(t, [span(t, 'Homie updates by email (optional): ', DIM), link(t, d.updates)], { wrap: 'truncate-end' }) : null,
609 ]);
610}
611
612export function checksCard(t, d, columns) {
613 return col(t, [
614 HEAD(t, d.summary, d.ok),
615 ...checkRows(t, d.rows, columns),
616 ...(d.weak ?? []).slice(0, 4).map((w) => span(t, ` weakest: ${fit(w, columns - 12)}`, { color: 'yellow' })),
617 d.report ? span(t, `report: ${d.report}`, DIM) : d.receipt ? span(t, `receipt: ${d.receipt}`, DIM) : null,
618 ]);
619}
620
621export function deployCard(t, d, columns) {
622 return col(t, [
623 line(t, [span(t, '✓ Live at ', { bold: true, color: 'green' }), link(t, d.url)]),
624 d.also ? line(t, [' also at ', link(t, d.also)], DIM) : null,
625 ...d.games.map((g) => line(t, [span(t, ` ▶ ${g.id} `), link(t, g.play, fit(g.play, columns - g.id.length - 8))])),
626 ...d.media.map((m) => line(t, [span(t, ` ♪ ${m.kind} ${m.slug} `), link(t, m.page)])),
627 d.cloudflare ? span(t, ` ${fit(d.cloudflare, columns * 2)}`, DIM) : null,
628 d.claim ? span(t, ' claimed in the homie.rocks directory', DIM) : null,
629 ]);
630}
631
632export function buildCard(t, b, columns) {
633 if (!b) return null;
634 const bars = bar(b.percent, 12);
635 return col(t, [
636 line(t, [HEAD(t, b.title, b.state === 'passed' ? true : b.state === 'failed' ? false : undefined), ' ', span(t, bars.done, { color: 'green' }), span(t, bars.left, DIM), ` ${b.percent}%`]),
637 stagesRow(t, b.stages),
638 ...checkRows(t, b.checks.slice(-8), columns),
639 b.preview ? line(t, [' ', link(t, linkable(b.preview), '▶ Play')]) : null,
640 ]);
641}
642
643export function studioCard(t, d, columns) {
644 return col(t, [
645 HEAD(t, `${d.name}${d.tagline ? ` · ${d.tagline}` : ''}`),
646 ...(d.games ?? []).map((g) => line(t, [span(t, ` ${g.live ? '●' : '○'} ${fit(g.name, 24)} `, g.live ? { color: 'green' } : {}), g.play?.live ? link(t, g.play.live, '▶ Play') : g.play?.dev ? link(t, linkable(g.play.dev), '▶ Play here') : span(t, 'not deployed yet', DIM)])),
647 d.site ? line(t, [' live: ', link(t, d.site)], DIM) : span(t, ' not online yet', DIM),
648 ...(d.rooms ?? []).slice(0, 4).map((r) => span(t, ` room ${r.room} of ${r.game}: ${r.players} playing`, { color: 'green' })),
649 ]);
650}
651
652/* ------------------------------------------------------------------ Tell Homie */
653
654const NOTE_KINDS = [['stuck', 'Stuck'], ['confusing', 'Confusing'], ['idea', 'Idea'], ['praise', 'Praise'], ['bug', 'Bug']];
655const KIND_WORD = Object.fromEntries(NOTE_KINDS);
656
657/** The note's words, in a frame: exactly what would go. */
658function noteBox(t, note, columns) {
659 return t.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: ACCENT, paddingX: 1, width: Math.max(24, Math.min(columns, 96)), children: [
660 span(t, KIND_WORD[note.kind] ?? note.kind, { bold: true, color: ACCENT }),
661 ...String(note.text).split('\n').map((l) => span(t, l || ' ', { wrap: 'wrap' })),
662 ] });
663}
664
665/**
666 * The Tell Homie pane: a note to the people who make Homie, exactly as it would go, with Send and Don't send; the
667 * words, the kind and a reply address can be changed first. With no note yet, a field for the person's own words and
668 * a button that asks Claude to draft one from the session. Nothing is sent but by Send.
669 */
670export function tellPane(t, { tell, columns, on }) {
671 const tl = tell ?? {};
672 const kids = [line(t, [span(t, '◆ Tell Homie', { bold: true, color: ACCENT }), span(t, ' a note to the people who make Homie', DIM)])];
673 if (tl.state === 'sent') {
674 kids.push(span(t, `✓ Sent to Homie. Thank you.${tl.reference ? ` (ref ${String(tl.reference).slice(0, 8)})` : ''}`, { bold: true, color: 'green' }));
675 if (tl.note) kids.push(noteBox(t, tl.note, columns));
676 kids.push(span(t, 'Notes are private: only the people who make Homie read them.', DIM));
677 kids.push(t.Button({ key: 'tell-close', label: 'Close', hotkey: 'c', plain: true, onPress: on.close }));
678 return col(t, kids);
679 }
680 if (tl.state === 'declined') {
681 kids.push(span(t, 'Not sent. Nothing left this computer.', { bold: true }));
682 kids.push(t.Button({ key: 'tell-close', label: 'Close', hotkey: 'c', plain: true, onPress: on.close }));
683 return col(t, kids);
684 }
685 if (!tl.note) {
686 kids.push(span(t, 'Say what was hard, confusing or good, in your own words. Nothing is sent until you press Send.', DIM));
687 kids.push(t.Select({ key: 'tell-kind', label: 'Kind', value: tl.kind ?? 'idea', options: NOTE_KINDS.map(([value, label]) => ({ value, label })), onSelect: on.kind }));
688 kids.push(t.Input({ key: 'tell-words', label: 'Your note', placeholder: 'What happened, what you expected, what you saw', value: '', submitLabel: 'show it', autoFocus: true, onSubmit: on.words }));
689 if (tl.why) kids.push(span(t, tl.why, { color: 'red' }));
690 kids.push(row(t, [
691 t.Button({ key: 'tell-draft', label: 'Ask Claude to draft it from this session', hotkey: 'd', plain: true, onPress: on.draft }),
692 t.Button({ key: 'tell-close', label: 'Close', hotkey: 'c', plain: true, dimColor: true, onPress: on.close }),
693 ], { columnGap: 3 }));
694 return col(t, kids);
695 }
696 kids.push(span(t, 'This is exactly what would go:', DIM));
697 kids.push(noteBox(t, tl.note, columns));
698 kids.push(span(t, `With it: ${tl.with ?? ''}`, { ...DIM, wrap: 'wrap' }));
699 if (tl.taken?.length) kids.push(span(t, `Homie took out ${tl.taken.join(', ')}.`, { ...DIM, wrap: 'wrap' }));
700 kids.push(t.Select({ key: 'tell-kind', label: 'Kind', value: tl.note.kind, options: NOTE_KINDS.map(([value, label]) => ({ value, label })), onSelect: on.kind }));
701 kids.push(t.Input({ key: 'tell-words', label: 'Change the words', placeholder: 'Type the note as you want it, then Enter', value: '', submitLabel: 'use these', onSubmit: on.words }));
702 kids.push(t.Input({ key: 'tell-email', label: 'Reply email (optional)', placeholder: tl.note.email ?? 'only if you want a reply', value: '', submitLabel: 'add', onSubmit: on.email }));
703 if (tl.why) kids.push(span(t, tl.why, { color: 'red', wrap: 'wrap' }));
704 kids.push(row(t, [
705 t.Button({ key: 'tell-send', label: tl.busy ? 'Sending…' : 'Send', hotkey: 's', plain: true, onPress: on.send }),
706 t.Button({ key: 'tell-no', label: 'Don’t send', hotkey: 'n', plain: true, onPress: on.decline }),
707 ], { columnGap: 3 }));
708 kids.push(span(t, `Nothing is sent until you press Send. Goes to ${tl.to ?? 'homie.rocks'}, privately.`, DIM));
709 return col(t, kids);
710}
711
712/** A homie_feedback result in the transcript: the note as it would go (or went), and what happens next. */
713export function feedbackCard(t, d, columns) {
714 if (!d?.note) return null;
715 const head = d.state === 'sent' ? ['✓ Sent to Homie', 'green'] : d.state === 'declined' ? ['Not sent', undefined] : d.state === 'draft' ? ['A note to Homie (not sent yet)', ACCENT] : ['Not sent', 'red'];
716 return col(t, [
717 span(t, `◆ ${head[0]}`, { bold: true, ...(head[1] ? { color: head[1] } : {}) }),
718 noteBox(t, d.note, columns),
719 d.with ? span(t, `With it: ${d.with}`, { ...DIM, wrap: 'wrap' }) : null,
720 d.state === 'draft' ? span(t, 'Nothing is sent until you say yes: Claude Code asks you, with this text, before anything leaves.', DIM) : null,
721 ]);
722}
723
724/** The ToolUse row of a Homie command: what it is in words, and the command itself, dim, so nothing is hidden. */
725export function toolUseRow(t, { label, command, state }) {
726 const m = state === 'running' ? ['◌', 'cyan'] : state === 'error' ? ['✗', 'red'] : ['⏺', ACCENT];
727 return line(t, [span(t, `${m[0]} `, { color: m[1] }), span(t, 'Homie', { bold: true, color: ACCENT }), span(t, ` · ${label} `, { bold: true }), span(t, command, DIM)], { wrap: 'truncate-end' });
728}
729hooks/lib/diff.mjs 110 lines1/**
2 * What an edit would change, as unified-diff hunks for the `Code` element (`format: 'diff'`): the file's text before
3 * and after, compared line by line, three lines of context, bounded so a dialog stays readable.
4 */
5
6const MAX_LINES = 1500;
7
8function lines(text) {
9 if (!text) return [];
10 const l = String(text).split('\n');
11 if (l.length > 1 && l[l.length - 1] === '') l.pop();
12 return l;
13}
14
15/** Line operations: [{ op: ' ' | '-' | '+', t, a, b }] with 1-based line numbers in the old (a) and new (b) text. */
16export function lineOps(before, after) {
17 let a = lines(before);
18 let b = lines(after);
19 // Common head and tail first: an edit in a long file compares only the part that changed.
20 let head = 0;
21 while (head < a.length && head < b.length && a[head] === b[head]) head++;
22 let tail = 0;
23 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
24 const ops = [];
25 for (let i = 0; i < head; i++) ops.push({ op: ' ', t: a[i], a: i + 1, b: i + 1 });
26 const am = a.slice(head, a.length - tail);
27 const bm = b.slice(head, b.length - tail);
28 if (am.length > MAX_LINES || bm.length > MAX_LINES || am.length * bm.length > 2_000_000) {
29 am.forEach((t, k) => ops.push({ op: '-', t, a: head + k + 1, b: null }));
30 bm.forEach((t, k) => ops.push({ op: '+', t, a: null, b: head + k + 1 }));
31 } else {
32 const n = am.length; const m = bm.length;
33 const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
34 for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = am[i] === bm[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
35 let i = 0; let j = 0;
36 while (i < n && j < m) {
37 if (am[i] === bm[j]) { ops.push({ op: ' ', t: am[i], a: head + i + 1, b: head + j + 1 }); i++; j++; }
38 else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ op: '-', t: am[i], a: head + i + 1, b: null }); i++; }
39 else { ops.push({ op: '+', t: bm[j], a: null, b: head + j + 1 }); j++; }
40 }
41 while (i < n) { ops.push({ op: '-', t: am[i], a: head + i + 1, b: null }); i++; }
42 while (j < m) { ops.push({ op: '+', t: bm[j], a: null, b: head + j + 1 }); j++; }
43 }
44 for (let k = 0; k < tail; k++) ops.push({ op: ' ', t: a[a.length - tail + k], a: a.length - tail + k + 1, b: b.length - tail + k + 1 });
45 return ops;
46}
47
48/**
49 * Unified-diff hunks of `before` → `after`: { source, added, removed, shown, total, rows }. `maxLines` bounds the lines
50 * drawn (a hunk cut short gets the counts of the lines it keeps, so it still parses), `lineWidth` cuts long lines,
51 * `maxChars` bounds the text (the Code element takes at most 10,000). `rows` counts what a dialog draws, headers too.
52 */
53export function unifiedDiff(before, after, { context = 3, maxLines = 40, maxChars = 7000, lineWidth = 300 } = {}) {
54 const ops = lineOps(before, after);
55 const added = ops.filter((o) => o.op === '+').length;
56 const removed = ops.filter((o) => o.op === '-').length;
57 const keep = ops.map((o, k) => o.op !== ' ' || ops.slice(Math.max(0, k - context), k + context + 1).some((x) => x.op !== ' '));
58 const hunks = [];
59 let cur = null;
60 ops.forEach((o, k) => {
61 if (!keep[k]) { cur = null; return; }
62 if (!cur) { cur = { ops: [] }; hunks.push(cur); }
63 cur.ops.push(o);
64 });
65 const total = hunks.reduce((n, h) => n + h.ops.length, 0);
66 const cut = (text) => { const t = text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ''); return t.length > lineWidth ? `${t.slice(0, Math.max(1, lineWidth - 1))}…` : t; };
67 const out = [];
68 let shown = 0;
69 let rows = 0;
70 for (const h of hunks) {
71 if (shown >= maxLines) break;
72 const room = maxLines - shown;
73 // A hunk that does not fit starts at its first change (its leading context is what goes), and never ends on
74 // context alone.
75 const first = h.ops.findIndex((o) => o.op !== ' ');
76 const part = h.ops.length <= room ? h.ops.slice() : h.ops.slice(Math.min(first, Math.max(0, h.ops.length - room)), Math.min(first, Math.max(0, h.ops.length - room)) + room);
77 while (part.length && part[part.length - 1].op === ' ' && part.length < h.ops.length) part.pop();
78 if (!part.length || !part.some((o) => o.op !== ' ')) break;
79 const firstA = part.find((o) => o.a !== null)?.a ?? null;
80 const firstB = part.find((o) => o.b !== null)?.b ?? null;
81 const aLen = part.filter((o) => o.op !== '+').length;
82 const bLen = part.filter((o) => o.op !== '-').length;
83 const aAt = aLen ? firstA : Math.max(0, (firstB ?? 1) - 1);
84 const bAt = bLen ? firstB : Math.max(0, (firstA ?? 1) - 1);
85 const lines = [`@@ -${aAt},${aLen} +${bAt},${bLen} @@`, ...part.map((o) => `${o.op}${cut(o.t)}`)];
86 if ([...out, ...lines].join('\n').length > maxChars) break;
87 out.push(...lines);
88 shown += part.length;
89 rows += lines.length;
90 }
91 const shownChanges = out.filter((l) => (l[0] === '+' || l[0] === '-') && !l.startsWith('@@')).length;
92 return { source: out.join('\n'), added, removed, shown, total, rows, shownChanges, hunks: hunks.length };
93}
94
95/** The file's text after an Edit, MultiEdit or Write (null when the edit cannot apply: the tool will refuse it). */
96export function applyEdit(tool, before, input) {
97 if (tool === 'Write') return String(input.content ?? '');
98 const edits = tool === 'MultiEdit' ? (Array.isArray(input.edits) ? input.edits : []) : [{ old_string: input.old_string, new_string: input.new_string, replace_all: input.replace_all }];
99 let text = String(before ?? '');
100 for (const e of edits) {
101 const old = String(e.old_string ?? '');
102 const next = String(e.new_string ?? '');
103 if (!old) { if (!text) { text = next; continue; } return null; }
104 const at = text.indexOf(old);
105 if (at < 0) return null;
106 text = e.replace_all ? text.split(old).join(next) : text.slice(0, at) + next + text.slice(at + old.length);
107 }
108 return text;
109}
110hooks/lib/arcade-pad.mjs 35 lines1/**
2 * The arcade's game pad: a one-line region of the pane that, once clicked, takes the arrow keys (a pane's buttons
3 * cannot: Claude Code keeps Tab and the arrows for moving between controls), WASD, space and Enter, and posts each
4 * press to the Homie mod, which hands it to the game's browser. Runs on Claude Code's drawing thread, with no mods
5 * API and no network: it can only draw and post.
6 */
7const MAP = {
8 up: 'up', down: 'down', left: 'left', right: 'right', w: 'up', a: 'left', s: 'down', d: 'right',
9 ' ': 'space', space: 'space', return: 'enter', enter: 'enter', e: 'e', q: 'q', x: 'x', z: 'z',
10};
11
12export default function ArcadePad(props, surface) {
13 const { Box, Text } = surface.elements;
14 if (surface.state === undefined) {
15 surface.setState({ presses: 0, last: '' });
16 surface.onKey((ev) => {
17 const key = MAP[String(ev.key).toLowerCase()];
18 if (!key) return;
19 surface.post({ key });
20 surface.setState({ presses: (surface.state?.presses ?? 0) + 1, last: key });
21 });
22 surface.onPointer((ev) => { if (ev.type === 'down') surface.post({ focus: true }); });
23 }
24 const s = surface.state ?? { presses: 0, last: '' };
25 const live = s.presses > 0;
26 const arrow = { up: '↑', down: '↓', left: '←', right: '→', space: '␣', enter: '⏎' }[s.last] ?? s.last;
27 return Box({
28 flexDirection: 'row',
29 children: [
30 Text({ color: live ? 'green' : 'yellow', bold: true, children: [live ? `● playing ${arrow} ` : '▶ Click here to play '] }),
31 Text({ dimColor: true, children: [`←↑↓→ or WASD to move · space acts${props && props.hint ? ` · ${props.hint}` : ''} · Esc gives the keys back to Claude`] }),
32 ],
33 });
34}
35