@homie-rocks/studio 0.30.2 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
package/lib/directory.mjs CHANGED
@@ -5,24 +5,187 @@
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
7
  import { request } from './net.mjs';
8
- import { listGames, readStudio, siteUrl } from './studio.mjs';
8
+ import { listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
9
9
  import { licenceProblems, readManifest } from './asset-manifest.mjs';
10
+ import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
10
11
 
11
- export async function publish(root, { homie, site } = {}) {
12
- const studio = readStudio(root);
13
- const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
14
- const url = site || siteUrl(root, studio);
15
- // Licences first: a public game may ship only assets with a licence record that allows it.
12
+ /*
13
+ * HOW MANY PUBLISHES ARE LEFT TODAY. In the beta the directory lets a studio publish only so many times a day, and
14
+ * the first a person heard of it was the refusal. The directory's answer is the only place the real number can come
15
+ * from, so `quotaOf` reads it wherever the directory puts it: a count in the body (`remaining`, `left`,
16
+ * `publishesLeft`, or those inside `quota` / `limits` / `rateLimit`), or the standard rate-limit headers
17
+ * (`ratelimit-remaining`, `x-ratelimit-remaining`, `retry-after`). What it finds is said after every publish.
18
+ *
19
+ * Before a publish, `publishBefore` asks the directory itself, with a read (GET /api/studio/publish?site=<origin>
20
+ * answers { ok, site, listed, limit, remaining, resetsAt } and changes nothing). When the directory cannot be
21
+ * reached, or is one that has no such read, it says so plainly and falls back to what THIS COMPUTER knows: how many
22
+ * it has sent today (UTC, the day the cap counts in), and what the directory said was left after the last one. That
23
+ * is a floor, never a promise: a publish from another computer or from the Homie connector is not counted here.
24
+ */
25
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : null);
26
+ const first = (...values) => { for (const v of values) { const n = num(v); if (n !== null) return n; } return null; };
27
+ const utcDay = (at = Date.now()) => new Date(at).toISOString().slice(0, 10);
28
+
29
+ /** What the directory's answer says about the day's publishes: { remaining, limit, resetsAt, retryAfter }, each null when it did not say. */
30
+ export function quotaOf(body, headers = null) {
31
+ const b = body && typeof body === 'object' ? body : {};
32
+ const nests = [b, b.quota, b.limits, b.limit, b.rateLimit, b.publishes, b.cap].filter((x) => x && typeof x === 'object');
33
+ const pick = (...keys) => first(...nests.flatMap((n) => keys.map((k) => n[k])));
34
+ const head = (k) => headers?.get?.(k) ?? null;
35
+ const reset = nests.map((n) => n.resetsAt ?? n.resetAt ?? n.reset).find((v) => typeof v === 'string' && !Number.isNaN(Date.parse(v))) ?? null;
36
+ return {
37
+ remaining: first(pick('remaining', 'left', 'publishesLeft', 'publishesRemaining'), head('ratelimit-remaining'), head('x-ratelimit-remaining')),
38
+ limit: first(pick('limit', 'max', 'perDay', 'daily', 'cap'), num(b.limit), head('ratelimit-limit'), head('x-ratelimit-limit')),
39
+ resetsAt: reset,
40
+ retryAfter: first(head('retry-after')),
41
+ };
42
+ }
43
+
44
+ /** Whether a refusal is the day's cap (a 429, or the directory's own words for it). */
45
+ export function isPublishCap(sent) {
46
+ if (sent?.ok) return false;
47
+ const words = `${sent?.body?.error ?? ''} ${sent?.body?.code ?? ''} ${sent?.body?.message ?? ''} ${sent?.why ?? ''}`;
48
+ return sent?.status === 429 || /\b(daily|per day|today)\b.*\b(cap|limit|quota|publish)|\b(cap|limit|quota)\b.*\b(reached|exceeded|used up|hit)\b|too many publish|rate.?limit/i.test(words);
49
+ }
50
+
51
+ /** One sentence about the day's publishes, from the directory's own numbers when it gave any. */
52
+ export function quotaLine(q, { sentToday = 0, refused = false } = {}) {
53
+ const when = q.resetsAt ? ` It resets at ${q.resetsAt}.` : q.retryAfter !== null ? ` Try again in ${q.retryAfter >= 120 ? `${Math.ceil(q.retryAfter / 60)} min` : `${q.retryAfter} s`}.` : refused ? ' The count is per UTC day: it resets at 00:00 UTC.' : '';
54
+ if (q.remaining !== null) return `Publishes left today: ${q.remaining}${q.limit !== null ? ` of ${q.limit}` : ''}.${when}`;
55
+ if (refused) return `The directory's daily publish cap for the beta is used up${q.limit !== null ? ` (${q.limit} a day)` : ''}.${when} Nothing is wrong with the studio: the listing it has stays as it is until then.`;
56
+ const mine = `This computer has sent ${sentToday} publish${sentToday === 1 ? '' : 'es'} today (UTC)`;
57
+ return `${mine}${q.limit !== null ? `, of the ${q.limit} a day the directory allows` : '; the directory has a daily cap in the beta and did not say how many are left'}.`;
58
+ }
59
+
60
+ /** What this computer knows before a publish: how many it sent today and what the directory last said was left. */
61
+ export function publishesSoFar(root, now = Date.now()) {
62
+ const p = readLocal(root).publishes;
63
+ if (!p || p.day !== utcDay(now)) return { day: utcDay(now), sent: 0, remaining: null, limit: null };
64
+ return { day: p.day, sent: Number(p.sent) || 0, remaining: num(p.remaining), limit: num(p.limit) };
65
+ }
66
+
67
+ /** The line said BEFORE a publish goes out. */
68
+ export function beforeLine(so) {
69
+ if (so.remaining !== null) return so.remaining > 0
70
+ ? `Publishing to the directory: it said ${so.remaining} publish${so.remaining === 1 ? ' was' : 'es were'} left today after the last one${so.limit !== null ? ` (of ${so.limit} a day)` : ''}.`
71
+ : `Publishing to the directory: it said none were left today after the last one${so.limit !== null ? ` (of ${so.limit} a day)` : ''}, so this one may be refused until 00:00 UTC.`;
72
+ return `Publishing to the directory: publish ${so.sent + 1} from this computer today (UTC). The beta has a daily cap; the directory's answer says how many are left when it gives the number.`;
73
+ }
74
+
75
+ /**
76
+ * The parts report for a listing: lib/parts-build.mjs partsPublishReport, for the games a listing shows (a private
77
+ * or invite-only game is not listed, so its parts are not this moment's question).
78
+ */
79
+ export function partsForListing(root) {
80
+ const report = partsPublishReport(root);
81
+ const hidden = new Set(listGames(root).filter((g) => g.launch === 'private' || g.launch === 'invite').map((g) => g.id));
82
+ const games = report.games.filter((g) => !hidden.has(g.game));
83
+ return { ...report, games, ok: games.every((g) => g.ok) && !report.why };
84
+ }
85
+
86
+ /** The asset licence problems that stop a listing: a public game may ship only assets with a licence record that allows it. */
87
+ function licenceRefusals(root) {
16
88
  const refused = [];
17
89
  for (const g of listGames(root)) {
18
90
  if (g.launch === 'private' || g.launch === 'invite') continue;
19
- for (const p of licenceProblems(root, g.id, readManifest(root, g.id), { public: g.share?.source !== false })) if (p.level === 'refuse') refused.push({ game: g.id, ...p });
91
+ for (const p of licenceProblems(root, g.id, readManifest(root, g.id))) if (p.level === 'refuse') refused.push({ game: g.id, ...p });
20
92
  }
93
+ return refused;
94
+ }
95
+
96
+ /**
97
+ * `homie-studio publish --before` and the studio_publish tool's `before: true`, one code path: what a publish would
98
+ * meet, and NOTHING is published. It sends one GET (the directory's own read-only count for this site), never a
99
+ * POST, and writes no file, not even this computer's count. { ok: true, published: false, listed, publishes: {
100
+ * remaining, limit, resetsAt, sentToday, from: 'directory' | 'this computer', line }, parts?, refused? }, and
101
+ * `unreached: { why }` when the directory could not be asked. `ok` says the preflight ran, never that a publish
102
+ * would go through.
103
+ */
104
+ export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
105
+ const studio = readStudio(root);
106
+ const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
107
+ const url = site || siteUrl(root, studio);
108
+ const refused = licenceRefusals(root);
109
+ const parts = partsForListing(root);
110
+ const local = publishesSoFar(root);
111
+ const mine = `this computer has sent ${local.sent} publish${local.sent === 1 ? '' : 'es'} today (UTC)${local.remaining !== null ? `, and the directory said ${local.remaining === 0 ? 'none were' : `${local.remaining} ${local.remaining === 1 ? 'was' : 'were'}`} left after the last one${local.limit !== null ? ` (of ${local.limit} a day)` : ''}` : ''}; a publish from another computer or from the Homie connector is not counted here`;
112
+ let origin = null;
113
+ try { origin = url ? new URL(url).origin : null; } catch { origin = null; }
114
+ let asked = null;
115
+ if (origin) asked = await request(`${directory}/api/studio/publish?site=${encodeURIComponent(origin)}`, { method: 'GET', headers: { accept: 'application/json' } }, { timeout: 15_000, ...(fetchFn ? { fetchFn } : {}) });
116
+ const q = asked?.ok ? quotaOf(asked.body, asked.headers) : null;
117
+ const counted = Boolean(q && q.remaining !== null);
118
+ const listed = asked?.ok && typeof asked.body?.listed === 'boolean' ? asked.body.listed : null;
119
+ const why = !origin ? null : asked.ok ? 'it answered without a count' : String(asked.body?.message ?? asked.why ?? 'no answer').split('\n')[0];
120
+ const line = !origin
121
+ ? 'Not published. This studio has no live site yet, so there is nothing to list: put it online first (npm run deploy).'
122
+ : counted
123
+ ? `Not published. The directory says: this site is ${listed === true ? 'listed' : listed === false ? 'not listed yet' : 'known to it or not, it did not say'}; ${quotaLine(q).replace(/^P/, 'p')}`
124
+ : `Not published. The directory could not be asked how many publishes are left (${why}), so this is only what this computer knows: ${mine}.`;
125
+ const lines = [
126
+ line,
127
+ ...(refused.length ? [`A publish now would be refused: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'} (${refused.slice(0, 6).map((p) => `${p.game}/${p.asset}: ${p.problem}`).join('; ')}${refused.length > 6 ? '; …' : ''}). homie-studio assets check <id> says how to fix each.`] : []),
128
+ ...partsPlanLines(parts, { at: 'publish' }),
129
+ ];
130
+ for (const l of lines) log(l);
131
+ return {
132
+ ok: true, command: 'publish before', published: false, directory, site: url ?? null, listed,
133
+ publishes: { remaining: counted ? q.remaining : local.remaining, limit: (counted ? q.limit : null) ?? local.limit, resetsAt: q?.resetsAt ?? null, sentToday: local.sent, from: counted ? 'directory' : 'this computer', line },
134
+ ...(origin && !counted ? { unreached: { why, ...(asked.status !== undefined ? { status: asked.status } : {}) } } : {}),
135
+ ...(refused.length ? { refused } : {}),
136
+ ...(parts.games.length || parts.sharing.length || parts.why ? { parts } : {}),
137
+ lines,
138
+ };
139
+ }
140
+
141
+ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
142
+ const studio = readStudio(root);
143
+ const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
144
+ const url = site || siteUrl(root, studio);
145
+ // Licences first: a public game may ship only assets with a licence record that allows it.
146
+ const refused = licenceRefusals(root);
21
147
  if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
22
148
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
149
+ // Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
150
+ // cannot be combined, in the same lines the deploy plan gave (lib/parts-build.mjs). Said before anything is sent.
151
+ // It does not stop the listing: the person decides, having been told.
152
+ const parts = partsForListing(root);
153
+ for (const line of partsPlanLines(parts, { at: 'publish' })) log(line);
154
+ const before = publishesSoFar(root);
155
+ log(beforeLine(before));
23
156
  const sent = await request(`${directory}/api/studio/publish`, {
24
157
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
25
- }, { timeout: 30_000 });
158
+ }, { timeout: 30_000, ...(fetchFn ? { fetchFn } : {}) });
159
+ const quota = quotaOf(sent.body, sent.headers);
160
+ const capped = isPublishCap(sent);
161
+ // A request that reached the directory counts against the day whether it listed or not; one that never got
162
+ // there (no network) does not.
163
+ const reached = sent.ok || sent.status !== undefined;
164
+ const sentToday = before.sent + (reached ? 1 : 0);
165
+ try {
166
+ writeLocal(root, {
167
+ publishes: { day: before.day, sent: sentToday, remaining: quota.remaining ?? (capped ? 0 : null), limit: quota.limit ?? before.limit },
168
+ // That THIS computer listed the studio: what lets a later deploy ask the directory to read it again (the one
169
+ // deploy that withdraws the whole-game source an older toolkit offered), and never lists a studio that was not
170
+ // listed (a deploy alone lists nothing).
171
+ ...(sent.ok ? { listed: { at: new Date().toISOString(), site: url, directory } } : {}),
172
+ });
173
+ } catch { /* a read-only folder: the count is only a convenience */ }
174
+ // The day's limit, once the directory has said it, is remembered for the answers that leave it out.
175
+ const known = { ...quota, limit: quota.limit ?? before.limit };
176
+ const publishes = { ...known, sentToday, before: before.remaining, line: quotaLine(known, { sentToday, refused: capped }) };
26
177
  // The directory's own answer (what it listed, or why not), or the connection's error as it is.
27
- return { ...(sent.body ?? {}), ok: sent.ok, command: 'publish', directory, site: url, ...(sent.ok ? {} : { why: sent.body?.message ?? sent.why, ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) }) };
178
+ return {
179
+ ...(sent.body ?? {}), ok: sent.ok, command: 'publish', directory, site: url, publishes,
180
+ ...(parts.games.length || parts.sharing.length || parts.why ? { parts } : {}),
181
+ ...(sent.ok ? {} : { why: `${sent.body?.message ?? sent.why}${capped ? `\n${publishes.line}` : ''}`, ...(sent.code ? { code: sent.code } : {}), ...(capped ? { needs: 'publish-cap' } : sent.needs ? { needs: sent.needs } : {}) }),
182
+ };
183
+ }
184
+
185
+ /** Whether this computer listed the studio at this site in this directory (lib/directory.mjs `publish` records it). */
186
+ export function listedHere(root, { site = null, directory = null } = {}) {
187
+ const l = readLocal(root).listed;
188
+ if (!l?.at) return null;
189
+ const same = (a, b) => !a || !b || String(a).replace(/\/+$/, '') === String(b).replace(/\/+$/, '');
190
+ return same(l.site, site) && same(l.directory, directory) ? l : null;
28
191
  }
package/lib/doctor.mjs CHANGED
@@ -4,15 +4,15 @@
4
4
  * unlocks, and the exact fix: one command the AI runs, or one page the person taps.
5
5
  *
6
6
  * Node.js required the toolkit runs on it
7
- * Homie connector recommended the directory's search and remixes, the cards and notes to Homie (the AI says
7
+ * Homie connector recommended the directory's search, the cards and notes to Homie (the AI says
8
8
  * whether its Homie tools are there: --connector yes|no). A session with a shell makes,
9
9
  * deploys and lists a studio without it, so a missing connector never blocks. When
10
10
  * the tools are missing and the app in use (Codex, Grok) has another MCP server named
11
11
  * homie in its own config, the row says so: that name is the plugin's connector's
12
12
  * Homie's holds in Codex and Grok Build only: whether the plugin's hooks ran just now (they leave a dated
13
13
  * mark, hooks/codex.mjs), so a session where nothing is held says so, and how to turn
14
- * them on (Codex: trust them in /hooks; Grok Build 1.0.41 runs no plugin's hooks, so
15
- * there the row is off until a Grok that runs them). The app is the one the AI names
14
+ * them on (Codex: trust them in /hooks; Grok Build: trust the plugin, which
15
+ * `grok plugin install … --trust` does). The app is the one the AI names
16
16
  * (--client codex|grok) or its environment shows
17
17
  * Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
18
18
  * first deploy; a deploy that went through proves it)
@@ -263,7 +263,7 @@ export async function setupStatus({
263
263
  else { state = 'unknown'; detail = net.ok ? `${host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `this computer's request to the directory failed: ${net.why}`; }
264
264
  rows.push({
265
265
  id: 'connector', label: 'Homie connector', need: 'recommended', state, detail,
266
- unlocks: 'searching the homie.rocks directory and remixing a game from it, the cards where your app draws them, and notes to Homie; a session with a shell makes, deploys and lists a studio without it',
266
+ unlocks: 'searching the homie.rocks directory, the cards where your app draws them, and notes to Homie; a session with a shell makes, deploys and lists a studio without it',
267
267
  fix: state === 'ok' ? null : net.blocked
268
268
  ? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${host} (keep the default package managers), and start a new session.` }
269
269
  : fixClash ?? (said === 'no' || net.ok ? fixConnector : null),
@@ -271,26 +271,27 @@ export async function setupStatus({
271
271
  });
272
272
  }
273
273
 
274
- // Homie's holds, in Codex and Grok Build: Codex runs the plugin's hooks only once the person has trusted them, Grok
275
- // Build 1.0.41 was seen to run no plugin's hooks at all, and neither app says so. The hooks leave a dated mark at
276
- // every message and tool call; a fresh one means they are on. Off in Grok is Grok's doing, so it is said as that.
274
+ // Homie's holds, in Codex and Grok Build: each app runs a plugin's hooks only once the person has trusted them
275
+ // (Codex in /hooks, Grok by trusting the plugin), and neither says so when it skips them. The hooks leave a dated
276
+ // mark at every message and tool call; a fresh one means they are on. (0.30.2 said Grok runs no plugin's hooks.
277
+ // It does: the plugin's root manifest named no hooks file, so Grok loaded the Claude Code mod's and found none.)
277
278
  if (app === 'codex' || app === 'grok') {
278
279
  const name = APPS[app].name;
279
280
  const seen = holdsMark(app, { env, now: nowMs });
280
281
  const on = Boolean(seen && seen.ageMs <= HOLDS_FRESH_MS);
281
282
  rows.push({
282
- // Off in Codex is the person's to fix now (trust the hooks); off in Grok is nothing they can fix.
283
- id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : app === 'codex' ? 'act' : 'optional', on, app,
283
+ // Off is the person's to fix now, in either app: trust the hooks.
284
+ id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : 'act', on, app,
284
285
  detail: on ? `on: Homie's hooks ran in ${name} ${ago(seen.ageMs)} ago`
285
286
  : seen ? `off: Homie's hooks last ran in ${name} ${ago(seen.ageMs)} ago, and not for this session's calls`
286
- : app === 'grok' ? 'off: Grok Build runs no plugin\'s hooks yet (1.0.41), so nothing is held in Grok' : `off: Homie's hooks have not run in ${name} on this computer`,
287
+ : `off: Homie's hooks have not run in ${name} on this computer`,
287
288
  ...(seen ? { seen: { at: new Date(seen.at).toISOString(), event: seen.event } } : {}),
288
289
  unlocks: 'a wait for your own "proceed <code>" before a production deploy, an edit to a file the studio protects, a Cloudflare change outside the deploy, a paid call past the budget and a model download; and secrets taken out of what your AI reads',
289
290
  fix: on ? null : {
290
291
  who: 'person',
291
292
  say: app === 'codex'
292
293
  ? 'In Codex, open /hooks and trust Homie\'s three hooks: Codex runs no plugin\'s hooks until you do. Then run this again. Until then nothing is held, so your AI asks you before each of those itself.'
293
- : 'Nothing to do on your side: Grok Build 1.0.41 runs no plugin\'s hooks (none are registered in its headless and agent sessions, from any plugin; checked 2026-10-04), so Homie\'s holds are off in Grok. Nothing is held and no secret is taken out of what your AI reads, so your AI asks you before each of those itself. This row turns on by itself when a Grok that runs them leaves the hooks\' mark.',
294
+ : 'Grok runs a plugin\'s hooks only once the plugin is trusted, and only from a Homie plugin new enough to name them for Grok. Install it again with trust (grok plugin install homie-rocks/homie#plugins/homie --trust), start a new session (/hooks lists Homie\'s three), then run this again. Until then nothing is held, so your AI asks you before each of those itself.',
294
295
  },
295
296
  });
296
297
  }
package/lib/handoff.mjs CHANGED
@@ -54,10 +54,6 @@ export function stepsFor(h, { games = [], client = 'chat' } = {}) {
54
54
  port: [
55
55
  `npx --no-install homie-studio port plan ${h.folder ? q(line(h.folder, 200)) : '<the game\'s folder>'}, then port import${id ? ` --id ${id}` : ''} (the plugin's port skill has the whole job).`,
56
56
  ],
57
- remix: [
58
- h.source && id ? `npx --no-install homie-studio game remix ${line(h.source, 300)} --id ${id}` : 'npx --no-install homie-studio game remix <source.json> --id <new id>',
59
- 'Then make it the brief\'s own.',
60
- ],
61
57
  change: ['Make the change the brief asks for, in small steps.'],
62
58
  }[h.kind] ?? ['Do what the brief asks.'];
63
59
  return [
@@ -91,11 +87,11 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
91
87
  const here = want ? (repoOf(root) ?? null)?.toLowerCase() ?? null : null;
92
88
  if (want && here && here !== want) return { ok: false, command: 'handoff', build, why: `this build is for ${body.repo}, but this session is in ${here}: open it in ${body.repo} (the card's Build it does), and change nothing here` };
93
89
  const h = {
94
- kind: ['make', 'port', 'remix', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
90
+ kind: ['make', 'port', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
95
91
  what: ['game', 'song', 'video'].includes(body.what) ? body.what : 'game',
96
92
  title: line(body.title, 120), studio: line(body.studio, 60) || studio.name, brief: lines(body.brief, 1200),
97
93
  id: ID.test(String(body.id ?? '')) ? body.id : null, name: line(body.name, 60) || null, from: ID.test(String(body.from ?? '')) ? body.from : null,
98
- source: /^https:\/\/\S{8,300}$/.test(String(body.source ?? '')) ? body.source : null, folder: line(body.folder, 200) || null,
94
+ folder: line(body.folder, 200) || null,
99
95
  setup: /^hs_[a-f0-9]{32}$/.test(String(body.setup ?? '')) ? body.setup : null,
100
96
  };
101
97
  let setup = null;
package/lib/library.mjs CHANGED
@@ -157,7 +157,7 @@ export async function addFromLibrary(root, game, itemId, { as = null, height = n
157
157
  id, kind: item.kind === 'creature' ? 'creature' : item.kind, tier: item.tier ?? 'prop', card, route: 'library',
158
158
  from: { library: 'homie-starter', version: idx.version ?? 'v0', pack: item.pack, packLabel: idx.packs?.find((p) => p.id === item.pack)?.label ?? item.pack, packUrl: idx.packs?.find((p) => p.id === item.pack)?.url ?? null, item: item.id, author: item.author ?? null, origin: item.origin ?? null, sha256: item.files?.[0]?.sha256 ?? null, paletteSwap: Boolean(item.paletteSwap) },
159
159
  files,
160
- license: { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, remix: 'include', notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` },
160
+ license: { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` },
161
161
  measured: measured ? { tris: measured.triangles, materials: measured.materials, drawCalls: measured.drawCalls, textures: measured.textures.map((t) => ({ px: t.px, format: t.mimeType, gpuKB: Math.round(t.gpuBytes / 1024) })), bones: measured.bones, clips: measured.clips.length, heightM: measured.heightM, box: measured.box, glbKB: Math.round(files[0].bytes / 1024), flat: Boolean(item.paletteSwap) }
162
162
  : { tris: item.tris ?? null, materials: item.materials ?? null, drawCalls: item.drawCalls ?? null, bones: item.bones ?? 0, clips: (item.clips ?? []).length, heightM: item.heightM ?? null, box: item.box ? { size: item.box } : null, glbKB: Math.round((files[0]?.bytes ?? 0) / 1024), flat: Boolean(item.paletteSwap) },
163
163
  made: { steps: [{ what: 'library', item: item.id, sha256: item.files?.[0]?.sha256 ?? null }, ...(files[0]?.ops ? [{ what: 'optimise', tool: '@gltf-transform/functions', ops: files[0].ops }] : [])] },
package/lib/mcp-tools.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
3
3
  * desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
4
4
  * the cards. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and the same input
5
- * shape: studio_scaffold, studio_card, game_make, game_remix, game_port, preview_run, studio_deploy,
5
+ * shape: studio_scaffold, studio_card, game_make, game_port, preview_run, studio_deploy,
6
6
  * studio_publish, build_open, build_progress, build_stop. The remote ones say what to run; these run it, here.
7
7
  *
8
8
  * Long work never holds a tool call: a check, a playtest, a deploy or an npm install starts as a job (lib/jobs.mjs)
@@ -28,7 +28,10 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
28
28
  import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
29
29
  import { newStudio, slugify } from './scaffold.mjs';
30
30
  import { repoFromUrl, studioRepo } from './repo.mjs';
31
- import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
31
+ import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl } from './studio.mjs';
32
+ import { runningDev } from './dev.mjs';
33
+ import { deployWords } from './deploy-state.mjs';
34
+ import { partsForListing, publishBefore, publishesSoFar } from './directory.mjs';
32
35
  import { STUDIO_VERSION } from './version.mjs';
33
36
  import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
34
37
  import { pinnedVersion } from './upgrade.mjs';
@@ -37,6 +40,9 @@ import { detectLocalAi } from './local-ai.mjs';
37
40
  import { LAB_PORT, runningLab } from './lab.mjs';
38
41
  import { pictureFor } from './pictures.mjs';
39
42
  import { ART_UI, artToolDefs } from './art-tools.mjs';
43
+ // GAME PARTS (parts/PARTS.md): parts_find, part_add, part_new and part_share are lib/parts-tools.mjs.
44
+ import { PARTS_FIRST, partsToolDefs } from './parts-tools.mjs';
45
+ import { partsPlanLines } from './parts-build.mjs';
40
46
  import { FEEDBACK_APPS, FEEDBACK_KINDS, FEEDBACK_LIMITS, appOf, cleanNote, draftId, noteBlock, sendNote, withLine } from './feedback.mjs';
41
47
 
42
48
  export const UI = Object.freeze({
@@ -285,12 +291,9 @@ function coverData(dir) {
285
291
  return null;
286
292
  }
287
293
 
294
+ /** This studio's dev server when it really runs (lib/dev.mjs: a stale record, or a recycled process id, is not one). */
288
295
  function devOf(root) {
289
- try {
290
- const rec = JSON.parse(readFileSync(join(workerDir(root), '.wrangler', 'homie-dev.json'), 'utf8'));
291
- process.kill(rec.pid, 0);
292
- return { url: `http://127.0.0.1:${rec.port}`, port: rec.port, pid: rec.pid };
293
- } catch { return null; }
296
+ try { return runningDev(root); } catch { return null; }
294
297
  }
295
298
 
296
299
  function published(root, kind) {
@@ -419,6 +422,10 @@ function buildCard(ctx, root, found) {
419
422
  feed.error ? `Last error: ${feed.error}` : null,
420
423
  ...(run?.look ? [`Pictures of the site (file_read shows one): ${run.look.join(', ')}`] : []),
421
424
  ...jobs.filter((j) => j.state === 'failed').map((j) => `${j.label} failed: ${j.result?.why ?? j.tail.slice(-2).join(' ')}`),
425
+ // A deploy that went through: which games it changed with each one's content hash, and anything it had to say
426
+ // about the studio's domain or the directory (lib/cloudflare.mjs).
427
+ ...jobs.filter((j) => j.result?.command === 'deploy' && j.result?.ok && (j.result.games ?? []).some((g) => g.hash)).map((j) => `Deployed: ${j.result.games.map((g) => `${g.id} ${deployWords(g)}`).join('; ')}.`),
428
+ ...jobs.filter((j) => j.result?.command === 'deploy').flatMap((j) => (j.result.notes ?? []).map((n) => `Note: ${n}`)),
422
429
  feed.state === 'running' ? 'The card on screen follows it live; you need not call this again to watch it.' : null,
423
430
  ].filter(Boolean).join('\n');
424
431
  // A check, playtest or deploy inside a build the person opened (build_open) ends before the build does: say so.
@@ -857,7 +864,7 @@ export function toolDefs(ctx, avail = {}) {
857
864
  },
858
865
  {
859
866
  name: 'game_make', title: 'Make a multiplayer game',
860
- description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
867
+ description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
861
868
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
862
869
  annotations: { title: 'Make a game', ...RW },
863
870
  run: async (a) => {
@@ -883,24 +890,6 @@ export function toolDefs(ctx, avail = {}) {
883
890
  return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts: file_read it once (a long file in two or three reads), decide every change, and make them in ONE file_edit with an edits list (never a call per change: twenty single edits use up a turn before the game is checked); then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
884
891
  },
885
892
  },
886
- {
887
- name: 'game_remix', title: 'Remix a shared game',
888
- description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own. The original stays credited: game.json remixOf says "Remix of <game> by <studio>" with a link back, shown on the new game\'s landing and in its credits. A game whose owner\'s licence says no remix is refused.',
889
- inputSchema: { type: 'object', properties: { game: str('The directory game: "<studio site>/<game id>" or its play URL'), id: str('The new game id in this studio'), name: str('Optional: its new name'), ...STUDIO_ARG }, required: ['game', 'id'] },
890
- annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
891
- run: async (a) => {
892
- const root = ctx.root(a.studio);
893
- let site; let gameId;
894
- try { const u = new URL(String(a.game ?? '')); site = u.origin; gameId = u.pathname.split('/').filter(Boolean)[0]; } catch { return fail('game is "<studio site>/<game id>" or its play URL'); }
895
- if (!/^https:\/\//.test(site) || !GAME_ID.test(String(gameId ?? '')) || !GAME_ID.test(String(a.id ?? ''))) return fail('game is "https://<studio site>/<game id>", and id a new lowercase id');
896
- const source = `${site}/games/${gameId}/source.json`;
897
- const r = await cli(ctx, root, `game remix ${a.id}`, ['game', 'remix', source, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
898
- if (!r.ended) return stillRunning(r.job, 'The remix');
899
- if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
900
- const credit = r.result?.credit ? ` Its game.json credits the original: "${r.result.credit}"${r.result.page ? ` (${r.result.page})` : ''}, on its landing and in its credits; keep it.` : '';
901
- return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).${credit}`, { kind: 'game', ...r.result });
902
- },
903
- },
904
893
  {
905
894
  name: 'game_port', title: 'Make an existing game multiplayer',
906
895
  description: 'Read an existing single-player web game on this computer and grade how hard making it multiplayer will be (easy, medium, hard, not a fit, with its risks); with id, also bring it into the studio as games/<id>/ for the port. studio_guide { "topic": "port" } has the whole job; check (and studio_run ["port","check",…]) proves it.',
@@ -928,7 +917,7 @@ export function toolDefs(ctx, avail = {}) {
928
917
  },
929
918
  {
930
919
  name: 'game_plan', title: 'Plan a game (its Game Codex)',
931
- description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it.',
920
+ description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
932
921
  inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), ...STUDIO_ARG }, required: ['id'] },
933
922
  annotations: { title: 'Plan a game', ...RW },
934
923
  run: async (a) => {
@@ -943,6 +932,7 @@ export function toolDefs(ctx, avail = {}) {
943
932
  return ok([
944
933
  made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
945
934
  'Now the interview: two or three questions a message, each with concrete options and your pick, so "yes" is an answer. Say back what you heard in one line before the next. Stop after three or four rounds; "just build it" means fill the rest with your own choices and list them under Open questions. Then fill CODEX.md in one call (file_read it once; then file_write the whole file, or one file_edit whose edits list has every section) and show it with game_codex.',
935
+ '', PARTS_FIRST,
946
936
  ...(guide ? ['', guide] : []),
947
937
  ].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
948
938
  },
@@ -977,7 +967,8 @@ export function toolDefs(ctx, avail = {}) {
977
967
  if (!r.ended) return stillRunning(r.job, 'The build');
978
968
  if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
979
969
  const g = r.result?.games ?? [];
980
- return ok(`Built ${g.length ? g.map((x) => `${x.id} (${Math.round(x.bytes / 1024)} KB)`).join(', ') : 'the home page (no game yet)'}.${devOf(root) ? ' Reload the preview to see it.' : ''}`, { kind: 'built', ...r.result });
970
+ // Each game's build hash, in the words the terminal prints: the hash a deploy reports and the live site answers with.
971
+ return ok(`Built ${g.length ? g.map((x) => `${x.id} (${Math.round(x.bytes / 1024)} KB${x.hash ? `, ${x.changed ?? 'new'}${x.changed && x.changed !== 'new' ? ' since the last build here' : ''}, build ${x.hash}` : ''})`).join(', ') : 'the home page (no game yet)'}.${devOf(root) ? ' Reload the preview to see it.' : ''}`, { kind: 'built', ...r.result });
981
972
  },
982
973
  },
983
974
  {
@@ -1008,7 +999,9 @@ export function toolDefs(ctx, avail = {}) {
1008
999
  run: async (a) => {
1009
1000
  const root = ctx.root(a.studio);
1010
1001
  const r = await cli(ctx, root, 'dev --stop', ['dev', '--stop'], { wait: 20_000 });
1011
- return ok(r.result?.stopped?.length ? 'Stopped the studio\'s site here.' : 'It was not running.', { kind: 'preview', stopped: r.result?.stopped ?? [] });
1002
+ // What a server stopped another way left behind is cleaned up and said (lib/dev.mjs).
1003
+ const note = r.result?.why && (r.result?.stale || r.result?.stopped?.length) ? ` ${r.result.why[0].toUpperCase()}${r.result.why.slice(1)}.` : '';
1004
+ return ok(`${r.result?.stopped?.length ? 'Stopped the studio\'s site here.' : 'It was not running.'}${note}`, { kind: 'preview', stopped: r.result?.stopped ?? [], ...(r.result?.stale ? { stale: true } : {}) });
1012
1005
  },
1013
1006
  },
1014
1007
  {
@@ -1092,7 +1085,7 @@ export function toolDefs(ctx, avail = {}) {
1092
1085
  {
1093
1086
  name: 'studio_deploy', title: 'Put the studio online',
1094
1087
  description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, free plan, no payment method. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
1095
- inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ...STUDIO_ARG } },
1088
+ inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ownRoute: { type: 'boolean', description: 'Only after the plan or a deploy warned that another site\'s route on the studio\'s custom domain covers its hostname, and the person agreed: add the studio\'s own exact-host route (the one line the warning gives) and deploy. It never edits or removes any other route' }, ...STUDIO_ARG } },
1096
1089
  annotations: { title: 'Put the studio online', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.build),
1097
1090
  run: async (a) => {
1098
1091
  const root = ctx.root(a.studio);
@@ -1102,10 +1095,10 @@ export function toolDefs(ctx, avail = {}) {
1102
1095
  if (!r.ended) return stillRunning(r.job, 'The deploy plan');
1103
1096
  if (r.job.code !== 0) return fail(whyOf(r.job));
1104
1097
  const p = r.result;
1105
- return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1098
+ return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1106
1099
  }
1107
1100
  const studio = readStudio(root);
1108
- return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
1101
+ return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1109
1102
  },
1110
1103
  },
1111
1104
  {
@@ -1126,15 +1119,26 @@ export function toolDefs(ctx, avail = {}) {
1126
1119
  },
1127
1120
  {
1128
1121
  name: 'studio_publish', title: 'List the studio in the Homie directory',
1129
- description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta).',
1130
- inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), ...STUDIO_ARG } },
1122
+ description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta). The beta also caps how many times a studio may publish in a day: the answer says how many are left when the directory gives the number, and before: true publishes nothing: it asks the directory how many are left and whether the site is listed (a read), and says so plainly when the directory cannot be reached.',
1123
+ inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), before: { type: 'boolean', description: 'Publish nothing: ask the directory (a read) whether this site is listed and how many publishes are left today, and say the parts\' licence lines. When the directory cannot be reached it says so, with what this computer has sent today' }, ...STUDIO_ARG } },
1131
1124
  annotations: { title: 'List in the directory', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1132
1125
  run: async (a) => {
1133
1126
  const root = ctx.root(a.studio);
1127
+ // The parts in the games a listing shows, in the deploy plan's own lines (licences, credits, and plainly when
1128
+ // two cannot be combined): said before the studio is listed, and again with the answer.
1129
+ const partsNow = partsForListing(root);
1130
+ const partsSaid = partsPlanLines(partsNow, { at: 'publish' });
1131
+ // before: true is `publish --before`, the same function the terminal runs (lib/directory.mjs publishBefore):
1132
+ // the directory's read-only count for this site, the parts' lines, and nothing published. Run here, by THIS
1133
+ // toolkit, never handed to the studio's pinned one: a 0.31.0 toolkit reads `publish --before` as `publish`.
1134
+ if (a.before === true) {
1135
+ const b = await publishBefore(root, { site: a.site ? String(a.site) : undefined });
1136
+ return ok(b.lines.join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...b });
1137
+ }
1134
1138
  const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
1135
1139
  if (!r.ended) return stillRunning(r.job, 'Listing');
1136
- if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`);
1137
- return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}`, { kind: 'publish', ...r.result });
1140
+ if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
1141
+ return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}${r.result?.publishes?.line ? `\n${r.result.publishes.line}` : ''}${partsSaid.length ? `\n${partsSaid.join('\n')}` : ''}`, { kind: 'publish', ...r.result });
1138
1142
  },
1139
1143
  },
1140
1144
  {
@@ -1184,7 +1188,7 @@ export function toolDefs(ctx, avail = {}) {
1184
1188
  },
1185
1189
  {
1186
1190
  name: 'studio_run', title: 'Run a studio command',
1187
- description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
1191
+ description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["shoot","<id>","--preview"], ["build","--types"], ["assets","use","<id>","<asset>","unused"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
1188
1192
  inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The command\'s words after `homie-studio`' }, ...STUDIO_ARG }, required: ['args'] },
1189
1193
  annotations: { title: 'Run a studio command', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1190
1194
  run: async (a) => {
@@ -1361,6 +1365,7 @@ export function toolDefs(ctx, avail = {}) {
1361
1365
  ];
1362
1366
  // Art direction, the cast, the starter library, generated props, checks, the lineup and the rights (lib/art-tools.mjs).
1363
1367
  tools.push(...artToolDefs(ctx, { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW, pictureFor, findNode, startJob, waitJob }));
1368
+ tools.push(...partsToolDefs(ctx, { ok, fail, str, STUDIO_ARG, RO, RW }));
1364
1369
  const topics = guideTopics(ctx);
1365
1370
  if (topics.length) {
1366
1371
  tools.push({
package/lib/mcp.mjs CHANGED
@@ -38,7 +38,7 @@ A new studio follows one checklist, in order and never ahead. Show it in your fi
38
38
  1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
39
39
  2. See a working game (game_demo): a live game on Homie Arcade with its Play link, nothing copied in. Copy a starter in (game_make) only if they ask.
40
40
  3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
41
- 4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex).
41
+ 4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex). Look for pieces first: the @homie-rocks/* packages for general mechanisms, and parts_find for pieces other studios shared (part_add brings one in).
42
42
  5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
43
43
  6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
44
44
  If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.