@crouton-kit/crouter 0.3.61 → 0.3.63

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 (114) hide show
  1. package/dist/builtin-views/canvas/core.mjs +2 -1
  2. package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
  3. package/dist/clients/attach/__tests__/context-message.test.js +46 -0
  4. package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
  6. package/dist/clients/attach/attach-cmd.js +747 -743
  7. package/dist/clients/attach/canvas-panels.d.ts +4 -4
  8. package/dist/clients/attach/canvas-panels.js +108 -37
  9. package/dist/clients/attach/chat-view.js +11 -0
  10. package/dist/clients/attach/context-message.d.ts +3 -1
  11. package/dist/clients/attach/context-message.js +30 -21
  12. package/dist/clients/attach/graph-overlay.d.ts +8 -1
  13. package/dist/clients/attach/graph-overlay.js +201 -22
  14. package/dist/clients/attach/transport-relay.d.ts +51 -0
  15. package/dist/clients/attach/transport-relay.js +196 -0
  16. package/dist/clients/attach/transport-socket.d.ts +29 -0
  17. package/dist/clients/attach/transport-socket.js +164 -0
  18. package/dist/clients/attach/transport.d.ts +32 -0
  19. package/dist/clients/attach/transport.js +26 -0
  20. package/dist/clients/attach/view-socket.d.ts +40 -44
  21. package/dist/clients/attach/view-socket.js +94 -197
  22. package/dist/commands/memory/delete.js +3 -3
  23. package/dist/commands/memory/origin.js +1 -1
  24. package/dist/commands/memory/read.js +3 -3
  25. package/dist/commands/memory/shared.d.ts +8 -6
  26. package/dist/commands/memory/shared.js +23 -9
  27. package/dist/commands/memory/write.js +3 -3
  28. package/dist/commands/node-worktree.js +9 -9
  29. package/dist/commands/node.js +71 -23
  30. package/dist/commands/sys/doctor.js +1 -0
  31. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  32. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  33. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  34. package/dist/core/__tests__/context-intro.test.js +11 -0
  35. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  36. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  37. package/dist/core/__tests__/full/broker-crash-teardown.test.js +17 -3
  38. package/dist/core/__tests__/kickoff.test.js +21 -0
  39. package/dist/core/__tests__/spawn-root.test.js +60 -0
  40. package/dist/core/__tests__/worktree.test.js +24 -1
  41. package/dist/core/canvas/browse/app.d.ts +2 -0
  42. package/dist/core/canvas/browse/app.js +83 -48
  43. package/dist/core/canvas/db.js +3 -2
  44. package/dist/core/canvas/index.d.ts +1 -0
  45. package/dist/core/canvas/index.js +1 -0
  46. package/dist/core/canvas/nav-model.d.ts +5 -3
  47. package/dist/core/canvas/nav-model.js +7 -6
  48. package/dist/core/canvas/paths.d.ts +8 -0
  49. package/dist/core/canvas/paths.js +10 -0
  50. package/dist/core/canvas/render.d.ts +6 -0
  51. package/dist/core/canvas/render.js +117 -20
  52. package/dist/core/canvas/source.d.ts +26 -0
  53. package/dist/core/canvas/source.js +29 -0
  54. package/dist/core/canvas/status-glyph.js +1 -0
  55. package/dist/core/canvas/types.d.ts +4 -3
  56. package/dist/core/config.d.ts +18 -2
  57. package/dist/core/config.js +88 -10
  58. package/dist/core/fault-classifier.d.ts +2 -2
  59. package/dist/core/memory-resolver.d.ts +9 -6
  60. package/dist/core/memory-resolver.js +28 -4
  61. package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
  62. package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
  63. package/dist/core/profiles/manifest.d.ts +2 -0
  64. package/dist/core/profiles/manifest.js +40 -7
  65. package/dist/core/profiles/select.d.ts +4 -4
  66. package/dist/core/profiles/select.js +13 -13
  67. package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
  69. package/dist/core/runtime/bearings.d.ts +5 -4
  70. package/dist/core/runtime/bearings.js +12 -6
  71. package/dist/core/runtime/broker.js +59 -7
  72. package/dist/core/runtime/fault-recovery.js +4 -0
  73. package/dist/core/runtime/front-door.js +1 -1
  74. package/dist/core/runtime/host.js +43 -3
  75. package/dist/core/runtime/kickoff.js +6 -0
  76. package/dist/core/runtime/launch.d.ts +6 -1
  77. package/dist/core/runtime/launch.js +7 -1
  78. package/dist/core/runtime/nodes.d.ts +7 -6
  79. package/dist/core/runtime/nodes.js +3 -3
  80. package/dist/core/runtime/situational-context.d.ts +12 -0
  81. package/dist/core/runtime/situational-context.js +48 -0
  82. package/dist/core/runtime/spawn-env.d.ts +27 -0
  83. package/dist/core/runtime/spawn-env.js +169 -0
  84. package/dist/core/runtime/spawn.d.ts +16 -7
  85. package/dist/core/runtime/spawn.js +8 -2
  86. package/dist/core/substrate/session-cache.js +7 -1
  87. package/dist/core/substrate/subject.d.ts +1 -1
  88. package/dist/core/view/remote-canvas-target.d.ts +3 -0
  89. package/dist/core/view/remote-canvas-target.js +25 -0
  90. package/dist/core/worktree.d.ts +4 -4
  91. package/dist/core/worktree.js +41 -19
  92. package/dist/daemon/crtrd.js +32 -0
  93. package/dist/daemon/predicate-eval.js +12 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +4 -0
  96. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  97. package/dist/pi-extensions/canvas-bash-valve.js +238 -0
  98. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
  99. package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
  100. package/dist/types.d.ts +32 -0
  101. package/dist/types.js +7 -0
  102. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  103. package/dist/web-client/assets/{index-BQufdMhB.js → index-BvzxXXGU.js} +18 -18
  104. package/dist/web-client/index.html +2 -2
  105. package/dist/web-client/sw.js +1 -1
  106. package/docs/public-api.md +5 -3
  107. package/package.json +2 -2
  108. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  109. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  110. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  111. package/dist/web-client/assets/index-FjfN-jvN.css +0 -2
  112. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  113. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
  114. package/dist/web-client/workbox-98f7a950.js +0 -1
@@ -174,6 +174,73 @@ export function renderForest() {
174
174
  }
175
175
  return parts.join('\n\n');
176
176
  }
177
+ async function nodeLineFromSource(source, nodeId, indent, connector) {
178
+ const node = await source.getNode(nodeId);
179
+ if (node === null)
180
+ return `${indent}${connector}? <missing meta: ${nodeId}>`;
181
+ const hanging = hangingFor(node);
182
+ const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
183
+ const tel = readNodeTelemetry(nodeId);
184
+ const ctx = fmtCtx(tel.tokens_in);
185
+ const asks = countAsks(nodeId);
186
+ const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
187
+ const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
188
+ return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
189
+ }
190
+ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out) {
191
+ if (visited.has(nodeId))
192
+ return;
193
+ visited.add(nodeId);
194
+ const connector = isLast ? '└─ ' : '├─ ';
195
+ out.push(await nodeLineFromSource(source, nodeId, indent, connector));
196
+ const children = await source.subscriptionsOf(nodeId).catch(() => []);
197
+ const childIndent = indent + (isLast ? ' ' : '│ ');
198
+ for (let i = 0; i < children.length; i++) {
199
+ const child = children[i];
200
+ const childIsLast = i === children.length - 1;
201
+ if (visited.has(child.node_id)) {
202
+ const cycleConnector = childIsLast ? '└─ ' : '├─ ';
203
+ out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
204
+ continue;
205
+ }
206
+ await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out);
207
+ }
208
+ }
209
+ export async function renderTreeFromSource(source, rootId) {
210
+ const node = await source.getNode(rootId);
211
+ if (node === null)
212
+ return `? <missing node: ${rootId}>`;
213
+ const tel = readNodeTelemetry(rootId);
214
+ const ctx = fmtCtx(tel.tokens_in);
215
+ const asks = countAsks(rootId);
216
+ const askSuffix = asks > 0 ? ` ⚑${asks}` : '';
217
+ const hanging = hangingFor(node);
218
+ const glyph = resolveNodeVisual(node.status, { hanging }).glyph;
219
+ const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
220
+ const out = [];
221
+ out.push(`${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`);
222
+ const visited = new Set([rootId]);
223
+ const children = await source.subscriptionsOf(rootId).catch(() => []);
224
+ for (let i = 0; i < children.length; i++) {
225
+ const child = children[i];
226
+ const isLast = i === children.length - 1;
227
+ await walkTreeFromSource(source, child.node_id, '', isLast, visited, out);
228
+ }
229
+ return out.join('\n');
230
+ }
231
+ export async function renderForestFromSource(source) {
232
+ const all = await source.listNodes();
233
+ if (all.length === 0)
234
+ return '(canvas is empty)';
235
+ const roots = all.filter((n) => n.parent === null && (n.status === 'active' || n.status === 'idle'));
236
+ if (roots.length === 0)
237
+ return '(no live roots)';
238
+ const parts = [];
239
+ for (const r of roots) {
240
+ parts.push(await renderTreeFromSource(source, r.node_id));
241
+ }
242
+ return parts.join('\n\n');
243
+ }
177
244
  /** The spawn prompt, read straight off disk (canvas-home state) and capped so a
178
245
  * giant initial-prompt.md can't bloat the snapshot. Mirrors how telemetry is
179
246
  * read here directly rather than via the runtime layer (which would invert the
@@ -433,26 +500,31 @@ function sessionMtime(nodeId, created) {
433
500
  */
434
501
  export function dashboardRowsAll() {
435
502
  const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
436
- return listNodes().map((row) => {
437
- const live = row.status === 'active' || row.status === 'idle';
438
- const hanging = activeFaultForDisplay(row, readFault(row.node_id));
439
- return {
440
- node_id: row.node_id,
441
- name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
442
- status: row.status,
443
- kind: row.kind,
444
- mode: row.mode,
445
- ctx_tokens: 0, // lazy: enrichRow
446
- asks: 0, // lazy: enrichRow / enrichRows
447
- cwd: row.cwd,
448
- created: row.created,
449
- lifecycle: row.lifecycle,
450
- mtimeMs: sessionMtime(row.node_id, row.created),
451
- streaming: hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
452
- hanging,
453
- viewed: focusedNodeIds.has(row.node_id),
454
- };
455
- });
503
+ return listNodes().map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
504
+ }
505
+ function dashboardRowFromNodeRow(row, focusedNodeIds) {
506
+ const live = row.status === 'active' || row.status === 'idle';
507
+ const hanging = activeFaultForDisplay(row, readFault(row.node_id));
508
+ return {
509
+ node_id: row.node_id,
510
+ name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
511
+ status: row.status,
512
+ kind: row.kind,
513
+ mode: row.mode,
514
+ ctx_tokens: 0, // lazy: enrichRow
515
+ asks: 0, // lazy: enrichRow / enrichRows
516
+ cwd: row.cwd,
517
+ created: row.created,
518
+ lifecycle: row.lifecycle,
519
+ mtimeMs: sessionMtime(row.node_id, row.created),
520
+ streaming: hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
521
+ hanging,
522
+ viewed: focusedNodeIds.has(row.node_id),
523
+ };
524
+ }
525
+ export async function dashboardRowsAllFromSource(source) {
526
+ const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
527
+ return (await source.listNodes()).map((row) => dashboardRowFromNodeRow(row, focusedNodeIds));
456
528
  }
457
529
  // ---------------------------------------------------------------------------
458
530
  // Lazy enrichment API — fold the expensive per-node reads into rows on demand,
@@ -493,6 +565,20 @@ export function enrichRows(rows) {
493
565
  row.enriched = true;
494
566
  }
495
567
  }
568
+ export async function enrichRowsFromSource(source, rows) {
569
+ const todo = rows.filter((r) => r.enriched !== true);
570
+ if (todo.length === 0)
571
+ return;
572
+ const asks = asksForNodes(todo.map((r) => r.node_id));
573
+ await Promise.all(todo.map(async (row) => {
574
+ const meta = await source.getNode(row.node_id);
575
+ if (meta !== null)
576
+ row.name = fullName(meta);
577
+ row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
578
+ row.asks = asks[row.node_id] ?? 0;
579
+ row.enriched = true;
580
+ }));
581
+ }
496
582
  /** Load the SELECTED row's preview text: the spawn `goal` (initial-prompt.md) plus
497
583
  * the whole-conversation `prompts` and the `lastAssistant` reply — the latter two
498
584
  * folded into ONE session-file read (see {@link readSessionParts}). Mutates the row
@@ -509,3 +595,14 @@ export function loadPreview(row) {
509
595
  row.previewLoaded = true;
510
596
  return row;
511
597
  }
598
+ export async function loadPreviewFromSource(source, row) {
599
+ if (row.previewLoaded === true)
600
+ return row;
601
+ row.goal = readGoalText(row.node_id);
602
+ const meta = await source.getNode(row.node_id);
603
+ const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
604
+ row.prompts = prompts;
605
+ row.lastAssistant = lastAssistant;
606
+ row.previewLoaded = true;
607
+ return row;
608
+ }
@@ -0,0 +1,26 @@
1
+ import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
2
+ export interface CanvasSource {
3
+ getNode(nodeId: string): Promise<NodeMeta | null>;
4
+ getRow(nodeId: string): Promise<NodeRow | null>;
5
+ listNodes(filter?: {
6
+ status?: NodeStatus | NodeStatus[];
7
+ }): Promise<NodeRow[]>;
8
+ subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
9
+ subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
10
+ view(root: string): Promise<string[]>;
11
+ askCountsForView(root: string): Promise<Record<string, number>>;
12
+ hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
13
+ }
14
+ export declare class LocalCanvasSource implements CanvasSource {
15
+ getNode(nodeId: string): Promise<NodeMeta | null>;
16
+ getRow(nodeId: string): Promise<NodeRow | null>;
17
+ listNodes(filter?: {
18
+ status?: NodeStatus | NodeStatus[];
19
+ }): Promise<NodeRow[]>;
20
+ subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
21
+ subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
22
+ view(root: string): Promise<string[]>;
23
+ askCountsForView(root: string): Promise<Record<string, number>>;
24
+ hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
25
+ }
26
+ export declare const localCanvasSource: CanvasSource;
@@ -0,0 +1,29 @@
1
+ import { getNode, getRow, listNodes, subscriptionsOf, subscribersOf, view, hasActiveLiveSubscription, } from './canvas.js';
2
+ import { asksForNodes } from './attention.js';
3
+ export class LocalCanvasSource {
4
+ async getNode(nodeId) {
5
+ return getNode(nodeId);
6
+ }
7
+ async getRow(nodeId) {
8
+ return getRow(nodeId);
9
+ }
10
+ async listNodes(filter) {
11
+ return listNodes(filter);
12
+ }
13
+ async subscriptionsOf(subscriber) {
14
+ return subscriptionsOf(subscriber);
15
+ }
16
+ async subscribersOf(publisher) {
17
+ return subscribersOf(publisher);
18
+ }
19
+ async view(root) {
20
+ return view(root);
21
+ }
22
+ async askCountsForView(root) {
23
+ return asksForNodes([root, ...view(root)]);
24
+ }
25
+ async hasActiveLiveSubscription(nodeId) {
26
+ return hasActiveLiveSubscription(nodeId);
27
+ }
28
+ }
29
+ export const localCanvasSource = new LocalCanvasSource();
@@ -45,6 +45,7 @@ export function hangingLabel(kind) {
45
45
  case 'auth': return 'auth';
46
46
  case 'protocol': return 'protocol';
47
47
  case 'wedged': return 'wedged';
48
+ case 'model-not-found': return 'model-not-found';
48
49
  default: return 'other';
49
50
  }
50
51
  }
@@ -73,7 +73,8 @@ export interface NodeIdentity {
73
73
  * to the row (like `cwd`/`host_kind`), NOT re-derived by `rebuildIndex()` —
74
74
  * it is chosen once at spawn (inherited from the spawner, or `--profile`) and
75
75
  * is the runtime→resolver seam (`CRTR_PROFILE_ID`) the memory/gate substrate
76
- * reads. NULL for a root/no-profile node never invent a literal root id. */
76
+ * reads. NULL only for historical root/no-profile rows; new root conversations
77
+ * use the stable root profile id. */
77
78
  profile_id?: string | null;
78
79
  /** Role the node was born as: explore | developer | plan | review | general… */
79
80
  kind: string;
@@ -200,8 +201,8 @@ export interface NodeRow {
200
201
  cwd: string;
201
202
  /** Which HOST launches + supervises this node; see NodeIdentity.host_kind. */
202
203
  host_kind: 'tmux' | 'broker' | null;
203
- /** The profile this node runs under; see NodeIdentity.profile_id. NULL for a
204
- * root/no-profile node. */
204
+ /** The profile this node runs under; see NodeIdentity.profile_id. NULL only
205
+ * for historical root/no-profile rows. */
205
206
  profile_id: string | null;
206
207
  parent: string | null;
207
208
  created: string;
@@ -9,6 +9,13 @@ export declare function ensureScopeInitialized(scope: Scope, root: string): void
9
9
  export interface MergedLaunchConfig {
10
10
  kinds: Record<string, KindConfig>;
11
11
  modelLadders: ModelLaddersConfig;
12
+ /** The merged `spawnEnv.allow` set (project stack > profile > user >
13
+ * builtin, additive) — read by `buildOperationalEnvBase`
14
+ * (`core/runtime/spawn-env.ts`) as source C of the broker spawn-env
15
+ * boundary. */
16
+ spawnEnv: {
17
+ allow: string[];
18
+ };
12
19
  }
13
20
  /** Merge launch knobs (`kinds`, `modelLadders`) across scopes in
14
21
  * project stack > profile > user > builtin precedence — the same precedence
@@ -29,8 +36,17 @@ export interface MergedLaunchConfig {
29
36
  * Callers (launch, kind registry) go through this function rather than
30
37
  * `readConfig` directly, so `ScopeConfig.kinds`/`modelLadders`/tools/
31
38
  * extensions/`availableTo` all honor the same profile + multi-project
32
- * precedence. */
33
- export declare function readMergedLaunchConfig(): MergedLaunchConfig;
39
+ * precedence.
40
+ *
41
+ * `targetCwd`/`targetProfileId` default to this PROCESS's own ambient cwd
42
+ * and `CRTR_PROFILE_ID` — correct for a caller resolving config for itself
43
+ * (front-door commands, kind listing). A caller resolving config on behalf
44
+ * of a DIFFERENT node (the broker spawn-env boundary, predicate-eval — both
45
+ * run inside the daemon, whose ambient cwd/profile is the daemon's own, not
46
+ * the target node's) MUST pass the target's own cwd/profile explicitly, or
47
+ * `spawnEnv.allow` resolves from the wrong scope entirely (the C-1
48
+ * follow-up fix this parameterization exists for). */
49
+ export declare function readMergedLaunchConfig(targetCwd?: string, targetProfileId?: string | null): MergedLaunchConfig;
34
50
  /** The effective `KindConfig` for one full kind string (top-level, e.g.
35
51
  * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
36
52
  * project > user > builtin precedence. Returns `undefined` for a kind no
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState, defaultCanvasNavConfig, defaultModelLaddersConfig, defaultKindsConfig } from '../types.js';
2
+ import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState, defaultCanvasNavConfig, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
3
3
  import { readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
4
4
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
5
5
  import { profileRoot } from './profiles/manifest.js';
@@ -176,6 +176,33 @@ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
176
176
  }
177
177
  return out;
178
178
  }
179
+ /** True when `pattern` (a `spawnEnv.allow` entry) matches EVERY possible env
180
+ * var name — a bare `*`, or any run of nothing but `*` characters (`**`,
181
+ * `***`, …). This is the one glob shape that reopens the inherit-all hole
182
+ * the whole spawn-env boundary exists to close, so it is rejected loud
183
+ * rather than silently treated as literal or silently dropped (per
184
+ * `cto-rejects-fallback-hedges`: fix the config, don't hedge around it). */
185
+ function isInheritAllGlob(pattern) {
186
+ return pattern !== '' && pattern.replace(/\*/g, '') === '';
187
+ }
188
+ /** Merge a raw `spawnEnv.allow` list over `base` (defaulting to empty):
189
+ * additive/union, not override — each scope only ADDS a name/glob it
190
+ * explicitly admits across the broker spawn-env boundary (`buildBrokerEnv`),
191
+ * never removes a lower-precedence scope's entry. Non-string entries are
192
+ * dropped, never thrown, mirroring `mergeKinds`/`mergeModelLadders` — but an
193
+ * inherit-all glob (`isInheritAllGlob`) throws immediately at config read: it
194
+ * is not a shape any legitimate entry can mean, and silently accepting or
195
+ * dropping it would either reopen the leak or hide a config mistake. */
196
+ function mergeSpawnEnv(raw, base = []) {
197
+ const r = raw;
198
+ const extra = Array.isArray(r?.allow) ? r.allow.filter((v) => typeof v === 'string') : [];
199
+ for (const pattern of extra) {
200
+ if (isInheritAllGlob(pattern)) {
201
+ throw new Error(`spawnEnv.allow: "${pattern}" matches every env var name (inherit-all) — this is exactly the leak the spawn-env boundary closes. Name the specific vars/prefixes you actually need instead (e.g. "AWS_*", "GIT_ASKPASS").`);
202
+ }
203
+ }
204
+ return { allow: [...new Set([...base, ...extra])] };
205
+ }
179
206
  /** Validate one raw `kinds.<name>` entry into a `KindConfig`, or drop it
180
207
  * (return null) rather than throwing — an invalid kind entry in config.json
181
208
  * must not break config parsing for every other kind. `whenToUse` is the
@@ -216,6 +243,41 @@ function mergeKinds(raw, base = defaultKindsConfig()) {
216
243
  }
217
244
  return out;
218
245
  }
246
+ /** Validate one raw `remoteCanvas.targets.<name>` entry, or drop it (return
247
+ * null) rather than throwing — same rule `normalizeKindEntry` follows. A
248
+ * valid entry needs a non-empty `previewEndpoint` and `relayTokenEnv`; the
249
+ * token itself is never stored here (see `RemoteCanvasTarget`). */
250
+ function normalizeRemoteCanvasTarget(raw) {
251
+ if (raw === null || typeof raw !== 'object')
252
+ return null;
253
+ const r = raw;
254
+ if (typeof r.previewEndpoint !== 'string' || r.previewEndpoint.trim() === '')
255
+ return null;
256
+ if (typeof r.relayTokenEnv !== 'string' || r.relayTokenEnv.trim() === '')
257
+ return null;
258
+ const out = { previewEndpoint: r.previewEndpoint, relayTokenEnv: r.relayTokenEnv };
259
+ if (typeof r.cpOrigin === 'string' && r.cpOrigin.trim() !== '')
260
+ out.cpOrigin = r.cpOrigin;
261
+ return out;
262
+ }
263
+ /** Merge a raw `remoteCanvas` block over the built-in defaults (no targets):
264
+ * each valid `targets.<name>` entry adds or shadows a target by name;
265
+ * invalid entries are dropped, never thrown. Mirrors `mergeKinds`'s
266
+ * layer-over-defaults shape. */
267
+ function mergeRemoteCanvas(raw, base = defaultRemoteCanvasConfig()) {
268
+ const out = { ...base.targets };
269
+ if (raw !== null && typeof raw === 'object') {
270
+ const r = raw;
271
+ if (r.targets !== null && typeof r.targets === 'object') {
272
+ for (const [name, value] of Object.entries(r.targets)) {
273
+ const normalized = normalizeRemoteCanvasTarget(value);
274
+ if (normalized !== null)
275
+ out[name] = normalized;
276
+ }
277
+ }
278
+ }
279
+ return { targets: out };
280
+ }
219
281
  function normalizeMode(value, fallback) {
220
282
  if (value === true)
221
283
  return 'notify';
@@ -247,7 +309,9 @@ function mergeConfig(partial) {
247
309
  const canvasNav = mergeCanvasNav(partial.canvasNav);
248
310
  const modelLadders = mergeModelLadders(partial.modelLadders);
249
311
  const kinds = mergeKinds(partial.kinds);
250
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav, modelLadders, kinds };
312
+ const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
313
+ const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
314
+ return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav, modelLadders, kinds, remoteCanvas, spawnEnv };
251
315
  }
252
316
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
253
317
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -281,9 +345,11 @@ function readRawProfileConfig(profileId) {
281
345
  * — every ancestor `.crouter/`, widened by a selected profile's `projects`),
282
346
  * ordered FARTHEST-first so `readMergedLaunchConfig` can layer them on last
283
347
  * and have the NEAREST root win — mirroring the resolver's nearest-project-
284
- * strongest replacement precedence. */
285
- function readRawProjectScopeConfigs() {
286
- const nearestFirst = findProjectScopeRoots();
348
+ * strongest replacement precedence. Walks from `targetCwd`/`targetProfileId`
349
+ * — the SCOPE THE CONFIG IS FOR, not necessarily the calling process's own
350
+ * ambient cwd/profile (see `readMergedLaunchConfig`). */
351
+ function readRawProjectScopeConfigs(targetCwd, targetProfileId) {
352
+ const nearestFirst = findProjectScopeRoots(targetCwd, targetProfileId);
287
353
  const farthestFirst = [...nearestFirst].reverse();
288
354
  const out = [];
289
355
  for (const root of farthestFirst) {
@@ -312,23 +378,35 @@ function readRawProjectScopeConfigs() {
312
378
  * Callers (launch, kind registry) go through this function rather than
313
379
  * `readConfig` directly, so `ScopeConfig.kinds`/`modelLadders`/tools/
314
380
  * extensions/`availableTo` all honor the same profile + multi-project
315
- * precedence. */
316
- export function readMergedLaunchConfig() {
381
+ * precedence.
382
+ *
383
+ * `targetCwd`/`targetProfileId` default to this PROCESS's own ambient cwd
384
+ * and `CRTR_PROFILE_ID` — correct for a caller resolving config for itself
385
+ * (front-door commands, kind listing). A caller resolving config on behalf
386
+ * of a DIFFERENT node (the broker spawn-env boundary, predicate-eval — both
387
+ * run inside the daemon, whose ambient cwd/profile is the daemon's own, not
388
+ * the target node's) MUST pass the target's own cwd/profile explicitly, or
389
+ * `spawnEnv.allow` resolves from the wrong scope entirely (the C-1
390
+ * follow-up fix this parameterization exists for). */
391
+ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileId = process.env['CRTR_PROFILE_ID'] || null) {
317
392
  const defaults = defaultScopeConfig();
318
393
  const userRaw = readRawScopeConfig('user');
319
- const profileRaw = readRawProfileConfig(process.env['CRTR_PROFILE_ID'] || null);
320
- const projectRawsFarthestFirst = readRawProjectScopeConfigs();
394
+ const profileRaw = readRawProfileConfig(targetProfileId);
395
+ const projectRawsFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
321
396
  let kinds = mergeKinds(userRaw?.kinds, defaults.kinds);
322
397
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
398
+ let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
323
399
  if (profileRaw !== null) {
324
400
  kinds = mergeKinds(profileRaw.kinds, kinds);
325
401
  modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
402
+ spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
326
403
  }
327
404
  for (const raw of projectRawsFarthestFirst) {
328
405
  kinds = mergeKinds(raw.kinds, kinds);
329
406
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
407
+ spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
330
408
  }
331
- return { kinds, modelLadders };
409
+ return { kinds, modelLadders, spawnEnv };
332
410
  }
333
411
  /** The effective `KindConfig` for one full kind string (top-level, e.g.
334
412
  * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
@@ -1,5 +1,5 @@
1
- export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'browser↔relay' | 'cli→exa' | 'daemon→node';
2
- export type FaultKind = 'rate-limit' | 'overloaded' | 'connection' | 'auth' | 'protocol' | 'other' | 'wedged';
1
+ export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'browser↔relay' | 'cli→exa' | 'daemon→node' | 'crtr→pi';
2
+ export type FaultKind = 'rate-limit' | 'overloaded' | 'connection' | 'auth' | 'protocol' | 'other' | 'wedged' | 'model-not-found';
3
3
  export type FaultRetryDisposition = 'auto' | 'manual' | 'fatal';
4
4
  export type FaultRetryOwner = 'sdk' | 'daemon' | 'client';
5
5
  export interface FaultRetry {
@@ -2,7 +2,7 @@ import type { InstalledPlugin, Scope } from '../types.js';
2
2
  /**
3
3
  * Thin memory-document resolver for the document substrate. Precedence is the
4
4
  * OUTER loop, not a global scan: scopes/sources are tried NEAREST-FIRST
5
- * (project stack nearest > ... > profile > user > builtin), and WITHIN each
5
+ * (node > project stack nearest > ... > profile > user > builtin), and WITHIN each
6
6
  * source, in order: (1) exact substrate-identity match (`doc.name === query`
7
7
  * — the explicit frontmatter `name`, or its path-derived fallback), (2) direct
8
8
  * `memory/<name>.md` physical-path lookup (plus the bare-dir/bare-plugin-name
@@ -38,11 +38,14 @@ export interface MemoryDoc {
38
38
  plugin?: string;
39
39
  }
40
40
  /** The memory-only scope union: the global `Scope` (`user|project|builtin`)
41
- * plus `profile`. Confined to the memory resolver/list/read/write/render
42
- * paths — deliberately NOT folded into the global `Scope` union, because that
43
- * would leak `profile` into config, plugin, marketplace, view, and doctor/
44
- * sys-config surfaces the spec does not extend. */
45
- export type MemoryScope = Scope | 'profile';
41
+ * plus `profile` and `node`. Confined to the memory resolver/list/read/write/
42
+ * render paths — deliberately NOT folded into the global `Scope` union, because
43
+ * that would leak these into config, plugin, marketplace, view, and doctor/
44
+ * sys-config surfaces the spec does not extend. `node` is the this-node store
45
+ * (`nodes/<id>/context/memory/`), available only inside a running node
46
+ * (`CRTR_NODE_ID`), and it is the NEAREST scope — a node doc overrides a
47
+ * same-named doc at any wider scope. */
48
+ export type MemoryScope = Scope | 'profile' | 'node';
46
49
  export interface MemoryResolutionOpts {
47
50
  /** Restrict resolution to a single scope. Conflicts with a scope prefix on
48
51
  * the identifier (e.g. `user/foo` with scope=project) throw. */
@@ -8,6 +8,7 @@ import { warn } from './output.js';
8
8
  import { pluginMemoryDir, projectScopeRoot, projectScopeRoots, scopeMemoryDir } from './scope.js';
9
9
  import { normalizeDocName, normalizeNameSegment, resolveDocName } from './substrate/schema.js';
10
10
  import { loadProfileManifest, profileMemoryDir } from './profiles/manifest.js';
11
+ import { memoryDir as nodeMemoryDir } from './runtime/memory.js';
11
12
  /** Canonical, unambiguous identifier for a memory document: `<scope>/<name>`. */
12
13
  export function memoryDocId(doc) {
13
14
  return `${doc.scope}/${doc.name}`;
@@ -18,14 +19,24 @@ export function memoryDocId(doc) {
18
19
  function selectedProfileId() {
19
20
  return process.env['CRTR_PROFILE_ID'] || '';
20
21
  }
21
- /** The memory scopes in resolution precedence: project stack > profile > user >
22
- * builtin. Project is included only when a project scope exists for the cwd
23
- * (or the selected profile's project stack); profile is included only when a
24
- * profile is selected. A single `scope` narrows to just that scope. */
22
+ /** The current node id from the process env, or '' when not running inside a
23
+ * node. The seam the `node` scope resolves through its memory dir is
24
+ * `nodeMemoryDir(id)` (`nodes/<id>/context/memory/`). */
25
+ function selectedNodeId() {
26
+ return process.env['CRTR_NODE_ID'] || '';
27
+ }
28
+ /** The memory scopes in resolution precedence: node > project stack > profile >
29
+ * user > builtin. Node is included only inside a running node (`CRTR_NODE_ID`)
30
+ * and ranks NEAREST — a node doc overrides any wider scope. Project is included
31
+ * only when a project scope exists for the cwd (or the selected profile's
32
+ * project stack); profile is included only when a profile is selected. A single
33
+ * `scope` narrows to just that scope. */
25
34
  function scopesInPrecedence(scope) {
26
35
  if (scope)
27
36
  return [scope];
28
37
  const out = [];
38
+ if (selectedNodeId() !== '')
39
+ out.push('node');
29
40
  if (projectScopeRoot())
30
41
  out.push('project');
31
42
  if (selectedProfileId() !== '')
@@ -65,6 +76,13 @@ function memorySourcesInPrecedence(scope) {
65
76
  }
66
77
  }
67
78
  }
79
+ else if (s === 'node') {
80
+ // The this-node store — a singleton dir in the node's context, no plugins.
81
+ // Silently absent when not running inside a node (CRTR_NODE_ID unset).
82
+ const nodeId = selectedNodeId();
83
+ if (nodeId !== '')
84
+ out.push({ scope: 'node', memoryDir: nodeMemoryDir(nodeId), plugins: [] });
85
+ }
68
86
  else {
69
87
  out.push({
70
88
  scope: s,
@@ -147,6 +165,12 @@ export function listMemoryDocs(scope, quiet = false) {
147
165
  return [];
148
166
  }
149
167
  }
168
+ if (scope === 'node') {
169
+ const nodeId = selectedNodeId();
170
+ if (nodeId === '')
171
+ return [];
172
+ return listMemoryDocsInDir('node', nodeMemoryDir(nodeId), quiet);
173
+ }
150
174
  return listMemoryDocsInDir(scope, scopeMemoryDir(scope), quiet);
151
175
  }
152
176
  /** All of one plugin's substrate docs, mounted under the virtual `<pluginName>/`
@@ -0,0 +1,69 @@
1
+ import { test, before, after, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ensureRootProfile, listProfiles, loadProfileManifest, profileManifestPath, profileMemoryDir, profileRoot, ROOT_PROFILE_ID, } from '../manifest.js';
7
+ import { selectProfileForCwd } from '../select.js';
8
+ import { resetScopeCache } from '../../scope.js';
9
+ let home;
10
+ let cwd;
11
+ let previousHome;
12
+ let previousCwd;
13
+ function forceNonInteractive() {
14
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true });
15
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true, writable: true });
16
+ }
17
+ function restoreInteractiveFlags() {
18
+ Reflect.deleteProperty(process.stdin, 'isTTY');
19
+ Reflect.deleteProperty(process.stdout, 'isTTY');
20
+ }
21
+ before(() => {
22
+ previousHome = process.env.HOME;
23
+ previousCwd = process.cwd();
24
+ });
25
+ beforeEach(() => {
26
+ home = mkdtempSync(join(tmpdir(), 'crtr-root-profile-home-'));
27
+ cwd = mkdtempSync(join(tmpdir(), 'crtr-root-profile-cwd-'));
28
+ process.env.HOME = home;
29
+ process.chdir(cwd);
30
+ forceNonInteractive();
31
+ resetScopeCache();
32
+ });
33
+ afterEach(() => {
34
+ restoreInteractiveFlags();
35
+ process.chdir(previousCwd);
36
+ rmSync(home, { recursive: true, force: true });
37
+ rmSync(cwd, { recursive: true, force: true });
38
+ resetScopeCache();
39
+ });
40
+ after(() => {
41
+ process.chdir(previousCwd);
42
+ if (previousHome === undefined)
43
+ delete process.env.HOME;
44
+ else
45
+ process.env.HOME = previousHome;
46
+ resetScopeCache();
47
+ });
48
+ test('ensureRootProfile creates the stable root profile and is idempotent', () => {
49
+ const first = ensureRootProfile();
50
+ const second = ensureRootProfile();
51
+ assert.equal(first.profileId, ROOT_PROFILE_ID);
52
+ assert.equal(second.profileId, ROOT_PROFILE_ID);
53
+ assert.deepEqual(second, first);
54
+ assert.equal(first.manifest.name, 'root');
55
+ assert.deepEqual(first.manifest.projects, []);
56
+ assert.equal(existsSync(profileRoot(ROOT_PROFILE_ID)), true);
57
+ assert.equal(existsSync(profileManifestPath(ROOT_PROFILE_ID)), true);
58
+ assert.equal(existsSync(profileMemoryDir(ROOT_PROFILE_ID)), true);
59
+ assert.deepEqual(listProfiles().map(({ profileId }) => profileId), [ROOT_PROFILE_ID]);
60
+ });
61
+ test('selectProfileForCwd falls back to the stable root profile when nothing covers cwd', async () => {
62
+ const profileId = await selectProfileForCwd(cwd);
63
+ assert.equal(profileId, ROOT_PROFILE_ID);
64
+ const entry = loadProfileManifest(ROOT_PROFILE_ID);
65
+ assert.equal(entry.profileId, ROOT_PROFILE_ID);
66
+ assert.equal(entry.manifest.name, 'root');
67
+ assert.equal(entry.manifest.last_used_at !== null, true);
68
+ assert.equal(existsSync(profileRoot(ROOT_PROFILE_ID)), true);
69
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ProfileManifest } from '../../types.js';
2
+ export declare const ROOT_PROFILE_ID = "root-00000000";
2
3
  export declare function profilesRoot(): string;
3
4
  export declare function profileRoot(profileId: string): string;
4
5
  export declare function profileManifestPath(profileId: string): string;
@@ -25,6 +26,7 @@ export declare function loadProfileManifest(profileIdOrName: string): ProfileEnt
25
26
  * mutations (create/rename/add-project/remove-project/delete/last-used)
26
27
  * below run inside this. */
27
28
  export declare function withProfileManifestLock<T>(profileId: string, fn: () => T): T;
29
+ export declare function ensureRootProfile(): ProfileEntry;
28
30
  export declare function createProfile(name: string, projects?: string[]): ProfileEntry;
29
31
  export declare function updateProfileLastUsed(profileId: string): ProfileEntry;
30
32
  export declare function renameProfile(profileId: string, name: string): ProfileEntry;