@homie-rocks/studio 0.7.0 → 0.8.0

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/README.md CHANGED
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
13
- npx -y --package=https://homie.rocks/npm/homie-studio-0.7.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.8.0.tgz homie-studio new ./night-owls --name "Night Owls"
14
14
  cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
@@ -64,6 +64,34 @@ npx homie-studio stats share on # tell the directory "pl
64
64
  Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
65
65
  studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
66
66
 
67
+ ## A build the person can watch (the progress feed)
68
+
69
+ A build of a game, song or video can keep a small progress feed, so the person follows it
70
+ from wherever they are (the Claude app on a phone, through the Homie MCP's build progress
71
+ card), sees each check go green, what it spent against its budget, and can press Stop:
72
+
73
+ ```sh
74
+ npx homie-studio progress start crown-thief --share --title "Crown Thief: faster rounds" --budget 2
75
+ npx homie-studio progress stage plan done --note "Rounds from 60 s to 45 s"
76
+ npm run build && npx homie-studio check crown-thief --url http://127.0.0.1:8787 && npm run deploy
77
+ ```
78
+
79
+ While a feed is open, `build`, `check`, `port check` and `deploy` report into it by
80
+ themselves: their stage (a game: plan → build → checks → deploy), each check as it runs
81
+ and passes, a small picture of the game and the address to play it; a deploy with every
82
+ check green ends the build. The AI marks what only it knows with `progress stage`,
83
+ `progress check`, `progress spend`, `progress shot` (a video's shots) and `progress song`
84
+ (a song's waveform and lyric check). Before each stage starts, and every few seconds while
85
+ one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
86
+ browsers and says so, and nothing already built or deployed is undone.
87
+
88
+ The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
89
+ directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
90
+ hours so the Claude app can show it: the feed only (plain bounded text, pictures under
91
+ 96 KB), never a key, a path or code. Its write key stays in `.studio/progress/` and is
92
+ never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
93
+ feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
94
+
67
95
  ## The site's address
68
96
 
69
97
  `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
@@ -29,6 +29,24 @@
29
29
  * homie-studio stats revoke (every stats key and page session ends)
30
30
  * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
31
31
  *
32
+ * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
33
+ * [--budget <dollars>] [--unit usd|credits] [--share]
34
+ * (a progress feed for one build; --share shows it in the Claude app
35
+ * through the Homie MCP's build_progress widget, which can press Stop)
36
+ * homie-studio progress stage <stage> running|done|failed|skipped [--note "<one line>"]
37
+ * homie-studio progress check <id> pending|running|pass|fail|skip [--label "<words>"] [--note "<one line>"]
38
+ * homie-studio progress preview [--url <address>] [--image <small .jpg/.png>] [--caption "<words>"]
39
+ * homie-studio progress spend <amount> --what "<what it bought>" [--receipt <file>]
40
+ * homie-studio progress shot <id> pending|running|pass|fail [--label "<words>"] [--image <small .jpg>]
41
+ * homie-studio progress song [--peaks <peaks.json>] [--lyric "<line>" --sung yes|no]
42
+ * homie-studio progress log "<one line>"
43
+ * homie-studio progress stop (ask the running build to stop at its next safe point)
44
+ * homie-studio progress end passed|failed|stopped [--note "<one line>"]
45
+ * homie-studio progress show [<build>]
46
+ * While a feed is open, build, check, port check and deploy report
47
+ * into it (stage, each check going green, a preview picture) and stop
48
+ * when asked. Without one, nothing changes.
49
+ *
32
50
  * Every command prints a few lines for a person; --json prints the result.
33
51
  */
34
52
  import { spawn, spawnSync } from 'node:child_process';
@@ -46,10 +64,11 @@ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
46
64
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
47
65
  import { STUDIO_VERSION } from '../lib/version.mjs';
48
66
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
67
+ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
49
68
 
50
69
  const argv = process.argv.slice(2);
51
70
  const flags = new Map();
52
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
71
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
53
72
  const positional = [];
54
73
  for (let i = 0; i < argv.length; i++) {
55
74
  const a = argv[i];
@@ -155,6 +174,27 @@ function print(result) {
155
174
  case 'stats share':
156
175
  lines.push(result.message);
157
176
  break;
177
+ case 'progress start':
178
+ lines.push(`Build ${result.build}: ${result.title} (${result.stages.join(' → ')})${result.budget !== null ? `, budget ${result.unit === 'usd' ? `$${result.budget.toFixed(2)}` : `${result.budget} credits`}` : ''}`,
179
+ ` feed: ${result.file}`,
180
+ ...(result.shared ? [` shared for the Claude app until ${result.shared.expiresAt ?? 'tomorrow'}: ${result.widget}`] : []),
181
+ ...(result.sharedWhy ? [` ${result.sharedWhy}`] : []),
182
+ ' build, check, port check and deploy now report into it; mark the plan done with: npx --no-install homie-studio progress stage plan done --note "<the plan in one line>"');
183
+ break;
184
+ case 'progress':
185
+ lines.push(result.message ?? `${result.feed?.title}: ${result.feed?.state}${result.feed?.stage ? ` (${result.feed.stage})` : ''}`);
186
+ break;
187
+ case 'progress show': {
188
+ const f = result.feed;
189
+ const mark = { done: 'ok ', pass: 'ok ', running: '... ', failed: 'FAIL', fail: 'FAIL', skipped: 'skip', skip: 'skip', stopped: 'stop', pending: ' ' };
190
+ lines.push(`${f.title} [${f.state}] build ${f.build}${f.shared ? ` (shared as ${f.shared.build})` : ''}`,
191
+ ...f.stages.map((s) => ` ${mark[s.state] ?? s.state} ${s.label}${s.note ? `: ${s.note}` : ''}`),
192
+ ...f.checks.map((c) => ` ${mark[c.state] ?? c.state} ${c.label}${c.note ? `: ${c.note}` : ''}`),
193
+ ...(f.preview?.url ? [` preview: ${f.preview.url}`] : []),
194
+ ` spent ${f.spend.unit === 'usd' ? `$${f.spend.used.toFixed(2)}` : `${f.spend.used} credits`}${f.spend.budget !== null ? ` of ${f.spend.unit === 'usd' ? `$${f.spend.budget.toFixed(2)}` : `${f.spend.budget} credits`}` : ''}`,
195
+ ...(f.stop?.requested ? [` stop asked (${f.stop.by}) at ${f.stop.at}`] : []));
196
+ break;
197
+ }
158
198
  case 'look':
159
199
  lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
160
200
  break;
@@ -181,17 +221,18 @@ async function main() {
181
221
 
182
222
  if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
183
223
  const root = requireStudio();
224
+ if (cmd === 'progress') return progressCommand(root, sub);
184
225
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
185
226
  if (cmd === 'port' && sub === 'check') {
186
227
  const game = positional[2] ?? listGames(root)[0]?.id;
187
228
  const url = flags.get('url') ?? siteUrl(root);
188
229
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
189
- return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
230
+ return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
190
231
  }
191
232
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
192
233
  if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
193
234
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
194
- if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
235
+ if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
195
236
  if (cmd === 'status') {
196
237
  const studio = readStudio(root);
197
238
  return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
@@ -202,7 +243,7 @@ async function main() {
202
243
  const game = positional[1] ?? listGames(root)[0]?.id;
203
244
  const url = flags.get('url') ?? siteUrl(root);
204
245
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
205
- return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
246
+ return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
206
247
  }
207
248
  if (cmd === 'look') {
208
249
  const url = flags.get('url') ?? siteUrl(root);
@@ -218,7 +259,7 @@ async function main() {
218
259
  return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
219
260
  }
220
261
  if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
221
- if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
262
+ if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
222
263
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
223
264
  if (cmd === 'storage') {
224
265
  const cf = readStudio(root).cloudflare ?? {};
@@ -241,6 +282,94 @@ async function main() {
241
282
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
242
283
  }
243
284
 
285
+ /*
286
+ * THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
287
+ * deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
288
+ * stop asked in the Claude app (or by `progress stop`) ends it at the next safe point. With no open feed, or a
289
+ * feed whose build has no such stage (a song has no deploy), the command runs exactly as it did before.
290
+ */
291
+ async function tracked(root, stage, run, command) {
292
+ const feed = currentFeed(root);
293
+ if (!feed || !feed.doc.stages.some((s) => s.id === stage)) return run(null);
294
+ await feed.sync();
295
+ if (feed.stopRequested()) {
296
+ feed.stage(stage, 'stopped', 'stopped by the person before it started');
297
+ feed.end('stopped', 'Stopped by the person.');
298
+ return { ok: false, command, stopped: true, why: 'stopped by the person (in the Claude app, or `progress stop`): the build is over; ask them before starting again' };
299
+ }
300
+ if (stage === 'checks') feed.resetChecks('checks');
301
+ feed.stage(stage, 'running');
302
+ const report = {
303
+ check: (id, state, opts) => feed.check(id, state, { ...opts, stage }),
304
+ preview: (p) => feed.preview(p),
305
+ stopped: () => feed.stopRequested(),
306
+ state: (id) => feed.doc?.checks.find((c) => c.id === id)?.state,
307
+ };
308
+ let result;
309
+ try { result = await run(report); } catch (error) { feed.stage(stage, 'failed', error instanceof Error ? error.message : String(error)); throw error; }
310
+ if (result?.stopped || feed.stopRequested()) {
311
+ feed.stage(stage, 'stopped', 'stopped by the person');
312
+ feed.end('stopped', 'Stopped by the person.');
313
+ return { ...result, ok: false, stopped: true, why: result?.why ?? 'stopped by the person' };
314
+ }
315
+ if (!result?.ok) { feed.stage(stage, 'failed', result?.why ?? 'failed'); return result; }
316
+ const note = stage === 'build' ? `Built ${(result.games ?? []).map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'the site'}`
317
+ : stage === 'deploy' ? `Live: ${result.url ?? 'deployed'}`
318
+ : result.command === 'port check' ? `${result.passed.length} passed${result.skipped.length ? `, ${result.skipped.length} skipped` : ''} in ${Math.round(result.totalMs / 1000)} s`
319
+ : `Room ${result.room}: round ${result.round?.n} finished with ${result.round?.humans} people in ${Math.round(result.totalMs / 1000)} s`;
320
+ feed.stage(stage, 'done', note);
321
+ if (stage === 'deploy') {
322
+ const doc = feed.doc;
323
+ const game = (result.games ?? []).find((g) => g.id === doc.id) ?? (result.games ?? [])[0];
324
+ if (game?.play) feed.preview({ url: game.play });
325
+ // Deployed, and every check that ran passed: the build is done.
326
+ if (doc.checks.every((c) => ['pass', 'skip'].includes(c.state))) feed.end('passed', `Live: ${game?.play ?? result.url}`);
327
+ }
328
+ return result;
329
+ }
330
+
331
+ async function progressCommand(root, sub) {
332
+ const arg = positional[2];
333
+ if (sub === 'start') {
334
+ return startProgress(root, {
335
+ what: flags.get('what') ?? 'game', id: arg ?? flags.get('id') ?? null, title: flags.get('title'),
336
+ budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
337
+ });
338
+ }
339
+ if (sub === 'show') {
340
+ const id = arg ?? currentId(root) ?? flags.get('build');
341
+ const doc = readFeed(root, id);
342
+ return doc ? { ok: true, command: 'progress show', feed: publicFeed(doc) } : { ok: false, command: 'progress show', why: id ? `no feed ${id} in .studio/progress/` : 'no build is open (homie-studio progress start)' };
343
+ }
344
+ const id = flags.get('build') ?? currentId(root);
345
+ if (!id || !readFeed(root, id)) return { ok: false, command: 'progress', why: 'no build is open: start one with `homie-studio progress start <id> --share`' };
346
+ const feed = new Feed(root, id);
347
+ const done = (message) => ({ ok: true, command: 'progress', build: id, message, feed: publicFeed(feed.doc) });
348
+ const note = flags.has('note') ? String(flags.get('note')) : undefined;
349
+ try {
350
+ switch (sub) {
351
+ case 'stage': feed.stage(arg, positional[3], note); return done(`${arg}: ${positional[3]}`);
352
+ case 'check': feed.check(arg, positional[3], { label: flags.get('label'), note }); return done(`check ${arg}: ${positional[3]}`);
353
+ case 'preview': feed.preview({ url: flags.get('url'), image: flags.get('image') ? resolve(String(flags.get('image'))) : undefined, caption: flags.get('caption') }); return done('preview updated');
354
+ case 'spend': feed.spend(arg, flags.get('what'), { receipt: flags.get('receipt'), unit: flags.get('unit') }); {
355
+ const s = feed.doc.spend;
356
+ return done(`spent ${s.unit === 'usd' ? `$${s.used.toFixed(2)}` : `${s.used} credits`}${s.budget !== null ? ` of ${s.unit === 'usd' ? `$${s.budget.toFixed(2)}` : `${s.budget} credits`}` : ''}`);
357
+ }
358
+ case 'shot': feed.shot(arg, positional[3], { label: flags.get('label'), image: flags.get('image') ? resolve(String(flags.get('image'))) : undefined }); return done(`shot ${arg}: ${positional[3]}`);
359
+ case 'song': {
360
+ const peaks = flags.get('peaks') ? JSON.parse(readFileSync(resolve(String(flags.get('peaks'))), 'utf8')) : undefined;
361
+ const sung = flags.get('sung') === 'yes' ? true : flags.get('sung') === 'no' ? false : null;
362
+ feed.song({ peaks: Array.isArray(peaks) ? peaks : peaks?.peaks, lyric: flags.get('lyric'), sung });
363
+ return done('song updated');
364
+ }
365
+ case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
366
+ case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
367
+ case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
368
+ default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, stage, check, preview, spend, shot, song, log, stop, end, show)` };
369
+ }
370
+ } catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
371
+ }
372
+
244
373
  /*
245
374
  * WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
246
375
  * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
@@ -325,11 +454,14 @@ function mediaPut(root, file, as) {
325
454
 
326
455
  try {
327
456
  const result = await main();
457
+ // A shared progress feed sends its last word before the command exits.
458
+ await flushProgress();
328
459
  if (result && result.command !== 'help') print(result);
329
460
  if (result?.ok === false) process.exitCode = 1;
330
461
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
331
462
  if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
332
463
  } catch (error) {
464
+ await flushProgress().catch(() => {});
333
465
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
334
466
  process.exitCode = 1;
335
467
  }
package/lib/check.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  * phone) press Play, land in the SAME public room, play, and both see the round
5
5
  * finish. Exit 0 only when all of that happened. Uses this Mac's Chrome through
6
6
  * puppeteer-core, on the GPU (a software renderer fakes a stuck game).
7
+ *
8
+ * `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
9
+ * small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
10
+ * says `stopped`. Without it the check is exactly what it was.
7
11
  */
8
12
  import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
9
13
  import { tmpdir } from 'node:os';
@@ -18,7 +22,17 @@ const CHROMES = [
18
22
 
19
23
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
24
 
21
- export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {} }) {
25
+ /** The steps a progress feed shows for this check, in order. */
26
+ export const CHECK_STEPS = [
27
+ ['computer-seated', 'A computer gets a seat'],
28
+ ['phone-seated', 'A phone gets a seat'],
29
+ ['same-room', 'Both in the same public room'],
30
+ ['round', 'They finish a round together'],
31
+ ];
32
+
33
+ class Stopped extends Error {}
34
+
35
+ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
22
36
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
23
37
  const chrome = CHROMES.find((p) => existsSync(p));
24
38
  if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
@@ -35,7 +49,22 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
35
49
  { name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
36
50
  ];
37
51
  const players = [];
52
+ const step = (id, state, opts = {}) => { try { report?.check?.(id, state, { label: CHECK_STEPS.find(([k]) => k === id)?.[1], ...opts }); } catch { /* a feed never breaks a check */ } };
53
+ const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
54
+ // The computer's view, small (480 px wide), for the feed's preview.
55
+ const picture = async (caption) => {
56
+ if (!report?.preview || !players[0]) return;
57
+ try {
58
+ const vp = kinds[0].viewport;
59
+ const b64 = await players[0].page.screenshot({ type: 'jpeg', quality: 62, clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale: 480 / vp.width }, encoding: 'base64' });
60
+ report.preview({ url: play, image: `data:image/jpeg;base64,${b64}`, caption });
61
+ } catch { /* no picture this time */ }
62
+ };
63
+ for (const [id] of CHECK_STEPS) step(id, 'pending');
38
64
  try {
65
+ halt();
66
+ step('computer-seated', 'running');
67
+ step('phone-seated', 'running');
39
68
  for (const kind of kinds) {
40
69
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
41
70
  profiles.push(profile);
@@ -58,15 +87,23 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
58
87
  const deadline = Date.now() + 45_000;
59
88
  while (Date.now() < deadline) {
60
89
  const s = await p.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, players: s.facts?.counts?.players ?? null } : null; }).catch(() => null);
61
- if (s?.room && s.seat !== null && s.seat !== undefined && s.role) { p.seatedAt = Date.now(); p.seat = s.seat; return { ...s, ms: Date.now() - p.t0 }; }
90
+ if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
91
+ p.seatedAt = Date.now(); p.seat = s.seat;
92
+ step(`${p.kind}-seated`, 'pass', { ms: Date.now() - p.t0, note: `seat ${s.seat}, ${s.role}` });
93
+ return { ...s, ms: Date.now() - p.t0 };
94
+ }
95
+ halt();
62
96
  await sleep(250);
63
97
  }
98
+ step(`${p.kind}-seated`, 'fail', { note: 'no seat within 45 s' });
64
99
  return null;
65
100
  }));
66
101
  if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 45 s', seated };
67
102
  const sameRoom = seated[0].room === seated[1].room;
68
103
  log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
104
+ step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
69
105
  if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
106
+ step('round', 'running');
70
107
  // Play: the computer holds keys, the phone drags from the lower left.
71
108
  const playing = (async () => {
72
109
  const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
@@ -80,10 +117,12 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
80
117
  await players[1].page.touchscreen.touchMove(vp.width * 0.2 + (i % 2 ? 60 : -60), vp.height * 0.8 - 40).catch(() => {});
81
118
  await sleep(300);
82
119
  await players[1].page.touchscreen.touchEnd().catch(() => {});
83
- if (players.every((p) => p.over)) break;
120
+ if (players.every((p) => p.over) || report?.stopped?.()) break;
84
121
  }
85
122
  })();
86
123
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
124
+ await sleep(1500);
125
+ await picture('Playing: computer and phone in one room');
87
126
  // Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results, counts: an
88
127
  // idle room remembers its last round for a while, and that stale result proves nothing.
89
128
  const bothSeated = Math.max(...players.map((p) => p.seatedAt));
@@ -97,15 +136,18 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
97
136
  return hit ? { n: hit.n, endsAt: hit.endsAt, results: hit.results, room: s.room } : null;
98
137
  }, bothSeated, seats).catch(() => null);
99
138
  if (r) { p.over = true; return { ...r, ms: Date.now() - p.t0 }; }
139
+ halt();
100
140
  await sleep(500);
101
141
  }
102
142
  return null;
103
143
  }));
104
144
  await playing.catch(() => {});
105
145
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
106
- if (over.some((o) => !o)) return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over };
146
+ if (over.some((o) => !o)) { step('round', 'fail', { note: 'no round finished in time' }); return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over }; }
107
147
  const humans = over[0].results.filter((r) => !r.bot).length;
108
148
  const sameRound = over[0].n === over[1].n && over[0].endsAt === over[1].endsAt && over[0].room === over[1].room;
149
+ step('round', sameRound && humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...over.map((o) => o.ms)), note: `round ${over[0].n}: ${humans} people, ${over[0].results.length - humans} bots` });
150
+ await picture(`Round ${over[0].n} finished with both; the next one is on`);
109
151
  return {
110
152
  ok: sameRoom && sameRound && humans >= 2,
111
153
  command: 'check',
@@ -116,6 +158,10 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
116
158
  why: !sameRound ? 'the two browsers saw different rounds finish' : humans >= 2 ? undefined : `the round's results list ${humans} human(s); both browsers should be in it`,
117
159
  totalMs: Date.now() - started,
118
160
  };
161
+ } catch (error) {
162
+ if (!(error instanceof Stopped)) throw error;
163
+ for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
164
+ return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
119
165
  } finally {
120
166
  for (const b of browsers) await b.close().catch(() => {});
121
167
  for (const p of profiles) rmSync(p, { recursive: true, force: true });
@@ -379,7 +379,19 @@ async function ownerTests(h, how, log) {
379
379
 
380
380
  /* ------------------------------------------------------------------ the check */
381
381
 
382
- export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000 }) {
382
+ /** What a progress feed calls each port check row. */
383
+ export const PORT_CHECK_LABELS = {
384
+ 'owner-desk': 'Moves on a computer', 'owner-phone': 'Moves on a phone', 'owner-iphone': 'Moves on an iPhone (WebKit)',
385
+ audio: 'Sound starts on the first touch', sandbox: 'Runs in the sandbox', 'ui-cover': 'UI stays out of the middle',
386
+ round: 'Two browsers finish a round', 'host-kill': 'The host leaves, the round goes on', 'late-join': 'A late joiner takes a bot\'s seat',
387
+ tv: 'The big screen watches', errors: 'No errors in the game',
388
+ };
389
+
390
+ /**
391
+ * `report` (optional; the open progress feed, lib/progress.mjs) hears each row as it runs, passes or fails, gets a
392
+ * small picture after each screenshot, and can ask the check to stop between rows. Without it nothing changes.
393
+ */
394
+ export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000, report = null }) {
383
395
  if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
384
396
  const base = String(url).replace(/\/+$/, '');
385
397
  const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
@@ -393,13 +405,21 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
393
405
  mkdirSync(out, { recursive: true });
394
406
  if (root) { try { writeFileSync(join(root, 'games', game, '.port', '.gitignore'), '*\n'); } catch { /* fine */ } }
395
407
  const rows = [];
396
- const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); };
408
+ const tell = (name, state, note) => { try { report?.check?.(name, state, { label: PORT_CHECK_LABELS[name] ?? name, ...(note ? { note } : {}) }); } catch { /* a feed never breaks a check */ } };
409
+ const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); tell(name, ok === null ? 'skip' : ok ? 'pass' : 'fail', detail?.why); };
397
410
  const open_ = [];
398
411
  const launch = async (kind) => { const h = await chrome(puppeteer, kind, exe); open_.push(h); return h; };
399
412
  const close = async (h) => { const i = open_.indexOf(h); if (i >= 0) open_.splice(i, 1); await h.close(); };
400
- const shot = async (h, name) => { await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000); };
413
+ const shot = async (h, name) => {
414
+ await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
415
+ if (report?.preview && h.vp) {
416
+ const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
417
+ if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name }); } catch { /* too big: no picture */ } }
418
+ }
419
+ };
401
420
  const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
402
421
  const errorsSeen = [];
422
+ let stoppedAt = null;
403
423
  const collect = (h) => { for (const e of h.errors) errorsSeen.push(`${h.engine}-${h.kind}: ${e}`); for (const r of h.badResponses) errorsSeen.push(`${h.engine}-${h.kind}: HTTP ${r}`); };
404
424
  const started = Date.now();
405
425
  // The site must answer before each row: a dev server that died mid-check made every later row fail as "no game
@@ -408,6 +428,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
408
428
  try { const r = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(8000) }); return r.ok; } catch { return false; }
409
429
  };
410
430
  const gate = async (name) => {
431
+ if (report?.stopped?.()) { row(name, null, { why: 'stopped by the person' }); stoppedAt ??= name; return false; }
432
+ tell(name, 'running');
411
433
  if (await alive()) return true;
412
434
  row(name, false, { why: `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
413
435
  return false;
@@ -631,7 +653,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
631
653
  }
632
654
  const failed = rows.filter((r) => r.ok === false);
633
655
  const skipped = rows.filter((r) => r.ok === null);
634
- const result = { ok: failed.length === 0 && rows.length > 0, command: 'port check', game, url: base, out, totalMs: Date.now() - started, passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
656
+ const result = { ok: failed.length === 0 && rows.length > 0 && !stoppedAt, command: 'port check', game, url: base, out, totalMs: Date.now() - started, ...(stoppedAt ? { stopped: true, why: `stopped by the person (before ${stoppedAt})` } : {}), passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
635
657
  writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
636
658
  return result;
637
659
  }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * THE PROGRESS FEED — what a build is doing right now, for a person watching from anywhere (a terminal, or the
3
+ * Homie MCP's build progress widget in the Claude app on a phone).
4
+ *
5
+ * One build is one small JSON document (kind "homie-studio-progress", v 1), rewritten whole at every step:
6
+ *
7
+ * .studio/progress/<build>.json the feed
8
+ * .studio/progress/<build>.key the shared copy's write key (only when shared; never printed, git-ignored)
9
+ * .studio/progress/current the open build's id
10
+ *
11
+ * The stages depend on what is being made:
12
+ * a game plan → build → checks → deploy
13
+ * a song plan → render → lyric check → publish
14
+ * a video plan → shots → cut → publish
15
+ *
16
+ * `homie-studio progress start` opens a feed. While one is open, `build`, `check`, `port check` and `deploy` write
17
+ * their stage into it, their checks as each goes green or red, and a preview (a small picture of the game and the
18
+ * address to play it), and they stop at the next safe point when a stop was asked. The AI marks what only it
19
+ * knows (the plan, money spent, a song's lyric check, a video's shots) with the other `progress` commands.
20
+ * WITHOUT AN OPEN FEED NOTHING CHANGES: every command behaves exactly as before.
21
+ *
22
+ * SHARED (`progress start --share`): the directory named in studio.json (homie.rocks by default) keeps a copy for
23
+ * 24 hours, so the Homie MCP tool `build_progress { build }` can show it in the Claude app, and answers every
24
+ * write with whether the person pressed Stop there. It receives the feed and nothing else: never a key, a file
25
+ * path, code, or more than the feed's last few log lines. Only a loopback directory may be plain http.
26
+ *
27
+ * The document (every text is plain, bounded; pictures are small data: URLs):
28
+ * { v: 1, kind, build, what: game|song|video, id, title, studio,
29
+ * state: running|passed|failed|stopped, stage, startedAt, updatedAt, endedAt,
30
+ * stages: [{ id, label, state: pending|running|done|failed|skipped|stopped, startedAt, endedAt, note }],
31
+ * checks: [{ id, label, stage, state: pending|running|pass|fail|skip, ms, note }],
32
+ * preview: { url, image, caption, at } | null,
33
+ * spend: { unit: usd|credits, used, budget, items: [{ what, amount, at, receipt }] },
34
+ * stop: { requested, at, by },
35
+ * song?: { peaks: [0..1], lyrics: [{ line, sung: true|false|null }] },
36
+ * video?: { shots: [{ id, label, state, image }] },
37
+ * log: [{ at, text }], error, shared?: { build, directory } }
38
+ */
39
+ import { randomBytes } from 'node:crypto';
40
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
41
+ import { extname, join } from 'node:path';
42
+ import { readStudio } from './studio.mjs';
43
+
44
+ export const PROGRESS_KIND = 'homie-studio-progress';
45
+ export const PROGRESS_DIR = join('.studio', 'progress');
46
+ export const STAGES = Object.freeze({
47
+ game: [['plan', 'Plan'], ['build', 'Build'], ['checks', 'Checks'], ['deploy', 'Deploy']],
48
+ song: [['plan', 'Plan'], ['render', 'Render'], ['check', 'Lyric check'], ['publish', 'Publish']],
49
+ video: [['plan', 'Plan'], ['shots', 'Shots'], ['cut', 'Cut'], ['publish', 'Publish']],
50
+ });
51
+ /** The same bounds the directory enforces on a shared feed. */
52
+ export const LIMITS = Object.freeze({
53
+ title: 120, note: 200, label: 80, logLines: 12, logLine: 160, checks: 24, shots: 24, items: 50, peaks: 240, lyrics: 80,
54
+ image: 96 * 1024, shotImage: 32 * 1024, doc: 256 * 1024,
55
+ });
56
+ const STAGE_STATES = new Set(['pending', 'running', 'done', 'failed', 'skipped', 'stopped']);
57
+ const CHECK_STATES = new Set(['pending', 'running', 'pass', 'fail', 'skip']);
58
+ const END_STATES = new Set(['passed', 'failed', 'stopped']);
59
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
60
+ const BUILD_ID = /^[a-z0-9][a-z0-9-]{5,63}$/;
61
+ const REMOTE_ID = /^hb_[a-f0-9]{32}$/;
62
+ const PUSH_GAP_MS = 1200;
63
+ const HEARTBEAT_MS = 5000;
64
+ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
65
+
66
+ const now = () => new Date().toISOString();
67
+ /** Plain text of at most `max` characters: no control characters, trimmed. */
68
+ export const plain = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
69
+ const dir = (root) => join(root, PROGRESS_DIR);
70
+ const docPath = (root, id) => join(dir(root), `${id}.json`);
71
+ const keyPath = (root, id) => join(dir(root), `${id}.key`);
72
+ const currentPath = (root) => join(dir(root), 'current');
73
+ const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
74
+
75
+ function writeAtomic(file, text) {
76
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
77
+ writeFileSync(tmp, text);
78
+ renameSync(tmp, file);
79
+ }
80
+
81
+ export function readFeed(root, id) {
82
+ if (!BUILD_ID.test(String(id ?? ''))) return null;
83
+ try { const d = JSON.parse(readFileSync(docPath(root, id), 'utf8')); return d?.kind === PROGRESS_KIND ? d : null; } catch { return null; }
84
+ }
85
+
86
+ /** The open build's id, or null. */
87
+ export function currentId(root) {
88
+ try { const id = readFileSync(currentPath(root), 'utf8').trim(); return readFeed(root, id)?.state === 'running' ? id : null; } catch { return null; }
89
+ }
90
+
91
+ /** A picture for the feed: a data: URL as it is, or a png/jpg/webp file, at most `max` bytes. */
92
+ export function imageData(value, max = LIMITS.image) {
93
+ const raw = String(value ?? '');
94
+ if (!raw) return null;
95
+ if (raw.startsWith('data:')) {
96
+ if (!/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(raw)) throw new Error('a picture is a data:image/png, jpeg or webp base64 URL, or a .png/.jpg/.webp file');
97
+ if (raw.length > Math.ceil(max * 4 / 3) + 40) throw new Error(`the picture is over ${Math.round(max / 1024)} KB; use a smaller JPEG`);
98
+ return raw;
99
+ }
100
+ const type = IMAGE_TYPES[extname(raw).toLowerCase()];
101
+ if (!type) throw new Error('a picture is a .png, .jpg or .webp file');
102
+ if (!existsSync(raw) || !statSync(raw).isFile()) throw new Error(`no picture at ${raw}`);
103
+ if (statSync(raw).size > max) throw new Error(`${raw} is over ${Math.round(max / 1024)} KB; use a smaller JPEG (about 480 px wide)`);
104
+ return `data:${type};base64,${readFileSync(raw).toString('base64')}`;
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ the shared copy */
108
+
109
+ const pushers = new Map();
110
+
111
+ async function pushDoc(root, id) {
112
+ const doc = readFeed(root, id);
113
+ if (!doc?.shared?.build) return null;
114
+ let key = null;
115
+ try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
116
+ const { shared, ...body } = doc;
117
+ const res = await fetch(`${shared.directory}/api/studio/progress/${shared.build}`, {
118
+ method: 'PUT',
119
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
120
+ body: JSON.stringify({ ...body, build: shared.build }),
121
+ signal: AbortSignal.timeout(8000),
122
+ });
123
+ if (res.status === 404 || res.status === 410) {
124
+ // The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
125
+ mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
126
+ return null;
127
+ }
128
+ const reply = await res.json().catch(() => null);
129
+ if (reply?.stop) {
130
+ mutateDoc(root, id, (d) => {
131
+ if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
132
+ });
133
+ }
134
+ return reply;
135
+ }
136
+
137
+ function schedule(root, id, { soon = false } = {}) {
138
+ const doc = readFeed(root, id);
139
+ if (!doc?.shared?.build || doc.shared.ended) return;
140
+ const p = pushers.get(id) ?? { root, last: 0, timer: null, inflight: null, dirty: false, beat: null };
141
+ pushers.set(id, p);
142
+ p.dirty = true;
143
+ if (p.inflight) return;
144
+ const wait = soon ? 0 : Math.max(0, p.last + PUSH_GAP_MS - Date.now());
145
+ if (p.timer) { if (!soon) return; clearTimeout(p.timer); }
146
+ p.timer = setTimeout(() => { p.timer = null; send(id, p); }, wait);
147
+ p.timer.unref?.();
148
+ }
149
+
150
+ function send(id, p) {
151
+ p.dirty = false;
152
+ p.last = Date.now();
153
+ p.inflight = pushDoc(p.root, id).catch(() => null).finally(() => {
154
+ p.inflight = null;
155
+ if (p.dirty) schedule(p.root, id);
156
+ });
157
+ return p.inflight;
158
+ }
159
+
160
+ /** While a stage runs, re-send the feed every few seconds: the widget sees it is alive, and hears Stop. */
161
+ function heartbeat(root, id, on) {
162
+ const p = pushers.get(id) ?? { root, last: 0, timer: null, inflight: null, dirty: false, beat: null };
163
+ pushers.set(id, p);
164
+ if (on && !p.beat) { p.beat = setInterval(() => { if (!p.inflight) send(id, p); }, HEARTBEAT_MS); p.beat.unref?.(); }
165
+ if (!on && p.beat) { clearInterval(p.beat); p.beat = null; }
166
+ }
167
+
168
+ /** Send whatever is waiting, now (a command calls this before it exits). Never throws, never waits past `ms`. */
169
+ export async function flushProgress(ms = 6000) {
170
+ const waits = [];
171
+ for (const [id, p] of pushers) {
172
+ if (p.beat) { clearInterval(p.beat); p.beat = null; }
173
+ if (p.timer) { clearTimeout(p.timer); p.timer = null; p.dirty = true; }
174
+ if (p.inflight) waits.push(p.inflight.then(() => (p.dirty ? send(id, p) : null)));
175
+ else if (p.dirty) waits.push(send(id, p));
176
+ }
177
+ if (!waits.length) return;
178
+ await Promise.race([Promise.allSettled(waits), new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); })]);
179
+ }
180
+
181
+ /* ------------------------------------------------------------------ the document */
182
+
183
+ function pushLog(d, text) {
184
+ d.log = [...(d.log ?? []), { at: now(), text: plain(text, LIMITS.logLine) }].slice(-LIMITS.logLines);
185
+ }
186
+
187
+ function mutateDoc(root, id, fn) {
188
+ const d = readFeed(root, id);
189
+ if (!d) return null;
190
+ fn(d);
191
+ d.updatedAt = now();
192
+ let text = `${JSON.stringify(d, null, 1)}\n`;
193
+ // A feed never grows past its bound: the oldest shot pictures, then the preview picture, give way first.
194
+ while (text.length > LIMITS.doc && d.video?.shots?.some((s) => s.image)) { d.video.shots.find((s) => s.image).image = null; text = `${JSON.stringify(d, null, 1)}\n`; }
195
+ if (text.length > LIMITS.doc && d.preview?.image) { d.preview.image = null; text = `${JSON.stringify(d, null, 1)}\n`; }
196
+ writeAtomic(docPath(root, id), text);
197
+ return d;
198
+ }
199
+
200
+ /** One open build's feed. Every call re-reads the file, changes it and writes it whole, so two commands can share it. */
201
+ export class Feed {
202
+ constructor(root, id) { this.root = root; this.id = id; }
203
+
204
+ get doc() { return readFeed(this.root, this.id); }
205
+
206
+ change(fn, { soon = false } = {}) {
207
+ const d = mutateDoc(this.root, this.id, fn);
208
+ if (d) schedule(this.root, this.id, { soon });
209
+ return d;
210
+ }
211
+
212
+ /** A stage's state. Starting one ends the stage before it (done) and skips any never started. */
213
+ stage(stage, state, note) {
214
+ if (!STAGE_STATES.has(state)) throw new Error(`a stage is ${[...STAGE_STATES].join(', ')}`);
215
+ const d = this.change((doc) => {
216
+ const at = doc.stages.findIndex((s) => s.id === stage);
217
+ if (at < 0) throw new Error(`this ${doc.what} build has no stage "${stage}" (${doc.stages.map((s) => s.id).join(', ')})`);
218
+ const s = doc.stages[at];
219
+ if (state === 'running') {
220
+ for (const before of doc.stages.slice(0, at)) {
221
+ if (before.state === 'running') { before.state = 'done'; before.endedAt = now(); }
222
+ else if (before.state === 'pending') before.state = 'skipped';
223
+ }
224
+ s.startedAt = now(); s.endedAt = null;
225
+ doc.stage = stage;
226
+ } else if (state !== 'pending') s.endedAt = now();
227
+ s.state = state;
228
+ if (note !== undefined) s.note = plain(note, LIMITS.note);
229
+ if (state === 'failed' && note) doc.error = plain(note, LIMITS.note);
230
+ if (state === 'running' || state === 'done') doc.error = null;
231
+ pushLog(doc, `${s.label}: ${state}${note ? ` (${plain(note, 100)})` : ''}`);
232
+ }, { soon: true });
233
+ heartbeat(this.root, this.id, d?.stages?.some((s) => s.state === 'running') && d.state === 'running');
234
+ return d;
235
+ }
236
+
237
+ /** One check's state; a check is added the first time it is named. */
238
+ check(id, state, { label, note, ms, stage } = {}) {
239
+ if (!CHECK_STATES.has(state)) throw new Error(`a check is ${[...CHECK_STATES].join(', ')}`);
240
+ return this.change((doc) => {
241
+ const key = plain(id, 40);
242
+ let c = doc.checks.find((x) => x.id === key);
243
+ if (!c) {
244
+ if (doc.checks.length >= LIMITS.checks) return;
245
+ c = { id: key, label: plain(label ?? key, LIMITS.label), stage: stage ?? doc.stage ?? null, state: 'pending', ms: null, note: '' };
246
+ doc.checks.push(c);
247
+ }
248
+ c.state = state;
249
+ if (label) c.label = plain(label, LIMITS.label);
250
+ if (note !== undefined) c.note = plain(note, LIMITS.note);
251
+ if (Number.isFinite(ms)) c.ms = Math.round(ms);
252
+ });
253
+ }
254
+
255
+ /** Forget the checks of one stage (a stage run again starts its checks afresh). */
256
+ resetChecks(stage) { return this.change((doc) => { doc.checks = doc.checks.filter((c) => c.stage !== stage); }); }
257
+
258
+ preview({ url, image, caption } = {}) {
259
+ const picture = image ? imageData(image) : undefined;
260
+ return this.change((doc) => {
261
+ doc.preview = {
262
+ url: url ? String(url).slice(0, 300) : doc.preview?.url ?? null,
263
+ image: picture ?? doc.preview?.image ?? null,
264
+ caption: caption !== undefined ? plain(caption, LIMITS.label) : doc.preview?.caption ?? '',
265
+ at: now(),
266
+ };
267
+ }, { soon: true });
268
+ }
269
+
270
+ spend(amount, what, { receipt, unit } = {}) {
271
+ const n = Number(amount);
272
+ if (!Number.isFinite(n) || n < 0 || n > 1e6) throw new Error('spend is a number of dollars (or credits) of 0 or more');
273
+ return this.change((doc) => {
274
+ if (unit && unit !== doc.spend.unit) throw new Error(`this build counts ${doc.spend.unit}, not ${unit}`);
275
+ doc.spend.used = Math.round((doc.spend.used + n) * 10000) / 10000;
276
+ doc.spend.items = [...doc.spend.items, { what: plain(what ?? 'spend', LIMITS.label), amount: n, at: now(), receipt: receipt ? plain(receipt, LIMITS.label) : null }].slice(-LIMITS.items);
277
+ pushLog(doc, `Spent ${doc.spend.unit === 'usd' ? `$${n.toFixed(2)}` : `${n} credits`} on ${plain(what ?? 'spend', 80)}`);
278
+ }, { soon: true });
279
+ }
280
+
281
+ log(text) { return this.change((doc) => pushLog(doc, text)); }
282
+
283
+ /** A video's shot (it fills in with its picture as it renders). */
284
+ shot(id, state, { label, image } = {}) {
285
+ if (!CHECK_STATES.has(state)) throw new Error(`a shot is ${[...CHECK_STATES].join(', ')}`);
286
+ const picture = image ? imageData(image, LIMITS.shotImage) : undefined;
287
+ return this.change((doc) => {
288
+ doc.video ??= { shots: [] };
289
+ let s = doc.video.shots.find((x) => x.id === plain(id, 40));
290
+ if (!s) { if (doc.video.shots.length >= LIMITS.shots) return; s = { id: plain(id, 40), label: plain(label ?? id, LIMITS.label), state: 'pending', image: null }; doc.video.shots.push(s); }
291
+ s.state = state;
292
+ if (label) s.label = plain(label, LIMITS.label);
293
+ if (picture !== undefined) s.image = picture;
294
+ });
295
+ }
296
+
297
+ /** A song's waveform (peaks of 0..1) and lyric lines as the check hears them. */
298
+ song({ peaks, lyric, sung } = {}) {
299
+ return this.change((doc) => {
300
+ doc.song ??= { peaks: [], lyrics: [] };
301
+ if (Array.isArray(peaks)) doc.song.peaks = peaks.slice(0, LIMITS.peaks).map((p) => Math.max(0, Math.min(1, Math.round(Number(p) * 1000) / 1000 || 0)));
302
+ if (lyric) {
303
+ const line = plain(lyric, LIMITS.label);
304
+ const known = doc.song.lyrics.find((l) => l.line === line);
305
+ const value = sung === true || sung === false ? sung : null;
306
+ if (known) known.sung = value;
307
+ else if (doc.song.lyrics.length < LIMITS.lyrics) doc.song.lyrics.push({ line, sung: value });
308
+ }
309
+ });
310
+ }
311
+
312
+ stopRequested() { return Boolean(this.doc?.stop?.requested); }
313
+
314
+ /**
315
+ * Ask the shared copy now (one write, answered with the person's Stop). A stage calls this before it starts,
316
+ * so a Stop pressed in the Claude app while nothing was running still stops the next command before it opens a
317
+ * browser or deploys. A local feed, or a directory that does not answer, changes nothing.
318
+ */
319
+ async sync() {
320
+ if (!this.doc?.shared?.build || this.doc.shared.ended) return;
321
+ await pushDoc(this.root, this.id).catch(() => null);
322
+ }
323
+
324
+ /** Ask the build to stop (the terminal's own Stop; the Claude app's arrives through the shared copy). */
325
+ stop(by = 'local') {
326
+ return this.change((doc) => { if (!doc.stop.requested) { doc.stop = { requested: true, at: now(), by }; pushLog(doc, 'Stop asked; stopping at the next safe point.'); } }, { soon: true });
327
+ }
328
+
329
+ /** The build is over: passed, failed or stopped. */
330
+ end(state, note) {
331
+ if (!END_STATES.has(state)) throw new Error(`a build ends ${[...END_STATES].join(', ')}`);
332
+ const d = this.change((doc) => {
333
+ for (const s of doc.stages) {
334
+ if (s.state === 'running') { s.state = state === 'passed' ? 'done' : state === 'stopped' ? 'stopped' : 'failed'; s.endedAt = now(); }
335
+ }
336
+ for (const c of doc.checks) if (c.state === 'running') c.state = state === 'passed' ? 'pass' : 'skip';
337
+ doc.state = state;
338
+ doc.endedAt = now();
339
+ if (note) { if (state === 'failed') doc.error = plain(note, LIMITS.note); pushLog(doc, note); }
340
+ pushLog(doc, state === 'passed' ? 'Done.' : state === 'stopped' ? 'Stopped.' : 'Failed.');
341
+ }, { soon: true });
342
+ heartbeat(this.root, this.id, false);
343
+ try { if (readFileSync(currentPath(this.root), 'utf8').trim() === this.id) rmSync(currentPath(this.root), { force: true }); } catch { /* none */ }
344
+ return d;
345
+ }
346
+ }
347
+
348
+ /** The open feed, or null (then a command reports nothing and behaves exactly as before). */
349
+ export function currentFeed(root) {
350
+ const id = root ? currentId(root) : null;
351
+ return id ? new Feed(root, id) : null;
352
+ }
353
+
354
+ /** The feed as the Claude app's widget and `progress show` see it (never the write key). */
355
+ export function publicFeed(doc) {
356
+ if (!doc) return null;
357
+ const { shared, ...rest } = doc;
358
+ return { ...rest, ...(shared ? { shared: { build: shared.build, directory: shared.directory, ...(shared.ended ? { ended: true } : {}) } } : {}) };
359
+ }
360
+
361
+ /**
362
+ * Open a feed for one build. `share` asks the studio's directory (studio.json `homie.directory`, or `directory`)
363
+ * to keep a copy for the Claude app's widget; if it cannot, the build goes on with the local feed only.
364
+ */
365
+ export async function startProgress(root, { what = 'game', id, title, budget, unit, share = false, directory } = {}) {
366
+ if (!STAGES[what]) return { ok: false, command: 'progress start', why: `what is being made: ${Object.keys(STAGES).join(', ')}` };
367
+ if (id !== undefined && id !== null && !SLUG.test(String(id))) return { ok: false, command: 'progress start', why: 'the id is the game, song or video id (lowercase letters, digits and hyphens)' };
368
+ const studio = readStudio(root);
369
+ const open = currentId(root);
370
+ if (open) return { ok: false, command: 'progress start', why: `build ${open} is still open: end it first (homie-studio progress end passed|failed|stopped)`, build: open };
371
+ const b = budget === undefined || budget === null || budget === true ? null : Number(budget);
372
+ if (b !== null && (!Number.isFinite(b) || b < 0)) return { ok: false, command: 'progress start', why: 'the budget is a number (dollars, or credits with --unit credits)' };
373
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '').toLowerCase();
374
+ const build = `${stamp}-${randomBytes(3).toString('hex')}`;
375
+ const doc = {
376
+ v: 1, kind: PROGRESS_KIND, build, what, id: id ?? null,
377
+ title: plain(title || `${id ?? studio.name ?? 'Studio'}`, LIMITS.title),
378
+ studio: plain(studio.name ?? '', 60),
379
+ state: 'running', stage: STAGES[what][0][0], startedAt: now(), updatedAt: now(), endedAt: null,
380
+ stages: STAGES[what].map(([sid, label], i) => ({ id: sid, label, state: i === 0 ? 'running' : 'pending', startedAt: i === 0 ? now() : null, endedAt: null, note: '' })),
381
+ checks: [], preview: null,
382
+ spend: { unit: unit === 'credits' ? 'credits' : 'usd', used: 0, budget: b, items: [] },
383
+ stop: { requested: false, at: null, by: null },
384
+ log: [{ at: now(), text: `Started: ${plain(title || id || what, 100)}` }],
385
+ error: null,
386
+ };
387
+ mkdirSync(dir(root), { recursive: true });
388
+ writeAtomic(docPath(root, build), `${JSON.stringify(doc, null, 1)}\n`);
389
+ writeAtomic(currentPath(root), `${build}\n`);
390
+ let shared = null;
391
+ let why = null;
392
+ if (share) {
393
+ const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
394
+ if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
395
+ else {
396
+ try {
397
+ const res = await fetch(`${at}/api/studio/progress`, {
398
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
399
+ body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
400
+ });
401
+ const body = await res.json().catch(() => null);
402
+ if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
403
+ writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
404
+ try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
405
+ shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
406
+ mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
407
+ schedule(root, build, { soon: true });
408
+ heartbeat(root, build, true);
409
+ } else why = body?.message ?? `${at} answered ${res.status}`;
410
+ } catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
411
+ }
412
+ }
413
+ return {
414
+ ok: true, command: 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: doc.title,
415
+ stages: doc.stages.map((s) => s.id), budget: doc.spend.budget, unit: doc.spend.unit,
416
+ shared: shared ? { build: shared.build, directory: shared.directory, expiresAt: shared.expiresAt } : null,
417
+ ...(share && !shared ? { sharedWhy: `not shared: ${why}; the feed is local only` } : {}),
418
+ widget: shared ? `In the Claude app: call the Homie MCP tool build_progress with { "build": "${shared.build}" }` : null,
419
+ };
420
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.7.0';
2
+ export const STUDIO_VERSION_TAG = '0.8.0';