@spawnco/client 0.1.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/dist/spawn.mjs ADDED
@@ -0,0 +1,1389 @@
1
+ // @bun
2
+ // src/bin.ts
3
+ import { existsSync as existsSync2 } from "fs";
4
+ import path3 from "path";
5
+ import { fileURLToPath } from "url";
6
+
7
+ // ../../apps/savi-workshop/cli/cli.ts
8
+ import { existsSync } from "fs";
9
+ import { mkdir, readFile, stat as stat2, writeFile as writeFile2 } from "fs/promises";
10
+ import { tmpdir } from "os";
11
+ import path2 from "path";
12
+
13
+ // ../../apps/savi-workshop/server/io-client.ts
14
+ class IoApiError extends Error {
15
+ code;
16
+ status;
17
+ detail;
18
+ constructor(code, status, message, detail) {
19
+ super(message ?? code);
20
+ this.code = code;
21
+ this.status = status;
22
+ this.detail = detail;
23
+ this.name = "IoApiError";
24
+ }
25
+ }
26
+ var REF_CORPUS_HEADER = "x-spawn-ref-corpus";
27
+ class IoClient {
28
+ origin;
29
+ token;
30
+ fetchImpl;
31
+ constructor(options) {
32
+ this.origin = options.apiOrigin.replace(/\/+$/, "");
33
+ this.token = options.token;
34
+ this.fetchImpl = options.fetchImpl ?? fetch;
35
+ }
36
+ setToken(token) {
37
+ if (!token || token === this.token)
38
+ return false;
39
+ this.token = token;
40
+ return true;
41
+ }
42
+ async manifest() {
43
+ const body = await this.request("GET", "/workshop/io/manifest");
44
+ const files = body.files;
45
+ return Array.isArray(files) ? files : [];
46
+ }
47
+ async presign(op, paths) {
48
+ if (paths.length === 0)
49
+ return new Map;
50
+ const body = await this.request("POST", "/workshop/io/presign", {
51
+ op,
52
+ paths
53
+ });
54
+ return normalizePresignUrls(body.urls);
55
+ }
56
+ async ingest(meta) {
57
+ const body = await this.request("POST", "/workshop/io/ingest", meta);
58
+ return body;
59
+ }
60
+ async assets() {
61
+ const body = await this.request("GET", "/workshop/io/assets");
62
+ const assets = body.assets;
63
+ return Array.isArray(assets) ? assets : [];
64
+ }
65
+ async fetchAsset(reference) {
66
+ return this.fetchImpl(`${this.origin}${buildAssetDoorPath(reference)}`, {
67
+ headers: { authorization: `Bearer ${this.token}` }
68
+ });
69
+ }
70
+ async attachments() {
71
+ const body = await this.request("GET", "/workshop/io/attachments");
72
+ const attachments = body.attachments;
73
+ return Array.isArray(attachments) ? attachments : [];
74
+ }
75
+ async fetchAttachment(id) {
76
+ return this.fetchImpl(`${this.origin}/workshop/io/attachments/${encodeURIComponent(id)}`, { headers: { authorization: `Bearer ${this.token}` } });
77
+ }
78
+ async refSkills(engine) {
79
+ const q = engine ? `?engine=${encodeURIComponent(engine)}` : "";
80
+ const response = await this.fetchImpl(`${this.origin}/workshop/io/ref/skills${q}`, { headers: { authorization: `Bearer ${this.token}` } });
81
+ if (!response.ok)
82
+ throw await this.errorFrom(response);
83
+ const body = await response.json();
84
+ return {
85
+ skills: Array.isArray(body.skills) ? body.skills : [],
86
+ corpus: response.headers.get(REF_CORPUS_HEADER) ?? (typeof body.corpus === "string" ? body.corpus : null)
87
+ };
88
+ }
89
+ async refSkillStream(id, engine) {
90
+ const q = engine ? `?engine=${encodeURIComponent(engine)}` : "";
91
+ const response = await this.fetchImpl(`${this.origin}/workshop/io/ref/skills/${encodeURIComponent(id)}${q}`, { headers: { authorization: `Bearer ${this.token}` } });
92
+ if (!response.ok)
93
+ throw await this.errorFrom(response);
94
+ return response;
95
+ }
96
+ async refSkill(id, engine) {
97
+ return (await this.refSkillStream(id, engine)).text();
98
+ }
99
+ async refGrep(pattern, engine) {
100
+ const q = new URLSearchParams({ q: pattern });
101
+ if (engine)
102
+ q.set("engine", engine);
103
+ const body = await this.request("GET", `/workshop/io/ref/grep?${q.toString()}`);
104
+ return {
105
+ hits: Array.isArray(body.hits) ? body.hits : [],
106
+ truncated: body.truncated === true
107
+ };
108
+ }
109
+ async toolList(lane) {
110
+ const q = lane ? `?lane=${encodeURIComponent(lane)}` : "";
111
+ const body = await this.request("GET", `/workshop/io/tool${q}`);
112
+ return Array.isArray(body.verbs) ? body.verbs : [];
113
+ }
114
+ async toolDescribe(name, lane) {
115
+ const q = lane ? `?lane=${encodeURIComponent(lane)}` : "";
116
+ const body = await this.request("GET", `/workshop/io/tool/${encodeURIComponent(name)}${q}`);
117
+ return body.verb ?? null;
118
+ }
119
+ async toolRun(name, args, lane) {
120
+ const body = await this.request("POST", `/workshop/io/tool/${encodeURIComponent(name)}`, { args, ...lane ? { lane } : {} });
121
+ return {
122
+ ok: body.ok === true,
123
+ kind: typeof body.kind === "string" ? body.kind : "text",
124
+ code: typeof body.code === "string" ? body.code : undefined,
125
+ text: typeof body.text === "string" ? body.text : "",
126
+ media: Array.isArray(body.media) ? body.media : undefined
127
+ };
128
+ }
129
+ async errorFrom(response) {
130
+ let code = `HTTP_${response.status}`;
131
+ let detail;
132
+ try {
133
+ const parsed = await response.json();
134
+ if (parsed && typeof parsed.error === "string")
135
+ code = parsed.error;
136
+ if (parsed && typeof parsed.message === "string" && parsed.message.length > 0) {
137
+ detail = parsed.message;
138
+ if (Array.isArray(parsed.available) && parsed.available.length > 0) {
139
+ detail += ` \u2014 available: ${parsed.available.join(", ")}`;
140
+ }
141
+ }
142
+ } catch {}
143
+ return new IoApiError(code, response.status, undefined, detail);
144
+ }
145
+ async request(method, path, body) {
146
+ const response = await this.fetchImpl(`${this.origin}${path}`, {
147
+ method,
148
+ headers: {
149
+ authorization: `Bearer ${this.token}`,
150
+ ...body !== undefined ? { "content-type": "application/json" } : {}
151
+ },
152
+ ...body !== undefined ? { body: JSON.stringify(body) } : {}
153
+ });
154
+ if (!response.ok) {
155
+ let code = `HTTP_${response.status}`;
156
+ let detail;
157
+ try {
158
+ const parsed = await response.json();
159
+ if (parsed && typeof parsed.error === "string")
160
+ code = parsed.error;
161
+ if (parsed && typeof parsed.message === "string" && parsed.message.length > 0) {
162
+ detail = parsed.message;
163
+ }
164
+ } catch {}
165
+ throw new IoApiError(code, response.status, `${method} ${path} \u2192 ${code}${detail ? `: ${detail}` : ""}`, detail);
166
+ }
167
+ return response.json();
168
+ }
169
+ }
170
+ function buildAssetDoorPath(reference) {
171
+ const queryIndex = reference.indexOf("?");
172
+ const path = queryIndex === -1 ? reference : reference.slice(0, queryIndex);
173
+ const query = queryIndex === -1 ? "" : reference.slice(queryIndex);
174
+ const encodedPath = path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
175
+ return `/workshop/io/asset/${encodedPath}${query}`;
176
+ }
177
+ function normalizePresignUrls(urls) {
178
+ const map = new Map;
179
+ if (Array.isArray(urls)) {
180
+ for (const entry of urls) {
181
+ if (!entry || typeof entry !== "object")
182
+ continue;
183
+ const record = entry;
184
+ if (typeof record.path === "string" && typeof record.url === "string") {
185
+ map.set(record.path, record.url);
186
+ continue;
187
+ }
188
+ for (const [key, value] of Object.entries(record)) {
189
+ if (typeof value === "string")
190
+ map.set(key, value);
191
+ }
192
+ }
193
+ return map;
194
+ }
195
+ if (urls && typeof urls === "object") {
196
+ for (const [key, value] of Object.entries(urls)) {
197
+ if (typeof value === "string")
198
+ map.set(key, value);
199
+ }
200
+ }
201
+ return map;
202
+ }
203
+
204
+ // ../../apps/savi-workshop/server/scan.ts
205
+ import { readdir, stat } from "fs/promises";
206
+ import path from "path";
207
+ var MANIFEST_FILENAME = ".workshop-manifest.json";
208
+ async function walkWorkspace(root) {
209
+ const snapshot = {};
210
+ const walk = async (dir) => {
211
+ let entries;
212
+ try {
213
+ entries = await readdir(dir, { withFileTypes: true });
214
+ } catch {
215
+ return;
216
+ }
217
+ for (const entry of entries) {
218
+ const abs = path.join(dir, entry.name);
219
+ const rel = path.relative(root, abs).split(path.sep).join("/");
220
+ if (rel === MANIFEST_FILENAME)
221
+ continue;
222
+ if (entry.isDirectory()) {
223
+ await walk(abs);
224
+ } else if (entry.isFile()) {
225
+ try {
226
+ const st = await stat(abs);
227
+ snapshot[rel] = { size: st.size, mtimeMs: st.mtimeMs };
228
+ } catch {}
229
+ }
230
+ }
231
+ };
232
+ await walk(root);
233
+ return snapshot;
234
+ }
235
+
236
+ // ../../apps/savi-workshop/server/status.ts
237
+ import { writeFile } from "fs/promises";
238
+ var STATUS_FILE_PATH = "/tmp/.workshop-status.json";
239
+ class StatusFile {
240
+ path;
241
+ appId;
242
+ quotaSoftBytes;
243
+ quotaHardBytes;
244
+ startedAt = new Date().toISOString();
245
+ budget = null;
246
+ constructor(path2, appId, quotaSoftBytes, quotaHardBytes) {
247
+ this.path = path2;
248
+ this.appId = appId;
249
+ this.quotaSoftBytes = quotaSoftBytes;
250
+ this.quotaHardBytes = quotaHardBytes;
251
+ }
252
+ recordBudgetHeader(raw) {
253
+ if (raw === null || raw === "")
254
+ return;
255
+ try {
256
+ this.budget = JSON.parse(raw);
257
+ } catch {
258
+ this.budget = raw;
259
+ }
260
+ }
261
+ async write(workspace) {
262
+ const status = {
263
+ started_at: this.startedAt,
264
+ updated_at: new Date().toISOString(),
265
+ app_id: this.appId,
266
+ workspace_bytes: workspace.bytes,
267
+ workspace_files: workspace.files,
268
+ quota_soft_bytes: this.quotaSoftBytes,
269
+ quota_hard_bytes: this.quotaHardBytes,
270
+ budget: this.budget
271
+ };
272
+ try {
273
+ await writeFile(this.path, `${JSON.stringify(status, null, 2)}
274
+ `);
275
+ } catch (error) {
276
+ console.error(`[workshop] status write failed: ${String(error)}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ // ../../apps/savi-workshop/cli/cli.ts
282
+ var UPLOAD_CAP_BYTES = 100 * 1024 * 1024;
283
+ var USAGE = `spawn \u2014 bridge between this workspace and the Spawn project
284
+
285
+ usage:
286
+ spawn upload <file> [--name <display>] upload a file as a project asset
287
+ spawn fetch <url-or-cdn-ref> [dest] download into the workspace
288
+ spawn attachments list creator-shared reference images from this game's chat
289
+ spawn attachments download <id> [dest] pull one into the workspace (images only)
290
+ spawn ls [--assets|--workspace] list uploaded assets / workspace files
291
+ spawn status workspace quota, budget, uptime
292
+
293
+ spawn ref ls the engine's skills \u2014 the craft, one line each
294
+ spawn ref read <skill> one skill, WHOLE (read it before building in its territory)
295
+ spawn ref grep <regex> which skill teaches a word
296
+
297
+ spawn tools the room's own verbs this shell carries (one line each)
298
+ spawn <tool> [action] [words\u2026] [--key value \u2026]
299
+ run one of them \u2014 the same tool the room runs, through
300
+ this shell: a value that reads as JSON is JSON, a bare
301
+ --flag is true; an image it answers is written under
302
+ /workspace/spawn-media/
303
+ spawn <tool> --help the whole contract: description + every input key
304
+
305
+ spawn client join [<@user/world>] [--as <name>] [--body <model-url>]
306
+ [--materials-json <path-or-inline>] [--ttl <s>] [--name <session>]
307
+ a real player in the world \u2014 this box's own grant, or
308
+ (with SPAWN_TOKEN=sak_\u2026 + a world) your account's
309
+ spawn client move <x> <y> <z> | --to <objectId> [--speed walk|run|teleport]
310
+ walk the body by input (teleport = labeled write)
311
+ spawn client look --at <objectId|x,y,z> aim the session's view
312
+ spawn client where position + world + place + nearest objects (sim read)
313
+ spawn client players every player body: who is here, who is parked
314
+ spawn client inputs the world's declared inputs \u2014 what a key does here
315
+ spawn client run <file> | -e "<source>" run a play script against the live client
316
+ spawn client crossing resident world, portal directives heard, slot census
317
+ spawn client screenshot [--out <file>] booth pixels when configured, else labeled sim-only
318
+ spawn client leave graceful depart \u2014 despawn journaled before close
319
+ spawn client status session liveness, entity id, ttl remaining
320
+ (client verbs take [--name <session>]; sessions self-expire at ttl)
321
+
322
+ fetch takes any https URL, or a /cdn/<filename> reference exactly as the
323
+ game spec carries it (query variants like ?animated=run-loop included).
324
+ `;
325
+ async function runCli(argv, deps) {
326
+ const [command, ...rest] = argv;
327
+ switch (command) {
328
+ case "upload":
329
+ return upload(rest, deps);
330
+ case "fetch":
331
+ return fetchCommand(rest, deps);
332
+ case "attachments":
333
+ return attachments(rest, deps);
334
+ case "ls":
335
+ return ls(rest, deps);
336
+ case "status":
337
+ return status(deps);
338
+ case "client":
339
+ return client(rest, deps);
340
+ case "ref":
341
+ return ref(rest, deps);
342
+ case "tools":
343
+ return roomTools(deps);
344
+ case undefined:
345
+ case "help":
346
+ case "--help":
347
+ case "-h":
348
+ deps.stdout(USAGE);
349
+ return command === undefined ? 2 : 0;
350
+ default:
351
+ return roomVerb(command, rest, deps);
352
+ }
353
+ }
354
+ var SPAWN_MEDIA_DIR = "spawn-media";
355
+ var extensionFor = (mediaType) => {
356
+ const subtype = mediaType.split("/")[1]?.split(";")[0]?.toLowerCase() ?? "";
357
+ if (subtype === "jpeg")
358
+ return "jpg";
359
+ if (subtype === "svg+xml")
360
+ return "svg";
361
+ return subtype.replace(/[^a-z0-9]/g, "") || "bin";
362
+ };
363
+ var laneOf = (deps) => deps.env.SPAWN_WISP_ID?.trim() || null;
364
+ function renderContract(verb) {
365
+ const schema = verb.inputSchema;
366
+ const lines = [
367
+ `spawn ${verb.name} \u2014 ${verb.hook}`,
368
+ "",
369
+ verb.description,
370
+ "",
371
+ `usage: spawn ${verb.name}${schema?.properties?.action ? " <action>" : ""}${verb.positional.map((p) => ` [<${p}>]`).join("")} [--key value \u2026]`
372
+ ];
373
+ const properties = schema?.properties ?? {};
374
+ const required = new Set(schema?.required ?? []);
375
+ const keys = Object.keys(properties);
376
+ if (keys.length > 0) {
377
+ lines.push("", "input:");
378
+ for (const key of keys) {
379
+ const property = properties[key] ?? {};
380
+ const type = Array.isArray(property.enum) ? property.enum.map(String).join(" | ") : Array.isArray(property.type) ? property.type.join(" | ") : typeof property.type === "string" ? property.type : "json";
381
+ const description = typeof property.description === "string" ? property.description : "";
382
+ lines.push(` --${key} <${type}>${required.has(key) ? " (required)" : ""}${description ? `
383
+ ${description}` : ""}`);
384
+ }
385
+ }
386
+ if (verb.positional.length > 0 || schema?.properties?.action) {
387
+ lines.push("", `bare words fill: ${[...schema?.properties?.action ? ["action"] : [], ...verb.positional].join(", ")}`);
388
+ }
389
+ return lines.join(`
390
+ `);
391
+ }
392
+ async function roomTools(deps) {
393
+ const client = makeClient(deps);
394
+ if (!client)
395
+ return 1;
396
+ let verbs;
397
+ try {
398
+ verbs = await client.toolList(laneOf(deps));
399
+ } catch (error) {
400
+ deps.stderr(`spawn tools: ${describeToolDoorError(error)}`);
401
+ return 1;
402
+ }
403
+ if (verbs.length === 0) {
404
+ deps.stdout("this shell carries none of the room's verbs");
405
+ return 0;
406
+ }
407
+ const width = Math.min(28, Math.max(...verbs.map((v) => v.name.length)));
408
+ for (const verb of verbs)
409
+ deps.stdout(`${verb.name.padEnd(width)} ${verb.hook}`);
410
+ deps.stdout(`
411
+ ${verbs.length} verbs \u2014 \`spawn <tool> --help\` for one's whole contract`);
412
+ return 0;
413
+ }
414
+ async function roomVerb(name, args, deps) {
415
+ const client = makeClient(deps);
416
+ if (!client) {
417
+ deps.stderr(`spawn: unknown command "${name}"
418
+
419
+ ${USAGE}`);
420
+ return 2;
421
+ }
422
+ const lane = laneOf(deps);
423
+ if (args.includes("--help") || args.includes("-h")) {
424
+ try {
425
+ const verb = await client.toolDescribe(name, lane);
426
+ if (!verb) {
427
+ deps.stderr(`spawn: unknown command "${name}"
428
+
429
+ ${USAGE}`);
430
+ return 2;
431
+ }
432
+ deps.stdout(renderContract(verb));
433
+ return 0;
434
+ } catch (error) {
435
+ if (error instanceof IoApiError && error.code === "UNKNOWN_VERB") {
436
+ deps.stderr(`spawn: unknown command "${name}"
437
+
438
+ ${USAGE}`);
439
+ return 2;
440
+ }
441
+ deps.stderr(`spawn ${name} --help: ${describeToolDoorError(error)}`);
442
+ return 1;
443
+ }
444
+ }
445
+ let answer;
446
+ try {
447
+ answer = await client.toolRun(name, args, lane);
448
+ } catch (error) {
449
+ deps.stderr(`spawn ${name}: ${describeToolDoorError(error)}`);
450
+ return 1;
451
+ }
452
+ if (!answer.ok && answer.code === "tool_unavailable") {
453
+ deps.stderr(`spawn: unknown command "${name}"
454
+
455
+ ${USAGE}`);
456
+ return 2;
457
+ }
458
+ const mediaLines = [];
459
+ for (const [index, item] of (answer.media ?? []).entries()) {
460
+ const dir = path2.resolve(deps.cwd ?? process.cwd(), SPAWN_MEDIA_DIR);
461
+ const file = path2.join(dir, `${name}-${Date.now()}-${index + 1}.${extensionFor(item.mediaType)}`);
462
+ try {
463
+ await mkdir(dir, { recursive: true });
464
+ await writeFile2(file, Buffer.from(item.data, "base64"));
465
+ mediaLines.push(`image saved: ${file} (${item.mediaType}${item.label ? ` \u2014 ${item.label}` : ""}) \u2014 \`spawn upload\` it and look { reference: <the printed url> } to see it`);
466
+ } catch (error) {
467
+ mediaLines.push(`image not saved (${String(error)}) \u2014 ${item.mediaType}${item.label ? ` \u2014 ${item.label}` : ""}`);
468
+ }
469
+ }
470
+ const out = [answer.text, ...mediaLines].filter((line) => line.length > 0);
471
+ (answer.ok ? deps.stdout : deps.stderr)(out.join(`
472
+ `));
473
+ return answer.ok ? 0 : 1;
474
+ }
475
+ function describeToolDoorError(error) {
476
+ if (error instanceof IoApiError) {
477
+ if (error.code === "WORKSHOP_NOT_CONFIGURED") {
478
+ return "this environment has no chat-room lane for the room's verbs";
479
+ }
480
+ return error.detail ?? error.message;
481
+ }
482
+ return String(error);
483
+ }
484
+ var CLIENT_USAGE = `spawn client \u2014 a real engine client in a world, driven from here (your body in the game)
485
+
486
+ join [<@user/world>] [--as <name>] [--body <model-url>] [--materials-json <path-or-inline>]
487
+ [--ttl <s>] [--ready-timeout <ms>] [--name <session>]
488
+ [--actor <agentActorId>] [--origin <kiln>] [--door <kernel origin>] [--engine <32hex>]
489
+ move <x> <y> <z> | --to <objectId> [--speed walk|run|teleport] [--timeout <s>] [--name <session>]
490
+ look --at <objectId|x,y,z> [--name <session>]
491
+ where [--name <session>]
492
+ players [--name <session>] (every player body: entity id, name, you, parked)
493
+ inputs [--name <session>] (the world's declared actions + axes + their keys)
494
+ run <file> | -e "<source>" [--timeout <s>] [--name <session>]
495
+ (a play module \u2014 export default async (play) => {\u2026} \u2014
496
+ against the LIVE client; the transcript is the answer)
497
+ crossing [--name <session>] (resident world \xB7 portal directives the sim posted
498
+ and what the shell did \xB7 the world-slot census)
499
+ prefetch --world <id> --manifest <hash> --hex <32hex> [--name <session>]
500
+ (arm the approach: the sim boots the target into
501
+ a foreign slot before you walk the portal)
502
+ screenshot [--out <file>] [--name <session>] (real pixels through a CONNECTED PLAYER'S client,
503
+ rendered from this session's viewpoint; sim-only
504
+ JSON when nobody is connected \u2014 the tier is printed)
505
+ leave [--name <session>]
506
+ status
507
+
508
+ TWO WAYS IN. With YOUR ACCOUNT TOKEN (SPAWN_TOKEN=sak_\u2026, the token signup or an invite answered)
509
+ name the world: \`spawn client join @alice/gauntlet\` resolves the address, mints a session grant
510
+ for that world at kiln's play doors, and attaches a real player \u2014 a body the room sees, wearing
511
+ your account's name. --origin (SPAWN_ORIGIN) points at another kiln; --door (SPAWN_DOOR_ORIGIN)
512
+ names the kernel's session door when the deployment spells none; --engine (SPAWN_ENGINE) pins the
513
+ engine build when the door names none; --actor is for a person's token embodying a seat it minted.
514
+ On a box that already holds a session grant (Savi's workshop), \`spawn client join\` with no world
515
+ attaches with that grant \u2014 the same boot a browser tab performs.
516
+ Walking into a portal crosses for real: the sim names the session the traveler and the shell
517
+ re-dials the session to the target world (where/crossing then report the new world).
518
+ Pre-6.0 rooms join as a DEV-room client (SPAWN_PROBE/Savi family). Sessions self-expire at
519
+ --ttl (default 900s); leave journals the despawn before the socket closes. --body dresses the
520
+ avatar; --materials-json paints its parts (the a3dc/model materials map \u2014 a JSON file path or
521
+ inline JSON; requires --body).
522
+
523
+ THE PLAY SCRIPT (\`run\`): \`play\` is your body and senses \u2014 hold(axis, v) / release(axis?) \xB7
524
+ press(action) \xB7 key("KeyE") (resolved through this world's own bindings; an unbound key is a
525
+ finding) \xB7 moveTo(target) \xB7 lookAt(target) \xB7 teleport(x, y, z) (the one labeled write) \xB7
526
+ pose() \xB7 where() \xB7 players() \xB7 state(entityId?) (an entity's tome/state; none = the world's
527
+ world/state ledger) \xB7 entities() \xB7 inputs() \xB7 until(check, {timeoutSec}) \xB7 seconds(n) \xB7 log(line).
528
+ The transcript and the return value come back; a thrown error or a timeout is an ok:false
529
+ transcript, and every held input is released when the script ends.
530
+ `;
531
+ async function resolveMaterialsFlag(raw) {
532
+ let text = raw;
533
+ if (!raw.trimStart().startsWith("{")) {
534
+ try {
535
+ text = await readFile(raw, "utf8");
536
+ } catch (error) {
537
+ return `--materials-json: could not read "${raw}" as a file (${String(error)}) \u2014 pass a JSON file path or inline JSON starting with '{'`;
538
+ }
539
+ }
540
+ let parsed;
541
+ try {
542
+ parsed = JSON.parse(text);
543
+ } catch (error) {
544
+ return `--materials-json: invalid JSON (${error instanceof Error ? error.message : String(error)})`;
545
+ }
546
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
547
+ return "--materials-json: must be a JSON object mapping part names to material entries";
548
+ }
549
+ return parsed;
550
+ }
551
+ async function loadSessionLib(deps) {
552
+ if (deps.sessionLibFactory)
553
+ return deps.sessionLibFactory(deps.env);
554
+ if (deps.sessionLib)
555
+ return deps.sessionLib;
556
+ const explicit = deps.env.SPAWN_CLIENT_SESSION_LIB?.trim();
557
+ const inRepo = new URL("../../cf-kernel/src/_entry/headless-room-host/session/index.ts", import.meta.url);
558
+ const candidate = explicit ?? (existsSync(inRepo.pathname) ? inRepo.href : null);
559
+ if (!candidate) {
560
+ deps.stderr("spawn client: session library not available in this shell \u2014 set SPAWN_CLIENT_SESSION_LIB to a built session library (repo checkouts find apps/cf-kernel/src/_entry/headless-room-host/session automatically)");
561
+ return null;
562
+ }
563
+ let mod;
564
+ try {
565
+ mod = await import(candidate);
566
+ } catch (error) {
567
+ deps.stderr(`spawn client: session library failed to load from ${candidate}: ${String(error)}`);
568
+ return null;
569
+ }
570
+ const factory = mod.createClientSessionLib;
571
+ if (typeof factory !== "function") {
572
+ deps.stderr(`spawn client: ${candidate} exports no createClientSessionLib`);
573
+ return null;
574
+ }
575
+ return factory(deps.env, deps.fetchImpl);
576
+ }
577
+ var fmtPos = (position) => position ? `(${position.x.toFixed(1)}, ${position.y.toFixed(1)}, ${position.z.toFixed(1)})` : "(unknown)";
578
+ var SENSES_CARD = [
579
+ "you are embodied \u2014 your senses and hands:",
580
+ " eyes : spawn client where \xB7 players \xB7 inputs (pose + nearby \xB7 every body \xB7 what a key does here)",
581
+ " hands: spawn client move <x> <y> <z> | --to <id> \xB7 look --at <id|x,y,z>",
582
+ ' play : spawn client run play.js | -e "export default async (play) => { \u2026 }"',
583
+ " play.key('KeyE') \xB7 press(action) \xB7 hold/release \xB7 moveTo \xB7 lookAt \xB7 pose() \xB7 players() \xB7 state() \xB7 until()",
584
+ " leave: spawn client leave (the body despawns; the session self-expires at --ttl)"
585
+ ].join(`
586
+ `);
587
+ async function client(args, deps) {
588
+ const [verb, ...rest] = args;
589
+ if (!verb || verb === "help" || verb === "--help") {
590
+ deps.stdout(CLIENT_USAGE);
591
+ return verb ? 0 : 2;
592
+ }
593
+ const { positional, flags } = parseArgs(rest, [
594
+ "--room",
595
+ "--as",
596
+ "--body",
597
+ "--materials-json",
598
+ "--ttl",
599
+ "--ready-timeout",
600
+ "--name",
601
+ "--to",
602
+ "--speed",
603
+ "--timeout",
604
+ "--at",
605
+ "--out",
606
+ "--world",
607
+ "--manifest",
608
+ "--hex",
609
+ "--actor",
610
+ "--origin",
611
+ "--door",
612
+ "--engine",
613
+ "-e"
614
+ ]);
615
+ const env = {
616
+ ...deps.env,
617
+ ...flags["--origin"] ? { SPAWN_ORIGIN: flags["--origin"] } : {},
618
+ ...flags["--door"] ? { SPAWN_DOOR_ORIGIN: flags["--door"] } : {},
619
+ ...flags["--engine"] ? { SPAWN_ENGINE: flags["--engine"] } : {}
620
+ };
621
+ const lib = await loadSessionLib({ ...deps, env });
622
+ if (!lib)
623
+ return 1;
624
+ const ownSession = env.SPAWN_SESSION || undefined;
625
+ const sessionName = flags["--name"] || ownSession;
626
+ if (ownSession && flags["--name"] && flags["--name"] !== ownSession && verb !== "join" && verb !== "status") {
627
+ deps.stderr(`spawn client ${verb}: your body is session "${ownSession}" \u2014 you never drive another agent's session ("${flags["--name"]}"). Each agent controls its own body; to move another agent, brief it.`);
628
+ return 2;
629
+ }
630
+ try {
631
+ switch (verb) {
632
+ case "join": {
633
+ const ttlRaw = flags["--ttl"];
634
+ const ttlS = ttlRaw !== undefined && ttlRaw !== "" ? Number(ttlRaw) : undefined;
635
+ if (ttlS !== undefined && (!Number.isFinite(ttlS) || ttlS <= 0)) {
636
+ deps.stderr(`spawn client join: --ttl must be a positive number of seconds, got "${ttlRaw}"`);
637
+ return 2;
638
+ }
639
+ const readyRaw = flags["--ready-timeout"];
640
+ const readyTimeoutMs = readyRaw !== undefined && readyRaw !== "" ? Number(readyRaw) : undefined;
641
+ if (readyTimeoutMs !== undefined && (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0)) {
642
+ deps.stderr(`spawn client join: --ready-timeout must be a positive number of milliseconds, got "${readyRaw}"`);
643
+ return 2;
644
+ }
645
+ const world = positional[0] || undefined;
646
+ const agentActorId = flags["--actor"] || undefined;
647
+ let bodyMaterials;
648
+ const materialsRaw = flags["--materials-json"];
649
+ if (materialsRaw) {
650
+ if (!flags["--body"]) {
651
+ deps.stderr("spawn client join: --materials-json paints a session avatar \u2014 pass --body <model-url> alongside it");
652
+ return 2;
653
+ }
654
+ const resolved = await resolveMaterialsFlag(materialsRaw);
655
+ if (typeof resolved === "string") {
656
+ deps.stderr(`spawn client join: ${resolved}`);
657
+ return 2;
658
+ }
659
+ bodyMaterials = resolved;
660
+ }
661
+ const record = await lib.join({
662
+ name: sessionName,
663
+ ...world !== undefined ? { world } : {},
664
+ ...agentActorId !== undefined ? { agentActorId } : {},
665
+ roomId: flags["--room"] || undefined,
666
+ as: flags["--as"] || undefined,
667
+ bodyModelUrl: flags["--body"] || undefined,
668
+ ...bodyMaterials !== undefined ? { bodyMaterials } : {},
669
+ ttlS,
670
+ ...readyTimeoutMs !== undefined ? { readyTimeoutMs } : {}
671
+ });
672
+ deps.stdout(`joined: session "${record.name}" \u2192 ${record.worldAddress ? `${record.worldAddress} (world ${record.roomId})` : `room ${record.roomId}`} as "${record.playerName}"${record.handle ? ` [${record.handle}]` : ""} (pid ${record.pid}, http :${record.httpPort})`);
673
+ deps.stdout(`entity: ${record.entityId ?? "(spawning \u2014 check status)"}`);
674
+ if (record.ttlDeadlineMs)
675
+ deps.stdout(`ttl: self-departs at ${new Date(record.ttlDeadlineMs).toISOString()}`);
676
+ deps.stdout(`log: ${record.logPath}`);
677
+ deps.stdout(SENSES_CARD);
678
+ return 0;
679
+ }
680
+ case "players": {
681
+ if (!lib.players) {
682
+ deps.stderr("spawn client players: this session library predates the play surface");
683
+ return 1;
684
+ }
685
+ const view = await lib.players(sessionName);
686
+ if (view.players.length === 0)
687
+ deps.stdout("no player bodies in this world yet");
688
+ for (const row of view.players) {
689
+ deps.stdout(` ${row.isSelf ? "you" : " "} ${fmtPos(row.position).padEnd(22)} ${row.entityId}${row.displayName ? ` "${row.displayName}"` : ""}${row.parked ? " (parked \u2014 no socket drives this body)" : ""}`);
690
+ }
691
+ return 0;
692
+ }
693
+ case "inputs": {
694
+ if (!lib.inputs) {
695
+ deps.stderr("spawn client inputs: this session library predates the play surface");
696
+ return 1;
697
+ }
698
+ const view = await lib.inputs(sessionName);
699
+ if (!view.inputs) {
700
+ deps.stdout("inputs: not readable yet (no spec mounted on this session \u2014 try again in a moment)");
701
+ return 1;
702
+ }
703
+ deps.stdout(JSON.stringify(view.inputs, null, 2));
704
+ return 0;
705
+ }
706
+ case "run": {
707
+ if (!lib.run) {
708
+ deps.stderr("spawn client run: this session library predates the play surface");
709
+ return 1;
710
+ }
711
+ const inline = flags["-e"];
712
+ const file = positional[0];
713
+ if (!inline && !file) {
714
+ deps.stderr('spawn client run: needs a module path or -e "<source>" \u2014 export default async (play) => { \u2026 }');
715
+ return 2;
716
+ }
717
+ let scriptPath;
718
+ if (inline) {
719
+ const scriptsDir = path2.join(sessionScriptsDir(env), "scripts");
720
+ await mkdir(scriptsDir, { recursive: true });
721
+ scriptPath = path2.join(scriptsDir, `inline-${Date.now()}.mjs`);
722
+ await writeFile2(scriptPath, inline.trim().startsWith("export") ? inline : `export default async (play) => {
723
+ ${inline}
724
+ };
725
+ `);
726
+ } else {
727
+ scriptPath = path2.resolve(deps.cwd ?? process.cwd(), file);
728
+ }
729
+ const timeoutRaw = Number(flags["--timeout"]);
730
+ const timeoutSec = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
731
+ const outcome = await lib.run({
732
+ scriptPath,
733
+ ...timeoutSec !== undefined ? { timeoutSec } : {}
734
+ }, sessionName);
735
+ for (const ev of outcome.events) {
736
+ deps.stdout(` ${(ev.atMs / 1000).toFixed(1).padStart(6)}s ${ev.kind.padEnd(9)} ${ev.detail}`);
737
+ }
738
+ const resultTail = outcome.result === undefined || outcome.result === null ? "" : ` \u2192 ${typeof outcome.result === "string" ? outcome.result : JSON.stringify(outcome.result)}`;
739
+ (outcome.ok ? deps.stdout : deps.stderr)(`play ${outcome.ok ? "ok" : "FAILED"} in ${(outcome.elapsedMs / 1000).toFixed(1)}s${outcome.error ? ` \u2014 ${outcome.error}` : ""}${resultTail}`);
740
+ return outcome.ok ? 0 : 1;
741
+ }
742
+ case "move": {
743
+ const speedRaw = flags["--speed"] || "walk";
744
+ if (speedRaw !== "walk" && speedRaw !== "run" && speedRaw !== "teleport") {
745
+ deps.stderr(`spawn client move: --speed must be walk|run|teleport, got "${speedRaw}"`);
746
+ return 2;
747
+ }
748
+ const timeoutRaw = flags["--timeout"];
749
+ const timeoutS = timeoutRaw !== undefined && timeoutRaw !== "" ? Number(timeoutRaw) : undefined;
750
+ let moveArgs;
751
+ if (flags["--to"]) {
752
+ moveArgs = { to: flags["--to"], speed: speedRaw, timeoutS };
753
+ } else {
754
+ const [x, y, z] = positional.map(Number);
755
+ if (positional.length < 3 || ![x, y, z].every(Number.isFinite)) {
756
+ deps.stderr("spawn client move: needs <x> <y> <z> or --to <objectId>");
757
+ return 2;
758
+ }
759
+ moveArgs = { x, y, z, speed: speedRaw, timeoutS };
760
+ }
761
+ const outcome = await lib.move(moveArgs, sessionName);
762
+ const line = `${outcome.arrived ? "arrived" : "did not arrive"} [${outcome.mode}] at ${fmtPos(outcome.finalPosition)}${outcome.distanceRemaining !== null ? ` \u2014 ${outcome.distanceRemaining.toFixed(1)}m from target` : ""}`;
763
+ (outcome.arrived ? deps.stdout : deps.stderr)(`${line}
764
+ ${outcome.note}`);
765
+ return outcome.arrived ? 0 : 1;
766
+ }
767
+ case "look": {
768
+ const at = flags["--at"] || positional[0];
769
+ if (!at) {
770
+ deps.stderr("spawn client look: needs --at <objectId|x,y,z>");
771
+ return 2;
772
+ }
773
+ const outcome = await lib.look({ at }, sessionName);
774
+ (outcome.aimed ? deps.stdout : deps.stderr)(`${outcome.aimed ? "aimed" : "not aimed"}${outcome.yaw !== null ? ` (yaw ${outcome.yaw.toFixed(2)}rad)` : ""} \u2014 ${outcome.note}`);
775
+ return outcome.aimed ? 0 : 1;
776
+ }
777
+ case "where": {
778
+ const view = await lib.where(sessionName);
779
+ deps.stdout(`position: ${fmtPos(view.position)} world: ${view.worldId ?? "?"} place: ${view.placeId ?? "?"} entity: ${view.entityId ?? "?"} [${view.tier}]`);
780
+ if (view.nearby.length === 0)
781
+ deps.stdout("nearby: nothing with a position in range");
782
+ for (const row of view.nearby) {
783
+ deps.stdout(` ${row.distance.toFixed(1).padStart(6)}m ${row.entityId}${row.specId && row.specId !== row.entityId ? ` (spec ${row.specId})` : ""}${row.displayName ? ` "${row.displayName}"` : ""}`);
784
+ }
785
+ return 0;
786
+ }
787
+ case "crossing": {
788
+ if (!lib.crossing) {
789
+ deps.stderr("spawn client crossing: this session library predates the crossing seam");
790
+ return 1;
791
+ }
792
+ const view = await lib.crossing(sessionName);
793
+ deps.stdout(`resident world: ${view.residentWorldId}${view.residentWorldId !== view.bootWorldId ? ` (booted into ${view.bootWorldId})` : ""} [${view.lane} lane]`);
794
+ if (view.directives.length === 0)
795
+ deps.stdout("directives: none heard yet");
796
+ for (const row of view.directives) {
797
+ deps.stdout(` ${new Date(row.heardAtMs).toISOString()} ${row.outcome.padEnd(8)} ${row.fromWorldId} \u2192 ${row.targetWorldId ?? "?"} link "${row.link}"${row.entryEntityId ? ` via #${row.entryEntityId}` : ""}${row.why ? ` (${row.why})` : ""}`);
798
+ }
799
+ if (!view.slots.slotTable) {
800
+ deps.stdout("slots: no slot table (single-world session that never prefetched or crossed)");
801
+ } else {
802
+ const r = view.slots.resident;
803
+ deps.stdout(`slots: resident ${r ? `${r.worldId} (${r.namespace === null ? "bare" : `ns ${r.namespace}`}, ${r.entities.length} entities)` : "none"}`);
804
+ for (const slot of view.slots.foreign) {
805
+ deps.stdout(` foreign ${slot.worldId} (ns ${slot.namespace ?? "bare"}, ${slot.entities.length} entities)`);
806
+ }
807
+ }
808
+ return 0;
809
+ }
810
+ case "prefetch": {
811
+ if (!lib.prefetch) {
812
+ deps.stderr("spawn client prefetch: this session library predates the crossing seam");
813
+ return 1;
814
+ }
815
+ const worldId = flags["--world"];
816
+ const manifest = flags["--manifest"];
817
+ const worldHex = flags["--hex"];
818
+ if (!worldId || !manifest || !worldHex) {
819
+ deps.stderr("spawn client prefetch: needs --world <id> --manifest <hash> --hex <32hex>");
820
+ return 2;
821
+ }
822
+ const outcome = await lib.prefetch({ worldId, manifest, worldHex }, sessionName);
823
+ (outcome.ok ? deps.stdout : deps.stderr)(outcome.note);
824
+ return outcome.ok ? 0 : 1;
825
+ }
826
+ case "screenshot": {
827
+ const out = flags["--out"] || `spawn-client-screenshot-${Date.now()}.png`;
828
+ const absOut = path2.resolve(deps.cwd ?? process.cwd(), out);
829
+ const shot = await lib.screenshot(absOut, sessionName);
830
+ deps.stdout(`screenshot [${shot.tier}]: ${shot.outPath}`);
831
+ deps.stdout(`label: ${shot.label}`);
832
+ if (shot.note)
833
+ deps.stdout(shot.note);
834
+ return 0;
835
+ }
836
+ case "leave": {
837
+ const outcome = await lib.leave(sessionName);
838
+ (outcome.exited ? deps.stdout : deps.stderr)(`left "${outcome.name}": ${outcome.note}`);
839
+ return outcome.exited ? 0 : 1;
840
+ }
841
+ case "status": {
842
+ const { sessions, reaped } = await lib.status();
843
+ if (sessions.length === 0)
844
+ deps.stdout("no live client sessions");
845
+ for (const row of sessions) {
846
+ deps.stdout(`${row.name}: pid ${row.pid} ${row.worldAddress ? `world ${row.worldAddress} (${row.roomId})` : `room ${row.roomId}`} phase ${row.phase ?? "unreachable"} entity ${row.entityId ?? "?"} ttl ${row.ttlRemainingS !== null ? `${row.ttlRemainingS}s` : "none"} "${row.playerName}"${row.handle ? ` [${row.handle}]` : ""}`);
847
+ }
848
+ for (const line of reaped)
849
+ deps.stdout(`reaped: ${line}`);
850
+ return 0;
851
+ }
852
+ default:
853
+ deps.stderr(`spawn client: unknown verb "${verb}"
854
+
855
+ ${CLIENT_USAGE}`);
856
+ return 2;
857
+ }
858
+ } catch (error) {
859
+ const message = error instanceof Error ? error.message : String(error);
860
+ if (ownSession && !flags["--name"] && message.startsWith(`no session named "${ownSession}"`)) {
861
+ deps.stderr(`spawn client ${verb}: your session "${ownSession}" has ended (a recycle or expiry). It re-embodies on its own \u2014 wait a beat and retry. Never drive another agent's session in the meantime.`);
862
+ return 1;
863
+ }
864
+ deps.stderr(`spawn client ${verb}: ${message}`);
865
+ return 1;
866
+ }
867
+ }
868
+ function sessionScriptsDir(env) {
869
+ const explicit = env.SPAWN_CLIENT_SESSION_DIR?.trim();
870
+ if (explicit)
871
+ return explicit;
872
+ const home = env.HOME?.trim();
873
+ return home ? path2.join(home, ".spawn", "client-sessions") : path2.join(tmpdir(), "spawn-client-sessions");
874
+ }
875
+ function makeClient(deps) {
876
+ const apiOrigin = deps.env.SPAWN_WORKSHOP_API;
877
+ const token = deps.env.SPAWN_WORKSHOP_TOKEN;
878
+ if (!apiOrigin || !token) {
879
+ deps.stderr("spawn: SPAWN_WORKSHOP_API / SPAWN_WORKSHOP_TOKEN not set \u2014 the project bridge is unavailable in this shell");
880
+ return null;
881
+ }
882
+ return new IoClient({
883
+ apiOrigin,
884
+ token,
885
+ fetchImpl: deps.fetchImpl
886
+ });
887
+ }
888
+ async function upload(args, deps) {
889
+ const { positional, flags } = parseArgs(args, ["--name"]);
890
+ const file = positional[0];
891
+ if (!file) {
892
+ deps.stderr("spawn upload: missing <file>");
893
+ return 2;
894
+ }
895
+ const abs = path2.resolve(deps.cwd ?? process.cwd(), file);
896
+ let size;
897
+ try {
898
+ const st = await stat2(abs);
899
+ if (!st.isFile()) {
900
+ deps.stderr(`spawn upload: not a file: ${file}`);
901
+ return 1;
902
+ }
903
+ size = st.size;
904
+ } catch {
905
+ deps.stderr(`spawn upload: no such file: ${file}`);
906
+ return 1;
907
+ }
908
+ const cap = deps.uploadCapBytes ?? UPLOAD_CAP_BYTES;
909
+ if (size > cap) {
910
+ deps.stderr(`spawn upload: ${file} is ${formatBytes(size)} \u2014 over the ${formatBytes(cap)} upload cap`);
911
+ return 1;
912
+ }
913
+ const client2 = makeClient(deps);
914
+ if (!client2)
915
+ return 1;
916
+ const filename = flags["--name"] ?? path2.basename(abs);
917
+ let grant;
918
+ try {
919
+ grant = await client2.ingest({
920
+ filename,
921
+ content_type: contentTypeFor(abs),
922
+ size
923
+ });
924
+ } catch (error) {
925
+ if (error instanceof IoApiError && error.code === "INGEST_NOT_CONFIGURED") {
926
+ deps.stderr("spawn upload: asset ingest is not configured for this environment yet \u2014 the file is still in this workspace, but it cannot become a project asset from here");
927
+ return 1;
928
+ }
929
+ deps.stderr(`spawn upload: ingest failed: ${String(error)}`);
930
+ return 1;
931
+ }
932
+ const put = await (deps.fetchImpl ?? fetch)(grant.put_url, {
933
+ method: "PUT",
934
+ headers: { "content-type": contentTypeFor(abs) },
935
+ body: await readFile(abs)
936
+ });
937
+ if (!put.ok) {
938
+ const { code, message } = await readErrorBody(put);
939
+ deps.stderr(`spawn upload: byte upload failed (${code ?? put.status})${message ? `: ${message}` : ""}`);
940
+ return 1;
941
+ }
942
+ deps.stdout(`uploaded: ${grant.asset_url}`);
943
+ return 0;
944
+ }
945
+ async function fetchCommand(args, deps) {
946
+ const { positional } = parseArgs(args, []);
947
+ const source = positional[0];
948
+ if (!source) {
949
+ deps.stderr("spawn fetch: missing <url-or-cdn-ref>");
950
+ return 2;
951
+ }
952
+ if (/^https?:\/\//.test(source)) {
953
+ let dest2 = positional[1];
954
+ if (!dest2) {
955
+ const fromUrl = path2.basename(new URL(source).pathname);
956
+ dest2 = fromUrl.length > 0 ? fromUrl : source;
957
+ }
958
+ const absDest2 = path2.resolve(deps.cwd ?? process.cwd(), dest2);
959
+ const response2 = await (deps.fetchImpl ?? fetch)(source);
960
+ if (!response2.ok) {
961
+ deps.stderr(`spawn fetch: download failed (${response2.status}) for ${source}`);
962
+ return 1;
963
+ }
964
+ await Bun.write(absDest2, response2);
965
+ const st2 = await stat2(absDest2);
966
+ deps.stdout(`fetched: ${dest2} (${formatBytes(st2.size)})`);
967
+ return 0;
968
+ }
969
+ const reference = source.replace(/^\/+/, "").replace(/^cdn\//, "");
970
+ if (reference.length === 0) {
971
+ deps.stderr(`spawn fetch: not an asset reference: ${source}`);
972
+ return 2;
973
+ }
974
+ const client2 = makeClient(deps);
975
+ if (!client2)
976
+ return 1;
977
+ let response;
978
+ try {
979
+ response = await client2.fetchAsset(reference);
980
+ } catch (error) {
981
+ deps.stderr(`spawn fetch: asset door unreachable: ${String(error)}`);
982
+ return 1;
983
+ }
984
+ const displayName = path2.basename(referencePath(reference));
985
+ if (response.status === 202) {
986
+ deps.stderr(`spawn fetch: ${displayName} is still generating \u2014 magic assets bake asynchronously. ` + `Re-run this fetch in a few seconds if you need the bytes here; the live render shows the paint the moment it serves.`);
987
+ return 1;
988
+ }
989
+ if (!response.ok) {
990
+ const { code, message } = await readErrorBody(response);
991
+ if (code === "NOT_FOUND") {
992
+ deps.stderr(`spawn fetch: this environment's worker has no asset door yet \u2014 pass the full https asset URL instead`);
993
+ return 1;
994
+ }
995
+ if (code === "ASSET_NOT_SERVABLE") {
996
+ deps.stderr(`spawn fetch: ${displayName} is not servable \u2014 ${message ?? `the CDN refused it (${response.status})`}. Check the reference against the spec.`);
997
+ return 1;
998
+ }
999
+ deps.stderr(`spawn fetch: asset fetch failed (${code ?? response.status})${message ? `: ${message}` : ""}`);
1000
+ return 1;
1001
+ }
1002
+ const dest = positional[1] ?? displayName;
1003
+ const absDest = path2.resolve(deps.cwd ?? process.cwd(), dest);
1004
+ await Bun.write(absDest, response);
1005
+ const st = await stat2(absDest);
1006
+ deps.stdout(`fetched: ${dest} (${formatBytes(st.size)})`);
1007
+ return 0;
1008
+ }
1009
+ var ATTACHMENTS_USAGE = `spawn attachments \u2014 the creator-shared reference images from this game's chat
1010
+
1011
+ list what the creator shared: id, mime, when, the upload pipeline's summary
1012
+ download <id> [dest] write one into the workspace (images only for now)
1013
+
1014
+ To SEE one with model eyes, read_url its "/cdn/<id>" reference \u2014 the list
1015
+ prints the exact call. Entries marked url-only can be downloaded here but
1016
+ have no /cdn/ reference to view.
1017
+ `;
1018
+ async function attachments(args, deps) {
1019
+ const [verb, ...rest] = args;
1020
+ if (!verb || verb === "help" || verb === "--help") {
1021
+ deps.stdout(ATTACHMENTS_USAGE);
1022
+ return verb ? 0 : 2;
1023
+ }
1024
+ const client2 = makeClient(deps);
1025
+ if (!client2)
1026
+ return 1;
1027
+ switch (verb) {
1028
+ case "list":
1029
+ return attachmentsList(client2, deps);
1030
+ case "download":
1031
+ return attachmentsDownload(client2, rest, deps);
1032
+ default:
1033
+ deps.stderr(`spawn attachments: unknown verb "${verb}"
1034
+
1035
+ ${ATTACHMENTS_USAGE}`);
1036
+ return 2;
1037
+ }
1038
+ }
1039
+ async function attachmentsList(client2, deps) {
1040
+ let entries;
1041
+ try {
1042
+ entries = await client2.attachments();
1043
+ } catch (error) {
1044
+ deps.stderr(`spawn attachments list: ${describeAttachmentsError(error)}`);
1045
+ return 1;
1046
+ }
1047
+ if (entries.length === 0) {
1048
+ deps.stdout("no creator-shared reference images in this game's chat yet \u2014 images the creator uploads or pastes into the conversation land here");
1049
+ return 0;
1050
+ }
1051
+ deps.stdout(`attachments (${entries.length} creator-shared reference image${entries.length === 1 ? "" : "s"}, newest last):`);
1052
+ for (const entry of entries) {
1053
+ const when = Number.isFinite(entry.sharedAt) ? new Date(entry.sharedAt).toISOString() : "unknown-time";
1054
+ deps.stdout(` ${entry.id} ${entry.mime} shared ${when}`);
1055
+ if (entry.summary) {
1056
+ deps.stdout(` "${entry.summary}"`);
1057
+ }
1058
+ deps.stdout(entry.urlOnly ? ` download-only from this shell (no /cdn/ reference): spawn attachments download ${entry.id}` : ` see it (model eyes): read_url "/cdn/${entry.id}" \xB7 bytes: spawn attachments download ${entry.id}`);
1059
+ }
1060
+ return 0;
1061
+ }
1062
+ async function attachmentsDownload(client2, args, deps) {
1063
+ const { positional } = parseArgs(args, []);
1064
+ const id = positional[0];
1065
+ if (!id) {
1066
+ deps.stderr("spawn attachments download: missing <id> \u2014 `spawn attachments list` names them");
1067
+ return 2;
1068
+ }
1069
+ let entry;
1070
+ try {
1071
+ const entries = await client2.attachments();
1072
+ const bare = id.replace(/^\/?(?:cdn\/)?/i, "");
1073
+ entry = entries.find((candidate) => candidate.id === id || candidate.id === bare || candidate.magicCdnPath === `/cdn/${bare}`);
1074
+ } catch (error) {
1075
+ deps.stderr(`spawn attachments download: ${describeAttachmentsError(error)}`);
1076
+ return 1;
1077
+ }
1078
+ if (!entry) {
1079
+ deps.stderr(`spawn attachments download: "${id}" is not a creator-shared attachment in this game's chat \u2014 \`spawn attachments list\` names what exists`);
1080
+ return 1;
1081
+ }
1082
+ if (!entry.mime.startsWith("image/")) {
1083
+ deps.stderr(`spawn attachments download: "${entry.id}" reads as ${entry.mime} \u2014 attachments serve images only for now`);
1084
+ return 1;
1085
+ }
1086
+ let response;
1087
+ try {
1088
+ response = await client2.fetchAttachment(entry.id);
1089
+ } catch (error) {
1090
+ deps.stderr(`spawn attachments download: ${describeAttachmentsError(error)}`);
1091
+ return 1;
1092
+ }
1093
+ if (response.status === 202) {
1094
+ deps.stderr(`spawn attachments download: ${id} is still processing on the CDN \u2014 re-run this in a few seconds`);
1095
+ return 1;
1096
+ }
1097
+ if (!response.ok) {
1098
+ const { code, message } = await readErrorBody(response);
1099
+ deps.stderr(`spawn attachments download: failed (${code ?? response.status})${message ? `: ${message}` : ""}`);
1100
+ return 1;
1101
+ }
1102
+ const dest = positional[1] ?? entry.id;
1103
+ const absDest = path2.resolve(deps.cwd ?? process.cwd(), dest);
1104
+ await Bun.write(absDest, response);
1105
+ const st = await stat2(absDest);
1106
+ const mime = response.headers.get("content-type");
1107
+ deps.stdout(`downloaded: ${dest} (${formatBytes(st.size)}${mime ? `, ${mime}` : ""})`);
1108
+ if (!entry.urlOnly) {
1109
+ deps.stdout(`see it with model eyes: read_url "/cdn/${entry.id}"`);
1110
+ }
1111
+ return 0;
1112
+ }
1113
+ var REF_USAGE = `spawn ref \u2014 the engine's reference: its skills, whole
1114
+
1115
+ ls every skill, one line: id \u2014 what it is for
1116
+ read <skill> the whole skill (markdown) \u2014 read it BEFORE building in its territory
1117
+ grep <regex> which skill teaches a word (skill:line: text)
1118
+
1119
+ The corpus is the engine this world runs (ROOM_HOST_ENGINE_SEMVER); the engine source itself is
1120
+ not here \u2014 this lane serves what the engine teaches, not how it is built.
1121
+ `;
1122
+ async function ref(args, deps) {
1123
+ const [verb, ...rest] = args;
1124
+ if (!verb || verb === "help" || verb === "--help") {
1125
+ deps.stdout(REF_USAGE);
1126
+ return verb ? 0 : 2;
1127
+ }
1128
+ const client2 = makeClient(deps);
1129
+ if (!client2)
1130
+ return 1;
1131
+ const engine = deps.env.ROOM_HOST_ENGINE_SEMVER?.trim() || deps.env.ROOM_HOST_ENGINE_VERSION?.trim() || null;
1132
+ try {
1133
+ switch (verb) {
1134
+ case "ls": {
1135
+ const { skills: rows, corpus } = await client2.refSkills(engine);
1136
+ if (rows.length === 0) {
1137
+ deps.stdout("no skills in this engine's reference");
1138
+ return 0;
1139
+ }
1140
+ const width = Math.min(28, Math.max(...rows.map((r) => r.id.length)));
1141
+ for (const row of rows)
1142
+ deps.stdout(`${row.id.padEnd(width)} ${row.hook}`);
1143
+ deps.stdout(`
1144
+ ${rows.length} skills \u2014 \`spawn ref read <skill>\` for the whole of one`);
1145
+ deps.stdout(describeRefCorpus(corpus, engine));
1146
+ return 0;
1147
+ }
1148
+ case "read": {
1149
+ const id = rest[0]?.trim();
1150
+ if (!id) {
1151
+ deps.stderr("spawn ref read: which skill? \u2014 `spawn ref ls` names them");
1152
+ return 2;
1153
+ }
1154
+ const response = await client2.refSkillStream(id, engine);
1155
+ if (deps.stdoutStream && response.body) {
1156
+ const reader = response.body.getReader();
1157
+ for (;; ) {
1158
+ const { done, value } = await reader.read();
1159
+ if (done)
1160
+ break;
1161
+ if (value)
1162
+ await deps.stdoutStream(value);
1163
+ }
1164
+ return 0;
1165
+ }
1166
+ deps.stdout(await response.text());
1167
+ return 0;
1168
+ }
1169
+ case "grep": {
1170
+ const pattern = rest.join(" ").trim();
1171
+ if (!pattern) {
1172
+ deps.stderr("spawn ref grep: a regex, please");
1173
+ return 2;
1174
+ }
1175
+ const { hits, truncated } = await client2.refGrep(pattern, engine);
1176
+ if (hits.length === 0) {
1177
+ deps.stdout(`no skill mentions /${pattern}/`);
1178
+ return 0;
1179
+ }
1180
+ for (const hit of hits)
1181
+ deps.stdout(`${hit.skill}:${hit.line}: ${hit.text}`);
1182
+ if (truncated)
1183
+ deps.stdout(`\u2026 more hits than shown \u2014 narrow the pattern`);
1184
+ return 0;
1185
+ }
1186
+ default:
1187
+ deps.stderr(`spawn ref: unknown verb "${verb}"
1188
+
1189
+ ${REF_USAGE}`);
1190
+ return 2;
1191
+ }
1192
+ } catch (error) {
1193
+ deps.stderr(`spawn ref ${verb}: ${error instanceof IoApiError ? error.detail ?? error.message : String(error)}`);
1194
+ return 1;
1195
+ }
1196
+ }
1197
+ function describeRefCorpus(corpus, engine) {
1198
+ if (!corpus)
1199
+ return `corpus: unknown (an older reference door \u2014 engine ${engine ?? "unset"})`;
1200
+ if (corpus.startsWith("versioned:")) {
1201
+ return `corpus: ${corpus} \u2014 the bundle this world's engine runs`;
1202
+ }
1203
+ if (corpus === "codebase") {
1204
+ return engine ? `corpus: codebase \u2014 the deploy's own tree, NOT a bundle for engine ${engine} (local dev, or no bundle published for that pin)` : "corpus: codebase \u2014 the deploy's own tree (no engine named in this box)";
1205
+ }
1206
+ return `corpus: ${corpus}`;
1207
+ }
1208
+ function describeAttachmentsError(error) {
1209
+ if (error instanceof IoApiError) {
1210
+ if (error.code === "WORKSHOP_NOT_CONFIGURED") {
1211
+ return "this environment has no chat-room lane for attachments yet";
1212
+ }
1213
+ return error.detail ?? error.message;
1214
+ }
1215
+ return String(error);
1216
+ }
1217
+ function referencePath(reference) {
1218
+ const queryIndex = reference.indexOf("?");
1219
+ return queryIndex === -1 ? reference : reference.slice(0, queryIndex);
1220
+ }
1221
+ async function readErrorBody(response) {
1222
+ try {
1223
+ const parsed = await response.json();
1224
+ return {
1225
+ code: typeof parsed.error === "string" ? parsed.error : null,
1226
+ message: typeof parsed.message === "string" ? parsed.message : null
1227
+ };
1228
+ } catch {
1229
+ return { code: null, message: null };
1230
+ }
1231
+ }
1232
+ async function ls(args, deps) {
1233
+ const { flags } = parseArgs(args, ["--assets", "--workspace"]);
1234
+ const assetsOnly = "--assets" in flags;
1235
+ const workspaceOnly = "--workspace" in flags;
1236
+ const both = !assetsOnly && !workspaceOnly;
1237
+ let exitCode = 0;
1238
+ if (assetsOnly || both) {
1239
+ const client2 = makeClient(deps);
1240
+ if (client2) {
1241
+ try {
1242
+ const assets = await client2.assets();
1243
+ deps.stdout(`assets (${assets.length}):`);
1244
+ for (const asset of assets) {
1245
+ deps.stdout(` ${asset.id} ${asset.kind ?? "-"} ${asset.name ?? "-"} ${asset.url}`);
1246
+ }
1247
+ } catch (error) {
1248
+ deps.stderr(`spawn ls: asset list failed: ${String(error)}`);
1249
+ exitCode = 1;
1250
+ }
1251
+ } else if (assetsOnly) {
1252
+ return 1;
1253
+ }
1254
+ }
1255
+ if (workspaceOnly || both) {
1256
+ const root = deps.env.SPAWN_WORKSPACE_ROOT ?? "/workspace";
1257
+ if (!existsSync(root)) {
1258
+ deps.stdout(`workspace (${root}): empty`);
1259
+ return exitCode;
1260
+ }
1261
+ const snapshot = await walkWorkspace(root);
1262
+ const files = Object.keys(snapshot).sort();
1263
+ const total = files.reduce((sum, file) => sum + snapshot[file].size, 0);
1264
+ deps.stdout(`workspace (${files.length} files, ${formatBytes(total)}):`);
1265
+ for (const file of files) {
1266
+ deps.stdout(` ${formatBytes(snapshot[file].size).padStart(10)} ${file}`);
1267
+ }
1268
+ }
1269
+ return exitCode;
1270
+ }
1271
+ async function status(deps) {
1272
+ const statusPath = deps.env.SPAWN_STATUS_FILE ?? STATUS_FILE_PATH;
1273
+ let parsed;
1274
+ try {
1275
+ parsed = JSON.parse(await readFile(statusPath, "utf8"));
1276
+ } catch {
1277
+ deps.stderr("spawn status: no status yet (the workshop server has not written one)");
1278
+ return 1;
1279
+ }
1280
+ const uptimeS = Math.max(0, Math.floor((Date.now() - Date.parse(parsed.started_at)) / 1000));
1281
+ deps.stdout(`app: ${parsed.app_id ?? "unknown"}`);
1282
+ deps.stdout(`workspace: ${formatBytes(parsed.workspace_bytes)} of ${formatBytes(parsed.quota_soft_bytes)} soft quota (hard cap ${formatBytes(parsed.quota_hard_bytes)}), ${parsed.workspace_files} files`);
1283
+ deps.stdout(`uptime: ${formatDuration(uptimeS)}`);
1284
+ if (parsed.budget !== null && parsed.budget !== undefined) {
1285
+ deps.stdout(`budget: ${JSON.stringify(parsed.budget)}`);
1286
+ }
1287
+ return 0;
1288
+ }
1289
+ var VALUE_FLAGS = new Set([
1290
+ "-n",
1291
+ "-e",
1292
+ "--name",
1293
+ "--room",
1294
+ "--as",
1295
+ "--body",
1296
+ "--materials-json",
1297
+ "--ttl",
1298
+ "--ready-timeout",
1299
+ "--to",
1300
+ "--speed",
1301
+ "--timeout",
1302
+ "--at",
1303
+ "--out",
1304
+ "--world",
1305
+ "--manifest",
1306
+ "--hex",
1307
+ "--actor",
1308
+ "--origin",
1309
+ "--door",
1310
+ "--engine"
1311
+ ]);
1312
+ function parseArgs(args, known) {
1313
+ const positional = [];
1314
+ const flags = {};
1315
+ for (let i = 0;i < args.length; i += 1) {
1316
+ const arg = args[i];
1317
+ if (known.includes(arg)) {
1318
+ const next = args[i + 1];
1319
+ if (arg.startsWith("-") && next !== undefined && !next.startsWith("--") && VALUE_FLAGS.has(arg)) {
1320
+ flags[arg] = next;
1321
+ i += 1;
1322
+ continue;
1323
+ }
1324
+ flags[arg] = "";
1325
+ } else {
1326
+ positional.push(arg);
1327
+ }
1328
+ }
1329
+ return { positional, flags };
1330
+ }
1331
+ var CONTENT_TYPES = {
1332
+ ".png": "image/png",
1333
+ ".jpg": "image/jpeg",
1334
+ ".jpeg": "image/jpeg",
1335
+ ".gif": "image/gif",
1336
+ ".webp": "image/webp",
1337
+ ".svg": "image/svg+xml",
1338
+ ".glb": "model/gltf-binary",
1339
+ ".gltf": "model/gltf+json",
1340
+ ".mp4": "video/mp4",
1341
+ ".webm": "video/webm",
1342
+ ".mp3": "audio/mpeg",
1343
+ ".wav": "audio/wav",
1344
+ ".ogg": "audio/ogg",
1345
+ ".json": "application/json",
1346
+ ".md": "text/markdown",
1347
+ ".txt": "text/plain",
1348
+ ".pdf": "application/pdf"
1349
+ };
1350
+ function contentTypeFor(filePath) {
1351
+ return CONTENT_TYPES[path2.extname(filePath).toLowerCase()] ?? "application/octet-stream";
1352
+ }
1353
+ function formatBytes(bytes) {
1354
+ if (bytes < 1024)
1355
+ return `${bytes}B`;
1356
+ if (bytes < 1048576)
1357
+ return `${(bytes / 1024).toFixed(1)}KB`;
1358
+ if (bytes < 1073741824)
1359
+ return `${(bytes / 1048576).toFixed(1)}MB`;
1360
+ return `${(bytes / 1073741824).toFixed(2)}GB`;
1361
+ }
1362
+ function formatDuration(totalSeconds) {
1363
+ const hours = Math.floor(totalSeconds / 3600);
1364
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
1365
+ const seconds = totalSeconds % 60;
1366
+ if (hours > 0)
1367
+ return `${hours}h${minutes}m`;
1368
+ if (minutes > 0)
1369
+ return `${minutes}m${seconds}s`;
1370
+ return `${seconds}s`;
1371
+ }
1372
+
1373
+ // src/bin.ts
1374
+ var here = path3.dirname(fileURLToPath(import.meta.url));
1375
+ var sessionLib = path3.join(here, "session-lib.mjs");
1376
+ var shellEntry = path3.join(here, "main.mjs");
1377
+ if (!process.env.SPAWN_CLIENT_SESSION_LIB && existsSync2(sessionLib))
1378
+ process.env.SPAWN_CLIENT_SESSION_LIB = sessionLib;
1379
+ if (!process.env.SPAWN_CLIENT_SHELL_ENTRY && existsSync2(shellEntry))
1380
+ process.env.SPAWN_CLIENT_SHELL_ENTRY = shellEntry;
1381
+ var code = await runCli(process.argv.slice(2), {
1382
+ env: process.env,
1383
+ stdout: (line) => console.log(line),
1384
+ stderr: (line) => console.error(line),
1385
+ stdoutStream: (chunk) => new Promise((resolve, reject) => {
1386
+ process.stdout.write(chunk, (error) => error ? reject(error) : resolve());
1387
+ })
1388
+ });
1389
+ process.exit(code);