@homie-rocks/studio 0.58.20 → 0.58.23
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.
- package/CHANGELOG.md +48 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/playtest/SKILL.md +2 -2
- package/guides/servers/SKILL.md +5 -5
- package/guides/studio-setup/SKILL.md +11 -11
- package/lib/doctor.mjs +1 -1
- package/lib/job-runner.mjs +133 -0
- package/lib/jobs.mjs +200 -37
- package/lib/local-ai.mjs +2 -2
- package/lib/mcp-tools.mjs +41 -31
- package/lib/parts-store.mjs +46 -2
- package/lib/parts.mjs +13 -1
- package/lib/scaffold.mjs +13 -12
- package/lib/template-history.json +150 -6
- package/package.json +20 -5
- package/parts/PARTS.md +27 -0
- package/parts/README.md +1 -0
- package/parts/catalogue.json +138 -0
- package/parts/venue-desk/LICENSE +202 -0
- package/parts/venue-desk/NOTICE +3 -0
- package/parts/venue-desk/README.md +34 -0
- package/parts/venue-desk/part.json +138 -0
- package/parts/venue-desk/preview/index.html +7 -0
- package/parts/venue-desk/src/index.ts +228 -0
- package/parts/venue-desk/tools/venue-booking-cancel.ts +2 -0
- package/parts/venue-desk/tools/venue-booking-confirm.ts +2 -0
- package/parts/venue-desk/tools/venue-booking-details.ts +2 -0
- package/parts/venue-desk/tools/venue-booking-status.ts +2 -0
- package/parts/venue-desk/tools/venue-desk.ts +2 -0
- package/parts/venue-desk/tools/venue-info-set.ts +2 -0
- package/parts/venue-desk/tools/venue-info.ts +2 -0
- package/parts/venue-desk/tools/venue-waitlist-join.ts +2 -0
- package/parts/venue-desk/tools/venue-waitlist-offer.ts +2 -0
- package/parts/venue-desk/tools/venue-waitlist-status.ts +2 -0
- package/parts/venue-desk/tuning.json +1 -0
- package/tools/TOOLS.md +14 -0
- package/worker/booking-availability.mjs +1 -1
- package/worker/booking-holds.mjs +48 -21
- package/worker/booking-options.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/customer-mcp.mjs +15 -1
- package/worker/mcp-room.mjs +3 -2
- package/worker/mcp-tools.mjs +9 -1
- package/worker/mcp.mjs +6 -1
- package/worker/room.mjs +3 -2
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,54 @@ 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.23] - 2026-10-29
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.15**
|
|
23
|
+
|
|
24
|
+
A booking agent books, finds its hold, pays and cancels with one hold id and a private token, and every refusal comes with a stable error code.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `studio_book` needs no `id`. The returned `data.id` (now `hold_` and 48 hex) is the hold: pass it as `hold` to the Venue desk tools, `studio_holds` and `studio_checkout`. An `id` you still send is an optional idempotency key, scoped to your account or private token, so two guests using the same slot name never collide (that was the `Booking owner required` error). Holds made by earlier versions keep their 64-hex ids and keep working.
|
|
29
|
+
- Every studio MCP tool refusal is an MCP error (`isError`) with `structuredContent` `{kind:'studio-error', error:{code, message, status}}` and a stable `code`, e.g. `hold_not_found`, `slot_full`, `hold_ended`, `invalid_input`, or a closed shop's `closed` with `details.missing`. `studio_checkout` no longer returns a closed shop as success.
|
|
30
|
+
- `studio_checkout` takes top-level `app`, `hold` and `token`, the same as the Venue desk tools, and fills in the booking's line (its service item × quantity). `venue_booking_confirm` (Venue desk 1.0.1), for a venue that takes payment in advance, returns `checkout: {tool:'studio_checkout', arguments, lines}` naming exactly what to call.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `studio_holds { token }` lists a guest's own active holds; `release` (a hold id, or `"all"`) releases them. Only that token's holds are ever shown or changed.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- A hold past its expiry no longer takes a place: availability stops counting it, and the next booking on that slot releases it.
|
|
39
|
+
|
|
40
|
+
## [0.58.22] - 2026-10-29
|
|
41
|
+
|
|
42
|
+
**Plugin 0.58.15**
|
|
43
|
+
|
|
44
|
+
The parts that come with the toolkit, like the Venue desk, add to a studio with no download.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- `part_add` and `parts_find` find the parts that come with the toolkit (`parts/catalogue.json`: Venue desk, Owner action checklist, Terrain route map) in the installed toolkit, with no download: `part_add { "part": "venue-desk" }` (or `"homie.rocks/venue-desk"`) copies it in, every file checked against the catalogue's SHA-256, and `parts_find` lists them even when the shared catalogue cannot be reached.
|
|
49
|
+
|
|
50
|
+
## [0.58.21] - 2026-10-29
|
|
51
|
+
|
|
52
|
+
**Plugin 0.58.15**
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- 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.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- 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.
|
|
65
|
+
- 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`).
|
|
66
|
+
- 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.
|
|
67
|
+
|
|
20
68
|
## [0.58.20] - 2026-10-26
|
|
21
69
|
|
|
22
70
|
**Plugin 0.58.14**
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
}
|
package/guides/playtest/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/guides/servers/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
**
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
`--trust` and start a new session. Until then I'll ask you before a deploy
|
|
78
|
-
|
|
79
|
-
do ask,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
100
|
-
* as
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
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.
|
|
116
|
-
job.
|
|
117
|
-
job
|
|
118
|
-
if (
|
|
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
|
|
124
|
-
child.on('close', (code) => end(code
|
|
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
|
-
/**
|
|
130
|
-
export function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
export
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
191
|
-
|
|
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):
|
|
12
|
-
*
|
|
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';
|