@homie-rocks/studio 0.30.1 → 0.31.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  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 +142 -123
  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 +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
package/lib/directory.mjs CHANGED
@@ -5,10 +5,84 @@
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
+ /*
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 there is nothing to ask (the directory has no "how many are left" call this toolkit knows), so
20
+ * the toolkit says what THIS COMPUTER knows: how many it has sent today (UTC, the day the cap counts in), and
21
+ * what the directory said was left after the last one. That is a floor, never a promise: a publish from another
22
+ * computer or from the Homie connector is not counted here.
23
+ */
24
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : null);
25
+ const first = (...values) => { for (const v of values) { const n = num(v); if (n !== null) return n; } return null; };
26
+ const utcDay = (at = Date.now()) => new Date(at).toISOString().slice(0, 10);
27
+
28
+ /** What the directory's answer says about the day's publishes: { remaining, limit, resetsAt, retryAfter }, each null when it did not say. */
29
+ export function quotaOf(body, headers = null) {
30
+ const b = body && typeof body === 'object' ? body : {};
31
+ const nests = [b, b.quota, b.limits, b.limit, b.rateLimit, b.publishes, b.cap].filter((x) => x && typeof x === 'object');
32
+ const pick = (...keys) => first(...nests.flatMap((n) => keys.map((k) => n[k])));
33
+ const head = (k) => headers?.get?.(k) ?? null;
34
+ const reset = nests.map((n) => n.resetsAt ?? n.resetAt ?? n.reset).find((v) => typeof v === 'string' && !Number.isNaN(Date.parse(v))) ?? null;
35
+ return {
36
+ remaining: first(pick('remaining', 'left', 'publishesLeft', 'publishesRemaining'), head('ratelimit-remaining'), head('x-ratelimit-remaining')),
37
+ limit: first(pick('limit', 'max', 'perDay', 'daily', 'cap'), num(b.limit), head('ratelimit-limit'), head('x-ratelimit-limit')),
38
+ resetsAt: reset,
39
+ retryAfter: first(head('retry-after')),
40
+ };
41
+ }
42
+
43
+ /** Whether a refusal is the day's cap (a 429, or the directory's own words for it). */
44
+ export function isPublishCap(sent) {
45
+ if (sent?.ok) return false;
46
+ const words = `${sent?.body?.error ?? ''} ${sent?.body?.code ?? ''} ${sent?.body?.message ?? ''} ${sent?.why ?? ''}`;
47
+ 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);
48
+ }
49
+
50
+ /** One sentence about the day's publishes, from the directory's own numbers when it gave any. */
51
+ export function quotaLine(q, { sentToday = 0, refused = false } = {}) {
52
+ 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.' : '';
53
+ if (q.remaining !== null) return `Publishes left today: ${q.remaining}${q.limit !== null ? ` of ${q.limit}` : ''}.${when}`;
54
+ 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.`;
55
+ const mine = `This computer has sent ${sentToday} publish${sentToday === 1 ? '' : 'es'} today (UTC)`;
56
+ 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'}.`;
57
+ }
58
+
59
+ /** What this computer knows before a publish: how many it sent today and what the directory last said was left. */
60
+ export function publishesSoFar(root, now = Date.now()) {
61
+ const p = readLocal(root).publishes;
62
+ if (!p || p.day !== utcDay(now)) return { day: utcDay(now), sent: 0, remaining: null, limit: null };
63
+ return { day: p.day, sent: Number(p.sent) || 0, remaining: num(p.remaining), limit: num(p.limit) };
64
+ }
65
+
66
+ /** The line said BEFORE a publish goes out. */
67
+ export function beforeLine(so) {
68
+ if (so.remaining !== null) return so.remaining > 0
69
+ ? `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)` : ''}.`
70
+ : `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.`;
71
+ 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.`;
72
+ }
73
+
74
+ /**
75
+ * The parts report for a listing: lib/parts-build.mjs partsPublishReport, for the games a listing shows (a private
76
+ * or invite-only game is not listed, so its parts are not this moment's question).
77
+ */
78
+ export function partsForListing(root) {
79
+ const report = partsPublishReport(root);
80
+ const hidden = new Set(listGames(root).filter((g) => g.launch === 'private' || g.launch === 'invite').map((g) => g.id));
81
+ const games = report.games.filter((g) => !hidden.has(g.game));
82
+ return { ...report, games, ok: games.every((g) => g.ok) && !report.why };
83
+ }
84
+
85
+ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
12
86
  const studio = readStudio(root);
13
87
  const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
14
88
  const url = site || siteUrl(root, studio);
@@ -16,13 +90,50 @@ export async function publish(root, { homie, site } = {}) {
16
90
  const refused = [];
17
91
  for (const g of listGames(root)) {
18
92
  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 });
93
+ for (const p of licenceProblems(root, g.id, readManifest(root, g.id))) if (p.level === 'refuse') refused.push({ game: g.id, ...p });
20
94
  }
21
95
  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
96
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
97
+ // Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
98
+ // cannot be combined, in the same lines the deploy plan gave (lib/parts-build.mjs). Said before anything is sent.
99
+ // It does not stop the listing: the person decides, having been told.
100
+ const parts = partsForListing(root);
101
+ for (const line of partsPlanLines(parts, { at: 'publish' })) log(line);
102
+ const before = publishesSoFar(root);
103
+ log(beforeLine(before));
23
104
  const sent = await request(`${directory}/api/studio/publish`, {
24
105
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
25
- }, { timeout: 30_000 });
106
+ }, { timeout: 30_000, ...(fetchFn ? { fetchFn } : {}) });
107
+ const quota = quotaOf(sent.body, sent.headers);
108
+ const capped = isPublishCap(sent);
109
+ // A request that reached the directory counts against the day whether it listed or not; one that never got
110
+ // there (no network) does not.
111
+ const reached = sent.ok || sent.status !== undefined;
112
+ const sentToday = before.sent + (reached ? 1 : 0);
113
+ try {
114
+ writeLocal(root, {
115
+ publishes: { day: before.day, sent: sentToday, remaining: quota.remaining ?? (capped ? 0 : null), limit: quota.limit ?? before.limit },
116
+ // That THIS computer listed the studio: what lets a later deploy ask the directory to read it again (the one
117
+ // deploy that withdraws the whole-game source an older toolkit offered), and never lists a studio that was not
118
+ // listed (a deploy alone lists nothing).
119
+ ...(sent.ok ? { listed: { at: new Date().toISOString(), site: url, directory } } : {}),
120
+ });
121
+ } catch { /* a read-only folder: the count is only a convenience */ }
122
+ // The day's limit, once the directory has said it, is remembered for the answers that leave it out.
123
+ const known = { ...quota, limit: quota.limit ?? before.limit };
124
+ const publishes = { ...known, sentToday, before: before.remaining, line: quotaLine(known, { sentToday, refused: capped }) };
26
125
  // 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 } : {}) }) };
126
+ return {
127
+ ...(sent.body ?? {}), ok: sent.ok, command: 'publish', directory, site: url, publishes,
128
+ ...(parts.games.length || parts.sharing.length || parts.why ? { parts } : {}),
129
+ ...(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 } : {}) }),
130
+ };
131
+ }
132
+
133
+ /** Whether this computer listed the studio at this site in this directory (lib/directory.mjs `publish` records it). */
134
+ export function listedHere(root, { site = null, directory = null } = {}) {
135
+ const l = readLocal(root).listed;
136
+ if (!l?.at) return null;
137
+ const same = (a, b) => !a || !b || String(a).replace(/\/+$/, '') === String(b).replace(/\/+$/, '');
138
+ return same(l.site, site) && same(l.directory, directory) ? l : null;
28
139
  }
package/lib/doctor.mjs CHANGED
@@ -4,8 +4,16 @@
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 required the directory, cards and the pinned toolkit's address (the AI says whether its
8
- * Homie tools are there: --connector yes|no)
7
+ * Homie connector recommended the directory's search, the cards and notes to Homie (the AI says
8
+ * whether its Homie tools are there: --connector yes|no). A session with a shell makes,
9
+ * deploys and lists a studio without it, so a missing connector never blocks. When
10
+ * the tools are missing and the app in use (Codex, Grok) has another MCP server named
11
+ * homie in its own config, the row says so: that name is the plugin's connector's
12
+ * Homie's holds in Codex and Grok Build only: whether the plugin's hooks ran just now (they leave a dated
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
16
+ * (--client codex|grok) or its environment shows
9
17
  * Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
10
18
  * first deploy; a deploy that went through proves it)
11
19
  * Workers AI for AI guides only when a server's guides think with Workers AI: the model they use
@@ -25,13 +33,16 @@
25
33
  * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
26
34
  * tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
27
35
  * is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
28
- * one-word call to the model), never changes anything, and never prints a key, a token or an account's name or id.
36
+ * one-word call to the model; in Codex or Grok, the `[mcp_servers.homie]` table of that app's config.toml, of which
37
+ * it keeps only whether it is an address or a command, and the hooks' mark in this user's cache), never changes
38
+ * anything, and never prints a key, a token, a command line or an account's name or id.
29
39
  * Every check has a time limit, so it answers in seconds even with no network.
30
40
  */
31
41
  import { spawn } from 'node:child_process';
32
42
  import { existsSync, readFileSync } from 'node:fs';
33
43
  import { join } from 'node:path';
34
44
  import { workersAiRow } from './brain-probe.mjs';
45
+ import { studioClient } from './client.mjs';
35
46
  import { detectLocalAi } from './local-ai.mjs';
36
47
  import { findChrome } from './chrome.mjs';
37
48
  import { whyFailed } from './net.mjs';
@@ -72,14 +83,95 @@ async function reach(url, fetchFn, ms = 5000) {
72
83
  }
73
84
  }
74
85
 
86
+ /** The apps whose own configuration this status reads: where it is, and that app's own words for a fix. */
87
+ const APPS = {
88
+ codex: { name: 'Codex', home: (env, home) => env.CODEX_HOME || (home && join(home, '.codex')) || null, project: '.codex', add: (name, url) => `codex mcp add ${name} --url ${url}` },
89
+ grok: { name: 'Grok', home: (env, home) => env.GROK_HOME || (home && join(home, '.grok')) || null, project: '.grok', add: (name, url) => `grok mcp add ${name} ${url}` },
90
+ };
91
+ /** The name Homie's connector takes when another MCP server already has `homie`. No underscore: Grok's hooks match on it. */
92
+ export const CONNECTOR_NAME = 'homie-rocks';
93
+
94
+ /**
95
+ * Which app this session runs in, for the rows that differ by app: the one the AI named (`--client`, or HOMIE_CLIENT),
96
+ * else what the app's own environment shows (Claude Code and Codex set variables of their own on the commands they
97
+ * run). Null when neither says: never a guess.
98
+ */
99
+ export function sessionApp(client, env = process.env) {
100
+ const said = studioClient(client, env).id;
101
+ if (said !== 'chat') return said;
102
+ if (env.CLAUDECODE === '1') return 'claude';
103
+ if (env.CODEX_THREAD_ID || env.CODEX_SANDBOX || env.CODEX_SANDBOX_NETWORK_DISABLED || env.CODEX_CI) return 'codex';
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Another MCP server under the name the Homie plugin's connector uses (`homie`), in the configuration of the app in
109
+ * use: its config.toml in the app's home folder, or the one in this folder (`.codex/`, `.grok/`). The person's own
110
+ * entry wins over a plugin's of the same name, so the plugin's tools never load. It reads only the
111
+ * `[mcp_servers.homie]` table's `url` and `command` keys, and returns only what kind of server it is and where it
112
+ * was found, never the command, its arguments, its environment or a header. Null when there is none, or it is
113
+ * Homie's own address.
114
+ */
115
+ export function connectorClash({ app, env = process.env, cwd = process.cwd(), root = null, directory = 'https://homie.rocks' } = {}) {
116
+ const a = APPS[app];
117
+ if (!a) return null;
118
+ const home = env.HOME || env.USERPROFILE || '';
119
+ const ours = new Set(['https://homie.rocks/mcp', `${String(directory).replace(/\/+$/, '')}/mcp`]);
120
+ const places = [
121
+ ...[...new Set([root, cwd].filter(Boolean))].map((dir) => ({ file: join(dir, a.project, 'config.toml'), where: `this folder's ${a.project}/config.toml` })),
122
+ { file: a.home(env, home) ? join(a.home(env, home), 'config.toml') : null, where: `config.toml in ${a.name}'s home folder` },
123
+ ];
124
+ for (const { file, where } of places) {
125
+ let text;
126
+ try { if (!file || !existsSync(file)) continue; text = readFileSync(file, 'utf8'); } catch { continue; }
127
+ if (text.length > 16 * 1024 * 1024) continue;
128
+ const lines = text.split(/\r?\n/);
129
+ const at = lines.findIndex((l) => /^\s*\[\s*mcp_servers\s*\.\s*(?:"homie"|'homie'|homie)\s*\]\s*(?:#.*)?$/.test(l));
130
+ if (at < 0) continue;
131
+ let url = null; let command = false;
132
+ for (let i = at + 1; i < lines.length && !/^\s*\[/.test(lines[i]); i += 1) {
133
+ const m = /^\s*(url|command)\s*=\s*(.*?)\s*$/.exec(lines[i]);
134
+ if (!m) continue;
135
+ if (m[1] === 'command') command = true;
136
+ else url = (/^(["'])(.*?)\1/.exec(m[2])?.[2] ?? m[2]).replace(/\/+$/, '');
137
+ }
138
+ if (url && !command && ours.has(url)) return null;
139
+ return { app, name: a.name, where, kind: command ? 'a local command' : url ? 'another address' : 'another server' };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** How long a mark of Homie's hooks counts as "ran just now": they stamp it at every message and every tool call. */
145
+ export const HOLDS_FRESH_MS = 10 * 60_000;
146
+
147
+ /**
148
+ * The mark Homie's hooks leave each time they run in Codex or Grok Build (the plugin's hooks/codex.mjs `mark`): a
149
+ * small dated file in this user's cache, <app>.json, { v, app, at, event }. { at, event, ageMs } or null. HOMIE_HOLDS_MARKS
150
+ * names another folder (the tests use it).
151
+ */
152
+ export function holdsMark(app, { env = process.env, now = Date.now() } = {}) {
153
+ const home = env.HOME || env.USERPROFILE || '';
154
+ const dir = env.HOMIE_HOLDS_MARKS || (home && join(home, '.cache', 'homie-studio', 'holds'));
155
+ if (!dir || !/^[a-z]+$/.test(String(app ?? ''))) return null;
156
+ try {
157
+ const m = JSON.parse(readFileSync(join(dir, `${app}.json`), 'utf8'));
158
+ const at = Number(m?.at);
159
+ if (!Number.isFinite(at) || at <= 0) return null;
160
+ return { at, event: typeof m.event === 'string' ? m.event.slice(0, 20) : null, ageMs: Math.max(0, now - at) };
161
+ } catch { return null; }
162
+ }
163
+
164
+ const ago = (ms) => (ms < 90_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : ms < 90 * 60_000 ? `${Math.round(ms / 60_000)} min` : ms < 36 * 3_600_000 ? `${Math.round(ms / 3_600_000)} h` : `${Math.round(ms / 86_400_000)} days`);
165
+
75
166
  /**
76
167
  * The checklist. `connector`: what the AI knows about its own tools ('yes' when the Homie MCP tools such as
77
- * studio_scaffold are in its tool list, 'no' when they are not, null when unsaid). `exec` and `fetchFn` are
168
+ * studio_scaffold are in its tool list, 'no' when they are not, null when unsaid). `client`: the app the AI says it
169
+ * is ('claude', 'codex', 'grok'; sessionApp reads the environment when it is unsaid). `exec`, `fetchFn` and `nowMs` are
78
170
  * replaceable for tests.
79
171
  */
80
172
  export async function setupStatus({
81
- cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null,
82
- exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node,
173
+ cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null, client = null,
174
+ exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node, nowMs = Date.now(),
83
175
  } = {}) {
84
176
  const root = findStudio(cwd);
85
177
  let studio = null;
@@ -148,24 +240,58 @@ export async function setupStatus({
148
240
  })(),
149
241
  ]);
150
242
 
151
- // The Homie connector.
243
+ // The Homie connector. A session with a shell makes, deploys and lists a studio without it, so it never blocks.
244
+ const app = sessionApp(client, env);
152
245
  {
246
+ const host = new URL(directory).host;
153
247
  const fixConnector = {
154
248
  who: 'person',
155
- say: 'Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace. Grok Build: the same plugin (.grok-plugin/plugin.json). The Claude app, or Grok: add the connector https://homie.rocks/mcp; its setup card (studio_setup) makes the studio on your own Cloudflare. Grok has no Cloudflare connector: you still approve Cloudflare in the browser. A Grok Bot on this computer runs the checklist and checks in with setup attach <hs_…> --client grok.',
249
+ say: `Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace (codex plugin marketplace add homie-rocks/homie, then codex plugin add homie@homie) and start a new session. Grok Build: grok plugin install homie-rocks/homie#plugins/homie (Grok asks whether to trust it), then a new session. The Claude app, or Grok chat: add the connector https://homie.rocks/mcp; its setup card (studio_setup) makes the studio on your own Cloudflare. Grok has no Cloudflare connector: you still approve Cloudflare in the browser. A Grok Bot on this computer runs the checklist and checks in with setup attach <hs_…> --client grok. Meanwhile a session with a shell goes on without it: ${cli} ${root ? '<command>' : 'new <folder> --name "<Name>"'} makes ${root ? 'everything in the studio' : 'the studio'}.`,
156
250
  };
251
+ // Another MCP server already called `homie` in this app's own config: the plugin's connector cannot load under it.
252
+ const clash = said !== 'yes' ? connectorClash({ app, env, cwd, root, directory }) : null;
253
+ const fixClash = clash ? {
254
+ who: 'ai', run: APPS[clash.app].add(CONNECTOR_NAME, `${directory}/mcp`),
255
+ say: `Another MCP server named homie is set up in ${clash.name} (${clash.where}). The Homie plugin's connector has the same name, and an app loads one server under a name, so the plugin's tools are missing while that one is there. This adds Homie's connector under its own name, ${CONNECTOR_NAME}, and leaves the other server as it is; you approve it, then start a new session. Until then your AI goes on with the studio's own commands.`,
256
+ } : null;
157
257
  let state; let detail;
158
- if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${new URL(directory).host}`; }
258
+ if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${host}`; }
159
259
  else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; this computer's request to the directory failed just now: ${net.why}`; }
160
- else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session'; }
260
+ else if (clash) { state = 'act'; detail = `${said === 'no' ? 'the Homie tools are not in this session' : 'your AI knows whether its Homie tools (studio_scaffold) are here'}; another MCP server named homie (${clash.kind}) is set up in ${clash.name}, in ${clash.where}, and it takes the name the Homie plugin's connector uses`; }
261
+ else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session; a studio is still made, checked, deployed and listed with the studio\'s own commands'; }
161
262
  // The directory not answering this computer says nothing about the connector: say what failed, as it is.
162
- else { state = 'unknown'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `this computer's request to the directory failed: ${net.why}`; }
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}`; }
163
264
  rows.push({
164
- id: 'connector', label: 'Homie connector', need: 'required', state, detail,
165
- unlocks: 'making the studio with the right toolkit version, listing games in the homie.rocks directory, and the setup cards',
265
+ id: 'connector', label: 'Homie connector', need: 'recommended', state, detail,
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',
166
267
  fix: state === 'ok' ? null : net.blocked
167
- ? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${new URL(directory).host} (keep the default package managers), and start a new session.` }
168
- : said === 'no' || net.ok ? fixConnector : null,
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
+ : fixClash ?? (said === 'no' || net.ok ? fixConnector : null),
270
+ ...(clash && !net.blocked ? { clash: { app: clash.app, server: 'homie', kind: clash.kind, where: clash.where, as: CONNECTOR_NAME } } : {}),
271
+ });
272
+ }
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.
277
+ if (app === 'codex' || app === 'grok') {
278
+ const name = APPS[app].name;
279
+ const seen = holdsMark(app, { env, now: nowMs });
280
+ const on = Boolean(seen && seen.ageMs <= HOLDS_FRESH_MS);
281
+ 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,
284
+ detail: on ? `on: Homie's hooks ran in ${name} ${ago(seen.ageMs)} ago`
285
+ : 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
+ ...(seen ? { seen: { at: new Date(seen.at).toISOString(), event: seen.event } } : {}),
288
+ 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
+ fix: on ? null : {
290
+ who: 'person',
291
+ say: app === 'codex'
292
+ ? '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
+ },
169
295
  });
170
296
  }
171
297
 
@@ -306,7 +432,8 @@ export async function setupStatus({
306
432
  { feature: 'Two-browser checks and playtests', state: ready(by.chrome.state), needs: ['chrome'] },
307
433
  { feature: 'Sound effects and a theme (free)', state: ready(by.ffmpeg.state), needs: ['ffmpeg'] },
308
434
  { feature: 'Put the studio online', state: ready(by.cloudflare.parts[0].state === 'ok' ? by.cloudflare.parts[1].state : by.cloudflare.parts[0].state), needs: ['cloudflare'] },
309
- { feature: 'List games in the homie.rocks directory', state: ready(by.connector.state), needs: ['connector'] },
435
+ // Listing needs the directory, not the connector: `homie-studio publish` reaches it from this computer.
436
+ { feature: 'List games in the homie.rocks directory', state: by.connector.state === 'ok' || net.ok ? 'ready' : 'not yet', needs: ['connector'] },
310
437
  { feature: 'Backup and pull requests', state: ready(by.github.state), needs: ['github'] },
311
438
  { feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
312
439
  { feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
package/lib/feedback.mjs CHANGED
@@ -20,10 +20,10 @@
20
20
  import { redactText } from './redact.mjs';
21
21
 
22
22
  export const FEEDBACK_KINDS = Object.freeze(['stuck', 'confusing', 'idea', 'praise', 'bug']);
23
- export const FEEDBACK_APPS = Object.freeze(['claude-code', 'codex', 'claude-desktop', 'claude-ai', 'other']);
23
+ export const FEEDBACK_APPS = Object.freeze(['claude-code', 'codex', 'grok', 'claude-desktop', 'claude-ai', 'other']);
24
24
  export const FEEDBACK_LIMITS = Object.freeze({ text: 1500, raw: 6000, lines: 30, step: 80, email: 254, version: 32, perSession: 5, offers: 3 });
25
25
  export const KIND_LABEL = Object.freeze({ stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' });
26
- export const APP_LABEL = Object.freeze({ 'claude-code': 'Claude Code', codex: 'Codex', 'claude-desktop': 'the Claude desktop app', 'claude-ai': 'Claude on the web or a phone', other: 'another app' });
26
+ export const APP_LABEL = Object.freeze({ 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok', 'claude-desktop': 'the Claude desktop app', 'claude-ai': 'Claude on the web or a phone', other: 'another app' });
27
27
 
28
28
  /** Where a note goes on a Homie directory (homie.rocks, or a local one for tests). */
29
29
  export const feedbackUrl = (directory = 'https://homie.rocks') => `${String(directory || 'https://homie.rocks').replace(/\/+$/, '')}/api/feedback/tell`;
@@ -175,6 +175,7 @@ export async function sendNote(note, { directory, source = 'plugin', consent = '
175
175
  export function appOf(clientInfo) {
176
176
  const name = String(clientInfo?.name ?? '').toLowerCase();
177
177
  if (/codex/.test(name)) return 'codex';
178
+ if (/grok/.test(name)) return 'grok';
178
179
  if (/claude[-_ ]?code/.test(name)) return 'claude-code';
179
180
  if (/claude/.test(name)) return 'claude-desktop';
180
181
  return 'other';
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 }] : [])] },