@homie-rocks/studio 0.58.19 → 0.58.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/playtest/SKILL.md +2 -2
  4. package/guides/servers/SKILL.md +5 -5
  5. package/guides/studio-setup/SKILL.md +11 -11
  6. package/lib/doctor.mjs +1 -1
  7. package/lib/job-runner.mjs +133 -0
  8. package/lib/jobs.mjs +200 -37
  9. package/lib/local-ai.mjs +2 -2
  10. package/lib/mcp-tools.mjs +41 -31
  11. package/lib/parts.mjs +13 -1
  12. package/lib/scaffold.mjs +13 -12
  13. package/lib/template-history.json +102 -0
  14. package/lib/worker-config.mjs +14 -1
  15. package/package.json +20 -5
  16. package/parts/PARTS.md +27 -0
  17. package/parts/README.md +1 -0
  18. package/parts/catalogue.json +138 -0
  19. package/parts/venue-desk/LICENSE +202 -0
  20. package/parts/venue-desk/NOTICE +3 -0
  21. package/parts/venue-desk/README.md +34 -0
  22. package/parts/venue-desk/part.json +138 -0
  23. package/parts/venue-desk/preview/index.html +7 -0
  24. package/parts/venue-desk/src/index.ts +228 -0
  25. package/parts/venue-desk/tools/venue-booking-cancel.ts +2 -0
  26. package/parts/venue-desk/tools/venue-booking-confirm.ts +2 -0
  27. package/parts/venue-desk/tools/venue-booking-details.ts +2 -0
  28. package/parts/venue-desk/tools/venue-booking-status.ts +2 -0
  29. package/parts/venue-desk/tools/venue-desk.ts +2 -0
  30. package/parts/venue-desk/tools/venue-info-set.ts +2 -0
  31. package/parts/venue-desk/tools/venue-info.ts +2 -0
  32. package/parts/venue-desk/tools/venue-waitlist-join.ts +2 -0
  33. package/parts/venue-desk/tools/venue-waitlist-offer.ts +2 -0
  34. package/parts/venue-desk/tools/venue-waitlist-status.ts +2 -0
  35. package/parts/venue-desk/tuning.json +1 -0
  36. package/tools/TOOLS.md +14 -0
  37. package/worker/build-id.mjs +1 -1
  38. package/worker/mcp-room.mjs +3 -2
  39. package/worker/room.mjs +3 -2
  40. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,34 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.58.21] - 2026-10-29
21
+
22
+ **Plugin 0.58.15**
23
+
24
+ Also released: `@homie-rocks/physics` 0.1.3 (README matches the code; the studio's optional peer pin follows) and `@homie-rocks/nav` 0.1.2 (package metadata), so every published version is exactly what this release packs.
25
+
26
+ A check, a playtest, a deploy or the site on your computer keeps going when the app or the AI's connection that started it goes away.
27
+
28
+ ### Added
29
+
30
+ - A shared **Venue desk** part (`parts/venue-desk`) for any venue: public `venue_info` (schema.org hours with time zone, holiday exceptions, policies, FAQ, contact), booking contact and confirmation on top of `studio_book` holds (`venue_booking_details`, `venue_booking_confirm`, `venue_booking_status`, `venue_booking_cancel`), a waitlist (`venue_waitlist_join`, `venue_waitlist_status`), and owner tools to edit the info, see the desk and offer a freed slot (`venue_info_set`, `venue_desk`, `venue_waitlist_offer`). Offers go to an outbox; Homie never sends them. Opt in by copying its one-line `tools/` files.
31
+
32
+ ### Fixed
33
+
34
+ - Long jobs (a check, a playtest, a deploy, an npm install, `preview_run`'s site, a sign-in or a Stripe webhook) run under a small supervisor of their own, with their state in `.studio/mcp/<job>.json`. They outlive the MCP server that started them, so a host that runs the server once per tool call, or quitting Claude Desktop, no longer stops them; `studio_job` reads a job an earlier server started, and a job whose process is gone is reported as failed, never as still running. The site keeps running until `preview_stop`. A finished job's files are removed a day later, when the next job starts in that studio.
35
+ - A studio made from 0.58.20 is recognised again by `homie-studio upgrade`: the template history now knows the five AGENTS.md sections (Apps, Commands, Making games, Servers and AI seats, Selling things) as 0.58.20 shipped them, so their untouched text is upgraded instead of being kept as the studio's own. Packing the toolkit now fails if the template history is behind its template (`scripts/studio-template-history.mjs --check`).
36
+ - The toolkit's own `npm test`, run from `packages/studio` on a fresh install, builds the workspace first and no longer depends on the working directory, so the collision, round-idle and selling-bundle tests run there as they do from the repo root.
37
+
38
+ ## [0.58.20] - 2026-10-26
39
+
40
+ **Plugin 0.58.14**
41
+
42
+ An upgrade keeps a studio's game routes when nothing has been built yet.
43
+
44
+ ### Fixed
45
+
46
+ - `homie-studio upgrade` in a fresh checkout (no `site/dist`, as on a new machine or a hosting platform's clone) no longer rewrites `wrangler.jsonc` without the studio's game routes (`/<game>`, `/<game>/*` and the static-asset exclusions). Without build output the Worker config keeps the routes the last build wrote; the next build still sets them from the games it finds.
47
+
20
48
  ## [0.58.19] - 2026-10-21
21
49
 
22
50
  **Plugin 0.58.13**
@@ -891,7 +891,7 @@ function unknownFlags(words, given) {
891
891
  async function main() {
892
892
  const [cmd, sub] = positional;
893
893
  if (!cmd || cmd === 'help' || flags.has('help')) {
894
- const text = readFileSync(new URL(import.meta.url), 'utf8').split('*/')[0].split('\n').slice(1).map((l) => l.replace(/^ \* ?/, '')).join('\n');
894
+ const text = readFileSync(new URL(import.meta.url), 'utf8').split('*/')[0].split('\n').slice(1).filter((l) => l.trim() !== '/**').map((l) => l.replace(/^ \* ?/, '')).join('\n');
895
895
  process.stdout.write(`homie-studio ${STUDIO_VERSION}\n${text}\n`);
896
896
  return { ok: true, command: 'help' };
897
897
  }
@@ -19,7 +19,7 @@ A local review is labelled local, not independent. If a fresh reviewer was reque
19
19
 
20
20
  The question is never "does it run". It is: **would a stranger who pressed Play stay for a whole
21
21
  round, and come back?** Instruments measure what can be measured; a fresh reviewer who never saw
22
- the code answers the rest. A builder never grades its own work.
22
+ the code answers the rest when the person asks for one.
23
23
 
24
24
  One script: `scripts/playtest.mjs` in this skill's folder (Claude Code:
25
25
  `node "${CLAUDE_PLUGIN_ROOT}/skills/playtest/scripts/playtest.mjs" <command>`), run from inside the
@@ -196,7 +196,7 @@ test, and the traps that make an instrument lie.
196
196
 
197
197
  ## Never
198
198
 
199
- - Never call a game good, fixed or finished from the code, a build that passed, or your own look at it.
199
+ - Never call a game good, fixed or finished from the code or a build that passed: play it first, and label your own look as local.
200
200
  - Never report a local review as a blind one, and never read a BLOCKED or N/A row, or a missing review, as a pass.
201
201
  - Never turn one scripted round into a rule ("people cannot win", "input does not matter"): say what was measured, and run it again.
202
202
  - Never call the builder's local verdict an independent review: an independent reviewer is fresh every time.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: servers
3
3
  description: Give a Homie studio game lasting servers - named communities with their own rooms and rules - humans-only, hybrid (seats reserved for AI companions), beginner (new players, AI guides, kids-safe) - plus agent passes, the AI skill dial, the party's level vote, and AI guides that talk (a game vocabulary; Cloudflare's Clef decision model on the studio's Workers AI by default, Llama or the owner's key instead, Clef on the person's own computer under dev, or the owner's own Claude in a seat). Use when someone asks for servers, realms or shards, human-only play, AI party members or companions, guides or helpers for new players or kids, guides that answer "help me with this quest", "fill empty spots with AI", letting Claude play as a guide, which model the guides think with, testing an agents.json, free local AI guides, or how strong the bots should be.
4
- compatibility: The studio's own Cloudflare account, with Workers AI through its Worker (Clef by default), Wrangler for the model catalogue and the doctor's probe; optionally Ollama with clef-flash on the person's own computer, downloaded only after their yes.
4
+ compatibility: The studio's own Cloudflare account, with Workers AI through its Worker (Clef by default), Wrangler for the model catalogue and the doctor's probe; optionally Ollama with clef-flash on the person's own computer, downloaded when they want it.
5
5
  metadata:
6
6
  providers: cloudflare ollama
7
7
  ---
@@ -36,7 +36,7 @@ Rooms currently hold at most 32 seats; a named server is a pool of rooms, not a
36
36
  | "Close that server" | `servers close <game> <server>` (`server_close`) | ASKED; its rooms finish their round, then everyone leaves with a thank-you. `--reopen` opens it again at once. Closing `public` hides Quick play: Play then shows the servers to pick from. |
37
37
  | "Make <player> a mentor" | `servers member <game> <server> <player id> --role mentor` (`server_member`) | At once: a mentor gets into a beginner server, with a badge. `--remove` is an ASK. |
38
38
  | "Can we sell things on this server?" / "a shop for the kids server" | nothing to run: the shop skill (`shop.json`) | A kids server (`--kids`) never shows a shop, takes no purchase and shows no supporter badges; a beginner server never offers, or counts as owned, an item with `"advantage": true`. Every other server shows the studio's shop sheet from the room button and the game's `shop.open()`. These are the protective shop preset defaults; the studio can edit policy in shop.json. |
39
- | "Let my Claude play" | `agents pass <game> --label Claude [--server <id>]` (`agent_pass`) | At once: a pass shown ONCE (give it to the AI only; never write it into the repository or a chat). The AI sits with `POST /<game>/api/agent` (`Authorization: Bearer <pass>`), only in a room with people in it, as "Claude · AI", on an open, hybrid or beginner server. `agents revoke <id>` ends it (the AI leaves at once). |
39
+ | "Let my Claude play" | the local MCP's `agent_sit { game, server }`, or the studio's `/mcp` `agent_sit` for a signed-in person's AI (no pass); for an AI with no MCP, `agents pass <game> --label Claude [--server <id>]` (`agent_pass`) | At once: a pass shown ONCE (give it to the AI only; never write it into the repository or a chat). The AI sits with `POST /<game>/api/agent` (`Authorization: Bearer <pass>`), only in a room with people in it, as "Claude · AI", on an open, hybrid or beginner server. `agents revoke <id>` ends it (the AI leaves at once). |
40
40
 
41
41
  ## Guides that talk (0.17.0)
42
42
 
@@ -66,9 +66,9 @@ signed in). Pick one that is not marked Workers Paid; the doctor's Workers AI ro
66
66
  **Under `homie-studio dev`** guides, chat review and game decisions think with Clef on this computer when Ollama (0.35.1 or
67
67
  later) has `clef-flash` (free, nothing sent to Cloudflare; `dev --no-local-ai` turns it off), else from the script;
68
68
  `dev --remote-ai` uses the real Workers AI (billed). The doctor's "Clef on this computer" row says what is there.
69
- **Ask first.** Nothing downloads a model by itself: before `ollama pull clef-flash`, tell the person it downloads about
70
- 11 GB (`clef`, the 27B, about 18 GB) and wait for their yes. Never pull it to make a test pass; the Homie mod (Claude Code) and
71
- Homie's hooks (Codex) hold the pull until they say Proceed.
69
+ **Pull it when they want it.** `ollama pull clef-flash` downloads about 11 GB (`clef`, the 27B, about 18 GB): pull it
70
+ when the person wants Clef on this computer, saying the size in the same line, not to make a test pass. Homie's hooks
71
+ show the size as a one-line note; they do not hold the pull.
72
72
  **Adopting guides in an existing RPG** (zones, quests, a party): `node_modules/@homie-rocks/studio/agents/GUIDES.md`
73
73
  is the short path, in the game's own repository and with its owner's say.
74
74
 
@@ -67,16 +67,16 @@ few seconds, and never prints a key:
67
67
  wanted; the skill that needs it (`music`, `art`, `video`) offers it then. A "do this now" row you can
68
68
  do (`fix.run`, like `npx wrangler login` or `brew install ffmpeg`): run it yourself as part of setup. A row the person does on their own (`fix.open`) can be done any time, even while they wait
69
69
  for something else: give them the link. The optional "Clef on this computer" row is a large download:
70
- say its size first (`ollama pull clef-flash`, about 11 GB) and run it only after their yes.
70
+ run it when they want Clef, saying its size in the same line (`ollama pull clef-flash`, about 11 GB).
71
71
  5. Run it again whenever they say they did something, and tick the row.
72
72
  6. **Homie's holds (Codex, Grok Build).** With `--client` the status has a `holds` row: whether Homie's
73
73
  hooks ran just now in this app (`"on": true`). When it is off, say so in one sentence in this first reply,
74
74
  with the row's fix. In Codex: "Homie's holds are off in this session: open `/hooks` and trust Homie's
75
- three hooks. Until then I'll ask you before a deploy, a Cloudflare change, a paid call or a model
76
- download." In Grok Build: "Homie's holds are off in this session: install the Homie plugin again with
77
- `--trust` and start a new session. Until then I'll ask you before a deploy, a Cloudflare change, a paid
78
- call or a model download." Then
79
- do ask, every time, also when the app approves commands for you. When it is on, say nothing about it and
75
+ three hooks. Until then I'll ask you before a deploy you didn't ask for, a Cloudflare change or a paid
76
+ call." In Grok Build: "Homie's holds are off in this session: install the Homie plugin again with
77
+ `--trust` and start a new session. Until then I'll ask you before a deploy you didn't ask for, a Cloudflare
78
+ change or a paid call." Then
79
+ do ask, also when the app approves commands for you; a deploy the person asked for is already approved. When it is on, say nothing about it and
80
80
  change nothing: the hooks hold what they hold. A status with no `holds` row in Codex or Grok (a toolkit
81
81
  before 0.30.2) cannot tell: say the sentence anyway.
82
82
 
@@ -118,8 +118,8 @@ the status line only to someone who wants the line under the prompt as well.
118
118
 
119
119
  **Homie's holds in Codex.** In Codex, Homie's hooks hold what the mod holds in Claude Code: an edit to a
120
120
  file `studio.json` `"protect"` lists, a production deploy, a change to the studio's Cloudflare account
121
- outside its deploy, a paid call past the budget or whose cost cannot be read first, and a Clef model
122
- download; they refuse what the mod refuses, and take secrets out of what you read. Codex runs a plugin's
121
+ outside its deploy, and a paid call past the budget or whose cost cannot be read first (a big file
122
+ into git and a Clef model download only get a one-line warning); they refuse what the mod refuses, and take secrets out of what you read. Codex runs a plugin's
123
123
  hooks only after the person trusts them once: after installing Homie, tell them to open `/hooks` and
124
124
  trust Homie's three (until then nothing is held, so ask before each of those yourself). Never assume they
125
125
  are on: the setup status says which it is (item 6 above). A held call comes
@@ -134,7 +134,7 @@ and the Codex hooks ask, `hooks/lib/holds.mjs`) hold and refuse the same calls a
134
134
  of what you read. Grok runs a plugin's hooks only once the person has trusted the plugin (`grok plugin install
135
135
  homie-rocks/homie#plugins/homie --trust`, then a new session; `/hooks` lists them). Never assume they are on:
136
136
  the setup status with `--client grok` says which it is (item 6 above), and while it says off, before a
137
- production deploy, a Cloudflare change outside that deploy, a paid call, a model download or an edit to a file
137
+ production deploy, a Cloudflare change outside that deploy, a paid call or an edit to a file
138
138
  `studio.json` `"protect"` lists, say what it would do in one sentence and wait for the person's yes, also when
139
139
  Grok approves commands by itself; and never repeat a key or a token you read. A held call comes back denied
140
140
  with a code, and the person answers `proceed <code>` or `cancel <code>` in their own message, as in Codex; then
@@ -280,7 +280,7 @@ Music, Videos, Rooms and Posts, each once the studio has something in it, in the
280
280
  1. `npx wrangler whoami` in the studio folder. Not signed in: run `npx wrangler login` and tell the
281
281
  person in one line that Cloudflare opened in their browser and they should approve it (their own account). That is their only step. Never ask for or write an API key.
282
282
  2. Before the first deploy, `npx --no-install homie-studio deploy --plan`, and tell them its gist in two
283
- or three lines: the resources (one Worker, one D1 database, two Durable Objects; no R2), the cost
283
+ or three lines: the resources (one Worker with its Durable Objects and two Queues, one D1 database; no R2), the cost
284
284
  (on your account), and what the directory stores. Then go on.
285
285
  3. `npm run deploy`. It creates the Worker and the D1 database named in `studio.json`, applies
286
286
  migrations, deploys, and reads the live site once, which makes the site claim itself in the
@@ -381,7 +381,7 @@ there (checked by SHA-256, at the same addresses); `homie-studio media move --dr
381
381
 
382
382
  ## Never
383
383
 
384
- - Never jump ahead of the checklist, and never copy a starter or another studio's game into the studio
384
+ - Never copy a starter or another studio's game into the studio
385
385
  unless the person asked for it.
386
386
  - Never read or copy from another studio in the same folder unless the person asks: it is somebody else's
387
387
  work.
package/lib/doctor.mjs CHANGED
@@ -264,7 +264,7 @@ export async function setupStatus({
264
264
  : seen ? `off: Homie's hooks last ran in ${name} ${ago(seen.ageMs)} ago, and not for this session's calls`
265
265
  : `off: Homie's hooks have not run in ${name} on this computer`,
266
266
  ...(seen ? { seen: { at: new Date(seen.at).toISOString(), event: seen.event } } : {}),
267
- unlocks: 'a wait for your own "proceed <code>" before a production deploy, an edit to a file the studio protects, a Cloudflare change outside the deploy, a paid call past the budget and a model download; and secrets taken out of what your AI reads',
267
+ unlocks: 'a wait for your own "proceed <code>" before a production deploy, an edit to a file the studio protects, a Cloudflare change outside the deploy, and a paid call past the budget; and secrets taken out of what your AI reads',
268
268
  fix: on ? null : {
269
269
  who: 'person',
270
270
  say: app === 'codex'
@@ -0,0 +1,133 @@
1
+ /**
2
+ * THE PROCESS A JOB RUNS IN (lib/jobs.mjs starts it): a small supervisor, detached from the MCP server that started
3
+ * it (its own process group on macOS and Linux), so a job outlives that server. A host may run the server once per
4
+ * tool call, and the desktop app's server ends when the app quits; a check, a deploy, an npm install or the site
5
+ * here goes on either way, and the next server reads how it went.
6
+ *
7
+ * Its whole state is one file, .studio/mcp/<job>.json: what it runs, the supervisor's pid, each step's start, end,
8
+ * exit code and JSON result, and the job's own end. Each step's output goes to files beside it (<job>[.<n>].out and
9
+ * .err) and to <job>.log. The file is replaced whole (written aside, then renamed), so a reader never sees half.
10
+ *
11
+ * Steps run in order; a step that fails ends the job unless it is soft. With `feed`, the build's progress feed is
12
+ * ended when the job ends (what the MCP server would do), so a build card never stays "running" after its job.
13
+ *
14
+ * Usage: node job-runner.mjs <state file>. Nothing here ever runs a shell (a Windows .cmd shim aside).
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const WIN = process.platform === 'win32';
22
+ export const MAX_OUT = 4 * 1024 * 1024;
23
+
24
+ /** The last JSON document a command printed (homie-studio --json prints one, pretty). */
25
+ export function lastJson(text) {
26
+ const s = String(text ?? '').trim();
27
+ if (!s) return null;
28
+ try { return JSON.parse(s); } catch { /* look for the last top-level object */ }
29
+ for (let i = s.lastIndexOf('\n{'); i >= 0; i = s.lastIndexOf('\n{', i - 1)) {
30
+ try { return JSON.parse(s.slice(i + 1)); } catch { /* earlier */ }
31
+ if (i === 0) break;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function readState(file) {
37
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
38
+ }
39
+
40
+ export function writeState(file, state) {
41
+ const aside = `${file}.${process.pid}.tmp`;
42
+ writeFileSync(aside, JSON.stringify(state));
43
+ renameSync(aside, file);
44
+ }
45
+
46
+ /** The last `bytes` of a file ('' when it is not there). */
47
+ export function tailOf(file, bytes = 64 * 1024) {
48
+ if (!file) return '';
49
+ let fd;
50
+ try {
51
+ const size = statSync(file).size;
52
+ const n = Math.min(size, bytes);
53
+ if (!n) return '';
54
+ const buf = Buffer.alloc(n);
55
+ fd = openSync(file, 'r');
56
+ readSync(fd, buf, 0, n, size - n);
57
+ return buf.toString('utf8');
58
+ } catch { return ''; } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closed */ } }
59
+ }
60
+
61
+ /** A step's own words when it failed: its `why`, else its last lines, else its exit code. */
62
+ export function stepWhy(step, { lines = 6 } = {}) {
63
+ if (step?.result?.why) return step.result.why;
64
+ const text = `${tailOf(step?.err, 16 * 1024)}\n${step?.json ? '' : tailOf(step?.out, 16 * 1024)}`.split('\n').map((l) => l.trimEnd()).filter(Boolean);
65
+ return text.slice(-lines).join('\n') || `exited with ${step?.code}`;
66
+ }
67
+
68
+ /** End a build's progress feed the way the MCP server does: pending stages skipped, then its end (once). */
69
+ export function endFeed(Feed, root, build, state, note) {
70
+ try {
71
+ const feed = new Feed(root, build);
72
+ const doc = feed.doc;
73
+ if (!doc || doc.state !== 'running') return;
74
+ feed.change((d) => { for (const s of d.stages) if (s.state === 'pending') s.state = 'skipped'; });
75
+ feed.end(state, note);
76
+ } catch { /* the feed stays as it was */ }
77
+ }
78
+
79
+ async function supervise(file) {
80
+ const state = readState(file);
81
+ if (!state) process.exit(2);
82
+ state.pid = process.pid;
83
+ writeState(file, state);
84
+ let child = null;
85
+ let stopped = false;
86
+ const log = (d) => { try { appendFileSync(state.log, d); } catch { /* full disk: keep going */ } };
87
+ const stop = (signal) => {
88
+ if (stopped) return;
89
+ stopped = true;
90
+ try { child?.kill(signal); } catch { /* gone */ }
91
+ };
92
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => stop(signal));
93
+ if (!WIN) process.on('SIGHUP', () => { /* the terminal or server that started it went away: carry on */ });
94
+ let failed = null;
95
+ for (const step of state.steps) {
96
+ if (stopped) break;
97
+ step.startedAt = new Date().toISOString();
98
+ writeState(file, state);
99
+ const code = await new Promise((done) => {
100
+ let outBytes = 0;
101
+ try {
102
+ child = spawn(step.cmd, step.args, { cwd: state.root, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: WIN && /\.cmd$/i.test(step.cmd) });
103
+ } catch (error) { appendFileSync(step.err, `\n${error.message}`); done(127); return; }
104
+ child.stdout.on('data', (d) => { if (outBytes < MAX_OUT) { outBytes += d.length; try { appendFileSync(step.out, d); } catch { /* keep going */ } } log(d); });
105
+ child.stderr.on('data', (d) => { try { appendFileSync(step.err, d); } catch { /* keep going */ } log(d); });
106
+ let ended = false;
107
+ const end = (c) => { if (!ended) { ended = true; done(c); } };
108
+ child.on('error', (error) => { try { appendFileSync(step.err, `\n${error.message}`); } catch { /* keep going */ } end(127); });
109
+ child.on('close', (c, signal) => end(c ?? (signal ? 128 + 15 : 1)));
110
+ });
111
+ child = null;
112
+ step.code = code;
113
+ step.endedAt = new Date().toISOString();
114
+ if (step.json) step.result = lastJson(existsSync(step.out) ? readFileSync(step.out, 'utf8') : '');
115
+ writeState(file, state);
116
+ if (code !== 0 && !step.soft) { failed = step; break; }
117
+ }
118
+ const ran = state.steps.filter((s) => s.endedAt);
119
+ const last = failed ?? ran.at(-1) ?? null;
120
+ state.code = stopped ? 143 : failed ? failed.code : 0;
121
+ state.result = last?.result ?? null;
122
+ state.stopped = stopped || undefined;
123
+ state.why = stopped ? 'it was stopped' : failed ? `${state.steps.length > 1 ? `${failed.label}: ` : ''}${stepWhy(failed)}` : null;
124
+ state.endedAt = new Date().toISOString();
125
+ writeState(file, state);
126
+ if (state.feed?.build) {
127
+ const { Feed } = await import('./progress.mjs');
128
+ endFeed(Feed, state.root, state.feed.build, stopped ? 'stopped' : failed ? 'failed' : 'passed', stopped ? `${state.feed.title ?? state.label}: stopped.` : failed ? state.why : `${state.feed.title ?? state.label}: done.`);
129
+ }
130
+ process.exit(0);
131
+ }
132
+
133
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await supervise(process.argv[2]);
package/lib/jobs.mjs CHANGED
@@ -10,13 +10,17 @@
10
10
  * install, Wrangler, esbuild), so commands always run with the first Node 22 or newer on the PATH. A GUI app's PATH
11
11
  * is short, so the usual places Node lives are added (Homebrew, nvm, Volta, asdf, fnm, the Node installer).
12
12
  *
13
- * Logs go to .studio/mcp/<job>.log in the studio (git-ignored). Nothing here ever runs a shell.
13
+ * A job outlives the server that started it (lib/job-runner.mjs supervises it, detached): its state is
14
+ * .studio/mcp/<job>.json and its logs .studio/mcp/<job>.log in the studio (git-ignored), which a later server reads.
15
+ * Nothing here ever runs a shell.
14
16
  */
15
17
  import { spawn, spawnSync } from 'node:child_process';
16
- import { appendFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
17
20
  import { homedir } from 'node:os';
18
21
  import { delimiter, dirname, join } from 'node:path';
19
22
  import { randomBytes } from 'node:crypto';
23
+ import { lastJson, readState, tailOf, writeState } from './job-runner.mjs';
20
24
  import { nodeHome } from './prepare.mjs';
21
25
  import { PACKAGE_ROOT } from './studio.mjs';
22
26
 
@@ -93,61 +97,215 @@ export function cliOf(root) {
93
97
  export const installed = (root) => existsSync(join(root, 'node_modules', '@homie-rocks', 'studio', 'package.json')) && existsSync(join(root, 'node_modules', 'wrangler'));
94
98
 
95
99
  const jobs = new Map();
96
- const MAX_OUT = 4 * 1024 * 1024;
100
+ const RUNNER = fileURLToPath(new URL('./job-runner.mjs', import.meta.url));
101
+ const JOB_ID = /^j_[a-f0-9]{12}$/;
102
+ /** A finished job's files are kept this long, then removed when a later job starts in the same studio (pruneJobs). */
103
+ export const JOB_KEPT_MS = 24 * 60 * 60_000;
104
+ /** A job whose supervisor never wrote its pid this long after it was started never ran. */
105
+ const NEVER_STARTED_MS = 60_000;
106
+ export { lastJson };
107
+
108
+ /** Is that process still there? (EPERM: it is, under another user.) */
109
+ export function alive(pid) {
110
+ if (!Number.isInteger(pid) || pid <= 0) return false;
111
+ try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
112
+ }
113
+
114
+ /**
115
+ * Is `pid` still this job's own supervisor, not another process the system gave its number to later? Its command line
116
+ * names the job's state file (node job-runner.mjs <dir>/<job>.json): read from /proc on Linux, from ps elsewhere (once
117
+ * every few seconds per job). Where neither can say (Windows, no ps), a live pid is taken as the job's.
118
+ */
119
+ export function jobAlive(pid, id, job = null) {
120
+ if (!alive(pid)) return false;
121
+ if (WIN || !JOB_ID.test(String(id ?? ''))) return true;
122
+ const now = Date.now();
123
+ if (job?.seen && job.seen.pid === pid && now - job.seen.at < 5000) return job.seen.ok;
124
+ let ok = true;
125
+ try {
126
+ if (process.platform === 'linux') ok = readFileSync(`/proc/${pid}/cmdline`, 'utf8').includes(`${id}.json`);
127
+ else {
128
+ const r = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, windowsHide: true });
129
+ if (!r.error) ok = r.status === 0 && r.stdout.includes(`${id}.json`);
130
+ }
131
+ } catch { ok = alive(pid); }
132
+ if (job) job.seen = { pid, at: now, ok };
133
+ return ok;
134
+ }
135
+
136
+ /**
137
+ * Remove the files of this studio's jobs that ended more than JOB_KEPT_MS ago (state, log, output), and of ones whose
138
+ * process went without an end that long ago; also a left-over file with no state beside it (an older toolkit's log, a
139
+ * write cut short) that old. Called when a job starts: no process of its own. A running job's files are never touched.
140
+ */
141
+ export function pruneJobs(dir, now = Date.now()) {
142
+ let names = [];
143
+ try { names = readdirSync(dir); } catch { return 0; }
144
+ const byJob = new Map();
145
+ for (const n of names) {
146
+ const m = /^(j_[a-f0-9]{12})(?:\.|$)/.exec(n);
147
+ if (m) byJob.set(m[1], [...(byJob.get(m[1]) ?? []), n]);
148
+ }
149
+ let removed = 0;
150
+ for (const [id, files] of byJob) {
151
+ if (jobs.get(id) && !jobs.get(id).endedAt) continue;
152
+ let old;
153
+ if (files.includes(`${id}.json`)) {
154
+ const s = readState(join(dir, `${id}.json`));
155
+ if (!s) continue;
156
+ const ended = Date.parse(s.endedAt ?? '');
157
+ const started = Date.parse(s.startedAt ?? '');
158
+ old = Number.isFinite(ended) ? now - ended > JOB_KEPT_MS : Number.isFinite(started) && now - started > JOB_KEPT_MS && !jobAlive(s.pid, id);
159
+ } else {
160
+ old = files.every((n) => { try { return now - statSync(join(dir, n)).mtimeMs > JOB_KEPT_MS; } catch { return false; } });
161
+ }
162
+ if (!old) continue;
163
+ for (const n of files) try { rmSync(join(dir, n), { force: true }); removed++; } catch { /* next time */ }
164
+ }
165
+ return removed;
166
+ }
167
+
168
+ /** A Node that can run the supervisor: the system's, else this one (the Claude app's own, as plain Node). */
169
+ function runnerNode(env) {
170
+ const node = findNode();
171
+ if (node) return { bin: node.bin, env };
172
+ return { bin: process.execPath, env: process.versions.electron ? { ...env, ELECTRON_RUN_AS_NODE: '1' } : env };
173
+ }
174
+
175
+ /** A job as this process sees it: its state file read again; its output read from its files when asked. */
176
+ function jobFrom(file, extra = {}) {
177
+ const job = { stateFile: file, child: null, done: null, onEnd: null, steps: [], ...extra };
178
+ Object.defineProperty(job, 'out', { enumerable: true, get: () => (job.json ? '' : job.steps.map((s) => tailOf(s.out)).join('')) });
179
+ Object.defineProperty(job, 'err', { enumerable: true, get: () => job.steps.map((s) => tailOf(s.err)).join('').slice(-64 * 1024) });
180
+ return refreshJob(job);
181
+ }
97
182
 
98
183
  /**
99
- * Start one process for a studio. `json`: the command prints a JSON result on stdout (homie-studio --json), kept
100
- * as job.result. `onEnd(job)` runs once when it exits.
184
+ * Read a job's state file again. A job whose supervisor is gone without having written its end (killed, or the
185
+ * computer restarted) is ended here as failed, and its file says so: no one is handed the id of a dead job as running.
101
186
  */
102
- export function startJob({ root, label, cmd, args, env = toolEnv(), json = false, onEnd = null, keep = false }) {
187
+ const now = () => Date.now();
188
+ export function refreshJob(job) {
189
+ if (job.endedAt && job.final) return job;
190
+ const s = readState(job.stateFile);
191
+ if (!s) return job;
192
+ Object.assign(job, { id: s.id, label: s.label, root: s.root, args: s.args, json: s.json, startedAt: s.startedAt, log: s.log, steps: s.steps, feed: s.feed ?? null, endsWithServer: Boolean(s.endsWithServer) });
193
+ job.pid = s.pid ?? job.pid ?? null;
194
+ if (s.endedAt) {
195
+ Object.assign(job, { endedAt: s.endedAt, code: s.code, result: s.result ?? null, why: s.why ?? null, final: true });
196
+ return job;
197
+ }
198
+ const neverStarted = !job.child && !job.pid && now() - Date.parse(s.startedAt ?? '') > NEVER_STARTED_MS;
199
+ if (neverStarted || (!job.child && job.pid && !jobAlive(job.pid, s.id, job))) {
200
+ s.code = 1;
201
+ s.why = neverStarted ? 'the job\'s process never started' : 'the job\'s process ended without finishing (it was stopped, or the computer restarted)';
202
+ s.endedAt = new Date().toISOString();
203
+ for (const st of s.steps) if (st.startedAt && !st.endedAt) { st.endedAt = s.endedAt; st.code = 1; try { appendFileSync(st.err, `\n${s.why}\n`); } catch { /* read-only */ } }
204
+ try { writeState(job.stateFile, s); } catch { /* the view below still says it */ }
205
+ Object.assign(job, { endedAt: s.endedAt, code: s.code, result: null, why: s.why, steps: s.steps, final: true });
206
+ }
207
+ return job;
208
+ }
209
+
210
+ /**
211
+ * Start one job for a studio: one command, or `steps` run in order ([{ label, cmd, args, json, soft }]; a step that
212
+ * fails ends the job unless it is soft). It runs under lib/job-runner.mjs, detached, so it outlives this MCP server;
213
+ * its state is .studio/mcp/<job>.json, which any later server reads (getJob). `json`: the command prints a JSON result
214
+ * on stdout (homie-studio --json), kept as job.result. `onEnd(job)` runs once when it ends, if this process is still
215
+ * here then. `feed`: { build, title }, the progress feed the supervisor ends with the job. `endsWithServer`: stopped
216
+ * when this server ends (stopAllJobs) instead.
217
+ */
218
+ export function startJob({ root, label, cmd, args, env = toolEnv(), json = false, onEnd = null, endsWithServer = false, steps = null, feed = null }) {
103
219
  const id = `j_${randomBytes(6).toString('hex')}`;
104
220
  const dir = join(root, '.studio', 'mcp');
105
221
  mkdirSync(dir, { recursive: true });
106
- const log = join(dir, `${id}.log`);
107
- appendFileSync(log, `$ ${[cmd, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')}\n`);
108
- const child = spawn(cmd, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: WIN && /\.cmd$/i.test(cmd) });
109
- const job = { id, label, root, args, pid: child.pid ?? null, startedAt: new Date().toISOString(), endedAt: null, code: null, out: '', err: '', log, result: null, json, keep, child };
222
+ pruneJobs(dir);
223
+ const base = join(dir, id);
224
+ const log = `${base}.log`;
225
+ const list = (steps ?? [{ label, cmd, args, json }]).map((st, n, all) => {
226
+ const name = all.length > 1 ? `${base}.${n}` : base;
227
+ return { label: st.label ?? label, cmd: st.cmd ?? cmd, args: st.args, json: Boolean(st.json), soft: Boolean(st.soft), out: `${name}.out`, err: `${name}.err`, startedAt: null, endedAt: null, code: null, result: null };
228
+ });
229
+ appendFileSync(log, list.map((st) => `$ ${[st.cmd, ...st.args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')}\n`).join(''));
230
+ const stateFile = `${base}.json`;
231
+ const startedAt = new Date().toISOString();
232
+ writeState(stateFile, { v: 1, id, label, root, args: list[0].args, json: Boolean(list.at(-1).json), startedAt, endedAt: null, code: null, result: null, pid: null, log, steps: list, feed, endsWithServer });
233
+ const node = runnerNode(env);
234
+ const child = spawn(node.bin, [RUNNER, stateFile], { cwd: root, env: node.env, stdio: 'ignore', detached: true, windowsHide: true });
235
+ child.unref();
236
+ const job = jobFrom(stateFile, { child, onEnd, pid: child.pid ?? null });
237
+ jobs.set(id, job);
110
238
  let finish;
111
239
  job.done = new Promise((r) => { finish = r; });
112
- child.stdout.on('data', (d) => { if (job.out.length < MAX_OUT) job.out += d; try { appendFileSync(log, d); } catch { /* full disk: keep going */ } });
113
- child.stderr.on('data', (d) => { job.err = (job.err + d).slice(-64 * 1024); try { appendFileSync(log, d); } catch { /* keep going */ } });
114
240
  const end = (code) => {
115
- if (job.endedAt) return;
116
- job.code = code;
117
- job.endedAt = new Date().toISOString();
118
- if (json) job.result = lastJson(job.out);
241
+ if (job.final) { finishOnce(); return; }
242
+ job.child = null;
243
+ refreshJob(job);
244
+ if (!job.endedAt) Object.assign(job, { endedAt: new Date().toISOString(), code: code === 0 ? 1 : code ?? 1, result: null, final: true });
245
+ finishOnce();
246
+ };
247
+ let finished = false;
248
+ const finishOnce = () => {
249
+ if (finished) return;
250
+ finished = true;
119
251
  job.child = null;
120
252
  try { onEnd?.(job); } catch { /* the job's own result stands */ }
121
253
  finish(job);
122
254
  };
123
- child.on('error', (error) => { job.err += `\n${error.message}`; end(127); });
124
- child.on('close', (code) => end(code ?? 1));
125
- jobs.set(id, job);
255
+ child.on('error', (error) => { for (const st of job.steps) try { appendFileSync(st.err, `\n${error.message}`); } catch { /* keep going */ } end(127); });
256
+ child.on('close', (code) => end(code));
126
257
  return job;
127
258
  }
128
259
 
129
- /** The last JSON document a command printed (homie-studio --json prints one, pretty). */
130
- export function lastJson(text) {
131
- const s = String(text ?? '').trim();
132
- if (!s) return null;
133
- try { return JSON.parse(s); } catch { /* look for the last top-level object */ }
134
- for (let i = s.lastIndexOf('\n{'); i >= 0; i = s.lastIndexOf('\n{', i - 1)) {
135
- try { return JSON.parse(s.slice(i + 1)); } catch { /* earlier */ }
136
- if (i === 0) break;
260
+ /** Each step of a job that has started, as a job of its own (what a build card lists); its `done` is the job's. */
261
+ export function stepJobs(job) {
262
+ refreshJob(job);
263
+ return (job.steps ?? []).filter((s) => s.startedAt).map((s) => {
264
+ const step = { id: job.id, label: s.label, root: job.root, args: s.args, startedAt: s.startedAt, endedAt: s.endedAt ?? (job.endedAt && !s.endedAt ? job.endedAt : null), code: s.code ?? (job.endedAt ? job.code : null), result: s.result ?? null, json: s.json, log: job.log, steps: [s], done: job.done };
265
+ Object.defineProperty(step, 'out', { enumerable: true, get: () => (s.json ? '' : tailOf(s.out)) });
266
+ Object.defineProperty(step, 'err', { enumerable: true, get: () => tailOf(s.err) });
267
+ return step;
268
+ });
269
+ }
270
+
271
+ /** A job by its id: this server's, else one an earlier server started in one of `roots` (its .studio/mcp). */
272
+ export function getJob(id, { roots = [] } = {}) {
273
+ const key = String(id ?? '');
274
+ const known = jobs.get(key);
275
+ if (known) return refreshJob(known);
276
+ if (!JOB_ID.test(key)) return null;
277
+ for (const root of new Set(roots.filter(Boolean))) {
278
+ const file = join(root, '.studio', 'mcp', `${key}.json`);
279
+ if (!existsSync(file)) continue;
280
+ const job = jobFrom(file);
281
+ if (!job.id) continue;
282
+ jobs.set(key, job);
283
+ return job;
137
284
  }
138
285
  return null;
139
286
  }
140
287
 
141
- export const getJob = (id) => jobs.get(String(id ?? '')) ?? null;
142
- export const runningJobs = (root) => [...jobs.values()].filter((j) => !j.endedAt && (!root || j.root === root));
288
+ /** The jobs still running: this server's, and (for a studio) any an earlier server started there. */
289
+ export function runningJobs(root) {
290
+ if (root) {
291
+ let names = [];
292
+ try { names = readdirSync(join(root, '.studio', 'mcp')).filter((n) => /^j_[a-f0-9]{12}\.json$/.test(n)); } catch { /* none yet */ }
293
+ for (const n of names) getJob(n.slice(0, -5), { roots: [root] });
294
+ }
295
+ return [...jobs.values()].map(refreshJob).filter((j) => !j.endedAt && (!root || j.root === root));
296
+ }
143
297
 
144
298
  /** Wait for a job at most `ms`; true when it ended. */
145
299
  export async function waitJob(job, ms) {
146
- if (job.endedAt) return true;
147
- let t;
148
- const ended = await Promise.race([job.done.then(() => true), new Promise((r) => { t = setTimeout(() => r(false), ms); })]);
149
- clearTimeout(t);
150
- return ended;
300
+ const until = Date.now() + ms;
301
+ refreshJob(job);
302
+ while (!job.endedAt && Date.now() < until) {
303
+ let t;
304
+ await Promise.race([...(job.done ? [job.done] : []), new Promise((r) => { t = setTimeout(r, Math.max(1, Math.min(500, until - Date.now()))); })]);
305
+ clearTimeout(t);
306
+ refreshJob(job);
307
+ }
308
+ return Boolean(job.endedAt);
151
309
  }
152
310
 
153
311
  /** What a person (and the model) is told about a job. */
@@ -184,10 +342,15 @@ export function installJob(root, opts = {}) {
184
342
  return startJob({ root, label: 'npm install (the studio\'s pinned toolkit and Wrangler)', ...npmInvocation(node), ...opts });
185
343
  }
186
344
 
187
- /** When the server ends (the app quit), nothing it started keeps running, except what asked to (none today). */
345
+ /**
346
+ * When the server ends (the app quit, or a host's one-call server is done): the jobs it started go on (each under its
347
+ * own supervisor, lib/job-runner.mjs) and the next server reads them; only one that asked (endsWithServer) is stopped,
348
+ * with everything it started (its process group).
349
+ */
188
350
  export function stopAllJobs() {
189
351
  for (const job of jobs.values()) {
190
- if (job.endedAt || !job.child || job.keep) continue;
191
- try { job.child.kill('SIGTERM'); } catch { /* gone */ }
352
+ // Only its own supervisor's process group: never a process that has the number since.
353
+ if (job.endedAt || !job.endsWithServer || !jobAlive(job.pid, job.id, job)) continue;
354
+ try { process.kill(WIN ? job.pid : -job.pid, 'SIGTERM'); } catch { try { process.kill(job.pid, 'SIGTERM'); } catch { /* gone */ } }
192
355
  }
193
356
  }
package/lib/local-ai.mjs CHANGED
@@ -8,8 +8,8 @@
8
8
  * a guide's seat that thinks on this computer every few seconds, with no AI turn per decision
9
9
  * homie-studio doctor says what is there, and what one sentence gets the rest
10
10
  *
11
- * It never downloads a model. `ollama pull clef-flash` is about 11 GB (clef, the 27B, about 18 GB): the person says yes
12
- * first, every time (the studio skill asks and names the size). It needs Ollama 0.35.1 or later (the first with Clef).
11
+ * It never downloads a model. `ollama pull clef-flash` is about 11 GB (clef, the 27B, about 18 GB): pulled when the
12
+ * person wants Clef here, with the size said in the same line. It needs Ollama 0.35.1 or later (the first with Clef).
13
13
  */
14
14
 
15
15
  export const OLLAMA_BASE = 'http://127.0.0.1:11434';