castle-web-cli 0.4.95 → 0.4.97

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.js CHANGED
@@ -24,7 +24,7 @@ import { rawDataToString } from "./ide.js";
24
24
  import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from "./agent-prompts.js";
25
25
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from "./openrouter-catalog.js";
26
26
  import { classifyProviderError, failureCopy, } from "./agent-failures.js";
27
- import { meteringHeaders, newAgentSessionId, withCustomHeaders, } from "./metering.js";
27
+ import { meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from "./metering.js";
28
28
  import { runAgentNative } from "./native/loop.js";
29
29
  import { createPlaytestBrowserManager } from "./native/playtest-browser.js";
30
30
  import { createPlaywrightPlaytestExecutor } from "./native/playtest-executor.js";
@@ -356,6 +356,9 @@ function handleModelCaps(req, res) {
356
356
  // Single `=` token because --allowedTools is variadic and would otherwise
357
357
  // swallow the trailing prompt positional.
358
358
  const OPENROUTER_ALLOWED_TOOLS = "--allowedTools=Edit,Write,NotebookEdit,Bash";
359
+ // Cursor's proprietary model. Also the slug reported to the metering ledger, so
360
+ // the two can never drift into disagreeing about what a cursor row ran on.
361
+ const CURSOR_MODEL = "composer-2.5-fast";
359
362
  function buildAgentInvocation(backend, role, prompt, claudeModel,
360
363
  // Already resolved for this role by the caller (router turns pass
361
364
  // settings.routerOpenrouterModel, task spawns settings.tasksOpenrouterModel).
@@ -426,7 +429,7 @@ metering) {
426
429
  "--stream-partial-output",
427
430
  "--trust",
428
431
  "--model",
429
- "composer-2.5-fast",
432
+ CURSOR_MODEL,
430
433
  ...(role === "router" ? ["--mode", "ask"] : ["--force"]),
431
434
  prompt,
432
435
  ],
@@ -1226,67 +1229,6 @@ function logAgentUsage(label, backend, usage) {
1226
1229
  const output = formatTokenCount(usage.output_tokens);
1227
1230
  console.error(`[agent usage] ${label} ${backend}: input=${input} cache_read=${read} cache_created=${created} output=${output}`);
1228
1231
  }
1229
- // Per-deck machine-readable usage ledger, appended to <deckDir>/.castle/agent/,
1230
- // harvested out-of-band (by the cloud launcher) for rough per-user token
1231
- // metering. Distinct from logAgentUsage's stderr line, which is lossy
1232
- // (rounds to "3.2k") and gets truncated when the serve restarts.
1233
- const USAGE_LEDGER_FILE = "usage.jsonl";
1234
- // The ledger is only useful where the cloud launcher harvests it, so the managed
1235
- // sandbox environments set CASTLE_USAGE_LEDGER=1 (E2B via cloudSandbox.serveOnPort,
1236
- // castle-sandboxes via its image). A local `castle-web serve` leaves it unset, so
1237
- // dev deck dirs don't accumulate a ledger nothing reads.
1238
- const USAGE_LEDGER_ENABLED = process.env.CASTLE_USAGE_LEDGER === "1";
1239
- // Concrete model behind a finished run: smith and claude-via-OpenRouter both
1240
- // bill the OpenRouter slug; plain claude bills its own slug; cursor has no
1241
- // per-model split tracked here.
1242
- function resolveRunModel(backend, claudeModel, openrouterModel) {
1243
- if (backend === "smith")
1244
- return openrouterModel;
1245
- if (backend === "claude") {
1246
- return claudeModel === "openrouter" ? openrouterModel : claudeModel;
1247
- }
1248
- return backend;
1249
- }
1250
- // One record per finished run: the human-readable stderr line PLUS a precise
1251
- // append-only JSONL line in the deck's usage ledger. Precise counts (not the
1252
- // stderr line's rounded values) and self-describing (id/role/backend/model) so
1253
- // the harvester can attribute and de-dup. Best-effort: a metering write must
1254
- // never fail an agent run.
1255
- function reportRunUsage(agentDir, role, backend, claudeModel, openrouterModel, result, taskId) {
1256
- const usage = result.usage;
1257
- logAgentUsage(taskId ? `task ${taskId}` : "router", backend, usage);
1258
- if (!USAGE_LEDGER_ENABLED)
1259
- return;
1260
- // cursor-agent's stream-json doesn't report token usage, so a cursor run has no
1261
- // `usage`; still record it (zero counts, tokens_reported:false) for run-count
1262
- // visibility. Other backends only write once they actually produced usage.
1263
- if (!usage && backend !== "cursor")
1264
- return;
1265
- try {
1266
- const line = JSON.stringify({
1267
- at: nowIso(),
1268
- id: nanoid(),
1269
- role,
1270
- backend,
1271
- model: resolveRunModel(backend, claudeModel, openrouterModel),
1272
- ...(taskId ? { taskId } : {}),
1273
- // A failed run still resolves with a usage object of all zeros (an auth
1274
- // error emits a result event with zeroed counts), so its zeros would
1275
- // otherwise be indistinguishable downstream from a real "measured zero".
1276
- ...(result.ok ? {} : { failed: true }),
1277
- tokens_reported: usage !== undefined,
1278
- input_tokens: usage?.input_tokens ?? 0,
1279
- output_tokens: usage?.output_tokens ?? 0,
1280
- cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0,
1281
- cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0,
1282
- });
1283
- fs.mkdirSync(agentDir, { recursive: true });
1284
- fs.appendFileSync(path.join(agentDir, USAGE_LEDGER_FILE), line + "\n");
1285
- }
1286
- catch {
1287
- /* best-effort: a metering write must never fail an agent run */
1288
- }
1289
- }
1290
1232
  // Build the per-run stdout event handler over a shared mutable parser state.
1291
1233
  // Splitting the cursor + claude stream decoding out of runAgentCli keeps each
1292
1234
  // within the max-lines budget; behavior is identical (same delta/activity/
@@ -1735,7 +1677,8 @@ async function runAgentTurn(opts) {
1735
1677
  });
1736
1678
  }
1737
1679
  const invocation = buildAgentInvocation(opts.backend, opts.role, opts.prompt, opts.claudeModel, opts.openrouterModel, { sessionId, deckDir: opts.cwd });
1738
- return runAgentCli({
1680
+ const startedMs = Date.now();
1681
+ const run = runAgentCli({
1739
1682
  cwd: opts.cwd,
1740
1683
  command: invocation.command,
1741
1684
  args: invocation.args,
@@ -1753,6 +1696,21 @@ async function runAgentTurn(opts) {
1753
1696
  ? opts.openrouterModel
1754
1697
  : undefined,
1755
1698
  });
1699
+ // Cursor is the only backend whose traffic never reaches the llm-proxy, so its
1700
+ // run is reported from here. Every other backend is already recorded upstream,
1701
+ // and reporting them here too would double-count them in the same table.
1702
+ if (opts.backend !== "cursor")
1703
+ return run;
1704
+ return run.then((result) => {
1705
+ reportCursorRun({
1706
+ deckDir: opts.cwd,
1707
+ sessionId,
1708
+ model: CURSOR_MODEL,
1709
+ durationMs: Date.now() - startedMs,
1710
+ ok: result.ok,
1711
+ });
1712
+ return result;
1713
+ });
1756
1714
  }
1757
1715
  // -- task store ---------------------------------------------------------------
1758
1716
  function persistTaskFile(tasksDir, task) {
@@ -1999,7 +1957,7 @@ async function runTaskAgentIn(ctx, task) {
1999
1957
  ctx.onFeed(`[${activity}]`);
2000
1958
  },
2001
1959
  });
2002
- reportRunUsage(path.dirname(ctx.tasksDir), "task", ctx.backend, ctx.claudeModel, ctx.openrouterModel, result, task.id);
1960
+ logAgentUsage(`task ${task.id}`, ctx.backend, result.usage);
2003
1961
  if (ctx.stopRequested.has(task.id))
2004
1962
  return result;
2005
1963
  if (!result.crashed)
@@ -2794,7 +2752,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2794
2752
  },
2795
2753
  })
2796
2754
  .then((result) => {
2797
- reportRunUsage(ctx.agentDir, "router", backend, ctx.claudeModel(), ctx.openrouterModel(), result);
2755
+ logAgentUsage("router", backend, result.usage);
2798
2756
  // Signals the finally -> onSettled(retryable): the turn failed cleanly
2799
2757
  // enough (transient, nothing salvaged) that the queue may re-run it.
2800
2758
  let retryable = false;
package/dist/init.js CHANGED
@@ -145,6 +145,23 @@ function tryMakeAgentsSymlink(agentsPath) {
145
145
  // symlink already exists / unsupported FS — non-fatal
146
146
  }
147
147
  }
148
+ // Scripts for a deck whose kit is an import. The same deck commands every
149
+ // scaffold gets, plus the kit's own authoring commands re-pointed at where the
150
+ // kit now lives -- a kit's CLAUDE.md tells the agent to run `npm run draw` and
151
+ // `npm run restart`, and those have to exist in the deck for that to be true.
152
+ function makeKitDeckScripts(kitDir, alias, cliCommand) {
153
+ const scripts = {
154
+ restart: `${cliCommand} restart .`,
155
+ screenshot: `${cliCommand} screenshot .`,
156
+ "save-deck": `${cliCommand} save-deck .`,
157
+ };
158
+ // `draw` is a kit script, not a CLI one, so it only exists if this kit ships
159
+ // it -- and it runs from the deck root, where the sprites belong.
160
+ if (fs.existsSync(path.join(kitDir, "scripts", "draw.mjs"))) {
161
+ scripts.draw = `node ${IMPORTS_DIR}/${alias}/scripts/draw.mjs`;
162
+ }
163
+ return scripts;
164
+ }
148
165
  function makePackageJson(projectDir) {
149
166
  const { sdkRef, cliCommand } = resolveScaffoldRefs();
150
167
  return {
@@ -291,6 +308,23 @@ something in the kit behaves, define your own behavior of the same name in
291
308
  \`behaviors/\` -- the deck's own files win over an import's.
292
309
  `;
293
310
  }
311
+ // How the deck names the kit it was built on. `scripts/publish-kits.mjs` stamps
312
+ // a published kit's castle.json with the deckId it lives at and the version it
313
+ // was published as, and both have to be present to pin it that way: a deckId
314
+ // with no version would read as "you have some unknown version", which the
315
+ // update check would report as an update available forever.
316
+ //
317
+ // Falling back to the CLI's copy is what makes a kit usable before it is ever
318
+ // published -- a new kit works from the day it exists, and starts offering
319
+ // updates the day it is published, with nothing in between to migrate.
320
+ function makeKitPin(kitConfig, kit) {
321
+ const deckId = kitConfig.deckId;
322
+ const version = kitConfig.publishedVersion;
323
+ if (typeof deckId === "string" && typeof version === "string") {
324
+ return { deckId, version };
325
+ }
326
+ return { source: "builtin", kit, version: getCliVersion() };
327
+ }
294
328
  // Scaffold a deck that IMPORTS its kit instead of copying it. The kit's files
295
329
  // land in `imports/<kit>/` (read-only, like any dependency) and the deck itself
296
330
  // holds only what is genuinely its own: an entry point, its config, and its
@@ -298,10 +332,11 @@ something in the kit behaves, define your own behavior of the same name in
298
332
  // rather than being frozen into the deck at scaffold time -- which is what
299
333
  // copying the kit in meant.
300
334
  //
301
- // The kit comes from the copy shipped with this CLI, so `init` works offline and
302
- // needs no account. The pin records that origin; once kits are published as
303
- // decks it becomes a deckId + version and the files are fetched instead, with
304
- // the same deck shape either way.
335
+ // The files always come from the copy shipped with this CLI, so `init` works
336
+ // offline and needs no account. What the pin SAYS depends on whether the kit has
337
+ // been published: an unpublished one can only be named as the CLI's own copy,
338
+ // while a published one is pinned by deckId so the deck can be told about kit
339
+ // releases and take them, without waiting for a CLI release.
305
340
  function scaffoldFromKitImport(kit, projectDir) {
306
341
  const kitDir = requireKitDir(kit);
307
342
  // Qualified like any other import (`<author>.<deck>`), and qualified NOW even
@@ -326,12 +361,12 @@ function scaffoldFromKitImport(kit, projectDir) {
326
361
  // the import pin.
327
362
  writeJsonFile(path.join(projectDir, "castle.json"), {
328
363
  ...(kitConfig.editor ? { editor: kitConfig.editor } : {}),
329
- imports: { [alias]: { source: "builtin", kit, version: getCliVersion() } },
364
+ imports: { [alias]: makeKitPin(kitConfig, kit) },
330
365
  });
331
366
  // The kit's code runs from THIS deck's node_modules, so its dependencies are
332
367
  // declared here (see syncImportDependencies, which does the same for imports
333
368
  // added later).
334
- const { sdkRef } = resolveScaffoldRefs();
369
+ const { sdkRef, cliCommand } = resolveScaffoldRefs();
335
370
  const dependencies = {};
336
371
  for (const [name, range] of Object.entries(kitPkg.dependencies ?? {})) {
337
372
  dependencies[name] =
@@ -343,6 +378,7 @@ function scaffoldFromKitImport(kit, projectDir) {
343
378
  name: path.basename(projectDir),
344
379
  private: true,
345
380
  type: "module",
381
+ scripts: makeKitDeckScripts(kitDir, alias, cliCommand),
346
382
  dependencies,
347
383
  });
348
384
  fs.writeFileSync(path.join(projectDir, "CLAUDE.md"), makeImportedKitClaudeMd(alias));
@@ -7,6 +7,29 @@ export type MeteringRoute = "anthropic" | "openrouter";
7
7
  * background-task spend" a prefix match with no extra column.
8
8
  */
9
9
  export declare function newAgentSessionId(role: "router" | "task"): string;
10
+ /**
11
+ * Report a cursor run to the llm-proxy, which stamps the user + sandbox from
12
+ * our token and forwards it to the ledger alongside the anthropic/openrouter
13
+ * rows the proxy records itself.
14
+ *
15
+ * Cursor needs this because it is the one backend the proxy cannot see:
16
+ * cursor-agent has no base-URL override, so it talks to Cursor directly on its
17
+ * own key. There are no token counts to send -- cursor-agent's stream-json
18
+ * reports none -- so a row is a run count with its deck, agent run, model, and
19
+ * outcome, and the ledger's NULL tokens read as "not measured".
20
+ *
21
+ * Fire-and-forget by contract: the run is already over and its output already
22
+ * reached the user, so every failure here is swallowed. A dropped post loses
23
+ * one row rather than retrying, which is the right trade for a signal whose
24
+ * value is aggregate.
25
+ */
26
+ export declare function reportCursorRun(opts: {
27
+ deckDir: string;
28
+ sessionId: string;
29
+ model: string;
30
+ durationMs: number;
31
+ ok: boolean;
32
+ }): void;
10
33
  export declare function meteringHeaders(opts: {
11
34
  deckDir: string;
12
35
  sessionId: string;
package/dist/metering.js CHANGED
@@ -48,6 +48,58 @@ function deckIdFor(deckDir) {
48
48
  return null;
49
49
  return parsed.deckId.replace(/[\r\n]/g, "") || null;
50
50
  }
51
+ // Path on the proxy that accepts a run the proxy never routed. Must match
52
+ // CASTLE_USAGE_PATH in castle-sandboxes/shared/src/index.ts.
53
+ const CASTLE_USAGE_PATH = "/castle/usage";
54
+ // A metering post must never delay a turn that has already finished, and its
55
+ // result is never awaited -- so a proxy that has gone away costs one socket
56
+ // timeout in the background, not a hung run.
57
+ const USAGE_POST_TIMEOUT_MS = 5_000;
58
+ /**
59
+ * Report a cursor run to the llm-proxy, which stamps the user + sandbox from
60
+ * our token and forwards it to the ledger alongside the anthropic/openrouter
61
+ * rows the proxy records itself.
62
+ *
63
+ * Cursor needs this because it is the one backend the proxy cannot see:
64
+ * cursor-agent has no base-URL override, so it talks to Cursor directly on its
65
+ * own key. There are no token counts to send -- cursor-agent's stream-json
66
+ * reports none -- so a row is a run count with its deck, agent run, model, and
67
+ * outcome, and the ledger's NULL tokens read as "not measured".
68
+ *
69
+ * Fire-and-forget by contract: the run is already over and its output already
70
+ * reached the user, so every failure here is swallowed. A dropped post loses
71
+ * one row rather than retrying, which is the right trade for a signal whose
72
+ * value is aggregate.
73
+ */
74
+ export function reportCursorRun(opts) {
75
+ const base = process.env.CASTLE_LLM_PROXY_URL;
76
+ const token = process.env.CASTLE_LLM_PROXY_TOKEN;
77
+ // Absent outside a sandbox, and on a host whose proxy predates this route --
78
+ // both cases correctly report nothing rather than posting into the void.
79
+ if (!base || !token)
80
+ return;
81
+ const deckId = deckIdFor(opts.deckDir);
82
+ void fetch(`${base}${CASTLE_USAGE_PATH}`, {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/json",
86
+ authorization: `Bearer ${token}`,
87
+ },
88
+ body: JSON.stringify({
89
+ provider: "cursor",
90
+ model: opts.model,
91
+ ...(deckId ? { deckId } : {}),
92
+ agentSessionId: opts.sessionId,
93
+ durationMs: opts.durationMs,
94
+ // A cursor run has no HTTP status; the ledger's column is one, and
95
+ // `>= 400 means it failed` is the reading every other row already gets.
96
+ status: opts.ok ? 200 : 500,
97
+ }),
98
+ signal: AbortSignal.timeout(USAGE_POST_TIMEOUT_MS),
99
+ }).catch(() => {
100
+ /* best-effort: metering must never surface in a finished run */
101
+ });
102
+ }
51
103
  export function meteringHeaders(opts) {
52
104
  if (opts.direct || !proxyInjected(opts.route))
53
105
  return {};
@@ -3,11 +3,30 @@
3
3
  "initialPanels": [
4
4
  {
5
5
  "width": 400,
6
- "column": [{ "type": "playtest" }, { "type": "files" }]
6
+ "column": [
7
+ {
8
+ "type": "playtest"
9
+ },
10
+ {
11
+ "type": "files"
12
+ }
13
+ ]
7
14
  },
8
- { "type": "editor", "file": "scenes/main.scene" }
15
+ {
16
+ "type": "editor",
17
+ "file": "scenes/main.scene"
18
+ }
9
19
  ],
10
20
  "hiddenPaths": [],
11
- "visiblePaths": ["drawings/**", "scenes/**", "blueprints/**", "behaviors/**"]
12
- }
21
+ "visiblePaths": [
22
+ "drawings/**",
23
+ "scenes/**",
24
+ "blueprints/**",
25
+ "behaviors/**"
26
+ ]
27
+ },
28
+ "deckId": "5R39TxaRfLLQ",
29
+ "cardId": "AZk0PWHTrFcT",
30
+ "title": "basic-2d",
31
+ "publishedVersion": "2026-07-28T17:52:37.298Z"
13
32
  }
@@ -1,5 +1,5 @@
1
1
  // Discover every behavior class from `behaviors/*.jsx` and the physics module's
2
- // `physics/behaviors/*.jsx`. Vite HMR is off, so a newly-added behavior file is
2
+ // `physics/behaviors/**/*.jsx`. Vite HMR is off, so a newly-added behavior file is
3
3
  // picked up on the next reload/restart. The physics glob is what lets the
4
4
  // self-contained physics module ship its behaviors without polluting the core
5
5
  // `behaviors/` folder — a kit adopts physics by copying `physics/` in.
@@ -8,10 +8,10 @@
8
8
  // own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
9
9
  // deck defines wins over one of the same name from a dependency.
10
10
  const modules = {
11
- ...import.meta.glob('/imports/*/behaviors/*.jsx', { eager: true }),
12
- ...import.meta.glob('/imports/*/physics/behaviors/*.jsx', { eager: true }),
13
- ...import.meta.glob('/behaviors/*.jsx', { eager: true }),
14
- ...import.meta.glob('/physics/behaviors/*.jsx', { eager: true }),
11
+ ...import.meta.glob('/imports/*/behaviors/**/*.jsx', { eager: true }),
12
+ ...import.meta.glob('/imports/*/physics/behaviors/**/*.jsx', { eager: true }),
13
+ ...import.meta.glob('/behaviors/**/*.jsx', { eager: true }),
14
+ ...import.meta.glob('/physics/behaviors/**/*.jsx', { eager: true }),
15
15
  };
16
16
  function isBehaviorClass(value) {
17
17
  return typeof value === 'function' && typeof value.behaviorName === 'string';
@@ -2,7 +2,7 @@
2
2
  // add fields to a behavior defined in the shared core WITHOUT editing that
3
3
  // behavior's file -- so the core behavior stays byte-identical across kits and
4
4
  // the module owns its own additions. Each extension module under any
5
- // `<module>/extensions/*.js` exports:
5
+ // `<module>/extensions/**/*.js` exports:
6
6
  //
7
7
  // export const behaviorExtension = {
8
8
  // behaviorName: 'Collider',
@@ -16,8 +16,8 @@
16
16
  // basic-2d). Symmetric with engine/systemRegistry.js and editors/behaviorRegistry.js.
17
17
  // Root-anchored, deck + imports (see engine/files.js).
18
18
  const modules = {
19
- ...import.meta.glob('/imports/*/*/extensions/*.js', { eager: true }),
20
- ...import.meta.glob('/*/extensions/*.js', { eager: true }),
19
+ ...import.meta.glob('/imports/*/*/extensions/**/*.js', { eager: true }),
20
+ ...import.meta.glob('/*/extensions/**/*.js', { eager: true }),
21
21
  };
22
22
  const extensions = Object.values(modules)
23
23
  .map((mod) => mod.behaviorExtension)
@@ -8,20 +8,23 @@
8
8
  // The first glob adds every import's content, keyed by its full path
9
9
  // (`imports/<alias>/drawings/rock.pxart`) -- which is exactly how a scene or
10
10
  // blueprint refers to a dependency's file, so no lookup elsewhere changes.
11
+ // Recursive (`**`), because the files panel shows these folders recursively and
12
+ // so lets you organize inside them -- a flat `drawings/*.pxart` meant art moved
13
+ // into a subfolder vanished from the map, and so from the editor and the game.
11
14
  // (Patterns and options must be literals: import.meta.glob is a compile-time
12
15
  // transform, so these can't be hoisted into shared constants.)
13
16
  const rawModules = {
14
17
  ...import.meta.glob(
15
18
  [
16
- '/imports/*/scenes/*.scene',
17
- '/imports/*/blueprints/*.scene',
18
- '/imports/*/drawings/*.pxart',
19
- '/imports/*/behaviors/*.jsx',
19
+ '/imports/*/scenes/**/*.scene',
20
+ '/imports/*/blueprints/**/*.scene',
21
+ '/imports/*/drawings/**/*.pxart',
22
+ '/imports/*/behaviors/**/*.jsx',
20
23
  ],
21
24
  { query: '?raw', import: 'default', eager: true }
22
25
  ),
23
26
  ...import.meta.glob(
24
- ['/scenes/*.scene', '/blueprints/*.scene', '/drawings/*.pxart', '/behaviors/*.jsx'],
27
+ ['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/behaviors/**/*.jsx'],
25
28
  { query: '?raw', import: 'default', eager: true }
26
29
  ),
27
30
  };
@@ -8,8 +8,8 @@
8
8
  // with editors/behaviorRegistry.js.
9
9
  // Root-anchored, deck + imports (see engine/files.js).
10
10
  const modules = {
11
- ...import.meta.glob('/imports/*/systems/*.js', { eager: true }),
12
- ...import.meta.glob('/systems/*.js', { eager: true }),
11
+ ...import.meta.glob('/imports/*/systems/**/*.js', { eager: true }),
12
+ ...import.meta.glob('/systems/**/*.js', { eager: true }),
13
13
  };
14
14
  export const systemInstallers = Object.values(modules)
15
15
  .map((mod) => mod.installSystem)
@@ -3,11 +3,30 @@
3
3
  "initialPanels": [
4
4
  {
5
5
  "width": 400,
6
- "column": [{ "type": "playtest" }, { "type": "files" }]
6
+ "column": [
7
+ {
8
+ "type": "playtest"
9
+ },
10
+ {
11
+ "type": "files"
12
+ }
13
+ ]
7
14
  },
8
- { "type": "editor", "file": "scenes/main.scene" }
15
+ {
16
+ "type": "editor",
17
+ "file": "scenes/main.scene"
18
+ }
9
19
  ],
10
20
  "hiddenPaths": [],
11
- "visiblePaths": ["drawings/**", "scenes/**", "blueprints/**", "behaviors/**"]
12
- }
21
+ "visiblePaths": [
22
+ "drawings/**",
23
+ "scenes/**",
24
+ "blueprints/**",
25
+ "behaviors/**"
26
+ ]
27
+ },
28
+ "deckId": "ckRZGFW4iPrx",
29
+ "cardId": "_oBFbAW6DxsO",
30
+ "title": "physics-2d",
31
+ "publishedVersion": "2026-07-28T17:50:39.741Z"
13
32
  }
@@ -1,5 +1,5 @@
1
1
  // Discover every behavior class from `behaviors/*.jsx` and the physics module's
2
- // `physics/behaviors/*.jsx`. Vite HMR is off, so a newly-added behavior file is
2
+ // `physics/behaviors/**/*.jsx`. Vite HMR is off, so a newly-added behavior file is
3
3
  // picked up on the next reload/restart. The physics glob is what lets the
4
4
  // self-contained physics module ship its behaviors without polluting the core
5
5
  // `behaviors/` folder — a kit adopts physics by copying `physics/` in.
@@ -8,10 +8,10 @@
8
8
  // own behaviors last: collectBehaviors keys by behaviorName, so a behavior the
9
9
  // deck defines wins over one of the same name from a dependency.
10
10
  const modules = {
11
- ...import.meta.glob('/imports/*/behaviors/*.jsx', { eager: true }),
12
- ...import.meta.glob('/imports/*/physics/behaviors/*.jsx', { eager: true }),
13
- ...import.meta.glob('/behaviors/*.jsx', { eager: true }),
14
- ...import.meta.glob('/physics/behaviors/*.jsx', { eager: true }),
11
+ ...import.meta.glob('/imports/*/behaviors/**/*.jsx', { eager: true }),
12
+ ...import.meta.glob('/imports/*/physics/behaviors/**/*.jsx', { eager: true }),
13
+ ...import.meta.glob('/behaviors/**/*.jsx', { eager: true }),
14
+ ...import.meta.glob('/physics/behaviors/**/*.jsx', { eager: true }),
15
15
  };
16
16
  function isBehaviorClass(value) {
17
17
  return typeof value === 'function' && typeof value.behaviorName === 'string';
@@ -8,20 +8,23 @@
8
8
  // The first glob adds every import's content, keyed by its full path
9
9
  // (`imports/<alias>/drawings/rock.pxart`) -- which is exactly how a scene or
10
10
  // blueprint refers to a dependency's file, so no lookup elsewhere changes.
11
+ // Recursive (`**`), because the files panel shows these folders recursively and
12
+ // so lets you organize inside them -- a flat `drawings/*.pxart` meant art moved
13
+ // into a subfolder vanished from the map, and so from the editor and the game.
11
14
  // (Patterns and options must be literals: import.meta.glob is a compile-time
12
15
  // transform, so these can't be hoisted into shared constants.)
13
16
  const rawModules = {
14
17
  ...import.meta.glob(
15
18
  [
16
- '/imports/*/scenes/*.scene',
17
- '/imports/*/blueprints/*.scene',
18
- '/imports/*/drawings/*.pxart',
19
- '/imports/*/behaviors/*.jsx',
19
+ '/imports/*/scenes/**/*.scene',
20
+ '/imports/*/blueprints/**/*.scene',
21
+ '/imports/*/drawings/**/*.pxart',
22
+ '/imports/*/behaviors/**/*.jsx',
20
23
  ],
21
24
  { query: '?raw', import: 'default', eager: true }
22
25
  ),
23
26
  ...import.meta.glob(
24
- ['/scenes/*.scene', '/blueprints/*.scene', '/drawings/*.pxart', '/behaviors/*.jsx'],
27
+ ['/scenes/**/*.scene', '/blueprints/**/*.scene', '/drawings/**/*.pxart', '/behaviors/**/*.jsx'],
25
28
  { query: '?raw', import: 'default', eager: true }
26
29
  ),
27
30
  };
@@ -8,8 +8,8 @@
8
8
  // with editors/behaviorRegistry.js.
9
9
  // Root-anchored, deck + imports (see engine/files.js).
10
10
  const modules = {
11
- ...import.meta.glob('/imports/*/systems/*.js', { eager: true }),
12
- ...import.meta.glob('/systems/*.js', { eager: true }),
11
+ ...import.meta.glob('/imports/*/systems/**/*.js', { eager: true }),
12
+ ...import.meta.glob('/systems/**/*.js', { eager: true }),
13
13
  };
14
14
  export const systemInstallers = Object.values(modules)
15
15
  .map((mod) => mod.installSystem)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.95",
3
+ "version": "0.4.97",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"