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.
- package/dist/agent-prompts.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +70 -24
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +108 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +19 -11
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +83 -2
- package/kits/base/sdk/commands.d.ts +37 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +20 -9
- package/kits/multiplayer-2d/castle.json +4 -4
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +45 -4
- package/kits/multiplayer-2d/package-lock.json +2 -4
- package/kits/multiplayer-2d/package.json +1 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +5 -5
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +108 -6
- package/kits/multiplayer-3d/package-lock.json +2 -4
- package/kits/multiplayer-3d/package.json +1 -0
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +85 -182
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +9 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +2 -2
- package/kits/turn-based/room.js +162 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +5 -5
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
package/dist/agent-prompts.d.ts
CHANGED
package/dist/agent-prompts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2631
|
+
atomicWriteFileSync(path.join(dir, 'progress'), '0\n');
|
|
2628
2632
|
if (!fs.existsSync(path.join(dir, 'notes.md')))
|
|
2629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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({
|
|
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);
|
package/dist/castle-host/host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/headlessCover.d.ts
CHANGED
|
@@ -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>;
|