@homie-rocks/studio 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mcp.mjs ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `homie-studio mcp` — the studio toolkit as a LOCAL MCP server (stdio), so the chat that shows Homie's cards also
3
+ * does the building: in the Claude desktop app through the Homie extension (.mcpb, desktop/ in this repository), in
4
+ * Claude Code, Codex or any MCP client. The remote Homie MCP (homie.rocks/mcp) coordinates (directory, grants, hub,
5
+ * progress relay for phones); this one does the work on this computer: studios, games, builds, checks with real
6
+ * browsers, deploys. Tools that overlap the remote ones keep their names and input shapes (lib/mcp-tools.mjs).
7
+ *
8
+ * homie-studio mcp [--studios <folder>] [--skills <folder>]
9
+ * --studios the folder the person's studios live in (the extension's setting; `${HOME}/Studios` is expanded);
10
+ * without it, the studio the server was started in (the working directory, or one above it)
11
+ * --skills Homie's guides (the plugin's skills folder): studio_guide and the media tools read them
12
+ * --no-install never run npm install in a studio (tests, and a studio whose node_modules are linked by hand)
13
+ * --homie the directory a new studio names (default https://homie.rocks)
14
+ *
15
+ * The transport is the MCP stdio transport: one JSON-RPC 2.0 message per line on stdin and stdout; logs go to
16
+ * stderr. No SDK. MCP Apps cards (spec 2026-01-26, `text/html;profile=mcp-app`) are served as ui:// resources from
17
+ * mcp/ui/ (real files, read as they are: a card's script is never built from a function's source), and every tool
18
+ * also answers in plain text for a host without cards.
19
+ */
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { PACKAGE_ROOT } from './studio.mjs';
23
+ import { StudioContext, UI, availability, toolDefs } from './mcp-tools.mjs';
24
+ import { stopAllJobs, toolPath } from './jobs.mjs';
25
+ import { STUDIO_VERSION } from './version.mjs';
26
+
27
+ export const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
28
+ export const MCP_APP_MIME = 'text/html;profile=mcp-app';
29
+
30
+ export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
31
+
32
+ A new studio follows one checklist, in order and never ahead. Show it in your first reply and again, ticked, as each step ends:
33
+ 0. Setup status (setup_status): what this computer and their accounts have; optional rows never block.
34
+ 1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
35
+ 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.
36
+ 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.
37
+ 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).
38
+ 5. Build it: build_open, then game_make under the planned id (the codex stays), file edits, build, preview_run, check. The card follows every build.
39
+ 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.
40
+ 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.
41
+
42
+ A studio made elsewhere (on a phone, with Deploy to Cloudflare, so it lives on GitHub): studio_open with its repo clones it into the studios folder with this computer's own GitHub sign-in; github_login signs the computer in with GitHub's one-time code. Never ask for a token.
43
+
44
+ Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
45
+
46
+ const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js' };
47
+ const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex' };
48
+ const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
49
+
50
+ /** One card's whole document: the shared look and bridge, and the card's own script, as the files are. */
51
+ export function cardHtml(uri) {
52
+ const own = CARD_FILES[uri];
53
+ if (!own) return null;
54
+ const css = readFileSync(join(UI_DIR, 'card.css'), 'utf8');
55
+ const bridge = readFileSync(join(UI_DIR, 'bridge.js'), 'utf8');
56
+ const script = readFileSync(join(UI_DIR, own), 'utf8');
57
+ const safe = (s) => s.replace(/<\/(script|style)/gi, '<\\/$1');
58
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${CARD_TITLES[uri]} · Homie</title><style>${safe(css)}</style></head><body><main id="root" class="card" aria-live="polite"><div class="skel"></div><div class="skel"></div></main><script>${safe(bridge)}\n${safe(script)}</script></body></html>`;
59
+ }
60
+
61
+ /** What a card may load: nothing from the network, except the codex page's Google Fonts. */
62
+ export function cardCsp(uri) {
63
+ if (uri === UI.codex) return { connectDomains: [], resourceDomains: ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'] };
64
+ return { connectDomains: [], resourceDomains: [] };
65
+ }
66
+
67
+ export const PROMPTS = [
68
+ { name: 'new-studio', title: 'Set up a game studio', description: 'Make a game studio on this computer and go through Homie\'s new-studio checklist.', arguments: [{ name: 'name', description: 'The studio\'s name', required: false }] },
69
+ { name: 'make-multiplayer', title: 'Make my game multiplayer', description: 'Port an existing single-player web game on this computer into a studio.', arguments: [{ name: 'folder', description: 'The game\'s folder', required: true }] },
70
+ ];
71
+
72
+ function promptText(name, args = {}) {
73
+ if (name === 'new-studio') return `Set up a game studio${args.name ? ` called ${String(args.name).slice(0, 60)}` : ''} with Homie, step by step.`;
74
+ if (name === 'make-multiplayer') return `Make the game in ${String(args.folder ?? '').slice(0, 200)} multiplayer on my Homie studio.`;
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * The server. `input`/`output` are streams (stdin/stdout by default). Returns a promise that ends with the input.
80
+ */
81
+ export async function serveMcp({ studios = null, skills = null, cwd = process.cwd(), input = process.stdin, output = process.stdout, waitMs, directory = null, install = true, log = (line) => process.stderr.write(`[homie-studio mcp] ${line}\n`) } = {}) {
82
+ // A GUI app starts this with a short PATH: everything it runs sees the usual places Node, npm and ffmpeg live.
83
+ process.env.PATH = toolPath();
84
+ const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
85
+ const ctx = new StudioContext({ studiosDir: studios, cwd, skillsDir, waitMs: waitMs ?? Number(process.env.HOMIE_MCP_WAIT_MS || 40_000), directory, install });
86
+ let avail = availability();
87
+ let names = '';
88
+ const send = (message) => output.write(`${JSON.stringify(message)}\n`);
89
+ const tools = () => {
90
+ const list = toolDefs(ctx, avail);
91
+ const now = list.map((t) => t.name).join(',');
92
+ names = now;
93
+ return list;
94
+ };
95
+ tools();
96
+ log(`ready: toolkit ${STUDIO_VERSION}, studios ${ctx.studiosDir ?? '(none set)'}${ctx.cwdStudio ? `, started in ${ctx.cwdStudio}` : ''}${skillsDir ? ', guides on' : ''}`);
97
+
98
+ async function handle(message) {
99
+ const { id, method, params } = message ?? {};
100
+ const isRequest = message && Object.hasOwn(message, 'id') && id !== null && id !== undefined;
101
+ const reply = (result) => { if (isRequest) send({ jsonrpc: '2.0', id, result }); };
102
+ const error = (code, text) => { if (isRequest) send({ jsonrpc: '2.0', id, error: { code, message: text } }); };
103
+ try {
104
+ switch (method) {
105
+ case 'initialize': {
106
+ const asked = params?.protocolVersion;
107
+ return reply({
108
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
109
+ capabilities: { tools: { listChanged: true }, resources: { listChanged: false }, prompts: { listChanged: false } },
110
+ serverInfo: { name: 'homie-studio', title: 'Homie Studio', version: STUDIO_VERSION },
111
+ instructions: INSTRUCTIONS,
112
+ });
113
+ }
114
+ case 'ping': return reply({});
115
+ case 'tools/list':
116
+ return reply({ tools: tools().map(({ run, ...t }) => t) });
117
+ case 'tools/call': {
118
+ const name = params?.name;
119
+ const tool = tools().find((t) => t.name === name);
120
+ if (!tool) return reply({ content: [{ type: 'text', text: `No tool ${name} here.` }], isError: true });
121
+ const t0 = Date.now();
122
+ let result;
123
+ try { result = await tool.run(params?.arguments ?? {}); } catch (e) { result = { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true }; }
124
+ log(`${name} ${result?.isError ? 'failed' : 'ok'} in ${Date.now() - t0} ms`);
125
+ reply(result);
126
+ // A media provider set up meanwhile adds its tool.
127
+ const before = names;
128
+ avail = availability();
129
+ tools();
130
+ if (names !== before) send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
131
+ return undefined;
132
+ }
133
+ case 'resources/list':
134
+ return reply({ resources: Object.values(UI).map((uri) => ({ uri, name: uri.split('/').pop(), title: CARD_TITLES[uri], mimeType: MCP_APP_MIME })) });
135
+ case 'resources/templates/list': return reply({ resourceTemplates: [] });
136
+ case 'resources/read': {
137
+ const uri = String(params?.uri ?? '');
138
+ const html = cardHtml(uri);
139
+ if (!html) return error(-32002, `no resource ${uri}`);
140
+ return reply({ contents: [{ uri, mimeType: MCP_APP_MIME, text: html, _meta: { ui: { csp: cardCsp(uri), prefersBorder: false } } }] });
141
+ }
142
+ case 'prompts/list': return reply({ prompts: PROMPTS });
143
+ case 'prompts/get': {
144
+ const p = PROMPTS.find((x) => x.name === params?.name);
145
+ if (!p) return error(-32602, `no prompt ${params?.name}`);
146
+ return reply({ description: p.description, messages: [{ role: 'user', content: { type: 'text', text: promptText(p.name, params?.arguments) } }] });
147
+ }
148
+ case 'logging/setLevel': return reply({});
149
+ default:
150
+ if (!isRequest) return undefined; // notifications/initialized, notifications/cancelled, …
151
+ return error(-32601, `method not found: ${method}`);
152
+ }
153
+ } catch (e) {
154
+ return error(-32603, e instanceof Error ? e.message : String(e));
155
+ }
156
+ }
157
+
158
+ let buffer = '';
159
+ const pending = new Set();
160
+ await new Promise((done) => {
161
+ input.setEncoding?.('utf8');
162
+ input.on('data', (chunk) => {
163
+ buffer += chunk;
164
+ let nl;
165
+ while ((nl = buffer.indexOf('\n')) >= 0) {
166
+ const line = buffer.slice(0, nl).trim();
167
+ buffer = buffer.slice(nl + 1);
168
+ if (!line) continue;
169
+ let msg;
170
+ try { msg = JSON.parse(line); } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); continue; }
171
+ const p = handle(msg).finally(() => pending.delete(p));
172
+ pending.add(p);
173
+ }
174
+ });
175
+ input.on('end', done);
176
+ input.on('close', done);
177
+ });
178
+ await Promise.allSettled([...pending]);
179
+ stopAllJobs();
180
+ }
package/lib/office.mjs ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
3
+ *
4
+ * homie-studio office [--url <site>] every live room of every game and who is in it, now
5
+ * homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office,
6
+ * or --to /<game>/play: a private game on the owner's phone)
7
+ * homie-studio office key [--hours 1] an office key for the Homie MCP's owner tools (studio_office,
8
+ * room_kick, room_announce, room_close, game_launch_state)
9
+ * homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
10
+ * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
11
+ * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
12
+ * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
13
+ * homie-studio office close <game> <room> [--minutes 10] [--reopen]
14
+ * homie-studio office revoke every office key ends; every play ticket and pending ask too
15
+ *
16
+ * THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN, as for the stats: a key is minted here and only its
17
+ * SHA-256 goes into the studio's D1. Looking, announcing and inviting happen at once. Kick, mute, closing a room and
18
+ * a game's launch state, remix switch or room size are only ASKED for here: the answer is a one-time sign-in link
19
+ * that opens the ask in the owner's own browser, where one tap does it (the AI that ran this cannot).
20
+ */
21
+ import { readStudio } from './studio.mjs';
22
+ import { runner } from './cloudflare.mjs';
23
+ import { drop, loopback, mint, siteOf } from './stats.mjs';
24
+
25
+ const HOUR = 3600_000;
26
+ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
+
28
+ /** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
29
+ async function withKey(root, url, fn) {
30
+ const { studio, site } = siteOf(root, url);
31
+ if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
32
+ const local = loopback(site);
33
+ const k = mint(root, studio, 'office', 10 * 60_000, { local });
34
+ if (!k.ok) return { ok: false, why: /no such table: stats_keys/.test(k.why) ? k.why : k.why };
35
+ try {
36
+ const call = async (path, body = null) => {
37
+ const res = await fetch(`${site}${path}`, {
38
+ method: body ? 'POST' : 'GET',
39
+ headers: { authorization: `Bearer ${k.key}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
40
+ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(20_000),
41
+ });
42
+ const out = await res.json().catch(() => ({ ok: false, message: `${site}${path} answered ${res.status}` }));
43
+ return { status: res.status, ...out };
44
+ };
45
+ return await fn(call, { studio, site, local });
46
+ } catch (error) {
47
+ return { ok: false, why: `could not reach ${site}: ${error.message}` };
48
+ } finally { drop(root, studio, k.hash, { local }); }
49
+ }
50
+
51
+ /** A one-time sign-in link for the owner's browser that lands on `to` (30 minutes). */
52
+ function signinTo(root, url, to) {
53
+ const { studio, site } = siteOf(root, url);
54
+ if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first' };
55
+ const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
56
+ if (!k.ok) return k;
57
+ return { ok: true, site, link: `${site}/_studio/signin?k=${k.key}${to && to !== '/_studio/stats' ? `&to=${encodeURIComponent(to)}` : ''}`, expiresAt: k.expiresAt };
58
+ }
59
+
60
+ const askedFor = (root, url, r, command) => {
61
+ if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
62
+ if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
63
+ const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
64
+ return {
65
+ ok: true, command, asked: true, ask: r.ask, what: r.ask.what,
66
+ link: link.ok ? link.link : r.ask.confirm,
67
+ use: `Waiting for the owner: ${r.ask.what} Give the owner this link to open in their own browser: it signs that browser in (once, within 30 minutes) and shows the ask with one button. It lasts 15 minutes; nothing happens unless the owner taps.`,
68
+ };
69
+ };
70
+
71
+ export async function officeShow(root, { url } = {}) {
72
+ const r = await withKey(root, url, (call) => call('/_studio/api/office'));
73
+ if (!r.ok) return { ok: false, command: 'office', why: r.why ?? r.message ?? 'the office did not answer' };
74
+ return { command: 'office', ...r, ok: true };
75
+ }
76
+
77
+ export function officeLink(root, { url, to } = {}) {
78
+ const target = to && /^\/[A-Za-z0-9_/-]{1,80}$/.test(String(to)) ? String(to) : '/_studio/office';
79
+ const r = signinTo(root, url, target);
80
+ if (!r.ok) return { ok: false, command: 'office link', why: r.why };
81
+ return {
82
+ ...r, command: 'office link', to: target,
83
+ use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in as the owner for 30 days (the office, the stats, and the owner\'s tools inside their own games). It is theirs alone; do not post it anywhere.',
84
+ };
85
+ }
86
+
87
+ export function officeKey(root, { url, hours } = {}) {
88
+ const { studio, site } = siteOf(root, url);
89
+ if (!site) return { ok: false, command: 'office key', why: 'this studio has no live site yet: `npm run deploy` first' };
90
+ const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
91
+ const k = mint(root, studio, 'office', h * HOUR, { local: loopback(site) });
92
+ if (!k.ok) return { ok: false, command: 'office key', why: k.why };
93
+ return {
94
+ ok: true, command: 'office key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
95
+ use: `Pass { "site": "${site}", "key": "<this key>" } to the Homie MCP's owner tools (studio_office, room_announce, room_kick, room_close, game_launch_state). With it the AI sees the live rooms, announces and makes invites; a kick, a closed room or a launch state change is only asked for, and the owner confirms it with one tap. It also reads the stats (studio_stats). It ends at ${k.expiresAt}; \`homie-studio office revoke\` ends it sooner. Never paste it anywhere else.`,
96
+ };
97
+ }
98
+
99
+ export async function officeAnnounce(root, text, { url, game, room, seconds } = {}) {
100
+ if (!String(text ?? '').trim()) return { ok: false, command: 'office announce', why: 'usage: homie-studio office announce "<text>" [--game <id>] [--room <code>]' };
101
+ const r = await withKey(root, url, (call) => call('/_studio/api/announce', { text, ...(game ? { game } : {}), ...(room ? { room } : {}), ...(seconds ? { seconds: Number(seconds) } : {}) }));
102
+ return r.ok ? { ok: true, command: 'office announce', ...r, message: `Announced to ${r.people ?? 0} people in ${r.rooms ?? 0} room(s).` } : { ok: false, command: 'office announce', why: r.message ?? r.why };
103
+ }
104
+
105
+ export async function officeInvite(root, game, { url, label, uses, count, days } = {}) {
106
+ if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'office invite', why: 'usage: homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1]' };
107
+ const r = await withKey(root, url, (call) => call('/_studio/api/invites', {
108
+ game, ...(label ? { label } : {}), uses: uses === 'any' ? 'any' : uses ? Number(uses) : 1, ...(count ? { count: Number(count) } : {}), ...(days ? { days: Number(days) } : {}),
109
+ }));
110
+ if (!r.ok) return { ok: false, command: 'office invite', why: r.message ?? r.why };
111
+ return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
112
+ }
113
+
114
+ export async function officeLaunch(root, game, state, { url, remixable, max } = {}) {
115
+ if (!GAME.test(String(game ?? '')) || (state !== undefined && state !== null && !['private', 'invite', 'public'].includes(state))) {
116
+ return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]' };
117
+ }
118
+ const body = { game, ...(state ? { launch: state } : {}), ...(remixable !== undefined ? { remix: remixable === 'on' || remixable === true } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
119
+ const r = await withKey(root, url, (call) => call('/_studio/api/game', body));
120
+ return askedFor(root, url, r, 'office launch');
121
+ }
122
+
123
+ export async function officeKick(root, game, room, who, { url, minutes } = {}) {
124
+ if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office kick', why: 'usage: homie-studio office kick <game> <room> <seat number | name> [--minutes 10]' };
125
+ const r = await withKey(root, url, async (call) => {
126
+ const office = await call('/_studio/api/office');
127
+ const g = (office.games ?? []).find((x) => x.id === game);
128
+ const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
129
+ if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
130
+ const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
131
+ const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
132
+ if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
133
+ return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
134
+ });
135
+ return askedFor(root, url, r, 'office kick');
136
+ }
137
+
138
+ export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
139
+ if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
140
+ const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));
141
+ return askedFor(root, url, r, 'office close');
142
+ }
143
+
144
+ /** Every office key ends, and the office secret is made anew: every play ticket and every signed control ends with it. */
145
+ export function officeRevoke(root, { url } = {}) {
146
+ const { studio, site } = siteOf(root, url);
147
+ const local = site ? loopback(site) : false;
148
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
149
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "DELETE FROM stats_keys WHERE kind = 'office'; DELETE FROM meta WHERE key = 'office_key'; UPDATE office_asks SET state = 'expired' WHERE state = 'pending';"]);
150
+ return res.code === 0
151
+ ? { ok: true, command: 'office revoke', site, message: 'Every office key has ended, and every play ticket and pending ask with it (a browser let into a private or invite-only game gets a new ticket the next time it opens the game). Invite passes and the owner\'s signed-in browsers stay; `homie-studio stats revoke` ends those sessions.' }
152
+ : { ok: false, command: 'office revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
153
+ }
154
+
155
+ /** One line per room for a person (the CLI's print). */
156
+ export function officeLines(r) {
157
+ const lines = [`${r.studio}: ${r.playing ? `${r.playing} playing now` : 'nobody playing right now'} (${r.site})`];
158
+ const now = r.now ?? Date.now();
159
+ const mins = (ms) => `${Math.max(0, Math.round(ms / 60000))} min`;
160
+ for (const g of r.games ?? []) {
161
+ lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, ${g.remix ? 'remixable' : 'source closed'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
162
+ for (const i of (g.invites ?? []).filter((x) => x.open)) lines.push(` invite ${i.code}${i.label ? ` (${i.label})` : ''}: ${i.uses}${i.maxUses ? ` of ${i.maxUses}` : ''} used · ${i.link}`);
163
+ if (!g.rooms.length) lines.push(' no live rooms');
164
+ for (const rm of g.rooms) {
165
+ const round = rm.round ? `round ${rm.round.n} ${rm.round.phase}` : 'no round';
166
+ lines.push(` ${rm.label}${rm.public ? '' : ' (named)'}: ${rm.players}/${rm.max} players, ${rm.bots} bots, ${round}, up ${rm.openedAt ? mins(now - rm.openedAt) : '?'}${rm.closedUntil ? `, CLOSED for ${mins(rm.closedUntil - now)}` : ''}`);
167
+ for (const c of rm.clients) lines.push(` ${c.seat !== null ? `seat ${c.seat + 1}` : 'watching'}: ${c.name} · ${c.device}${c.role === 'host' ? ' · host' : ''} · ${c.as}${c.muted ? ' · muted' : ''} · here ${mins(now - c.joinedAt)}`);
168
+ for (const b of rm.bans ?? []) lines.push(` kicked: ${b.name} (back in ${mins(b.until - now)})`);
169
+ }
170
+ }
171
+ return lines;
172
+ }
package/lib/scaffold.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * AGENTS.md what this studio is and how to work in it (both apps)
6
6
  * CLAUDE.md imports AGENTS.md (Claude Code's documented way to share it)
7
+ * HANDOFF.md what a Claude Code session started from the Claude app does with its one-line prompt
7
8
  * studio.json the studio's name, slug, and its Cloudflare resources
8
9
  * package.json @homie-rocks/studio and wrangler, pinned
9
10
  * games/<id>/ one folder per game (game.json, index.html, src/)
@@ -25,8 +26,8 @@ import { dirname, join, relative, resolve } from 'node:path';
25
26
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
27
  import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
27
28
  import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
29
+ import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
28
30
  import { themeFile } from './site.mjs';
29
- import { PACKAGE_ROOT } from './studio.mjs';
30
31
 
31
32
  export const COMPAT_DATE = '2026-06-01';
32
33
  /** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
@@ -113,8 +114,11 @@ directory lists its games; homie.rocks does not host them.
113
114
  Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
114
115
  studio's pinned copy, never a registry lookup of the bare name.
115
116
 
117
+ - \`npx --no-install homie-studio demo\` — a live multiplayer game to try right now (on Homie Arcade, with
118
+ whoever is playing and bots in the empty seats). Nothing is copied into this studio.
116
119
  - \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
117
- multiplayer starter (one live public room from its first build, bots fill seats).
120
+ multiplayer starter (one live public room from its first build, bots fill seats). A new studio starts with no
121
+ game: copy a starter only when the person asks for one, or once their game is planned.
118
122
  - \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
119
123
  how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
120
124
  owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
@@ -167,6 +171,8 @@ studio's pinned copy, never a registry lookup of the bare name.
167
171
  - **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
168
172
  Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
169
173
  page is not found.
174
+ - **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
175
+ (games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
170
176
  - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
171
177
  (\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
172
178
  with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
@@ -222,6 +228,35 @@ studio's pinned copy, never a registry lookup of the bare name.
222
228
  Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
223
229
  block anything.
224
230
 
231
+ ## Running live games (the back office)
232
+
233
+ - The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
234
+ the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
235
+ sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
236
+ the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
237
+ room; and per game: launch state, Remixable, players per room and invites.
238
+ - **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
239
+ in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
240
+ as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
241
+ the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
242
+ from its first deploy with \`"launch": "private"\` in its game.json. **Remixable** publishes or withdraws its source.
243
+ - The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
244
+ Mute, Kick; Announce). Nobody else's page has it.
245
+ - **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
246
+ \`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
247
+ one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
248
+ owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
249
+ \`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
250
+ - A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
251
+ player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
252
+
253
+ ## Continuing a build from the Claude app
254
+
255
+ A Claude Code session started from the Claude app's card gets one short line, like
256
+ \`Continue building Night Owls: build hb_…\`. \`HANDOFF.md\` says what to do with it:
257
+ \`npx --no-install homie-studio handoff hb_…\` fetches the brief the person gave in the chat, takes the build (the
258
+ chat's card follows the work from then on) and, for a studio still being set up, checks in from this repository.
259
+
225
260
  ## Rules
226
261
 
227
262
  - Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
@@ -257,6 +292,39 @@ and ask for a game; \`AGENTS.md\` says how everything here works.
257
292
  `;
258
293
  }
259
294
 
295
+ /**
296
+ * HANDOFF.md: what a Claude Code session started from the Claude app does with the one line it is given
297
+ * ("Continue building <Studio>: build hb_…"). The brief stays with the build in the directory; the session fetches it
298
+ * (`homie-studio handoff`, lib/handoff.mjs), so no wall of text ever goes through a prompt.
299
+ */
300
+ export const HANDOFF_MD = `# Continuing a build from the Claude app
301
+
302
+ The Claude app (claude.ai on a phone or the web) hands a build to a Claude Code session in this repository with
303
+ one short line:
304
+
305
+ Continue building <Studio>: build hb_<32 hex digits>
306
+
307
+ That line is all the session is given. The brief (what the person asked for, in their words) stays with the build:
308
+
309
+ 1. \`npm install\` (once per session).
310
+ 2. \`npx --no-install homie-studio handoff hb_…\` prints the brief and the steps for this kind of build (a new
311
+ studio's first session, a new game, a port, a remix or a change). It takes the build once, so the chat's card
312
+ follows the work, and for a studio still being set up it checks in from this repository.
313
+ 3. If it says this session's network does not reach homie.rocks and the Homie connector's tools are in this session,
314
+ call \`build_progress\` with \`{ "build": "hb_…" }\`: its answer carries the same brief. Otherwise tell the person
315
+ in one line and ask what to build; the card in their chat shows the brief.
316
+ 4. Do the work the brief asks for, as AGENTS.md says: \`npm run build\`, \`npm run dev\` in the background, and
317
+ \`npx --no-install homie-studio check <id> --url http://127.0.0.1:8787\` (on Linux without Chrome, first
318
+ \`npx --no-install homie-studio chrome install\`).
319
+ 5. \`npx --no-install homie-studio progress change "<what it does, one line>"\`; commit on a new branch, push,
320
+ \`gh pr create\`; then \`npx --no-install homie-studio progress pr --url <the pull request>\`.
321
+
322
+ Never merge the pull request: the person publishes it from the chat's card, in GitHub. A new studio has no game
323
+ yet: copy a starter only when the brief or the person asks for one.
324
+
325
+ On a computer, the Claude desktop app with the Homie extension builds in the same chat, with no hand-off at all.
326
+ `;
327
+
260
328
  const MIGRATION = `-- A studio's own D1: the directory claim, and every finished round of every public room.
261
329
  CREATE TABLE IF NOT EXISTS meta (
262
330
  key TEXT PRIMARY KEY,
@@ -379,10 +447,11 @@ export function studioFiles({ name, slug, homie, template = false }) {
379
447
  'AGENTS.md': agentsMd({ name, slug }),
380
448
  'CLAUDE.md': claudeMd(),
381
449
  'README.md': template ? templateReadme() : readme({ name }),
450
+ 'HANDOFF.md': HANDOFF_MD,
382
451
  'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
383
452
  'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
384
453
  '.gitignore': GITIGNORE,
385
- 'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
454
+ 'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
386
455
  'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
387
456
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
388
457
  'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
@@ -397,33 +466,19 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
397
466
  'site/migrations/0001_studio.sql': MIGRATION,
398
467
  [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
399
468
  [`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
469
+ [`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
400
470
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
401
471
  '.claude/skills/.gitkeep': '',
402
472
  };
403
473
  if (template) {
404
- // A first game, so the site plays the moment Cloudflare deploys it; and a band on Home that connects the new
405
- // studio to the Claude chat that set it up (`setup attach` removes both once Claude works in the studio).
406
- for (const [rel, text] of starterFiles('gem-rush')) files[`games/gem-rush/${rel}`] = text;
474
+ // No game: a new studio goes live with its own Home ("first game coming soon"), and a band there that connects
475
+ // it to the Claude chat that set it up (`setup attach` removes the band once Claude works in the studio). A
476
+ // starter is copied in only when the person asks for one (`homie-studio game new`).
407
477
  files['site/partials/home.html'] = CONNECT_BAND;
408
478
  }
409
479
  return files;
410
480
  }
411
481
 
412
- /** A starter's files, as text, relative to its folder. */
413
- function starterFiles(id) {
414
- const dir = join(PACKAGE_ROOT, 'starters', id);
415
- const out = [];
416
- const walk = (rel) => {
417
- for (const entry of readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
418
- const path = rel ? `${rel}/${entry.name}` : entry.name;
419
- if (entry.isDirectory()) walk(path);
420
- else out.push([path, readFileSync(join(dir, path), 'utf8')]);
421
- }
422
- };
423
- walk('');
424
- return out;
425
- }
426
-
427
482
  /** The template's first-run band on Home: one link that connects this studio to the Claude chat that set it up. */
428
483
  export const CONNECT_BAND = `<!-- A new studio from the Homie template. This band goes away once Claude works in the studio
429
484
  (homie-studio setup attach removes it); delete it by hand any time. -->
@@ -445,7 +500,7 @@ and a site with public multiplayer rooms that runs on **your own Cloudflare acco
445
500
 
446
501
  The button copies this studio into your GitHub, creates its Worker, database and rooms on your Cloudflare, and
447
502
  deploys it with Workers Builds: every push to \`main\` goes live, and every other branch gets its own Preview.
448
- The site plays a first game the moment it is up.
503
+ The site goes live at once with its own home page ("first game coming soon"); the games come next.
449
504
 
450
505
  Then open the site and tap **Connect to Claude**, or ask Claude in the Claude app to set up your studio with the
451
506
  Homie connector: it makes games, songs and videos here, in a pull request you merge with one tap.
@@ -466,6 +521,18 @@ export function ensureStatsMigration(root) {
466
521
  return `site/migrations/${STATS_MIGRATION_FILE}`;
467
522
  }
468
523
 
524
+ /**
525
+ * A studio made before 0.13.0 has no back-office migration (worker/office.mjs): add it, so the next
526
+ * `d1 migrations apply` makes its tables. Returns the file it wrote, or null when it was there.
527
+ */
528
+ export function ensureOfficeMigration(root) {
529
+ const file = join(root, 'site', 'migrations', OFFICE_MIGRATION_FILE);
530
+ if (existsSync(file)) return null;
531
+ mkdirSync(dirname(file), { recursive: true });
532
+ writeFileSync(file, OFFICE_MIGRATION);
533
+ return `site/migrations/${OFFICE_MIGRATION_FILE}`;
534
+ }
535
+
469
536
  /**
470
537
  * A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
471
538
  * so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
@@ -480,7 +547,12 @@ export function ensurePlayersMigration(root) {
480
547
 
481
548
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
482
549
  export function ensureMigrations(root) {
483
- return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
550
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root)].filter(Boolean);
551
+ }
552
+
553
+ /** What a migration file the template added is for, in a few words (deploy and dev say it). */
554
+ export function migrationWord(file) {
555
+ return /players/.test(file) ? 'player accounts and cloud saves' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
484
556
  }
485
557
 
486
558
  /**
@@ -540,8 +612,9 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
540
612
  const q = JSON.stringify(realpathSync(dir));
541
613
  return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
542
614
  `cd ${q}${install ? '' : ' && npm install'}`,
543
- 'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
544
- 'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
615
+ 'see a working game first, with nothing copied here: npx --no-install homie-studio demo (a live game on Homie Arcade)',
616
+ 'a copy of a starter only when the person asks for one: npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
617
+ 'npm run dev (the home page, "first game coming soon", at http://127.0.0.1:8787/; with a game: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
545
618
  'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
546
619
  ], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
547
620
  }
package/lib/stats.mjs CHANGED
@@ -48,7 +48,7 @@ export function mint(root, studio, kind, ttlMs, { local }) {
48
48
  return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
49
49
  }
50
50
 
51
- function drop(root, studio, hash, { local }) {
51
+ export function drop(root, studio, hash, { local }) {
52
52
  const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
53
53
  w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
54
54
  }
package/lib/studio.mjs CHANGED
@@ -108,7 +108,10 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
108
108
  const src = join(PACKAGE_ROOT, 'starters', from);
109
109
  if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
110
110
  const dest = join(root, 'games', id);
111
- if (existsSync(dest)) throw new Error(`games/${id} already exists; pick another id or change that game`);
111
+ if (existsSync(join(dest, 'game.json'))) throw new Error(`games/${id} already exists; pick another id or change that game`);
112
+ // A planned game (its folder holds the Game Codex, maybe art, and no game.json yet): the starter goes in around
113
+ // what is there, and nothing of the plan is overwritten.
114
+ if (existsSync(dest) && readdirSync(dest).some((f) => existsSync(join(src, f)))) throw new Error(`games/${id} holds files of its own that a starter would overwrite; pick another id`);
112
115
  cpSync(src, dest, { recursive: true });
113
116
  const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
114
117
  meta.id = id;