castle-web-cli 0.4.169 → 0.4.171

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 (106) hide show
  1. package/dist/agent-prompts.d.ts +1 -0
  2. package/dist/agent-prompts.js +3 -0
  3. package/dist/agent.js +70 -24
  4. package/dist/atomicFile.d.ts +29 -0
  5. package/dist/atomicFile.js +120 -0
  6. package/dist/castle-host/host.js +108 -0
  7. package/dist/get-deck.js +2 -11
  8. package/dist/headlessCover.d.ts +4 -0
  9. package/dist/headlessCover.js +7 -5
  10. package/dist/ide.d.ts +10 -0
  11. package/dist/ide.js +19 -11
  12. package/dist/index.js +10 -6
  13. package/dist/init.d.ts +1 -1
  14. package/dist/init.js +11 -1
  15. package/dist/remix.js +2 -14
  16. package/dist/save-deck.d.ts +3 -0
  17. package/dist/save-deck.js +40 -73
  18. package/dist/saveCover.d.ts +18 -0
  19. package/dist/saveCover.js +61 -0
  20. package/dist/serve.js +18 -5
  21. package/dist/serveSecurity.d.ts +11 -1
  22. package/dist/serveSecurity.js +38 -4
  23. package/dist/shell/assets/index-6odVZQSZ.css +1 -0
  24. package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
  25. package/dist/shell/index.html +3 -3
  26. package/dist/versionStore.js +13 -4
  27. package/kits/base/CLAUDE.md +9 -0
  28. package/kits/base/castle.json +16 -11
  29. package/kits/base/sdk/README.md +83 -2
  30. package/kits/base/sdk/commands.d.ts +37 -1
  31. package/kits/base/sdk/commands.js +9 -0
  32. package/kits/base/sdk/lifecycle.d.ts +20 -0
  33. package/kits/base/sdk/lifecycle.js +7 -1
  34. package/kits/base/sdk/resumeState.d.ts +2 -0
  35. package/kits/base/sdk/resumeState.js +84 -0
  36. package/kits/base/sdk/runtime.js +65 -0
  37. package/kits/base/sdk/transport.d.ts +3 -0
  38. package/kits/base/sdk/transport.js +39 -5
  39. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  40. package/kits/base/sdk/unloadHandshake.js +47 -0
  41. package/kits/base/sdk/user.d.ts +4 -0
  42. package/kits/base/sdk/user.js +36 -1
  43. package/kits/multiplayer-2d/CLAUDE.md +20 -9
  44. package/kits/multiplayer-2d/castle.json +4 -4
  45. package/kits/multiplayer-2d/code/server/players.js +27 -6
  46. package/kits/multiplayer-2d/code/server/world.js +24 -1
  47. package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
  48. package/kits/multiplayer-2d/package-lock.json +2 -4
  49. package/kits/multiplayer-2d/package.json +1 -0
  50. package/kits/multiplayer-3d/CLAUDE.md +28 -9
  51. package/kits/multiplayer-3d/castle.json +5 -5
  52. package/kits/multiplayer-3d/code/server/players.js +54 -5
  53. package/kits/multiplayer-3d/code/server/world.js +45 -1
  54. package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
  55. package/kits/multiplayer-3d/package-lock.json +2 -4
  56. package/kits/multiplayer-3d/package.json +1 -0
  57. package/kits/physics-2d/CLAUDE.md +75 -43
  58. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  59. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  60. package/kits/physics-2d/castle.json +6 -6
  61. package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
  62. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  63. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  64. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  65. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  66. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  67. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  68. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  69. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  70. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  71. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  72. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  73. package/kits/physics-2d/engine/icons.js +56 -0
  74. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  75. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  76. package/kits/physics-2d/engine/physics/controls.js +76 -12
  77. package/kits/physics-2d/engine/scene.js +47 -9
  78. package/kits/physics-2d/engine/ui.jsx +13 -125
  79. package/kits/physics-2d/engine/ui.module.css +53 -28
  80. package/kits/physics-2d/package-lock.json +0 -29
  81. package/kits/physics-2d/package.json +3 -1
  82. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  83. package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
  84. package/kits/physics-3d/castle.json +5 -5
  85. package/kits/physics-3d/package-lock.json +0 -29
  86. package/kits/physics-3d/package.json +0 -1
  87. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  88. package/kits/real-time/CLAUDE.md +23 -4
  89. package/kits/real-time/castle.json +1 -1
  90. package/kits/real-time/code/client/connection.js +9 -4
  91. package/kits/real-time/code/client/joinOverlay.js +46 -0
  92. package/kits/real-time/code/client/messages.js +4 -0
  93. package/kits/real-time/code/server/gameHooks.js +4 -0
  94. package/kits/real-time/code/server/persist.js +105 -0
  95. package/kits/real-time/code/server/session.js +289 -19
  96. package/kits/real-time/package-lock.json +1139 -0
  97. package/kits/turn-based/CLAUDE.md +76 -20
  98. package/kits/turn-based/castle.json +3 -3
  99. package/kits/turn-based/code/server/index.js +16 -5
  100. package/kits/turn-based/package-lock.json +0 -27
  101. package/kits/turn-based/package.json +2 -2
  102. package/kits/turn-based/room.js +162 -13
  103. package/kits/turn-based/testing.js +276 -92
  104. package/package.json +5 -5
  105. package/dist/shell/assets/index-CVHj503j.css +0 -1
  106. package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
@@ -90,6 +90,7 @@ export declare function userTurnInstruction(opts: {
90
90
  messages: string[];
91
91
  interruptedDraft?: string;
92
92
  attachments?: string[];
93
+ files?: string[];
93
94
  }): string;
94
95
  export declare function buildTaskPrompt(opts: {
95
96
  deckLabel: string;
@@ -882,6 +882,9 @@ export function userTurnInstruction(opts) {
882
882
  // while the CLI backends open the saved files themselves.
883
883
  parts.push(`The user attached image file(s), saved in the deck at: ${opts.attachments.join(", ")}. If the images are not already visible in this message, open them with your read tool. Take them into account; pass the paths along to task agents that need them.`);
884
884
  }
885
+ if (opts.files && opts.files.length > 0) {
886
+ parts.push(`The user pointed at these deck files: ${opts.files.join(", ")}. Read them with your read tool if you need them to reply. When you spawn a task one of these files concerns, name that path in that task's prompt so the agent reads it; do not mention it to tasks it does not concern.`);
887
+ }
885
888
  return parts.join("\n\n");
886
889
  }
887
890
  export function buildTaskPrompt(opts) {
package/dist/agent.js CHANGED
@@ -18,12 +18,14 @@ import { execFileSync, spawn } from 'child_process';
18
18
  import { quickReferenceFor } from './platformDoc.js';
19
19
  import { IMPORTS_DIR } from './imports.js';
20
20
  import { isSameOriginUpgrade } from './wsOrigin.js';
21
+ import { resolveDeckPath } from './ide.js';
21
22
  import * as fs from 'fs';
22
23
  import * as os from 'os';
23
24
  import * as path from 'path';
24
25
  import { nanoid } from 'nanoid';
25
26
  import { WebSocketServer } from 'ws';
26
27
  import { rawDataToString } from './rawData.js';
28
+ import { atomicWriteFileSync } from './atomicFile.js';
27
29
  import { AGENT_ATTACHMENT_PREFIX, AGENT_PLAYTEST_PREFIX, PLAN_FILE } from './localPaths.js';
28
30
  import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
29
31
  import { readCastleJson } from './castleJson.js';
@@ -615,6 +617,9 @@ const RESULT_SUMMARY_CHARS = 600;
615
617
  // TRANSCRIPT_BYTE_BUDGET in agent-prompts.ts), so this term needs its own bound.
616
618
  const PLAN_BYTE_BUDGET = 8 * 1024;
617
619
  const MAX_ATTACHMENTS = 6;
620
+ // @-mentioned files are path references, so the only cost is instruction bytes
621
+ // -- but a client is not the one to decide how many the router is told about.
622
+ const MAX_MESSAGE_FILES = 12;
618
623
  const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
619
624
  const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
620
625
  function nowIso() {
@@ -2218,7 +2223,7 @@ async function runAgentTurn(opts) {
2218
2223
  }
2219
2224
  // -- task store ---------------------------------------------------------------
2220
2225
  function persistTaskFile(tasksDir, task) {
2221
- fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
2226
+ atomicWriteFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
2222
2227
  }
2223
2228
  // The task index: one row per task this deck has ever spawned, addressed by the
2224
2229
  // plan item it advanced. It is the complete address book (step 2 searches it by
@@ -2261,18 +2266,17 @@ function writeTaskIndex(tasksDir, tasks) {
2261
2266
  catch {
2262
2267
  /* no index yet -- writing it below is the change */
2263
2268
  }
2264
- const tmp = `${file}.tmp`;
2265
2269
  try {
2266
2270
  // Written aside and renamed: a running task agent may be reading this file
2267
2271
  // at any moment, and a torn read is a claims board with rows missing.
2268
- fs.writeFileSync(tmp, body);
2269
- fs.renameSync(tmp, file);
2272
+ atomicWriteFileSync(file, body);
2270
2273
  }
2271
2274
  catch (err) {
2272
2275
  console.error(`[tasks] could not write ${TASK_INDEX_FILE}: ${err instanceof Error ? err.message : String(err)}`);
2273
2276
  }
2274
2277
  }
2275
- // Tasks left "running" by a dead serve are as finished as they will get. A
2278
+ // Tasks left "running" or "waiting" by a dead serve are as finished as they
2279
+ // will get. They are interrupted rather than scheduled again on boot. A
2276
2280
  // persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
2277
2281
  // never reconsiders it and it can't wedge or auto-start; it just sits on the
2278
2282
  // board (blockedBy intact) until the router stops it, same as before restart.
@@ -2282,7 +2286,7 @@ function loadTasks(tasksDir) {
2282
2286
  const rec = readJsonFile(path.join(tasksDir, entry, 'task.json'));
2283
2287
  if (!rec)
2284
2288
  continue;
2285
- if (rec.status === 'running') {
2289
+ if (rec.status === 'running' || rec.status === 'waiting') {
2286
2290
  rec.status = 'interrupted';
2287
2291
  rec.updatedAt = nowIso();
2288
2292
  persistTaskFile(tasksDir, rec);
@@ -2624,9 +2628,9 @@ async function runTaskAgentIn(ctx, task) {
2624
2628
  // (by skipping the slot free) nor crash the serve.
2625
2629
  function startTask(ctx, task) {
2626
2630
  const dir = path.join(ctx.tasksDir, task.id);
2627
- fs.writeFileSync(path.join(dir, 'progress'), '0\n');
2631
+ atomicWriteFileSync(path.join(dir, 'progress'), '0\n');
2628
2632
  if (!fs.existsSync(path.join(dir, 'notes.md')))
2629
- fs.writeFileSync(path.join(dir, 'notes.md'), '');
2633
+ atomicWriteFileSync(path.join(dir, 'notes.md'), '');
2630
2634
  task.status = 'running';
2631
2635
  task.startedAt = nowIso();
2632
2636
  ctx.touch(task);
@@ -3001,7 +3005,7 @@ function saveAttachments(attachmentsDir, messageId, images) {
3001
3005
  const fileName = `${messageId}-${index}.${ext}`;
3002
3006
  try {
3003
3007
  fs.mkdirSync(attachmentsDir, { recursive: true });
3004
- fs.writeFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
3008
+ atomicWriteFileSync(path.join(attachmentsDir, fileName), Buffer.from(match[2], 'base64'));
3005
3009
  saved.push(fileName);
3006
3010
  }
3007
3011
  catch {
@@ -3010,6 +3014,20 @@ function saveAttachments(attachmentsDir, messageId, images) {
3010
3014
  }
3011
3015
  return saved;
3012
3016
  }
3017
+ // Deck-relative paths a client @-mentioned, keeping only the ones that name a
3018
+ // real place in this deck. Nothing is read here: the paths travel as references
3019
+ // and whoever needs the contents opens them.
3020
+ function validMessageFiles(deckDir, files) {
3021
+ if (!Array.isArray(files))
3022
+ return [];
3023
+ const kept = [];
3024
+ for (const entry of files.slice(0, MAX_MESSAGE_FILES)) {
3025
+ const resolved = resolveDeckPath(deckDir, entry);
3026
+ if (resolved.ok && !kept.includes(resolved.rel))
3027
+ kept.push(resolved.rel);
3028
+ }
3029
+ return kept;
3030
+ }
3013
3031
  // Cap on the failure detail shown in a board row -- just enough for the
3014
3032
  // router to reason about what went wrong, not the full crash dump.
3015
3033
  const ERROR_PREVIEW_CHARS = 200;
@@ -3148,7 +3166,7 @@ function createMessageLog(messagesPath, broadcast, welcomeMessage, forClient) {
3148
3166
  .filter((m) => m.text.trim() !== '' || m.role === 'user')
3149
3167
  .map((m) => (m.status === 'streaming' ? { ...m, status: 'done' } : m));
3150
3168
  function persist() {
3151
- fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
3169
+ atomicWriteFileSync(messagesPath, JSON.stringify(messages, null, 2) + '\n');
3152
3170
  }
3153
3171
  if (messages.length === 0) {
3154
3172
  messages.push({
@@ -3624,7 +3642,7 @@ function applyAgentSettings(incoming, ctx) {
3624
3642
  }
3625
3643
  if (changes.length === 0)
3626
3644
  return;
3627
- fs.writeFileSync(ctx.settingsPath, serializeAgentSettings(settings));
3645
+ atomicWriteFileSync(ctx.settingsPath, serializeAgentSettings(settings));
3628
3646
  // The value is saved and broadcast IMMEDIATELY -- validation never gates a
3629
3647
  // write. The verdict follows in a second frame once the catalog answers.
3630
3648
  ctx.broadcast({ type: 'settings', settings });
@@ -3814,7 +3832,7 @@ function startChildRegistry(registryPath, groups) {
3814
3832
  // messages.json was committed by a prior drain (its turn ran, finished or
3815
3833
  // not -- a logged message is considered handled), so re-queueing it would
3816
3834
  // double-deliver. Order is preserved.
3817
- function loadRecoverableSends(pendingPath, committedIds) {
3835
+ function loadRecoverableSends(deckDir, pendingPath, committedIds) {
3818
3836
  const stored = readJsonFile(pendingPath);
3819
3837
  if (!Array.isArray(stored))
3820
3838
  return [];
@@ -3829,6 +3847,11 @@ function loadRecoverableSends(pendingPath, committedIds) {
3829
3847
  id: item.id,
3830
3848
  text: item.text,
3831
3849
  attachments: item.attachments.filter((a) => typeof a === 'string'),
3850
+ // Re-validated rather than trusted: enqueue is where a path earns its
3851
+ // place, and a mirror on disk is not evidence it ever did. A build that
3852
+ // predates @-mentions wrote no files at all, which is a send worth
3853
+ // recovering for its words, not a malformed one.
3854
+ files: validMessageFiles(deckDir, item.files),
3832
3855
  });
3833
3856
  }
3834
3857
  }
@@ -3860,7 +3883,7 @@ function reclaimInFlightSends(pendingSends, inFlightSends) {
3860
3883
  // Mirror the in-memory queue to disk. Called on every mutation (enqueue,
3861
3884
  // drain, cancel, recover) so a restart never loses an unsent queued message.
3862
3885
  function persistPendingSends(ctx) {
3863
- fs.writeFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
3886
+ atomicWriteFileSync(ctx.pendingPath, JSON.stringify(ctx.state.pendingSends, null, 2) + '\n');
3864
3887
  }
3865
3888
  function computeQueuedSnippets(pendingSends) {
3866
3889
  return pendingSends.map((p) => p.text.trim()).filter(Boolean);
@@ -3918,11 +3941,12 @@ function startRouterTurn(ctx, instruction, attachments = []) {
3918
3941
  }
3919
3942
  // Commit each drained send to the message log (skipping ones already logged
3920
3943
  // -- see PendingSend.logged -- so a re-carried send doesn't double its user
3921
- // bubble) and collect the instruction pieces: message texts and attachment
3922
- // paths, in drain order.
3944
+ // bubble) and collect the instruction pieces: message texts, attachment paths
3945
+ // and @-mentioned file paths, in drain order.
3923
3946
  function commitDrainedSends(drained, log) {
3924
3947
  const texts = [];
3925
3948
  const attachmentPaths = [];
3949
+ const filePaths = [];
3926
3950
  for (const item of drained) {
3927
3951
  if (!item.logged) {
3928
3952
  const message = {
@@ -3934,6 +3958,8 @@ function commitDrainedSends(drained, log) {
3934
3958
  };
3935
3959
  if (item.attachments.length > 0)
3936
3960
  message.attachments = item.attachments;
3961
+ if (item.files.length > 0)
3962
+ message.files = item.files;
3937
3963
  log.add(message);
3938
3964
  }
3939
3965
  if (item.text.trim())
@@ -3941,8 +3967,12 @@ function commitDrainedSends(drained, log) {
3941
3967
  for (const name of item.attachments) {
3942
3968
  attachmentPaths.push(path.join('.castle', 'agent', 'attachments', name));
3943
3969
  }
3970
+ for (const file of item.files) {
3971
+ if (!filePaths.includes(file))
3972
+ filePaths.push(file);
3973
+ }
3944
3974
  }
3945
- return { texts, attachmentPaths };
3975
+ return { texts, attachmentPaths, filePaths };
3946
3976
  }
3947
3977
  // Drain the queue into the log as real user messages and start one follow-up
3948
3978
  // turn addressing them all (a burst batches into a single turn). A pending
@@ -3958,7 +3988,7 @@ function maybeStartRouterQueueTurn(ctx) {
3958
3988
  // Kept so a fold or manual interrupt of THIS turn can hand these sends back
3959
3989
  // to reclaimInFlightSends if it dies before producing anything visible.
3960
3990
  state.inFlightSends = drained;
3961
- const { texts, attachmentPaths } = commitDrainedSends(drained, ctx.log);
3991
+ const { texts, attachmentPaths, filePaths } = commitDrainedSends(drained, ctx.log);
3962
3992
  // The queue is now committed to messages.json; clear its durable mirror.
3963
3993
  persistPendingSends(ctx);
3964
3994
  const draft = state.pendingInterruptedDraft;
@@ -3969,6 +3999,7 @@ function maybeStartRouterQueueTurn(ctx) {
3969
3999
  messages: texts,
3970
4000
  interruptedDraft: draft || undefined,
3971
4001
  attachments: attachmentPaths,
4002
+ files: filePaths,
3972
4003
  }), attachmentPaths);
3973
4004
  }
3974
4005
  // The turn settled: clear the busy flag, broadcast it, then flush anything
@@ -4017,7 +4048,7 @@ function safeClientMessageId(clientId) {
4017
4048
  return null;
4018
4049
  return /^[A-Za-z0-9_-]{1,32}$/.test(clientId) ? clientId : null;
4019
4050
  }
4020
- function handleQueueUserMessage(ctx, text, images, clientId) {
4051
+ function handleQueueUserMessage(ctx, text, images, files, clientId) {
4021
4052
  const { state } = ctx;
4022
4053
  // Mid-run: queue (don't interrupt). It shows as a queued row in the
4023
4054
  // composer and flushes when the current turn settles. Idle: start now.
@@ -4028,9 +4059,24 @@ function handleQueueUserMessage(ctx, text, images, clientId) {
4028
4059
  // The id is the SENDER's when it supplied one: the client shows the message
4029
4060
  // the moment you hit send, and reusing that row's id means the echo lands on
4030
4061
  // the same row rather than beside it as a duplicate.
4031
- const id = safeClientMessageId(clientId) ?? nanoid(8);
4062
+ const sentId = safeClientMessageId(clientId);
4063
+ // seenClientIds dies with the process; the client's stored copy of an
4064
+ // unacknowledged message does not, so a resend can outlive a restart and
4065
+ // arrive at a serve that never witnessed the first delivery. The log and
4066
+ // the queue did: either holding the id means this send already landed.
4067
+ if (sentId !== null &&
4068
+ (ctx.messages.some((m) => m.id === sentId) ||
4069
+ state.pendingSends.some((p) => p.id === sentId))) {
4070
+ return;
4071
+ }
4072
+ const id = sentId ?? nanoid(8);
4032
4073
  const attachments = saveAttachments(ctx.attachmentsDir, id, images);
4033
- state.pendingSends.push({ id, text, attachments });
4074
+ state.pendingSends.push({
4075
+ id,
4076
+ text,
4077
+ attachments,
4078
+ files: validMessageFiles(ctx.deckDir, files),
4079
+ });
4034
4080
  persistPendingSends(ctx);
4035
4081
  if (state.routerRunning) {
4036
4082
  // The new send was just pushed above, so folding now (which unshifts the
@@ -4111,11 +4157,11 @@ function createRouterQueue(deps) {
4111
4157
  };
4112
4158
  // Restart recovery: re-enqueue sends that never reached the message log,
4113
4159
  // so an interrupted serve resumes them exactly once instead of losing them.
4114
- ctx.state.pendingSends.push(...loadRecoverableSends(ctx.pendingPath, new Set(deps.messages.map((m) => m.id))));
4160
+ ctx.state.pendingSends.push(...loadRecoverableSends(ctx.deckDir, ctx.pendingPath, new Set(deps.messages.map((m) => m.id))));
4115
4161
  persistPendingSends(ctx);
4116
4162
  maybeStartRouterQueueTurn(ctx);
4117
4163
  return {
4118
- handleUserMessage: (text, images, clientId) => handleQueueUserMessage(ctx, text, images, clientId),
4164
+ handleUserMessage: (text, images, files, clientId) => handleQueueUserMessage(ctx, text, images, files, clientId),
4119
4165
  interruptRouter: () => interruptRouterQueue(ctx),
4120
4166
  cancelQueued: (index) => cancelQueuedSend(ctx, index),
4121
4167
  isRunning: () => ctx.state.routerRunning,
@@ -4308,7 +4354,7 @@ export function createAgentServer(opts) {
4308
4354
  broadcast({ type: 'message-picker', id, pickerAnswers: msg.pickerAnswers });
4309
4355
  }
4310
4356
  if (text.trim())
4311
- routerQueue.handleUserMessage(text.trim(), undefined);
4357
+ routerQueue.handleUserMessage(text.trim(), undefined, undefined);
4312
4358
  }
4313
4359
  function handleTaskAck(id, rejected) {
4314
4360
  taskStore.acknowledge(id, rejected);
@@ -4394,7 +4440,7 @@ export function createAgentServer(opts) {
4394
4440
  if (!duplicate) {
4395
4441
  if (clientId !== null)
4396
4442
  rememberClientId(clientId);
4397
- routerQueue.handleUserMessage(typeof msg.text === 'string' ? msg.text.trim() : '', msg.images, clientId ?? undefined);
4443
+ routerQueue.handleUserMessage(typeof msg.text === 'string' ? msg.text.trim() : '', msg.images, msg.files, clientId ?? undefined);
4398
4444
  }
4399
4445
  if (clientId !== null) {
4400
4446
  if (socket.readyState === socket.OPEN) {
@@ -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);
@@ -24,6 +24,7 @@ const COMMAND_NAMES = [
24
24
  "leaderboard.save",
25
25
  "leaderboard.show",
26
26
  "user.getCurrent",
27
+ "user.get",
27
28
  "time.getServerTime",
28
29
  "pass.has",
29
30
  "pass.offer",
@@ -39,6 +40,7 @@ const COMMAND_NAMES = [
39
40
  "cauldronStorage.boardSubmit",
40
41
  "cauldronStorage.boardTop",
41
42
  "cauldronStorage.boardGet",
43
+ "lifecycle.restoreState",
42
44
  ];
43
45
  // Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
44
46
  // to the host's optional platformHandler (mobile renders native UI; web shows an
@@ -50,6 +52,7 @@ const PLATFORM_COMMAND_NAMES = [
50
52
  "portal.open",
51
53
  "portal.prefetch",
52
54
  "haptics.play",
55
+ "lifecycle.restoreState",
53
56
  ];
54
57
  // Mirrors CASTLE_LEADERBOARD_PAYLOAD_VERSION in leaderboardPanel.ts. Duplicated
55
58
  // rather than imported because every import here has to stay type-only; bump
@@ -118,6 +121,8 @@ function runCommand(ctx, command, params, caps) {
118
121
  return leaderboardShow(ctx, params, caps);
119
122
  case "user.getCurrent":
120
123
  return Promise.resolve(userGetCurrent(ctx));
124
+ case "user.get":
125
+ return userGet(params, gql);
121
126
  case "time.getServerTime":
122
127
  return timeGetServerTime(gql);
123
128
  case "pass.has":
@@ -146,6 +151,7 @@ function runCommand(ctx, command, params, caps) {
146
151
  case "portal.open":
147
152
  case "portal.prefetch":
148
153
  case "haptics.play":
154
+ case "lifecycle.restoreState":
149
155
  return runPlatformCommand(ctx, command, params, caps);
150
156
  }
151
157
  }
@@ -208,6 +214,8 @@ async function runPlatformCommand(ctx, command, params, caps) {
208
214
  return portalPrefetch(ctx, params, caps);
209
215
  case "haptics.play":
210
216
  return hapticsPlay(ctx, params, caps);
217
+ case "lifecycle.restoreState":
218
+ return lifecycleRestoreState(ctx, caps);
211
219
  default:
212
220
  return unavailableOutcome();
213
221
  }
@@ -321,6 +329,38 @@ function normalizeHapticsOutcome(value) {
321
329
  }
322
330
  return { status: "unavailable" };
323
331
  }
332
+ // Hand back whatever the host kept for this deck the last time it unloaded it.
333
+ // Unlike pass/portal, an absent deckId is `unavailable` rather than a thrown
334
+ // MISSING_DECK_ID: a deck asking to resume must never have to catch — an unsaved
335
+ // deck, a host with no store, and a host with nothing kept all read the same.
336
+ async function lifecycleRestoreState(ctx, caps) {
337
+ if (!caps.platformHandler || !ctx.deckId)
338
+ return { status: "unavailable" };
339
+ const outcome = await caps.platformHandler("lifecycle.restoreState", { deckId: ctx.deckId }, ctx);
340
+ return normalizeRestoreStateOutcome(outcome);
341
+ }
342
+ function normalizeRestoreStateOutcome(value) {
343
+ const record = typeof value === "object" && value !== null
344
+ ? value
345
+ : {};
346
+ const status = record.status;
347
+ const valid = [
348
+ "restored",
349
+ "none",
350
+ "unavailable",
351
+ ];
352
+ if (typeof status !== "string" || !valid.includes(status)) {
353
+ return { status: "none" };
354
+ }
355
+ if (status !== "restored") {
356
+ return { status: status };
357
+ }
358
+ // A "restored" with no string state would reach the deck as a resume with
359
+ // nothing to resume from, which JSON.parse would then throw on.
360
+ return typeof record.state === "string"
361
+ ? { status: "restored", state: record.state }
362
+ : { status: "none" };
363
+ }
324
364
  function unavailableOutcome() {
325
365
  return { status: "unavailable" };
326
366
  }
@@ -632,6 +672,56 @@ function userGetCurrent(ctx) {
632
672
  user: { userId: ctx.userId, username: ctx.username, isAnonymous: ctx.isAnonymous === true },
633
673
  };
634
674
  }
675
+ // One user's public profile, or null when there is no such user. The photo and
676
+ // frame are fetched here and handed over as data: urls, since a deck's CSP
677
+ // allows no image host and the host is outside it.
678
+ async function userGet(params, gql) {
679
+ const userId = asString(params.userId, "userId", "user.get");
680
+ const data = await graphql(gql, USER_QUERY, { userId }, "user.get");
681
+ const user = data.user;
682
+ if (!user?.userId || !user.username)
683
+ return { user: null };
684
+ const [photoUrl, frameUrl] = await Promise.all([
685
+ imageDataUrl(user.photo?.avatarUrl),
686
+ imageDataUrl(user.photoFrame?.smallFrameUrl),
687
+ ]);
688
+ return {
689
+ user: {
690
+ userId: user.userId,
691
+ username: user.username,
692
+ isAnonymous: user.isAnonymous === true,
693
+ photoUrl,
694
+ frameUrl,
695
+ color: asOptionalString(user.usernameStyle?.color),
696
+ },
697
+ };
698
+ }
699
+ // An image as a data: url, or null when there is none or the fetch fails. A
700
+ // missing avatar is not an error for the deck.
701
+ async function imageDataUrl(url) {
702
+ if (typeof url !== "string" || url.length === 0 || typeof fetch !== "function") {
703
+ return null;
704
+ }
705
+ try {
706
+ const response = await fetch(url);
707
+ if (!response.ok)
708
+ return null;
709
+ const type = response.headers.get("content-type") ?? "image/png";
710
+ return `data:${type};base64,${base64(new Uint8Array(await response.arrayBuffer()))}`;
711
+ }
712
+ catch {
713
+ return null;
714
+ }
715
+ }
716
+ // `btoa` takes a binary string, built here in chunks small enough for
717
+ // `fromCharCode` to spread.
718
+ function base64(bytes) {
719
+ let binary = "";
720
+ for (let i = 0; i < bytes.length; i += 8192) {
721
+ binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
722
+ }
723
+ return btoa(binary);
724
+ }
635
725
  async function timeGetServerTime(gql) {
636
726
  const data = await graphql(gql, SERVER_TIME_QUERY, {}, "time.getServerTime");
637
727
  return {
@@ -1079,6 +1169,24 @@ const PASSES_FOR_DECK_QUERY = `
1079
1169
  }
1080
1170
  }
1081
1171
  `;
1172
+ const USER_QUERY = `
1173
+ query CastleUserProfile($userId: ID!) {
1174
+ user(userId: $userId) {
1175
+ userId
1176
+ username
1177
+ isAnonymous
1178
+ photo {
1179
+ avatarUrl
1180
+ }
1181
+ photoFrame {
1182
+ smallFrameUrl
1183
+ }
1184
+ usernameStyle {
1185
+ color
1186
+ }
1187
+ }
1188
+ }
1189
+ `;
1082
1190
  const SERVER_TIME_QUERY = `
1083
1191
  query CastleServerTime {
1084
1192
  serverTime {
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>;