castle-web-cli 0.4.168 → 0.4.170

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/agent-prompts.js +17 -0
  2. package/dist/agent.js +23 -13
  3. package/dist/atomicFile.d.ts +29 -0
  4. package/dist/atomicFile.js +120 -0
  5. package/dist/castle-host/host.js +37 -0
  6. package/dist/get-deck.js +2 -11
  7. package/dist/headlessCover.d.ts +4 -0
  8. package/dist/headlessCover.js +7 -5
  9. package/dist/ide.js +14 -6
  10. package/dist/index.js +10 -6
  11. package/dist/init.d.ts +1 -1
  12. package/dist/init.js +11 -1
  13. package/dist/remix.js +2 -14
  14. package/dist/save-deck.d.ts +3 -0
  15. package/dist/save-deck.js +51 -74
  16. package/dist/saveCover.d.ts +18 -0
  17. package/dist/saveCover.js +61 -0
  18. package/dist/serve.js +18 -5
  19. package/dist/serveSecurity.d.ts +11 -1
  20. package/dist/serveSecurity.js +38 -4
  21. package/dist/shell/assets/index-BvQmVwlO.css +1 -0
  22. package/dist/shell/assets/index-CV5sBby1.js +445 -0
  23. package/dist/shell/index.html +2 -2
  24. package/dist/versionStore.js +13 -4
  25. package/kits/base/CLAUDE.md +9 -0
  26. package/kits/base/castle.json +16 -11
  27. package/kits/base/sdk/README.md +66 -1
  28. package/kits/base/sdk/commands.d.ts +23 -1
  29. package/kits/base/sdk/commands.js +9 -0
  30. package/kits/base/sdk/lifecycle.d.ts +20 -0
  31. package/kits/base/sdk/lifecycle.js +7 -1
  32. package/kits/base/sdk/resumeState.d.ts +2 -0
  33. package/kits/base/sdk/resumeState.js +84 -0
  34. package/kits/base/sdk/runtime.js +65 -0
  35. package/kits/base/sdk/transport.d.ts +3 -0
  36. package/kits/base/sdk/transport.js +39 -5
  37. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  38. package/kits/base/sdk/unloadHandshake.js +47 -0
  39. package/kits/multiplayer-2d/castle.json +3 -3
  40. package/kits/multiplayer-2d/package-lock.json +0 -27
  41. package/kits/multiplayer-2d/package.json +0 -1
  42. package/kits/multiplayer-3d/castle.json +4 -4
  43. package/kits/multiplayer-3d/package-lock.json +0 -27
  44. package/kits/multiplayer-3d/package.json +0 -1
  45. package/kits/physics-2d/CLAUDE.md +75 -43
  46. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  47. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  48. package/kits/physics-2d/castle.json +6 -6
  49. package/kits/physics-2d/editors/PxArtEditor.jsx +83 -180
  50. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  51. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  52. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  53. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  54. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  55. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  56. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  57. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  58. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  59. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  60. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  61. package/kits/physics-2d/engine/icons.js +56 -0
  62. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  63. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  64. package/kits/physics-2d/engine/physics/controls.js +76 -12
  65. package/kits/physics-2d/engine/scene.js +47 -9
  66. package/kits/physics-2d/engine/ui.jsx +13 -125
  67. package/kits/physics-2d/engine/ui.module.css +53 -28
  68. package/kits/physics-2d/package-lock.json +0 -29
  69. package/kits/physics-2d/package.json +3 -1
  70. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  71. package/kits/physics-3d/castle.json +5 -5
  72. package/kits/physics-3d/package-lock.json +0 -29
  73. package/kits/physics-3d/package.json +0 -1
  74. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  75. package/kits/turn-based/castle.json +3 -3
  76. package/kits/turn-based/package-lock.json +0 -27
  77. package/kits/turn-based/package.json +0 -1
  78. package/package.json +5 -4
  79. package/dist/shell/assets/index-CVHj503j.css +0 -1
  80. package/dist/shell/assets/index-D-qJ5VGp.js +0 -425
  81. package/kits/blank/.castle/agent/children.json +0 -1
  82. package/kits/blank/.castle/agent/messages.json +0 -9
  83. package/kits/blank/.castle/agent/pending-sends.json +0 -1
  84. package/kits/blank/.castle/agent/tasks/index.md +0 -2
  85. package/kits/blank/.castle/docs/platform.md +0 -36
  86. package/kits/blank/node_modules/.vite/deps/_metadata.json +0 -8
  87. package/kits/blank/node_modules/.vite/deps/package.json +0 -3
@@ -102,6 +102,7 @@ const PLAN_RULES = `Reading the user, fresh from EACH message -- the same user h
102
102
  - CLEAR SPEC (mechanics named, a reference given, "like X but Y"): the gaps are closed. Build what they said; never probe taste they already expressed.
103
103
  - FRAGMENT ("something with ants", "a cozy game about tea?"): gaps they cannot name yet. Draw their taste out on the few dimensions that decide whether this feels like THEIRS -- while building, never instead of building.
104
104
  - A fragment is not only a whole-game ask. "how about some obstacles?", "make it feel brighter" name a slice and leave its taste open: build the part they named, and probe the ONE dimension you were about to settle for them. Deciding it silently is how a deck becomes yours instead of theirs.
105
+ - A build turn that was going to carry NO question at all is where a feel question belongs, and on a deck already underway most turns are that: they name a change, you make it, nothing is asked, and the feel of it was yours by default. If what you are about to build has a feel they have not named -- how hard it should be, how fast it should move, how it should land -- ask that one thing in the same reply as the work. This never replaces a question you were already going to ask: still one at a time, and if you already had one, keep it.
105
106
  - DELEGATION ("surprise me", "you decide"): the open calls are yours. Decide; probe nothing.
106
107
  - Praise, a question, plain conversation: answer it and do NOTHING else -- no tasks, no probe, no chips, no plan fence. Never manufacture work to seem engaged.
107
108
 
@@ -339,11 +340,15 @@ function takeSource(text) {
339
340
  }
340
341
  const QUOTE_GLYPHS = /["'‘’‚‛′“”„‟″]/g;
341
342
  const DASH_GLYPHS = /[‐-―−]/g;
343
+ const BACKSLASH_ESCAPE = /\\(?=[^\\])/g;
344
+ const DASH_RUN = /-{2,}/g;
342
345
  const EDGE_PUNCT = /^['.,;:!?()[\]{}\s-]+|['.,;:!?()[\]{}\s-]+$/g;
343
346
  function normalizeQuote(text) {
344
347
  return text
348
+ .replace(BACKSLASH_ESCAPE, "")
345
349
  .replace(QUOTE_GLYPHS, "'")
346
350
  .replace(DASH_GLYPHS, "-")
351
+ .replace(DASH_RUN, "-")
347
352
  .toLowerCase()
348
353
  .replace(/\s+/g, " ")
349
354
  .replace(EDGE_PUNCT, "");
@@ -354,6 +359,18 @@ function normalizeQuote(text) {
354
359
  // whitespace, and failing a citation over a smart apostrophe would teach the
355
360
  // router to stop quoting. The words and their order have to match exactly.
356
361
  //
362
+ // Two transcription habits cost real citations before they were normalized
363
+ // here, both measured 26-09-06 over every stored t1 run (17 of 17 routed taste
364
+ // ops recovered, 0 genuine fabrications among them):
365
+ // - The router writes `--` because the whole prompt is written in `--`, where
366
+ // the user typed an em dash. Mapping one glyph to one hyphen is not enough;
367
+ // the RUN has to collapse.
368
+ // - It escapes inner quotes -- ("Rename the game to \"Cube-Tac\"") -- when the
369
+ // words it is quoting contain quotes, which is what naming a thing is. Both
370
+ // of Eric's dogfood decks lost their game's NAME to this: the decision was
371
+ // filed as a proposal and asked back at him a few turns later.
372
+ // Neither can make a false citation match: the words still have to be there.
373
+ //
357
374
  // Exported so the eval harness checks a fence with the byte-identical function
358
375
  // the runtime applies it with.
359
376
  export function quoteMatches(fragment, sourceText) {
package/dist/agent.js CHANGED
@@ -24,6 +24,7 @@ import * as path from 'path';
24
24
  import { nanoid } from 'nanoid';
25
25
  import { WebSocketServer } from 'ws';
26
26
  import { rawDataToString } from './rawData.js';
27
+ import { atomicWriteFileSync } from './atomicFile.js';
27
28
  import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
28
29
  import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
29
30
  import { readCastleJson } from './castleJson.js';
@@ -2218,7 +2219,7 @@ async function runAgentTurn(opts) {
2218
2219
  }
2219
2220
  // -- task store ---------------------------------------------------------------
2220
2221
  function persistTaskFile(tasksDir, task) {
2221
- fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
2222
+ atomicWriteFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
2222
2223
  }
2223
2224
  // The task index: one row per task this deck has ever spawned, addressed by the
2224
2225
  // plan item it advanced. It is the complete address book (step 2 searches it by
@@ -2261,18 +2262,17 @@ function writeTaskIndex(tasksDir, tasks) {
2261
2262
  catch {
2262
2263
  /* no index yet -- writing it below is the change */
2263
2264
  }
2264
- const tmp = `${file}.tmp`;
2265
2265
  try {
2266
2266
  // Written aside and renamed: a running task agent may be reading this file
2267
2267
  // at any moment, and a torn read is a claims board with rows missing.
2268
- fs.writeFileSync(tmp, body);
2269
- fs.renameSync(tmp, file);
2268
+ atomicWriteFileSync(file, body);
2270
2269
  }
2271
2270
  catch (err) {
2272
2271
  console.error(`[tasks] could not write ${TASK_INDEX_FILE}: ${err instanceof Error ? err.message : String(err)}`);
2273
2272
  }
2274
2273
  }
2275
- // Tasks left "running" by a dead serve are as finished as they will get. A
2274
+ // Tasks left "running" or "waiting" by a dead serve are as finished as they
2275
+ // will get. They are interrupted rather than scheduled again on boot. A
2276
2276
  // persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
2277
2277
  // never reconsiders it and it can't wedge or auto-start; it just sits on the
2278
2278
  // board (blockedBy intact) until the router stops it, same as before restart.
@@ -2282,7 +2282,7 @@ function loadTasks(tasksDir) {
2282
2282
  const rec = readJsonFile(path.join(tasksDir, entry, 'task.json'));
2283
2283
  if (!rec)
2284
2284
  continue;
2285
- if (rec.status === 'running') {
2285
+ if (rec.status === 'running' || rec.status === 'waiting') {
2286
2286
  rec.status = 'interrupted';
2287
2287
  rec.updatedAt = nowIso();
2288
2288
  persistTaskFile(tasksDir, rec);
@@ -2624,9 +2624,9 @@ async function runTaskAgentIn(ctx, task) {
2624
2624
  // (by skipping the slot free) nor crash the serve.
2625
2625
  function startTask(ctx, task) {
2626
2626
  const dir = path.join(ctx.tasksDir, task.id);
2627
- fs.writeFileSync(path.join(dir, 'progress'), '0\n');
2627
+ atomicWriteFileSync(path.join(dir, 'progress'), '0\n');
2628
2628
  if (!fs.existsSync(path.join(dir, 'notes.md')))
2629
- fs.writeFileSync(path.join(dir, 'notes.md'), '');
2629
+ atomicWriteFileSync(path.join(dir, 'notes.md'), '');
2630
2630
  task.status = 'running';
2631
2631
  task.startedAt = nowIso();
2632
2632
  ctx.touch(task);
@@ -3001,7 +3001,7 @@ function saveAttachments(attachmentsDir, messageId, images) {
3001
3001
  const fileName = `${messageId}-${index}.${ext}`;
3002
3002
  try {
3003
3003
  fs.mkdirSync(attachmentsDir, { recursive: true });
3004
- fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
3004
+ atomicWriteFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
3005
3005
  saved.push(fileName);
3006
3006
  }
3007
3007
  catch {
@@ -3148,7 +3148,7 @@ function createMessageLog(messagesPath, broadcast, welcomeMessage, forClient) {
3148
3148
  .filter((m) => m.text.trim() !== '' || m.role === 'user')
3149
3149
  .map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : m));
3150
3150
  function persist() {
3151
- fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
3151
+ atomicWriteFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
3152
3152
  }
3153
3153
  if (messages.length === 0) {
3154
3154
  messages.push({
@@ -3624,7 +3624,7 @@ function applyAgentSettings(incoming, ctx) {
3624
3624
  }
3625
3625
  if (changes.length === 0)
3626
3626
  return;
3627
- fs.writeFileSync(ctx.settingsPath, serializeAgentSettings(settings));
3627
+ atomicWriteFileSync(ctx.settingsPath, serializeAgentSettings(settings));
3628
3628
  // The value is saved and broadcast IMMEDIATELY -- validation never gates a
3629
3629
  // write. The verdict follows in a second frame once the catalog answers.
3630
3630
  ctx.broadcast({ type: 'settings', settings });
@@ -3860,7 +3860,7 @@ function reclaimInFlightSends(pendingSends, inFlightSends) {
3860
3860
  // Mirror the in-memory queue to disk. Called on every mutation (enqueue,
3861
3861
  // drain, cancel, recover) so a restart never loses an unsent queued message.
3862
3862
  function persistPendingSends(ctx) {
3863
- fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
3863
+ atomicWriteFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
3864
3864
  }
3865
3865
  function computeQueuedSnippets(pendingSends) {
3866
3866
  return pendingSends.map((p) => p.text.trim()).filter(Boolean);
@@ -4028,7 +4028,17 @@ function handleQueueUserMessage(ctx, text, images, clientId) {
4028
4028
  // The id is the SENDER's when it supplied one: the client shows the message
4029
4029
  // the moment you hit send, and reusing that row's id means the echo lands on
4030
4030
  // the same row rather than beside it as a duplicate.
4031
- const id = safeClientMessageId(clientId) ?? nanoid(8);
4031
+ const sentId = safeClientMessageId(clientId);
4032
+ // seenClientIds dies with the process; the client's stored copy of an
4033
+ // unacknowledged message does not, so a resend can outlive a restart and
4034
+ // arrive at a serve that never witnessed the first delivery. The log and
4035
+ // the queue did: either holding the id means this send already landed.
4036
+ if (sentId !== null &&
4037
+ (ctx.messages.some((m) => m.id === sentId) ||
4038
+ state.pendingSends.some((p) => p.id === sentId))) {
4039
+ return;
4040
+ }
4041
+ const id = sentId ?? nanoid(8);
4032
4042
  const attachments = saveAttachments(ctx.attachmentsDir, id, images);
4033
4043
  state.pendingSends.push({ id, text, attachments });
4034
4044
  persistPendingSends(ctx);
@@ -0,0 +1,29 @@
1
+ import * as fs from 'fs';
2
+ export interface AtomicFileOps {
3
+ closeSync(fd: number): void;
4
+ fchmodSync(fd: number, mode: number): void;
5
+ fsyncSync(fd: number): void;
6
+ linkSync(existingPath: string, newPath: string): void;
7
+ lstatSync(file: string): fs.Stats;
8
+ openSync(file: string, flags: number, mode?: number): number;
9
+ renameSync(oldPath: string, newPath: string): void;
10
+ unlinkSync(file: string): void;
11
+ writeFileSync(file: number, data: string | NodeJS.ArrayBufferView): void;
12
+ }
13
+ export interface AtomicWriteOptions {
14
+ /** Installed mode. Existing destinations keep their mode when omitted. */
15
+ mode?: number;
16
+ /** Install only when the destination does not already exist. */
17
+ exclusive?: boolean;
18
+ }
19
+ /** Reserved sibling temporary names that checkpoint capture must exclude. */
20
+ export declare const ATOMIC_TEMP_FILE_RE: RegExp;
21
+ /**
22
+ * Replace a regular file without exposing partial bytes to readers.
23
+ *
24
+ * The temporary is unique, exclusively created beside the destination, synced
25
+ * before rename, and cannot follow a pre-planted temporary symlink. The parent
26
+ * directory is synced after rename so the new name is durable.
27
+ */
28
+ export declare function createAtomicFileWriter(ops: AtomicFileOps): (file: string, content: string | NodeJS.ArrayBufferView, options?: AtomicWriteOptions) => void;
29
+ export declare const atomicWriteFileSync: (file: string, content: string | NodeJS.ArrayBufferView, options?: AtomicWriteOptions) => void;
@@ -0,0 +1,120 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ function lstatIfPresent(ops, file) {
5
+ try {
6
+ return ops.lstatSync(file);
7
+ }
8
+ catch (error) {
9
+ if (error.code === 'ENOENT')
10
+ return null;
11
+ throw error;
12
+ }
13
+ }
14
+ function checkedDestination(ops, file) {
15
+ const stat = lstatIfPresent(ops, file);
16
+ if (stat?.isSymbolicLink())
17
+ throw new Error(`Refusing to replace symlink: ${file}`);
18
+ return stat;
19
+ }
20
+ function uniqueTempPath(file) {
21
+ return path.join(path.dirname(file), `.castle-atomic-${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`);
22
+ }
23
+ /** Reserved sibling temporary names that checkpoint capture must exclude. */
24
+ export const ATOMIC_TEMP_FILE_RE = /^\.castle-atomic-[1-9]\d*-[0-9a-f]{24}\.tmp$/;
25
+ /**
26
+ * Replace a regular file without exposing partial bytes to readers.
27
+ *
28
+ * The temporary is unique, exclusively created beside the destination, synced
29
+ * before rename, and cannot follow a pre-planted temporary symlink. The parent
30
+ * directory is synced after rename so the new name is durable.
31
+ */
32
+ export function createAtomicFileWriter(ops) {
33
+ return function atomicWriteFileSync(file, content, options = {}) {
34
+ const existing = checkedDestination(ops, file);
35
+ if (existing && !existing.isFile())
36
+ throw new Error(`Refusing to replace non-file: ${file}`);
37
+ const mode = options.mode ?? (existing ? existing.mode & 0o777 : 0o666);
38
+ const setExactMode = options.mode !== undefined || existing !== null;
39
+ const temp = uniqueTempPath(file);
40
+ let tempExists = false;
41
+ let fd = null;
42
+ let directoryFd = null;
43
+ let failure;
44
+ try {
45
+ const flags = fs.constants.O_WRONLY |
46
+ fs.constants.O_CREAT |
47
+ fs.constants.O_EXCL |
48
+ (fs.constants.O_NOFOLLOW ?? 0);
49
+ fd = ops.openSync(temp, flags, mode);
50
+ tempExists = true;
51
+ ops.writeFileSync(fd, content);
52
+ // A new file with no requested mode keeps the mode produced by open +
53
+ // process umask. Replacements preserve their old mode, and explicit
54
+ // modes (the version store's 0444 files) are exact.
55
+ if (setExactMode)
56
+ ops.fchmodSync(fd, mode);
57
+ ops.fsyncSync(fd);
58
+ ops.closeSync(fd);
59
+ fd = null;
60
+ // Catch ordinary accidental symlink replacement. The containing
61
+ // directory must still be trusted against a hostile rename race.
62
+ checkedDestination(ops, file);
63
+ if (options.exclusive) {
64
+ // link(2) is the portable no-replace primitive: exactly one competing
65
+ // writer can install this name. The temporary is then removed while
66
+ // the installed hard link keeps the fully synced inode.
67
+ ops.linkSync(temp, file);
68
+ ops.unlinkSync(temp);
69
+ tempExists = false;
70
+ }
71
+ else {
72
+ ops.renameSync(temp, file);
73
+ tempExists = false;
74
+ }
75
+ // Windows does not permit opening directories this way. The rename is
76
+ // still atomic there, but only Unix deployments get directory-entry
77
+ // durability across a power loss.
78
+ if (process.platform !== 'win32') {
79
+ directoryFd = ops.openSync(path.dirname(file), fs.constants.O_RDONLY);
80
+ ops.fsyncSync(directoryFd);
81
+ ops.closeSync(directoryFd);
82
+ directoryFd = null;
83
+ }
84
+ }
85
+ catch (error) {
86
+ failure = error;
87
+ }
88
+ if (fd !== null) {
89
+ try {
90
+ ops.closeSync(fd);
91
+ }
92
+ catch (error) {
93
+ failure ??= error;
94
+ }
95
+ }
96
+ if (directoryFd !== null) {
97
+ try {
98
+ ops.closeSync(directoryFd);
99
+ }
100
+ catch (error) {
101
+ failure ??= error;
102
+ }
103
+ }
104
+ if (tempExists) {
105
+ try {
106
+ ops.unlinkSync(temp);
107
+ }
108
+ catch (error) {
109
+ if (error.code !== 'ENOENT')
110
+ failure ??= error;
111
+ }
112
+ }
113
+ if (failure) {
114
+ throw failure instanceof Error
115
+ ? failure
116
+ : new Error('Atomic file operation failed', { cause: failure });
117
+ }
118
+ };
119
+ }
120
+ export const atomicWriteFileSync = createAtomicFileWriter(fs);
@@ -39,6 +39,7 @@ const COMMAND_NAMES = [
39
39
  "cauldronStorage.boardSubmit",
40
40
  "cauldronStorage.boardTop",
41
41
  "cauldronStorage.boardGet",
42
+ "lifecycle.restoreState",
42
43
  ];
43
44
  // Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
44
45
  // to the host's optional platformHandler (mobile renders native UI; web shows an
@@ -50,6 +51,7 @@ const PLATFORM_COMMAND_NAMES = [
50
51
  "portal.open",
51
52
  "portal.prefetch",
52
53
  "haptics.play",
54
+ "lifecycle.restoreState",
53
55
  ];
54
56
  // Mirrors CASTLE_LEADERBOARD_PAYLOAD_VERSION in leaderboardPanel.ts. Duplicated
55
57
  // rather than imported because every import here has to stay type-only; bump
@@ -146,6 +148,7 @@ function runCommand(ctx, command, params, caps) {
146
148
  case "portal.open":
147
149
  case "portal.prefetch":
148
150
  case "haptics.play":
151
+ case "lifecycle.restoreState":
149
152
  return runPlatformCommand(ctx, command, params, caps);
150
153
  }
151
154
  }
@@ -208,6 +211,8 @@ async function runPlatformCommand(ctx, command, params, caps) {
208
211
  return portalPrefetch(ctx, params, caps);
209
212
  case "haptics.play":
210
213
  return hapticsPlay(ctx, params, caps);
214
+ case "lifecycle.restoreState":
215
+ return lifecycleRestoreState(ctx, caps);
211
216
  default:
212
217
  return unavailableOutcome();
213
218
  }
@@ -321,6 +326,38 @@ function normalizeHapticsOutcome(value) {
321
326
  }
322
327
  return { status: "unavailable" };
323
328
  }
329
+ // Hand back whatever the host kept for this deck the last time it unloaded it.
330
+ // Unlike pass/portal, an absent deckId is `unavailable` rather than a thrown
331
+ // MISSING_DECK_ID: a deck asking to resume must never have to catch — an unsaved
332
+ // deck, a host with no store, and a host with nothing kept all read the same.
333
+ async function lifecycleRestoreState(ctx, caps) {
334
+ if (!caps.platformHandler || !ctx.deckId)
335
+ return { status: "unavailable" };
336
+ const outcome = await caps.platformHandler("lifecycle.restoreState", { deckId: ctx.deckId }, ctx);
337
+ return normalizeRestoreStateOutcome(outcome);
338
+ }
339
+ function normalizeRestoreStateOutcome(value) {
340
+ const record = typeof value === "object" && value !== null
341
+ ? value
342
+ : {};
343
+ const status = record.status;
344
+ const valid = [
345
+ "restored",
346
+ "none",
347
+ "unavailable",
348
+ ];
349
+ if (typeof status !== "string" || !valid.includes(status)) {
350
+ return { status: "none" };
351
+ }
352
+ if (status !== "restored") {
353
+ return { status: status };
354
+ }
355
+ // A "restored" with no string state would reach the deck as a resume with
356
+ // nothing to resume from, which JSON.parse would then throw on.
357
+ return typeof record.state === "string"
358
+ ? { status: "restored", state: record.state }
359
+ : { status: "none" };
360
+ }
324
361
  function unavailableOutcome() {
325
362
  return { status: "unavailable" };
326
363
  }
package/dist/get-deck.js CHANGED
@@ -4,7 +4,7 @@ import * as path from 'path';
4
4
  import { nanoid } from 'nanoid';
5
5
  import * as api from './api.js';
6
6
  import { normalizeDeckPackageJson } from './normalize.js';
7
- import { archiveSource, runTar, SOURCE_ARCHIVE_EXCLUDES } from './save-deck.js';
7
+ import { archiveSource, downloadSourceArchive, runTar, SOURCE_ARCHIVE_EXCLUDES, } from './save-deck.js';
8
8
  import { readCastleJson } from './castleJson.js';
9
9
  // Refreshing a deck already in the target replaces its source, so a file deleted
10
10
  // upstream actually disappears here -- untarring over the old tree would leave it
@@ -87,16 +87,7 @@ export async function getDeck(dir, options = {}) {
87
87
  }
88
88
  }
89
89
  // Everything that can fail over the network happens before anything is deleted.
90
- console.log(`Fetching ${source.archiveUrl}`);
91
- const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
92
- if (!res.ok) {
93
- throw new Error(`Archive fetch failed: HTTP ${res.status}`);
94
- }
95
- const buf = Buffer.from(await res.arrayBuffer());
96
- const sizeKB = buf.length / 1024;
97
- console.log(`Archive: ${sizeKB.toFixed(1)}KB (updated ${source.updatedAt})`);
98
- const tmpFile = path.join(os.tmpdir(), `castle-get-${nanoid(8)}.tar.gz`);
99
- fs.writeFileSync(tmpFile, buf);
90
+ const tmpFile = await downloadSourceArchive(source, 'castle-get');
100
91
  try {
101
92
  if (refreshing) {
102
93
  // A corrupt archive discovered mid-extract would find the old source already
@@ -1,6 +1,7 @@
1
1
  import { type PlaytestBrowserManager } from "./native/playtest-browser.js";
2
2
  export interface HeadlessCoverResult {
3
3
  ok: boolean;
4
+ outcome?: 'succeeded' | 'timed_out' | 'capture_failed';
4
5
  outPath?: string;
5
6
  error?: string;
6
7
  installedMs?: number;
@@ -10,4 +11,7 @@ export declare function captureCoverHeadless(opts: {
10
11
  outPath: string;
11
12
  manager?: PlaytestBrowserManager;
12
13
  onProgress?: (message: string) => void;
14
+ /** Remaining caller budget after content-grant discovery; defaults to the fixed remote cap. */
15
+ remoteBudgetMs?: number;
16
+ signal?: AbortSignal;
13
17
  }): Promise<HeadlessCoverResult>;
@@ -54,7 +54,7 @@ const CARD_SELECTOR = "#castle-card, [data-castle-card]";
54
54
  export async function captureCoverHeadless(opts) {
55
55
  const remoteGrant = contentGrantFromBrowserUrl(opts.serveUrl);
56
56
  if (remoteGrant) {
57
- return captureCoverRemote(remoteGrant, opts.outPath);
57
+ return captureCoverRemote(remoteGrant, opts.outPath, opts.remoteBudgetMs ?? REMOTE_CAPTURE_BUDGET_MS, opts.signal);
58
58
  }
59
59
  // A manager we made is a manager we must close. `withBrowser` deliberately
60
60
  // leaves the browser RUNNING so the next call reuses it -- right for the
@@ -94,8 +94,9 @@ export async function captureCoverHeadless(opts) {
94
94
  await manager.shutdown().catch(() => undefined);
95
95
  }
96
96
  }
97
- async function captureCoverRemote(contentGrant, outPath) {
98
- const signal = AbortSignal.timeout(REMOTE_CAPTURE_BUDGET_MS);
97
+ async function captureCoverRemote(contentGrant, outPath, budgetMs, callerSignal) {
98
+ const budgetSignal = AbortSignal.timeout(Math.max(1, budgetMs));
99
+ const signal = callerSignal ? AbortSignal.any([budgetSignal, callerSignal]) : budgetSignal;
99
100
  try {
100
101
  const result = await api.runBrowserJob(contentGrant, { kind: 'cover' }, signal);
101
102
  const pngBase64 = result.pngBase64;
@@ -117,10 +118,11 @@ async function captureCoverRemote(contentGrant, outPath) {
117
118
  if (signal.aborted) {
118
119
  return {
119
120
  ok: false,
120
- error: `remote cover gave up after ${REMOTE_CAPTURE_BUDGET_MS / 1000}s`,
121
+ outcome: 'timed_out',
122
+ error: `remote cover gave up after ${budgetMs / 1000}s`,
121
123
  };
122
124
  }
123
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
125
+ return { ok: false, outcome: 'capture_failed', error: error instanceof Error ? error.message : String(error) };
124
126
  }
125
127
  }
126
128
  // Resolve with `work`, or with null if the budget runs out first. The loser is
package/dist/ide.js CHANGED
@@ -482,6 +482,15 @@ function handleVersionRestore(deckDir, req, res, restart) {
482
482
  }
483
483
  });
484
484
  }
485
+ // A curated deck hides anything not covered by editor.visiblePaths. Writes,
486
+ // moves, and uploads already mkdir parent dirs; they also have to register the
487
+ // dest folder or a new path lands on disk and stays invisible in Files.
488
+ function ensureFileParentVisible(deckDir, rel) {
489
+ const dir = path.posix.dirname(rel);
490
+ if (dir === '.')
491
+ return ensureVisibleGlob(deckDir, rel, rel);
492
+ return ensureVisiblePath(deckDir, dir);
493
+ }
485
494
  function handleFilesWrite(deckDir, req, res) {
486
495
  withMutationPath(deckDir, req, res, (target, body) => {
487
496
  if (typeof body.contents !== 'string') {
@@ -490,7 +499,8 @@ function handleFilesWrite(deckDir, req, res) {
490
499
  try {
491
500
  fs.mkdirSync(path.dirname(target.abs), { recursive: true });
492
501
  fs.writeFileSync(target.abs, body.contents, 'utf8');
493
- sendJson(res, 200, { ok: true, path: target.rel });
502
+ const visiblePathAdded = ensureFileParentVisible(deckDir, target.rel);
503
+ sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
494
504
  }
495
505
  catch (err) {
496
506
  sendFailure(res, 'write', target.rel, err);
@@ -571,10 +581,7 @@ function handleFilesUpload(deckDir, req, res) {
571
581
  catch (err) {
572
582
  return sendFailure(res, 'upload', target.rel, err);
573
583
  }
574
- const dir = path.posix.dirname(target.rel);
575
- const visiblePathAdded = dir === '.'
576
- ? ensureVisibleGlob(deckDir, target.rel, target.rel)
577
- : ensureVisiblePath(deckDir, dir);
584
+ const visiblePathAdded = ensureFileParentVisible(deckDir, target.rel);
578
585
  sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
579
586
  }, (err) => {
580
587
  if (err instanceof RangeError)
@@ -703,7 +710,8 @@ function handleFilesRename(deckDir, req, res) {
703
710
  try {
704
711
  fs.mkdirSync(path.dirname(to.abs), { recursive: true });
705
712
  fs.renameSync(from.abs, to.abs);
706
- sendJson(res, 200, { ok: true, path: to.rel });
713
+ const visiblePathAdded = ensureFileParentVisible(deckDir, to.rel);
714
+ sendJson(res, 200, { ok: true, path: to.rel, visiblePathAdded });
707
715
  }
708
716
  catch (err) {
709
717
  sendFailure(res, 'rename', from.rel, err);
package/dist/index.js CHANGED
@@ -62,6 +62,10 @@ function readPositionals() {
62
62
  }
63
63
  return out;
64
64
  }
65
+ function readImportFileArgs() {
66
+ const positionals = readPositionals();
67
+ return { deckDir: path.resolve(positionals[1] ?? '.'), relPosix: positionals[0] ?? '' };
68
+ }
65
69
  function getFlagValue(flag) {
66
70
  const idx = args.indexOf(flag);
67
71
  return idx >= 0 ? args[idx + 1] : undefined;
@@ -89,7 +93,7 @@ function getServeSocketPort(dir) {
89
93
  }
90
94
  function usage() {
91
95
  console.log(`Usage:
92
- castle-web init <dir> [--kit NAME] (kits: physics-2d (default), physics-3d, none)
96
+ castle-web init <dir> [--deck-id ID] [--card-id ID] [--kit NAME] (kits: physics-2d (default), physics-3d, none)
93
97
  castle-web init <dir> --remix <deckId> [--deck-id ID] [--card-id ID] (starts from a published deck instead of a kit)
94
98
  castle-web serve [dir] [--port PORT] [--host HOST] [--open] [--detach]
95
99
  castle-web restart [--port PORT]
@@ -130,7 +134,7 @@ async function main() {
130
134
  case 'init': {
131
135
  const dir = findPositionalDir();
132
136
  if (dir === '.') {
133
- console.error('Usage: castle-web init <dir> [--kit NAME | --remix DECK_ID]');
137
+ console.error('Usage: castle-web init <dir> [--deck-id ID] [--card-id ID] [--kit NAME | --remix DECK_ID]');
134
138
  process.exit(1);
135
139
  }
136
140
  const kit = getFlagValue('--kit');
@@ -177,8 +181,8 @@ async function main() {
177
181
  }
178
182
  case 'fork-import-file': {
179
183
  // `castle-web fork-import-file <imports/...> [dir]`
180
- const positionals = readPositionals();
181
- const result = forkImportFile(path.resolve(positionals[1] ?? '.'), positionals[0] ?? '');
184
+ const { deckDir, relPosix } = readImportFileArgs();
185
+ const result = forkImportFile(deckDir, relPosix);
182
186
  if (!result.ok) {
183
187
  console.error(result.error);
184
188
  process.exit(1);
@@ -187,8 +191,8 @@ async function main() {
187
191
  break;
188
192
  }
189
193
  case 'revert-import-file': {
190
- const positionals = readPositionals();
191
- const result = revertImportFile(path.resolve(positionals[1] ?? '.'), positionals[0] ?? '');
194
+ const { deckDir, relPosix } = readImportFileArgs();
195
+ const result = revertImportFile(deckDir, relPosix);
192
196
  if (!result.ok) {
193
197
  console.error(result.error);
194
198
  process.exit(1);
package/dist/init.d.ts CHANGED
@@ -19,7 +19,7 @@ export declare function init(dir: string, opts?: {
19
19
  serve?: boolean;
20
20
  /** Remix this published deck instead of starting from a kit. */
21
21
  remixOf?: string;
22
- /** The identity the server already minted for a remix. */
22
+ /** The identity the server already minted for this new deck. */
23
23
  deckId?: string;
24
24
  cardId?: string;
25
25
  }): Promise<void>;
package/dist/init.js CHANGED
@@ -55,7 +55,7 @@ if (!(await mountEditorRoute())) startGame(initCard());
55
55
  // Registry version of castle-web-sdk to inject when scaffolding from a
56
56
  // globally-installed castle-web (not from inside the workspace). Bumped
57
57
  // alongside cli/sdk version bumps.
58
- const PUBLISHED_SDK_VERSION = '0.4.26';
58
+ const PUBLISHED_SDK_VERSION = '0.4.27';
59
59
  // Range, not a pin: `castle-web-fonts` is its own package precisely so a tenth
60
60
  // face can ship without a cli release, and a caret range means an existing
61
61
  // deck's next install picks it up. Nothing here has to move when it does.
@@ -651,6 +651,16 @@ export async function init(dir, opts = {}) {
651
651
  else {
652
652
  scaffoldFromKitImport(kit, projectDir);
653
653
  }
654
+ if (!opts.remixOf && (opts.deckId || opts.cardId)) {
655
+ // Apply server identity before install/version 1, so retries and a restore
656
+ // to the original scaffold cannot allocate a second deck or card.
657
+ const configPath = path.join(projectDir, 'castle.json');
658
+ writeJsonFile(configPath, {
659
+ ...readJsonFile(configPath),
660
+ ...(opts.deckId ? { deckId: opts.deckId } : {}),
661
+ ...(opts.cardId ? { cardId: opts.cardId } : {}),
662
+ });
663
+ }
654
664
  if (!opts.remixOf) {
655
665
  console.log(`Created project in ${projectDir}/${bare ? '' : ` (from kit "${kit}")`}`);
656
666
  }
package/dist/remix.js CHANGED
@@ -1,9 +1,7 @@
1
1
  import * as fs from 'fs';
2
- import * as os from 'os';
3
2
  import * as path from 'path';
4
- import { nanoid } from 'nanoid';
5
3
  import * as api from './api.js';
6
- import { runTar } from './save-deck.js';
4
+ import { downloadSourceArchive, runTar } from './save-deck.js';
7
5
  // Scaffolding a deck from another published deck, for `init --remix`.
8
6
  // Dropped from the copied castle.json. `autoUpdateWhenImported` is the parent's
9
7
  // promise to ITS importers, which a remix hasn't made.
@@ -34,17 +32,7 @@ export async function fetchParentSource(parentDeckId) {
34
32
  console.error(`Deck ${parentDeckId} has no saved source to remix. Only decks saved by the web CLI can be remixed.`);
35
33
  process.exit(1);
36
34
  }
37
- console.log(`Fetching ${source.archiveUrl}`);
38
- const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
39
- if (!res.ok) {
40
- throw new Error(`Archive fetch failed: HTTP ${res.status}`);
41
- }
42
- const buf = Buffer.from(await res.arrayBuffer());
43
- console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
44
- // Read and untarred out of, never moved out of: in a sandbox the deck is on a
45
- // different filesystem from tmp, and a rename across that is EXDEV.
46
- const archivePath = path.join(os.tmpdir(), `castle-remix-${nanoid(8)}.tar.gz`);
47
- fs.writeFileSync(archivePath, buf);
35
+ const archivePath = await downloadSourceArchive(source, 'castle-remix');
48
36
  // `./castle.json`, because archiveSource packs with `-C <dir> .`.
49
37
  const text = await runTar(['-xzOf', archivePath, './castle.json'], { capture: true });
50
38
  return { archivePath, castleJson: parseJson(text) };
@@ -1,10 +1,13 @@
1
+ import * as api from './api.js';
1
2
  export declare const SOURCE_ARCHIVE_EXCLUDES: string[];
2
3
  export declare const SAVE_STAGE_TELEMETRY_PREFIX = "CASTLE_SAVE_TELEMETRY ";
4
+ export declare const SAVE_COVER_SELECTION_TELEMETRY_PREFIX = "CASTLE_SAVE_COVER_SELECTION ";
3
5
  export type SaveStage = 'bundle' | 'cover' | 'scene_upload_config' | 'scene_upload' | 'content_publish' | 'server_publish' | 'source_archive' | 'source_upload_config' | 'source_upload' | 'source_commit';
4
6
  export declare function runTar(args: string[], opts?: {
5
7
  capture?: boolean;
6
8
  }): Promise<string>;
7
9
  export declare function archiveSource(projectDir: string): Promise<Buffer>;
10
+ export declare function downloadSourceArchive(source: api.WebDeckSource, namePrefix: string): Promise<string>;
8
11
  export type SaveVisibility = 'unlisted' | 'private';
9
12
  export interface SaveDeckOptions {
10
13
  title?: string;