SLOPSHOPPER

Homie

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…

newpanebandrowsguardcommand
★ 7v0.38.0Apache-2.0updated 2026-10-09homie-rocks/homie/plugins/homie
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · homie
│ ┃ Parts ✕ › fix the failing auth test and add an audit log call │ ┃ No parts yet. │ ┃ When Claude builds in parallel (the parallel ⏺ Read(src/auth.ts) │ ┃ skill: game logic, art, sound, the landing ⎿ Read 6 lines │ ┃ page), each agent shows here with what it is ⏺ Update(src/auth.ts) │ ┃ doing. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /studio │ ⎿ homie: Not inside a Homie studio (no studio.json here or above). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Parts
No parts yet. When Claude builds in parallel (the parallel skill: game logic, art, sound, the landing page), each agent shows here with what it is doing.
Pane · Arcade
◆ Homie Arcade Play a real Homie game with strangers while Claude works: a public room on a live studio, rendered here. Looking for games… One headless Chrome on this computer runs the game for you (lowest priority, paused whenever this pane is hidden).
Pane · Tell Homie
◆ Tell Homie a note to the people who make Homie Say what was hard, confusing or good, in your own words. Nothing is sent until you press Send. Kind: Idea ▾ Your note: What happened, what you expected, what you saw ⏎ d: Ask Claude to draft it from this session c: Close
README

Homie plugin

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 (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 server (.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.
  • The Homie mod (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.
  • Homie's hooks for Codex (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.
  • Homie's hooks for Grok (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.
  • Tell Homie (the Homie MCP's 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.
  • The providers' own tools (providers.json): see below.
  • Manifests: .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:

  • Claude Code, inside a session (2.1.275 or later): /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: 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 Build: grok plugin install homie-rocks/homie#plugins/homie, then a new session.
  • The Claude app (web, desktop, phone), the connector alone: add Homie as a connector (the link opens "Add custom connector" with Homie filled in, and you confirm), or by hand Settings → Connectors → Add custom connector → https://homie.rocks/mcp.

Report a vulnerability as SECURITY.md says.

The providers' own tools

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.

ProviderWhat Homie's skills useOffered when the person wants it
CloudflareWrangler, 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
ElevenLabsElevenLabs' 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
falfal'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
TripoTripo's models on fal (tripo3d/...)none: Tripo's own CLI and MCP server bill a separate account
GitHubthe GitHub CLI (gh auth login --web, gh pr create)GitHub's MCP server (github@claude-plugins-official)
StripeStripe'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 connectStripe'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
OllamaClef 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.

The Homie mod

Inside a studio (a folder with studio.json at or above where Claude Code runs):

  • The band above the prompt: ◆ 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.
  • The Studio pane (/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.
  • Build: the build's progress feed: Plan → Build → Checks → Deploy, each check going green, the latest check frame (coloured cells in the terminal, a picture on the desktop), ▶ Play and ◉ Watch links, Stop the build, and Watch it live, a live view of a room of the game being built, drawn in the tab.
  • Rooms: every live room on the live site and on this computer's dev site, with players and AI, Watch and Join links, and Watch in the pane. The owner view (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.
  • Games: each game's launch state (private, invite-only beta, public), changed the same asked-for way.
  • Stats: the studio's own counts (visits, plays, rounds, peak players, where people came from), read on request.
  • Codex: each Game Codex at a glance (sections filled, open questions, milestones, latest lines) and a private link to its page.
  • Lab: the Game Lab (the 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.
  • Parts: see below.
  • Art: art direction (the 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.
  • The parts pane (/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.
  • The arcade (/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".
  • Instant commands (no Claude turn; they print links or open a pane, and never open a browser): /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).
  • Tell Homie (/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.
  • Guards: a call is held in Claude Code's own question dialog (Proceed or Cancel), with what would change drawn above it and in full in the Hold pane:
  • an edit (Edit, Write, MultiEdit, NotebookEdit) to a file studio.json "protect" lists, with its diff;
  • a production deploy (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;
  • a paid media call (fal, ElevenLabs, Tripo: the skills' 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;
  • a change to the studio's Cloudflare account outside its deploy, inside a studio: Wrangler deleting something (a Worker, a D1 database, an R2 bucket or object, a KV namespace or key, a queue, a secret), a 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;
  • a Clef model downloaded through Ollama (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.
  • a note to Homie from Claude (the Homie MCP's 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").
  • Refused outright (nobody is asked; the reason says what to do instead):
  • an Edit, Write or MultiEdit to 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);
  • a production deploy while a public game (not private or invite-only) ships an asset whose 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;
  • a 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/;
  • a write through Stripe's MCP (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.
  • Secrets out of tool output: before Claude reads any tool's result, keys and tokens come out: office and stats keys (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.
  • Homie's results, drawn: the setup status as a checklist with what to do now, a check's and a port check's rows, a playtest's verdicts with the weakest first, a deploy with its live link and each game's Play, a note to Homie in its frame, and the Homie MCP's cards; each Homie command's row says what it is in words, with the command beside it.

Studio settings it reads

{
  "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.

Its own settings (/config, or pluginConfigs in settings.json)

OptionDefaultWhat it turns on
paneAutoOpenonThe Studio pane when a build starts, the parts pane for two agents
bandonThe band above the prompt
guardFilesonHolding edits to protected files; refusing changes to locked art decisions and files over 5 MB under games/ into git
guardDeploysonHolding 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
guardSpendonHolding 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)
redactSecretsonTaking secrets out of tool output
renderResultsonHomie's results and command rows drawn natively
arcadeon/arcade and the live Watch views
picturesblocksblocks: frames as coloured cells (any truecolor terminal); image: real pixels where the terminal draws images (kitty, Ghostty, iTerm2, WezTerm)

Where it draws

WhereWhat you get
claude in a terminalEverything. Pictures are coloured cells (▀, two pixels a cell), or real pixels with pictures: image
The desktop app's Code tabEverything; pictures are drawn as images (Svg), refreshed up to four times a second
VS Code's chat panel, claude -p, the Agent SDK, cloud sessionsThe 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

What the Homie mod does

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.to
Source 12 files
hooks/homie.mjs 1634 lines
1/**
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 lines
1/**
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}
265
hooks/lib/codex.mjs 63 lines
1/**
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}
63
hooks/lib/commands.mjs 434 lines
1/**
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}
434
hooks/lib/feed.mjs 93 lines
1/**
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}
93
hooks/lib/holds.mjs 701 lines
1/**
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}
701
hooks/lib/redact.mjs 100 lines
1/**
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}
100
hooks/lib/feedback.mjs 183 lines
1/**
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}
183
hooks/lib/results.mjs 142 lines
1/**
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}
142
hooks/lib/views.mjs 729 lines
1/**
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}
729
hooks/lib/diff.mjs 110 lines
1/**
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}
110
hooks/lib/arcade-pad.mjs 35 lines
1/**
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