@homie-rocks/studio 0.20.0 → 0.21.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/CHANGELOG.md +40 -1
- package/bin/homie-studio.mjs +6 -3
- package/lib/check.mjs +3 -1
- package/lib/office.mjs +30 -8
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/scaffold.mjs +6 -1
- package/lib/template-history.json +37 -0
- package/lib/thumb.mjs +82 -0
- package/package.json +1 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,48 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.21.0] - 2026-10-02
|
|
21
|
+
|
|
22
|
+
**Plugin 0.22.0** · [#31](https://github.com/homie-rocks/homie/pull/31)
|
|
23
|
+
|
|
24
|
+
The Homie mod for Claude Code: your studio, its rooms and a real seat in a live game beside the chat, with a look
|
|
25
|
+
before protected files, deploys and spending, and keys kept out of what Claude reads.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Plugin: the Homie mod (Claude Code 2.1.287 or later). Inside a studio, a one-row band above the prompt shows the
|
|
30
|
+
studio, the game, the build step, ▶ Play and how many people are playing now. Outside a studio it adds nothing.
|
|
31
|
+
- Plugin: the Studio pane (`/studio`) opens by itself when a build starts, on a wide enough terminal. **Build** has the
|
|
32
|
+
live Plan, Build, Checks, Deploy feed and the latest check frame, or a live Watch of a room of the game being built.
|
|
33
|
+
**Rooms** lists who is playing and the AI players, with Watch and Join links; Announce, Mute and Kick go through
|
|
34
|
+
the office, and Mute and Kick are only ever asked for, so you still confirm them with one tap. **Games** has
|
|
35
|
+
launch states and the remix switch; **Stats** and **Codex** link to the rest. **Lab** shows each game's last Game
|
|
36
|
+
Lab check (New's phases beside Today's, whether the replays match) and the lab's link while it runs.
|
|
37
|
+
- Plugin: the Arcade pane (`/arcade`). Take a real seat in a public room of a Homie game (Homie Arcade's, or your
|
|
38
|
+
studio's own) and play it in the pane with w/a/s/d or the arrow keys while Claude works. It runs in one headless
|
|
39
|
+
Chrome on your computer at the lowest priority, about 8 frames a second in a terminal, pauses while the pane is
|
|
40
|
+
hidden and stops when you leave.
|
|
41
|
+
- Plugin: a Parts pane for the `parallel` skill: each part, its last step, and the merge waiting for all of them.
|
|
42
|
+
- Plugin: instant commands that answer without a Claude turn: `/play`, `/watch [room]`, `/rooms`, `/build`, `/codex`,
|
|
43
|
+
`/deploy-status`, `/perf-numbers`, `/parts`, `/arcade`. They print links or open a pane; nothing opens a browser.
|
|
44
|
+
- Plugin: guards. An edit to a file matched by studio.json `"protect"` (globs, such as `"games/*/game.json"`) shows
|
|
45
|
+
the diff and waits for Proceed or Cancel. So does a deploy (where it goes, what it creates, the commits and files
|
|
46
|
+
since the last one, the checks) and a paid fal or ElevenLabs run that would pass studio.json `"budget"`.
|
|
47
|
+
- Plugin: Cloudflare tokens, provider keys, agent passes, office keys and one-time owner links are taken out of
|
|
48
|
+
command output before Claude reads it.
|
|
49
|
+
- Plugin: setup status, check, playtest and deploy results are drawn as checklists and rows, with the live link.
|
|
50
|
+
- `homie-studio office mute <game> <room> <seat | name> [--minutes 10] [--off]`, asked for like `office kick`.
|
|
51
|
+
- A build's latest check frame is also kept as a small picture beside its progress feed, for the mod to draw.
|
|
52
|
+
|
|
53
|
+
### Upgrade notes
|
|
54
|
+
|
|
55
|
+
- Every part of the mod has its own switch (`/config`, or `pluginConfigs` in settings.json). Codex installs the plugin
|
|
56
|
+
as before; it does not run plugin hooks, so there it is the skills and tools you had.
|
|
57
|
+
- `homie-studio upgrade --apply` adds `office mute` and the `protect` and `budget` lines to AGENTS.md.
|
|
58
|
+
|
|
20
59
|
## [0.20.0] - 2026-10-02
|
|
21
60
|
|
|
22
|
-
**Plugin 0.21.0** · [#30](https://github.com/homie-rocks/homie/pull/30)
|
|
61
|
+
**Plugin 0.21.0** · [#30](https://github.com/homie-rocks/homie/pull/30) · [release-2026-10-02-studio-0.20.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-02-studio-0.20.0)
|
|
23
62
|
|
|
24
63
|
A Game Lab: tune how one move of your game feels (a jump, a hit, a dash), your new version beside your last commit,
|
|
25
64
|
frame by frame.
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -94,8 +94,9 @@
|
|
|
94
94
|
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>] [--server <id>]
|
|
95
95
|
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
96
96
|
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
97
|
+
* homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
|
|
97
98
|
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
98
|
-
* (kick, close and launch are ASKED for: the owner confirms each with one
|
|
99
|
+
* (kick, mute, close and launch are ASKED for: the owner confirms each with one
|
|
99
100
|
* tap in their own browser, from the link this prints)
|
|
100
101
|
* homie-studio office revoke (every office key, play ticket and pending ask ends)
|
|
101
102
|
*
|
|
@@ -192,7 +193,7 @@ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, star
|
|
|
192
193
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
193
194
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
194
195
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
195
|
-
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
196
|
+
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
196
197
|
import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
|
|
197
198
|
import { AgentSeat } from '../lib/agent-seat.mjs';
|
|
198
199
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
@@ -206,7 +207,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
|
206
207
|
|
|
207
208
|
const argv = process.argv.slice(2);
|
|
208
209
|
const flags = new Map();
|
|
209
|
-
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile'];
|
|
210
|
+
const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile'];
|
|
210
211
|
const positional = [];
|
|
211
212
|
for (let i = 0; i < argv.length; i++) {
|
|
212
213
|
const a = argv[i];
|
|
@@ -411,6 +412,7 @@ function print(result) {
|
|
|
411
412
|
case 'agents brain':
|
|
412
413
|
case 'office launch':
|
|
413
414
|
case 'office kick':
|
|
415
|
+
case 'office mute':
|
|
414
416
|
case 'office close':
|
|
415
417
|
lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
|
|
416
418
|
break;
|
|
@@ -654,6 +656,7 @@ async function main() {
|
|
|
654
656
|
if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days'), server: flags.get('server') });
|
|
655
657
|
if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
|
|
656
658
|
if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
|
|
659
|
+
if (sub === 'mute') return officeMute(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes'), off: flags.has('off') });
|
|
657
660
|
if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
|
|
658
661
|
if (sub === 'revoke') return officeRevoke(root, { url });
|
|
659
662
|
}
|
package/lib/check.mjs
CHANGED
|
@@ -82,7 +82,9 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
|
|
|
82
82
|
try {
|
|
83
83
|
const vp = kinds[0].viewport;
|
|
84
84
|
const b64 = await players[0].page.screenshot({ type: 'jpeg', quality: 62, clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale: 480 / vp.width }, encoding: 'base64' });
|
|
85
|
-
|
|
85
|
+
// And a small PNG, kept as raw pixels beside the feed for Claude Code's Homie mod (lib/thumb.mjs).
|
|
86
|
+
const png = await players[0].page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale: 160 / vp.width } }).catch(() => null);
|
|
87
|
+
report.preview({ url: play, image: `data:image/jpeg;base64,${b64}`, caption, ...(png ? { png: Buffer.from(png) } : {}) });
|
|
86
88
|
} catch { /* no picture this time */ }
|
|
87
89
|
};
|
|
88
90
|
for (const [id] of CHECK_STEPS) step(id, 'pending');
|
package/lib/office.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
11
11
|
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
12
12
|
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
13
|
+
* homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
|
|
13
14
|
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
14
15
|
* homie-studio office revoke every office key ends; every play ticket and pending ask too
|
|
15
16
|
*
|
|
@@ -121,21 +122,42 @@ export async function officeLaunch(root, game, state, { url, remixable, max } =
|
|
|
121
122
|
return askedFor(root, url, r, 'office launch');
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/** One player of one live room, by seat number (from 1) or name: { room, client } or { message }. */
|
|
126
|
+
async function playerIn(call, game, room, who) {
|
|
127
|
+
const office = await call('/_studio/api/office');
|
|
128
|
+
const g = (office.games ?? []).find((x) => x.id === game);
|
|
129
|
+
const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
|
|
130
|
+
if (!rm) return { message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
|
|
131
|
+
const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
|
|
132
|
+
const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
|
|
133
|
+
if (!c) return { message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
|
|
134
|
+
return { room: rm, client: c };
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
export async function officeKick(root, game, room, who, { url, minutes } = {}) {
|
|
125
138
|
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]' };
|
|
126
139
|
const r = await withKey(root, url, async (call) => {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
|
|
131
|
-
const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
|
|
132
|
-
const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
|
|
133
|
-
if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
|
|
134
|
-
return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
|
|
140
|
+
const p = await playerIn(call, game, room, who);
|
|
141
|
+
if (!p.client) return { ok: false, message: p.message };
|
|
142
|
+
return call('/_studio/api/kick', { game, room: p.room.room, id: p.client.id, minutes: Number(minutes) || 10 });
|
|
135
143
|
});
|
|
136
144
|
return askedFor(root, url, r, 'office kick');
|
|
137
145
|
}
|
|
138
146
|
|
|
147
|
+
/**
|
|
148
|
+
* `office mute <game> <room> <seat | name> [--minutes 10] [--off]`: the player's chat and emotes reach nobody for a
|
|
149
|
+
* while (`--off` lifts it). Like a kick, it is only ASKED for: the owner confirms with one tap.
|
|
150
|
+
*/
|
|
151
|
+
export async function officeMute(root, game, room, who, { url, minutes, off = false } = {}) {
|
|
152
|
+
if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office mute', why: 'usage: homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]' };
|
|
153
|
+
const r = await withKey(root, url, async (call) => {
|
|
154
|
+
const p = await playerIn(call, game, room, who);
|
|
155
|
+
if (!p.client) return { ok: false, message: p.message };
|
|
156
|
+
return call('/_studio/api/mute', { game, room: p.room.room, id: p.client.id, minutes: Number(minutes) || 10, ...(off ? { off: true } : {}) });
|
|
157
|
+
});
|
|
158
|
+
return askedFor(root, url, r, 'office mute');
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
|
|
140
162
|
if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
|
|
141
163
|
const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));
|
package/lib/port-check.mjs
CHANGED
|
@@ -409,7 +409,9 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
409
409
|
await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
|
|
410
410
|
if (report?.preview && h.vp) {
|
|
411
411
|
const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
|
|
412
|
-
|
|
412
|
+
// And a small PNG, kept as raw pixels beside the feed for Claude Code's Homie mod (lib/thumb.mjs).
|
|
413
|
+
const png = b64 ? await T(h.page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 160 / h.vp.width) } }), 15_000, null) : null;
|
|
414
|
+
if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name, ...(png ? { png: Buffer.from(png) } : {}) }); } catch { /* too big: no picture */ } }
|
|
413
415
|
}
|
|
414
416
|
};
|
|
415
417
|
const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
|
package/lib/progress.mjs
CHANGED
|
@@ -56,6 +56,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
56
56
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
57
57
|
import { extname, join } from 'node:path';
|
|
58
58
|
import { refreshCodexFile } from './codex.mjs';
|
|
59
|
+
import { dropThumbs, rgbThumb, thumbName } from './thumb.mjs';
|
|
59
60
|
import { readStudio, siteUrl } from './studio.mjs';
|
|
60
61
|
import { request, whyRefused } from './net.mjs';
|
|
61
62
|
|
|
@@ -307,8 +308,22 @@ export class Feed {
|
|
|
307
308
|
/** Forget the checks of one stage (a stage run again starts its checks afresh). */
|
|
308
309
|
resetChecks(stage) { return this.change((doc) => { doc.checks = doc.checks.filter((c) => c.stage !== stage); }); }
|
|
309
310
|
|
|
310
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The build's preview: the address to play it, a small picture (the feed's JPEG, for the Claude app's card) and its
|
|
313
|
+
* caption. `png`, a PNG screenshot's bytes (or a .png `image` file), is also kept small as raw RGB beside the feed
|
|
314
|
+
* (lib/thumb.mjs), for Claude Code's Homie mod, which has no image decoder; it never goes into the feed or the
|
|
315
|
+
* shared copy.
|
|
316
|
+
*/
|
|
317
|
+
preview({ url, image, caption, png } = {}) {
|
|
311
318
|
const picture = image ? imageData(image) : undefined;
|
|
319
|
+
const raw = png ?? (typeof image === 'string' && /\.png$/i.test(image) && existsSync(image) ? readFileSync(image) : null);
|
|
320
|
+
if (raw) {
|
|
321
|
+
try {
|
|
322
|
+
const t = rgbThumb(raw, 160);
|
|
323
|
+
dropThumbs(dir(this.root), this.id);
|
|
324
|
+
writeFileSync(join(dir(this.root), thumbName(this.id, t.w, t.h)), t.rgb);
|
|
325
|
+
} catch { /* no raw preview this time: the feed's picture stands */ }
|
|
326
|
+
}
|
|
312
327
|
return this.change((doc) => {
|
|
313
328
|
doc.preview = {
|
|
314
329
|
url: url ? String(url).slice(0, 300) : doc.preview?.url ?? null,
|
package/lib/scaffold.mjs
CHANGED
|
@@ -279,7 +279,7 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
279
279
|
- The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
|
|
280
280
|
Mute, Kick; Announce). Nobody else's page has it.
|
|
281
281
|
- **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
|
|
282
|
-
\`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
282
|
+
\`office invite <id>\` happen at once; \`office kick\`, \`office mute\`, \`office close\` and \`office launch\` only ASK, and print a
|
|
283
283
|
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
|
|
284
284
|
owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
|
|
285
285
|
\`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
|
|
@@ -337,6 +337,11 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
337
337
|
studio.json as \`cloudflare.domain\`.
|
|
338
338
|
- A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
|
|
339
339
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
340
|
+
- Optional in studio.json: \`"protect"\` (globs of files the owner wants to look at before any change, such as
|
|
341
|
+
\`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
|
|
342
|
+
in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
|
|
343
|
+
the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
|
|
344
|
+
you meant to do and why; do not retry it unless the person asks.
|
|
340
345
|
|
|
341
346
|
## Beta
|
|
342
347
|
|
|
@@ -887,6 +887,43 @@
|
|
|
887
887
|
}
|
|
888
888
|
}
|
|
889
889
|
}
|
|
890
|
+
},
|
|
891
|
+
"0.21.0": {
|
|
892
|
+
"files": {
|
|
893
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
894
|
+
"README.md": "684b3cca8001673c",
|
|
895
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
896
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
897
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
898
|
+
"videos/README.md": "16babc3437d5545b",
|
|
899
|
+
"posts/README.md": "37841aae476bd540",
|
|
900
|
+
"site/README.md": "ca131cd0472464fb",
|
|
901
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
902
|
+
},
|
|
903
|
+
"sections": {
|
|
904
|
+
"AGENTS.md": {
|
|
905
|
+
"(intro)": "214aa46eb3a895d5",
|
|
906
|
+
"## Layout": "43a4c5bf07db36f9",
|
|
907
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
908
|
+
"## The site": "ab922bb6bde23293",
|
|
909
|
+
"## Making games": "a8a893eeada33bf5",
|
|
910
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
911
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
912
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
913
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
914
|
+
"## Rules": "7e8458adc1b81ece",
|
|
915
|
+
"## Beta": "9d02638207a26e73"
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
"named": {
|
|
919
|
+
"Homie Arcade": {
|
|
920
|
+
"sections": {
|
|
921
|
+
"AGENTS.md": {
|
|
922
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
890
927
|
}
|
|
891
928
|
}
|
|
892
929
|
}
|
package/lib/thumb.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A build's preview as raw pixels, for Claude Code's Homie mod (the plugin's hooks/homie.mjs): a mod's code has no
|
|
3
|
+
* image decoder, so the latest check frame is also kept, small, as plain RGB beside the feed:
|
|
4
|
+
*
|
|
5
|
+
* .studio/progress/<build>.preview.<width>x<height>.rgb width * height * 3 bytes, rows top to bottom
|
|
6
|
+
*
|
|
7
|
+
* The mod draws it as coloured terminal cells (or hands the file to the terminal as a picture). The feed's own JPEG
|
|
8
|
+
* stays what the Claude app's card shows. Plain Node: zlib inflates the PNG Chrome wrote; nothing else is needed.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { inflateSync } from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** A PNG (8-bit RGB or RGBA, not interlaced: what Chrome writes) as { w, h, rgba }. */
|
|
15
|
+
export function decodePng(buf) {
|
|
16
|
+
if (!Buffer.isBuffer(buf) || buf.length < 33 || buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
|
|
17
|
+
let at = 8; let w = 0; let h = 0; let type = 0; const idat = [];
|
|
18
|
+
while (at + 8 <= buf.length) {
|
|
19
|
+
const len = buf.readUInt32BE(at);
|
|
20
|
+
const kind = buf.toString('latin1', at + 4, at + 8);
|
|
21
|
+
const data = buf.subarray(at + 8, at + 8 + len);
|
|
22
|
+
if (kind === 'IHDR') {
|
|
23
|
+
w = data.readUInt32BE(0); h = data.readUInt32BE(4); type = data[9];
|
|
24
|
+
if (data[8] !== 8 || data[12] !== 0) throw new Error('only 8-bit, non-interlaced PNGs');
|
|
25
|
+
} else if (kind === 'IDAT') idat.push(data);
|
|
26
|
+
else if (kind === 'IEND') break;
|
|
27
|
+
at += 12 + len;
|
|
28
|
+
}
|
|
29
|
+
const bpp = type === 6 ? 4 : type === 2 ? 3 : 0;
|
|
30
|
+
if (!bpp || !w || !h) throw new Error('only RGB or RGBA PNGs');
|
|
31
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
32
|
+
const stride = w * bpp;
|
|
33
|
+
const px = Buffer.alloc(stride * h);
|
|
34
|
+
for (let y = 0; y < h; y++) {
|
|
35
|
+
const f = raw[y * (stride + 1)];
|
|
36
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
37
|
+
const o = y * stride;
|
|
38
|
+
for (let x = 0; x < stride; x++) {
|
|
39
|
+
const a = x >= bpp ? px[o + x - bpp] : 0;
|
|
40
|
+
const b = y ? px[o - stride + x] : 0;
|
|
41
|
+
const c = x >= bpp && y ? px[o - stride + x - bpp] : 0;
|
|
42
|
+
let v = line[x];
|
|
43
|
+
if (f === 1) v += a;
|
|
44
|
+
else if (f === 2) v += b;
|
|
45
|
+
else if (f === 3) v += (a + b) >> 1;
|
|
46
|
+
else if (f === 4) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
|
|
47
|
+
px[o + x] = v & 255;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const rgba = Buffer.alloc(w * h * 4);
|
|
51
|
+
for (let i = 0, j = 0; i < w * h; i++, j += bpp) { rgba[i * 4] = px[j]; rgba[i * 4 + 1] = px[j + 1]; rgba[i * 4 + 2] = px[j + 2]; rgba[i * 4 + 3] = bpp === 4 ? px[j + 3] : 255; }
|
|
52
|
+
return { w, h, rgba };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A PNG scaled down (each pixel the average of the ones it covers) to at most `width` pixels wide, as plain RGB. */
|
|
56
|
+
export function rgbThumb(png, width = 160) {
|
|
57
|
+
const img = decodePng(png);
|
|
58
|
+
const w = Math.max(1, Math.min(width, img.w));
|
|
59
|
+
const h = Math.max(1, Math.round((img.h * w) / img.w));
|
|
60
|
+
const rgb = Buffer.alloc(w * h * 3);
|
|
61
|
+
for (let y = 0; y < h; y++) {
|
|
62
|
+
const y0 = Math.floor((y * img.h) / h); const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * img.h) / h));
|
|
63
|
+
for (let x = 0; x < w; x++) {
|
|
64
|
+
const x0 = Math.floor((x * img.w) / w); const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * img.w) / w));
|
|
65
|
+
let r = 0; let g = 0; let b = 0; let n = 0;
|
|
66
|
+
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) { const i = (yy * img.w + xx) * 4; r += img.rgba[i]; g += img.rgba[i + 1]; b += img.rgba[i + 2]; n++; }
|
|
67
|
+
const o = (y * w + x) * 3;
|
|
68
|
+
rgb[o] = Math.round(r / n); rgb[o + 1] = Math.round(g / n); rgb[o + 2] = Math.round(b / n);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { w, h, rgb };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The file name of a build's raw preview. */
|
|
75
|
+
export const thumbName = (build, w, h) => `${build}.preview.${w}x${h}.rgb`;
|
|
76
|
+
|
|
77
|
+
/** Remove a build's earlier raw previews (one is kept: the newest). */
|
|
78
|
+
export function dropThumbs(dir, build) {
|
|
79
|
+
let names = [];
|
|
80
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
81
|
+
for (const n of names) if (n.startsWith(`${build}.preview.`) && n.endsWith('.rgb')) rmSync(join(dir, n), { force: true });
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.21.0';
|