@crouton-kit/crouter 0.3.60 → 0.3.62

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 (120) 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/clients/web/server.js +1 -0
  23. package/dist/commands/memory/delete.js +3 -3
  24. package/dist/commands/memory/origin.js +1 -1
  25. package/dist/commands/memory/read.js +3 -3
  26. package/dist/commands/memory/shared.d.ts +8 -6
  27. package/dist/commands/memory/shared.js +23 -9
  28. package/dist/commands/memory/write.js +3 -3
  29. package/dist/commands/node-worktree.js +9 -9
  30. package/dist/commands/node.js +71 -23
  31. package/dist/commands/sys/doctor.js +1 -0
  32. package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
  33. package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
  35. package/dist/core/__tests__/context-intro.test.js +11 -0
  36. package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
  37. package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
  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/apple-touch-icon.png +0 -0
  103. package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
  104. package/dist/web-client/assets/index-BvzxXXGU.js +77 -0
  105. package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  106. package/dist/web-client/icon-192.png +0 -0
  107. package/dist/web-client/icon-512.png +0 -0
  108. package/dist/web-client/icon-maskable-512.png +0 -0
  109. package/dist/web-client/index.html +6 -3
  110. package/dist/web-client/manifest.webmanifest +1 -0
  111. package/dist/web-client/sw.js +1 -0
  112. package/docs/public-api.md +5 -3
  113. package/package.json +4 -3
  114. package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  115. package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  116. package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  117. package/dist/web-client/assets/index-72YQdji_.js +0 -77
  118. package/dist/web-client/assets/index-bDhImjJy.css +0 -2
  119. package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  120. package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
@@ -18,7 +18,7 @@ import { connect } from 'node:net';
18
18
  import { getNode } from '../canvas/index.js';
19
19
  import { nodeDir, jobDir, viewSocketPath } from '../canvas/paths.js';
20
20
  import { encodeFrame } from './broker-protocol.js';
21
- import { FRONT_DOOR_ENV } from './front-door.js';
21
+ import { buildBrokerEnv } from './spawn-env.js';
22
22
  import { hostExecPath } from './branded-host.js';
23
23
  import { isPidAlive, killProcessTreePids, isAnyPidAlive, captureTeardownSnapshot } from '../canvas/pid.js';
24
24
  import { logger } from '../log.js';
@@ -36,6 +36,17 @@ function resolveBrokerEntry() {
36
36
  return join(here, 'broker-cli.js');
37
37
  }
38
38
  const DEFAULT_BROKER_ENGINE = '@earendil-works/pi-coding-agent';
39
+ /** Resolve the broker engine spec for THIS launch, mirroring broker-sdk.ts's
40
+ * own resolution order (`inv.env` first, then the launching process's ambient
41
+ * `CRTR_BROKER_ENGINE` — the T11 test seam — then the real SDK default).
42
+ * Called once by `preflightBrokerLaunch` (to validate resolvability before we
43
+ * detach a process) and again by `launch` (to inject the SAME resolved value
44
+ * into the child's env — see the child-env comment at the spawn() call for
45
+ * why: the default-deny allowlist in spawn-env.ts strips `CRTR_*` from
46
+ * ambient env, so the child can no longer re-derive this itself). */
47
+ function resolveEngineSpec(inv) {
48
+ return inv.env['CRTR_BROKER_ENGINE'] ?? process.env['CRTR_BROKER_ENGINE'] ?? DEFAULT_BROKER_ENGINE;
49
+ }
39
50
  function isPlainLaunchSessionPath(token) {
40
51
  return token.includes('/') || token.endsWith('.jsonl');
41
52
  }
@@ -67,7 +78,7 @@ export function preflightBrokerLaunch(nodeId, inv, cwd) {
67
78
  if (!Array.isArray(recipe.argv)) {
68
79
  throw new Error(`[broker] refusing to launch ${nodeId}: broker-launch.json is missing the argv array`);
69
80
  }
70
- const engineSpec = inv.env['CRTR_BROKER_ENGINE'] ?? process.env['CRTR_BROKER_ENGINE'] ?? DEFAULT_BROKER_ENGINE;
81
+ const engineSpec = resolveEngineSpec(inv);
71
82
  try {
72
83
  const resolvedEngine = import.meta.resolve(engineSpec);
73
84
  if (resolvedEngine.startsWith('file:') && !existsSync(fileURLToPath(resolvedEngine))) {
@@ -179,11 +190,40 @@ export const headlessBrokerHost = {
179
190
  // Launch from the crouter-branded host binary (a copy of node) so the
180
191
  // broker shows "crouter" in macOS Full Disk Access, not "node". On
181
192
  // non-darwin / dev / tests this is just process.execPath (see branded-host).
193
+ // buildBrokerEnv's default-deny allowlist has no `CRTR_` prefix, so a bare
194
+ // `CRTR_BROKER_ENGINE` set only in the LAUNCHING process's ambient env (the
195
+ // T11 test seam — see broker-sdk.ts) would never reach the child, which
196
+ // would silently fall back to booting the real SDK. Inject the SAME value
197
+ // preflightBrokerLaunch already resolved+validated above: it is
198
+ // parent-resolved, crtr-trusted data (the same trust category as
199
+ // `inv.env`), not a blanket `CRTR_*` passthrough, so this does not weaken
200
+ // the allowlist's hardening. In production `engineSpec` is always the real
201
+ // SDK default, so this is a no-op there.
202
+ const engineSpec = resolveEngineSpec(inv);
203
+ const childEnv = { ...buildBrokerEnv(inv), CRTR_BROKER_ENGINE: engineSpec };
204
+ // NODE_OPTIONS is deliberately absent from the operational allowlist (it is
205
+ // a code-execution vector — `--require`/`--import` — and DAEMON_ENV_STRIP_KEYS
206
+ // strips it from the daemon's own env for the identical reason: a poisoned
207
+ // NODE_OPTIONS in an inheriting shell must never reach a process that then
208
+ // handles model credentials). It is genuinely needed ONLY for the T11 test
209
+ // seam above: the fake engine is a `.ts` fixture, and the broker CLI entry
210
+ // itself is only a `.js` file under the compiled `dist/`, so the harness
211
+ // relies on `NODE_OPTIONS=--import tsx/esm` (set on its own ambient env, same
212
+ // seam as CRTR_BROKER_ENGINE) to make the spawned child resolve both under
213
+ // tsx. Gating this carry on `engineSpec !== DEFAULT_BROKER_ENGINE` — i.e. only
214
+ // when the trusted test seam above already fired — keeps production (where
215
+ // the engine is never overridden) byte-identical to today: NODE_OPTIONS is
216
+ // never forwarded there, so an ambient poisoned value stays contained.
217
+ if (engineSpec !== DEFAULT_BROKER_ENGINE) {
218
+ const nodeOptions = inv.env['NODE_OPTIONS'] ?? process.env['NODE_OPTIONS'];
219
+ if (nodeOptions !== undefined)
220
+ childEnv['NODE_OPTIONS'] = nodeOptions;
221
+ }
182
222
  const child = spawn(hostExecPath(), [resolveBrokerEntry(), nodeId], {
183
223
  cwd: opts.cwd,
184
224
  detached: true,
185
225
  stdio: ['ignore', logFd, logFd],
186
- env: { ...process.env, ...inv.env, [FRONT_DOOR_ENV]: '1' },
226
+ env: childEnv,
187
227
  });
188
228
  // The child holds its own dup of the fd; release the parent's copy so the
189
229
  // launching process (CLI or daemon) never leaks it.
@@ -249,6 +249,12 @@ export function buildReviveKickoff(meta, bearings, wakeReason) {
249
249
  }
250
250
  const files = listContextDir(nodeId);
251
251
  parts.push(`<context-dir path="${contextDir(nodeId)}">\n${files.length > 0 ? files.join('\n') : '(empty)'}\n</context-dir>`);
252
+ // Hidden applet/situation-origin ambient context (see situational-context.ts)
253
+ // is deliberately NOT injected here — this kickoff is the VISIBLE revive prompt.
254
+ // canvas-context-intro's session_start handler re-fires on every fresh/cycling
255
+ // revive (a new pi process boot) and re-injects buildContextIntro(), which
256
+ // already carries the sidecar as a hidden sibling of <crtr-bearings>; that seam
257
+ // covers this path, so duplicating it into the visible prompt would leak it.
252
258
  parts.push(feedBlock(nodeId, bearings.unreadDigest));
253
259
  parts.push(bearings.yieldMsg !== null
254
260
  ? `<yield-message>\n${bearings.yieldMsg.trim()}\n</yield-message>`
@@ -9,6 +9,7 @@ export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
9
9
  export declare const CANVAS_CONTEXT_INTRO_PATH: string;
10
10
  export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
11
11
  export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
12
+ export declare const CANVAS_BASH_VALVE_PATH: string;
12
13
  /** The canvas extensions every node loads, in order: stophook (routing +
13
14
  * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
14
15
  * graph chrome), recap (the per-node inactivity recap card — Haiku over the
@@ -18,7 +19,11 @@ export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
18
19
  * context-intro (inject the <crtr-bearings> block + the <memory kind="knowledge">
19
20
  * catalog as its own session message, once per brand-new chat),
20
21
  * doc-substrate (the unified document substrate's two hooks: the
21
- * <memory kind="preference"> block at boot, on-read context injection).
22
+ * <memory kind="preference"> block at boot, on-read context injection),
23
+ * bash-valve (replaces the builtin bash tool with a file-backed exec
24
+ * backend: commands under 5min are byte-identical to today; past 5min
25
+ * with no explicit timeout, the tool call returns with output-so-far and
26
+ * the command keeps running detached, alerting via inbox on exit).
22
27
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
23
28
  * reads the raw user text. */
24
29
  export declare const CANVAS_EXTENSIONS: string[];
@@ -36,6 +36,7 @@ export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-cont
36
36
  export const CANVAS_CONTEXT_INTRO_PATH = resolveExtension('canvas-context-intro');
37
37
  export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate');
38
38
  export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
39
+ export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
39
40
  /** The canvas extensions every node loads, in order: stophook (routing +
40
41
  * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
41
42
  * graph chrome), recap (the per-node inactivity recap card — Haiku over the
@@ -45,7 +46,11 @@ export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured
45
46
  * context-intro (inject the <crtr-bearings> block + the <memory kind="knowledge">
46
47
  * catalog as its own session message, once per brand-new chat),
47
48
  * doc-substrate (the unified document substrate's two hooks: the
48
- * <memory kind="preference"> block at boot, on-read context injection).
49
+ * <memory kind="preference"> block at boot, on-read context injection),
50
+ * bash-valve (replaces the builtin bash tool with a file-backed exec
51
+ * backend: commands under 5min are byte-identical to today; past 5min
52
+ * with no explicit timeout, the tool call returns with output-so-far and
53
+ * the command keeps running detached, alerting via inbox on exit).
49
54
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
50
55
  * reads the raw user text. */
51
56
  export const CANVAS_EXTENSIONS = [
@@ -58,6 +63,7 @@ export const CANVAS_EXTENSIONS = [
58
63
  CANVAS_CONTEXT_INTRO_PATH,
59
64
  CANVAS_DOC_SUBSTRATE_PATH,
60
65
  CANVAS_STRUCTURED_OUTPUT_PATH,
66
+ CANVAS_BASH_VALVE_PATH,
61
67
  ];
62
68
  // ---------------------------------------------------------------------------
63
69
  // Two-axis model resolution: (provider × strength) → concrete `model:thinking`.
@@ -47,12 +47,13 @@ export interface SpawnNodeOpts {
47
47
  * persona default. Persisted to `meta.model_override` so polymorphs preserve
48
48
  * it. Omit to use the persona default. */
49
49
  modelOverride?: string | null;
50
- /** The profile-directory id (`<slug>-<id>`) this node runs under; null for a
51
- * root/no-profile node. Written to `meta.profile_id` and the row, and mirrored
52
- * into the pi process env as `CRTR_PROFILE_ID` by `nodeEnv` — the runtime
53
- * → resolver seam every downstream memory/gate consumer reads. Callers
54
- * resolve/validate the operand (via `loadProfileManifest`) before it reaches
55
- * here; this layer just persists the already-resolved id. */
50
+ /** The profile-directory id (`<slug>-<id>`) this node runs under; null only
51
+ * for historical root/no-profile nodes. Written to `meta.profile_id` and the
52
+ * row, and mirrored into the pi process env as `CRTR_PROFILE_ID` by
53
+ * `nodeEnv` — the runtime → resolver seam every downstream memory/gate
54
+ * consumer reads. Callers resolve/validate the operand (via
55
+ * `loadProfileManifest`) before it reaches here; this layer just persists the
56
+ * already-resolved id. */
56
57
  profile_id?: string | null;
57
58
  /** Managed worktree already created for this node, if any. */
58
59
  managedWorktree?: ManagedWorktree;
@@ -65,9 +65,9 @@ export function nodeEnv(meta) {
65
65
  CRTR_CONTEXT_DIR: contextDir(meta.node_id),
66
66
  CRTR_CYCLES: (meta.cycles ?? 0).toString(),
67
67
  // The runtime→resolver seam: memory-resolver.ts/scope.ts/config.ts read
68
- // this process-wide env var (Phase 5) to pull in the node's profile memory
69
- // store and multi-project pointer set. '' (never omitted) for a root/
70
- // no-profile node, matching every other CRTR_* scalar here.
68
+ // this process-wide env var to pull in the node's profile memory store and
69
+ // multi-project pointer set. '' (never omitted) only for historical
70
+ // null-profile rows, matching every other CRTR_* scalar here.
71
71
  CRTR_PROFILE_ID: meta.profile_id ?? '',
72
72
  };
73
73
  if (meta.parent)
@@ -0,0 +1,12 @@
1
+ /** The `customType` stamped on the hidden custom message the inbox watcher
2
+ * sends for an existing node's situational-context update. */
3
+ export declare const SITUATIONAL_CONTEXT_CUSTOM_TYPE = "crtr-situational-context";
4
+ /** Upsert the node's current situational context (replace, not append) — the
5
+ * one sanctioned writer for the sidecar. No-op for blank text, so a caller
6
+ * can pass through an optional/empty value with no guard at the call site. */
7
+ export declare function appendSituationalContext(nodeId: string, text: string): void;
8
+ /** Read the current situational context, or null if none was ever set. */
9
+ export declare function readSituationalContext(nodeId: string): string | null;
10
+ /** The `<situational-context>` block for `nodeId`, or '' when none is set —
11
+ * callers append/filter this like any other optional sibling block. */
12
+ export declare function situationalContextBlock(nodeId: string): string;
@@ -0,0 +1,48 @@
1
+ // situational-context.ts — the hidden ambient "current situation" sidecar.
2
+ //
3
+ // A node-owned runtime file (nodes/<id>/situational-context.md, NOT under
4
+ // context/ — see `situationalContextPath`) carrying the LATEST applet/
5
+ // situation-origin ambient text: current state (e.g. which applet/page a
6
+ // concierge is attached to right now), not task history, and never the node's
7
+ // goal or a visible chat message. Upserted (replace), never appended: only the
8
+ // newest block is ever carried, so stale applet pages never accumulate across
9
+ // concierge reuse.
10
+ //
11
+ // Read at three injection seams, always rendered as a `<situational-context>`
12
+ // block SIBLING to whatever bearings/kickoff frame is being built (never
13
+ // nested inside it, and never routed through coalesce()'s visible digest
14
+ // renderer):
15
+ // - buildContextBearings() — fresh node kickoff (canvas-context-intro).
16
+ // - buildReviveKickoff() — fresh/cycling revive.
17
+ // - canvas-inbox-watcher's flush() — a hidden pi.sendMessage custom message
18
+ // for an existing live/dormant node, delivered ahead of (and never as
19
+ // part of) the visible inbox digest.
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import { situationalContextPath, nodeDir } from '../canvas/paths.js';
22
+ /** The `customType` stamped on the hidden custom message the inbox watcher
23
+ * sends for an existing node's situational-context update. */
24
+ export const SITUATIONAL_CONTEXT_CUSTOM_TYPE = 'crtr-situational-context';
25
+ /** Upsert the node's current situational context (replace, not append) — the
26
+ * one sanctioned writer for the sidecar. No-op for blank text, so a caller
27
+ * can pass through an optional/empty value with no guard at the call site. */
28
+ export function appendSituationalContext(nodeId, text) {
29
+ const body = text.trim();
30
+ if (body === '')
31
+ return;
32
+ mkdirSync(nodeDir(nodeId), { recursive: true });
33
+ writeFileSync(situationalContextPath(nodeId), body + '\n', 'utf8');
34
+ }
35
+ /** Read the current situational context, or null if none was ever set. */
36
+ export function readSituationalContext(nodeId) {
37
+ const p = situationalContextPath(nodeId);
38
+ if (!existsSync(p))
39
+ return null;
40
+ const body = readFileSync(p, 'utf8').trim();
41
+ return body !== '' ? body : null;
42
+ }
43
+ /** The `<situational-context>` block for `nodeId`, or '' when none is set —
44
+ * callers append/filter this like any other optional sibling block. */
45
+ export function situationalContextBlock(nodeId) {
46
+ const text = readSituationalContext(nodeId);
47
+ return text === null ? '' : `<situational-context>\n${text}\n</situational-context>`;
48
+ }
@@ -0,0 +1,27 @@
1
+ import type { PiInvocation } from './launch.js';
2
+ /** Sources A–C: the operational base shared by both the broker boundary (this
3
+ * module's `buildBrokerEnv`) and the predicate-eval `when_script` path
4
+ * (`daemon/predicate-eval.ts`) — the single shared definition of "non-secret
5
+ * host state the runtime exposes to any spawned shell." Does not include
6
+ * `inv.env`/`FRONT_DOOR_ENV` (source D), which is broker-specific. */
7
+ export declare function buildOperationalEnvBase(opts: {
8
+ /** The TARGET node's cwd — never the calling/daemon process's own ambient
9
+ * `process.cwd()`. Threaded into `readMergedLaunchConfig` so source C
10
+ * (`spawnEnv.allow`) resolves from the TARGET's own project-scope config,
11
+ * not whichever cwd happened to launch the process that builds this env. */
12
+ targetCwd: string;
13
+ /** The TARGET node's profile id — never the calling process's own ambient
14
+ * `CRTR_PROFILE_ID`. `null`/`''` for no profile. */
15
+ targetProfileId: string | null;
16
+ host?: NodeJS.ProcessEnv;
17
+ }): NodeJS.ProcessEnv;
18
+ /** The one authoritative broker child env: sources A–C (the operational
19
+ * base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
20
+ * `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
21
+ * `process.cwd()`/`CRTR_PROFILE_ID`, which may be a launcher's or the
22
+ * daemon's own and belong to an unrelated scope) plus D — the trusted,
23
+ * crtr-constructed `inv.env` overlay and the fork-bomb recursion guard
24
+ * `FRONT_DOOR_ENV=1`. Every broker launch (front-door root, managed child,
25
+ * `--root`, every revive/daemon relaunch) funnels through
26
+ * `headlessBrokerHost.launch()`, which calls this. */
27
+ export declare function buildBrokerEnv(inv: PiInvocation): NodeJS.ProcessEnv;
@@ -0,0 +1,169 @@
1
+ // The spawn-env boundary (design: C-1 crtr substrate spawn-env boundary).
2
+ //
3
+ // One authoritative builder for every broker child's process env. The prior
4
+ // shape was `{ ...process.env, ...inv.env }` — the whole host env, including
5
+ // every ambient credential (`MISTRAL_API_KEY`, `GITHUB_TOKEN`, `AWS_*`, …),
6
+ // inherited verbatim into the broker and, through it, into pi's bash tool
7
+ // (`printenv`/`ps eww` both expose it). This module replaces that spread with
8
+ // a default-deny allowlist: nothing in `process.env` crosses unless it is a
9
+ // literal non-secret operational name (A1), a value-guarded proxy var (A2), a
10
+ // configured model-provider key (B), or an explicit `spawnEnv.allow` entry
11
+ // (C). `inv.env` (D) is crtr-constructed and trusted, so it overlays
12
+ // unconditionally.
13
+ //
14
+ // `GIT_*` is deliberately absent from the allowlist: the whole namespace is an
15
+ // auth/config-injection surface (`GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*`,
16
+ // `GIT_ASKPASS`, `GIT_SSH_COMMAND`), not a non-secret operational family, and
17
+ // ordinary git needs none of it (identity/credential-helper config via
18
+ // `~/.gitconfig`, reached through the allowlisted `HOME`; SSH remotes via the
19
+ // allowlisted `SSH_AUTH_SOCK`). A deliberate `GIT_*` need goes through
20
+ // `spawnEnv.allow`.
21
+ import { readMergedLaunchConfig } from '../config.js';
22
+ import { FRONT_DOOR_ENV } from './front-door.js';
23
+ /** A1 — literal env-var names copied verbatim (no value inspection): a login
24
+ * shell plus dev toolchain needs exactly these, and none of them can carry a
25
+ * credential. */
26
+ const OPERATIONAL_LITERAL_NAMES = new Set([
27
+ 'HOME',
28
+ 'PATH',
29
+ 'SHELL',
30
+ 'USER',
31
+ 'LOGNAME',
32
+ 'PWD',
33
+ 'TMPDIR',
34
+ 'TERM',
35
+ 'TERMINFO',
36
+ 'COLORTERM',
37
+ 'LANG',
38
+ 'LANGUAGE',
39
+ 'TZ',
40
+ 'NO_PROXY',
41
+ 'no_proxy',
42
+ 'SSH_AUTH_SOCK',
43
+ // Bash safety-valve deadline override (ms) — an operator-wide knob set in
44
+ // the daemon's own ambient env, not a per-node secret.
45
+ 'CRTR_BASH_VALVE_MS',
46
+ ]);
47
+ /** A1 — prefixes admitted in full: each is a structurally non-secret family
48
+ * (locale, XDG base-dir paths, pi's own config/rendering knobs — pi's auth is
49
+ * file-backed in `auth.json`, never an env value). `GIT_` is NOT here — see
50
+ * module comment. */
51
+ const OPERATIONAL_PREFIXES = ['LC_', 'XDG_', 'PI_'];
52
+ /** A2 — proxy var names. Admitted only when the value's URL authority carries
53
+ * no userinfo component (no `user@` / `user:pass@`) — the one value-inspected
54
+ * entry in the whole builder, because a proxy var is the single operational
55
+ * name whose *value* (not name) can carry a secret. */
56
+ const PROXY_NAMES = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
57
+ /** B — the exact env-var name of each crtr-configured model provider's API
58
+ * key. Deliberately an explicit provider→env-key mapping, NOT inferred from
59
+ * `MODEL_PROVIDER_KEYS` in launch.ts (that constant is a provider-ID list —
60
+ * `['anthropic', 'openai']` — not env-var names; conflating the two would
61
+ * silently admit the wrong names the moment either list changes). */
62
+ const PROVIDER_ENV_KEYS = {
63
+ anthropic: 'ANTHROPIC_API_KEY',
64
+ openai: 'OPENAI_API_KEY',
65
+ };
66
+ /** True when `value` is an absolute `http(s)` proxy URL with a host and NO
67
+ * embedded userinfo (`user@` / `user:pass@` / `:pass@`) — the only shape
68
+ * allowed to cross by default. Rejects three leak shapes a naive
69
+ * `new URL(value).username === ''` check misses:
70
+ * - password-only authority (`http://:pass@host`) — `username` is empty but
71
+ * `password` is not, so both must be checked.
72
+ * - scheme-less userinfo (`user:pass@host:3128`, no `http(s)://` prefix) —
73
+ * `URL` parses the text before the first `:` as a CUSTOM scheme (here
74
+ * `user:`), leaving `username`/`password` both empty even though the
75
+ * value plainly carries credentials. Requiring `protocol` to be exactly
76
+ * `http:`/`https:` rejects this shape before userinfo parsing could ever
77
+ * be fooled by it, rather than normalizing/re-parsing a scheme-less form.
78
+ * - anything else unparseable, protocol-mismatched, or hostless — safe-fail
79
+ * to "never admit" rather than silently passing a value through. */
80
+ function isCredentialFreeProxyValue(value) {
81
+ let parsed;
82
+ try {
83
+ parsed = new URL(value);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
89
+ return false;
90
+ if (parsed.hostname === '')
91
+ return false;
92
+ if (parsed.username !== '' || parsed.password !== '')
93
+ return false;
94
+ return true;
95
+ }
96
+ /** Anchored FULL-STRING glob match for one `spawnEnv.allow` entry: `*` matches
97
+ * any (possibly empty) run of characters; every other character — including
98
+ * regex metacharacters — matches literally. A pattern with no `*` is a plain
99
+ * exact-name match (the common case, and the only shape prior to this fix).
100
+ * A bare/all-`*` "match everything" pattern is rejected earlier, at config
101
+ * read (`core/config.ts`'s `mergeSpawnEnv`), so it can never reach here. */
102
+ function matchesAllowPattern(name, pattern) {
103
+ if (!pattern.includes('*'))
104
+ return name === pattern;
105
+ const escaped = pattern
106
+ .split('*')
107
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
108
+ .join('.*');
109
+ return new RegExp(`^${escaped}$`).test(name);
110
+ }
111
+ /** Sources A–C: the operational base shared by both the broker boundary (this
112
+ * module's `buildBrokerEnv`) and the predicate-eval `when_script` path
113
+ * (`daemon/predicate-eval.ts`) — the single shared definition of "non-secret
114
+ * host state the runtime exposes to any spawned shell." Does not include
115
+ * `inv.env`/`FRONT_DOOR_ENV` (source D), which is broker-specific. */
116
+ export function buildOperationalEnvBase(opts) {
117
+ const host = opts.host ?? process.env;
118
+ const out = {};
119
+ // A1 — literal names + non-secret prefixes, copied verbatim.
120
+ for (const [key, value] of Object.entries(host)) {
121
+ if (value === undefined)
122
+ continue;
123
+ if (OPERATIONAL_LITERAL_NAMES.has(key) || OPERATIONAL_PREFIXES.some((prefix) => key.startsWith(prefix))) {
124
+ out[key] = value;
125
+ }
126
+ }
127
+ // A2 — proxy vars, admitted only past the userinfo guard.
128
+ for (const key of PROXY_NAMES) {
129
+ const value = host[key];
130
+ if (value !== undefined && isCredentialFreeProxyValue(value))
131
+ out[key] = value;
132
+ }
133
+ // B — configured model-provider keys, by exact env-var name.
134
+ for (const envKey of Object.values(PROVIDER_ENV_KEYS)) {
135
+ const value = host[envKey];
136
+ if (value !== undefined)
137
+ out[envKey] = value;
138
+ }
139
+ // C — explicit config-declared extra allowlist (literal names and/or
140
+ // globs), resolved from the TARGET's own cwd/profile scope (default empty).
141
+ const allow = readMergedLaunchConfig(opts.targetCwd, opts.targetProfileId).spawnEnv.allow;
142
+ if (allow.length > 0) {
143
+ for (const [key, value] of Object.entries(host)) {
144
+ if (value === undefined)
145
+ continue;
146
+ if (allow.some((pattern) => matchesAllowPattern(key, pattern)))
147
+ out[key] = value;
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+ /** The one authoritative broker child env: sources A–C (the operational
153
+ * base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
154
+ * `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
155
+ * `process.cwd()`/`CRTR_PROFILE_ID`, which may be a launcher's or the
156
+ * daemon's own and belong to an unrelated scope) plus D — the trusted,
157
+ * crtr-constructed `inv.env` overlay and the fork-bomb recursion guard
158
+ * `FRONT_DOOR_ENV=1`. Every broker launch (front-door root, managed child,
159
+ * `--root`, every revive/daemon relaunch) funnels through
160
+ * `headlessBrokerHost.launch()`, which calls this. */
161
+ export function buildBrokerEnv(inv) {
162
+ const targetCwd = inv.env['CRTR_NODE_CWD'] ?? process.cwd();
163
+ const targetProfileId = inv.env['CRTR_PROFILE_ID'] || null;
164
+ return {
165
+ ...buildOperationalEnvBase({ targetCwd, targetProfileId }),
166
+ ...inv.env,
167
+ [FRONT_DOOR_ENV]: '1',
168
+ };
169
+ }
@@ -7,9 +7,10 @@ export interface BootRootOpts {
7
7
  /** Optional starter prompt (bare `crtr` requires none). */
8
8
  prompt?: string;
9
9
  /** Explicit `--profile <id-or-name>` on the front-door invocation, validated
10
- * through `loadProfileManifest`. Omit to run the MRU/create-or-root startup
11
- * selector for `cwd` (see `selectProfileForCwd`) — a root has no spawner to
12
- * inherit from, so this is the front door's only source of profile identity. */
10
+ * through `loadProfileManifest`. Omit to run the MRU/create-or-root-profile
11
+ * startup selector for `cwd` (see `selectProfileForCwd`) — a root has no
12
+ * spawner to inherit from, so this is the front door's only source of profile
13
+ * identity. */
13
14
  profile?: string | null;
14
15
  /** `crtr --pick-profile`: force the startup profile chooser to open even when
15
16
  * a per-cwd default is pinned, so the user can re-pin or unpin it. Without
@@ -56,15 +57,23 @@ export interface SpawnChildOpts {
56
57
  /** Select the profile this node runs under — an exact profile id or a unique
57
58
  * manifest name, validated through `loadProfileManifest`. Omit to INHERIT the
58
59
  * spawner's current `profile_id` (managed child or --root alike — --root only
59
- * means top-level, it does not reset to no profile). Passed through unchanged
60
- * into a deferred trigger recipe so a triggered birth re-resolves the SAME
61
- * choice at fire time (an explicit value re-validates; inheritance re-reads
62
- * the spawner's THEN-current profile). */
60
+ * means top-level, it does not reset to a different profile; a shell root
61
+ * falls back to the stable root profile in the startup selector). Passed
62
+ * through unchanged into a deferred trigger recipe so a triggered birth
63
+ * re-resolves the SAME choice at fire time (an explicit value re-validates;
64
+ * inheritance re-reads the spawner's THEN-current profile). */
63
65
  profile?: string | null;
64
66
  /** Create a managed worktree for this node and pin its cwd to it. */
65
67
  worktree?: boolean;
66
68
  /** Preallocated node id, used when a managed worktree must be named before birth. */
67
69
  nodeId?: string;
70
+ /** Hidden ambient "current situation" text — NOT the visible prompt/body.
71
+ * Persisted via `appendSituationalContext` BEFORE launch so canvas-context-
72
+ * intro's session_start bearings carry it as a `<situational-context>`
73
+ * sibling from the node's very first turn. Passed through unchanged into a
74
+ * deferred trigger recipe (the same field survives a scheduled node_birth).
75
+ * Omit for no ambient context. */
76
+ situationalContext?: string;
68
77
  }
69
78
  /** Resolve a `--fork-from` value to an ABSOLUTE `.jsonl` source path for the
70
79
  * broker fork (`SessionManager.forkFrom`, which loads a file — never a bare id).
@@ -21,6 +21,7 @@ import { gitSync } from '../git.js';
21
21
  import { createManagedWorktree } from '../worktree.js';
22
22
  import { usage } from '../errors.js';
23
23
  import { writeGoal } from './kickoff.js';
24
+ import { appendSituationalContext } from './situational-context.js';
24
25
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
25
26
  import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
26
27
  import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
@@ -68,7 +69,8 @@ export async function bootRoot(opts) {
68
69
  const kind = opts.kind ?? 'general';
69
70
  // The front door's only source of profile identity — a root has no spawner
70
71
  // to inherit from. Runs BEFORE spawnNode: explicit --profile > MRU profile
71
- // covering cwd > a synchronous create-or-root prompt (or root, headless).
72
+ // covering cwd > a synchronous create-or-root-profile prompt (the stable root
73
+ // profile, headless).
72
74
  const profileId = await selectProfileForCwd(opts.cwd, opts.profile, opts.pickProfile ?? false);
73
75
  // A born-resident root starts in base mode; it earns the orchestrator persona
74
76
  // the first time it delegates (or on promotion). Resident lifecycle either way.
@@ -258,7 +260,7 @@ export function resolveForkSource(value) {
258
260
  * there is NO spawner at all — `crtr node new --root` run directly from a
259
261
  * shell, not from inside a node — there is no profile to inherit, so this runs
260
262
  * the same startup selector the front door uses (MRU covering `cwd`, else the
261
- * synchronous create-or-root prompt / headless root default). */
263
+ * root-profile prompt/default). */
262
264
  async function resolveProfileId(explicit, spawner, cwd) {
263
265
  if (explicit !== undefined && explicit !== null && explicit !== '') {
264
266
  return loadProfileManifest(explicit).profileId;
@@ -347,6 +349,10 @@ export async function spawnChild(opts) {
347
349
  });
348
350
  // Persist the task as the child's goal for a fresh revive to re-read.
349
351
  writeGoal(meta.node_id, opts.prompt);
352
+ // Hidden ambient context, written BEFORE the engine launches so canvas-
353
+ // context-intro's session_start bearings carry it from the first turn.
354
+ if (opts.situationalContext !== undefined)
355
+ appendSituationalContext(meta.node_id, opts.situationalContext);
350
356
  // A fork inherits the SOURCE node's entire first-person conversation, so the
351
357
  // identity re-assertion must ride the STRONGEST channel too, not only the
352
358
  // session-start bearings (a trailing custom_message the model weighs only by
@@ -42,7 +42,13 @@ export function clearSessionCache() {
42
42
  * `listFn` is injected by the caller to avoid circular imports at module init. */
43
43
  export function cachedAllMemoryDocs(listFn) {
44
44
  if (_cache.allMemoryDocs === null) {
45
- _cache.allMemoryDocs = listFn();
45
+ // The boot pipeline (render + on-read) EXCLUDES node-scope docs: node-local
46
+ // memory surfaces at boot via render.ts's nodeLocalDocs (any kind, floored
47
+ // into the knowledge block), NOT through the resolver corpus, so a node doc
48
+ // must not also enter here (it would double-surface at boot and could fire
49
+ // on-read). The `node` scope reaches CLI read/list/find/delete through the
50
+ // uncached listAllMemoryDocs() path instead.
51
+ _cache.allMemoryDocs = listFn().filter((d) => d.scope !== 'node');
46
52
  }
47
53
  return _cache.allMemoryDocs;
48
54
  }
@@ -24,7 +24,7 @@ export interface NodeConfigSubject {
24
24
  };
25
25
  /** DERIVED: `meta.profile_id` resolved to its manifest's mutable `name` (NOT
26
26
  * the stable directory id — gate authors write `gate: {profile: <name>}`).
27
- * null for a root/no-profile node, and null (never throwing) when the
27
+ * null for historical null-profile rows, and null (never throwing) when the
28
28
  * profile has been deleted or its manifest is unreadable — this is a HOT,
29
29
  * every-doc-render path and must never brick boot/on-read gating. */
30
30
  profile: string | null;
@@ -0,0 +1,3 @@
1
+ import type { Scope } from '../../types.js';
2
+ import type { RelayTarget } from '../../clients/attach/transport-relay.js';
3
+ export declare function resolveRemoteCanvasTarget(name: string, scope?: Scope): RelayTarget;
@@ -0,0 +1,25 @@
1
+ // remote-canvas-target.ts — resolves a named `remoteCanvas.targets.<name>`
2
+ // config entry (see `RemoteCanvasTarget`, `src/types.ts`) into a `RelayTarget`
3
+ // ready for `RelayTransport` (`src/clients/attach/transport-relay.ts`).
4
+ //
5
+ // The raw bearer token NEVER lives in config or argv — only the name of an
6
+ // environment variable holding it (`relayTokenEnv`), read here at connect
7
+ // time. This is the Phase 1 interim contract; Phase 3 owns the real
8
+ // secret-store-backed `crtr canvas config`/`crtr canvas use` CLI.
9
+ import { usage } from '../errors.js';
10
+ import { readConfig } from '../config.js';
11
+ export function resolveRemoteCanvasTarget(name, scope = 'user') {
12
+ const cfg = readConfig(scope);
13
+ const target = cfg.remoteCanvas.targets[name];
14
+ if (target === undefined) {
15
+ const known = Object.keys(cfg.remoteCanvas.targets);
16
+ throw usage(`no remote canvas target named '${name}'` +
17
+ (known.length > 0 ? ` — configured: ${known.join(', ')}` : ' — none configured yet') +
18
+ `. Add remoteCanvas.targets.${name} to config.json (run \`crtr sys config path\`): {"previewEndpoint": "https://<hash>.preview.bl.run", "relayTokenEnv": "<ENV_VAR_NAME>"}.`);
19
+ }
20
+ const relayToken = process.env[target.relayTokenEnv];
21
+ if (relayToken === undefined || relayToken === '') {
22
+ throw usage(`remote canvas target '${name}' needs its relay token in $${target.relayTokenEnv} — that environment variable is unset. Export it before running this command; the token is never read from config or argv.`);
23
+ }
24
+ return { previewEndpoint: target.previewEndpoint, relayToken };
25
+ }
@@ -20,14 +20,14 @@ export interface CloseManagedWorktreeResult {
20
20
  node_id: string;
21
21
  branch: string;
22
22
  worktree_path: string;
23
- pushed_sha: string;
24
- /** False when the push landed but the worktree checkout could not be removed
25
- * automatically afterward — the close still counts as done (state is
23
+ landed_sha: string;
24
+ /** False when the landing happened but the worktree checkout could not be
25
+ * removed automatically afterward — the close still counts as done (state is
26
26
  * closed, push-final is unblocked); `worktree_remove_error` carries the
27
27
  * manual cleanup note. */
28
28
  worktree_removed: boolean;
29
29
  worktree_remove_error?: string;
30
- /** False when the push landed but the local branch could not be deleted
30
+ /** False when the landing happened but the local branch could not be deleted
31
31
  * afterward — same non-fatal treatment as worktree_removed. */
32
32
  branch_deleted: boolean;
33
33
  branch_delete_error?: string;