@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
@@ -29,24 +29,24 @@ function mapWorktreeError(err) {
29
29
  const worktreeClose = defineLeaf({
30
30
  name: 'close',
31
31
  description: 'land and close the current node-managed worktree',
32
- whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto origin/main, push it to main, and (best-effort) remove the worktree and delete the branch before finishing',
32
+ whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto local main, fast-forward local main onto it, and (best-effort) remove the worktree and delete the branch before finishing',
33
33
  help: {
34
34
  name: 'node worktree close',
35
35
  summary: 'land and close the current node-managed worktree',
36
36
  params: [],
37
37
  output: [
38
38
  { name: 'node_id', type: 'string', required: true, constraint: 'The caller node id whose managed worktree was closed.' },
39
- { name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into main.' },
40
- { name: 'pushed_sha', type: 'string', required: true, constraint: 'The landed commit SHA that was pushed to origin/main.' },
39
+ { name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into local main.' },
40
+ { name: 'landed_sha', type: 'string', required: true, constraint: 'The commit SHA that local main was fast-forwarded onto.' },
41
41
  { name: 'worktree_path', type: 'string', required: true, constraint: 'The managed worktree path that was closed (best-effort removed; see worktree_removed).' },
42
- { name: 'worktree_removed', type: 'boolean', required: true, constraint: 'True when the worktree directory was removed; false means the push still landed (the close is done) but automatic removal failed and needs a manual follow-up.' },
42
+ { name: 'worktree_removed', type: 'boolean', required: true, constraint: 'True when the worktree directory was removed; false means the landing still happened (the close is done) but automatic removal failed and needs a manual follow-up.' },
43
43
  { name: 'worktree_remove_error', type: 'string', required: false, constraint: 'Present only when worktree_removed is false: the manual cleanup command to run.' },
44
- { name: 'branch_deleted', type: 'boolean', required: true, constraint: 'True when the local branch was deleted; false means the push still landed (the close is done) but the local branch cleanup failed and needs a manual follow-up.' },
44
+ { name: 'branch_deleted', type: 'boolean', required: true, constraint: 'True when the local branch was deleted; false means the landing still happened (the close is done) but the local branch cleanup failed and needs a manual follow-up.' },
45
45
  { name: 'branch_delete_error', type: 'string', required: false, constraint: 'Present only when branch_deleted is false: the manual cleanup command to run.' },
46
46
  ],
47
47
  outputKind: 'object',
48
48
  effects: [
49
- 'Fetches origin/main, rebases the worktree onto it, fast-forwards origin/main, marks the node-managed worktree closed, then attempts (best-effort) to remove the worktree and delete the local branch.',
49
+ 'Rebases the worktree onto local main, fast-forwards local main onto it (never pushes to origin), marks the node-managed worktree closed, then attempts (best-effort) to remove the worktree and delete the local branch.',
50
50
  ],
51
51
  },
52
52
  run: async () => {
@@ -63,9 +63,9 @@ const worktreeClose = defineLeaf({
63
63
  },
64
64
  render: (r) => {
65
65
  const branch = String(r['branch'] ?? '');
66
- const sha = String(r['pushed_sha'] ?? '');
66
+ const sha = String(r['landed_sha'] ?? '');
67
67
  const path = String(r['worktree_path'] ?? '');
68
- let base = `Managed worktree landed — branch ${branch} pushed as ${sha}.`;
68
+ let base = `Managed worktree landed — local main fast-forwarded to ${branch} at ${sha}.`;
69
69
  const notes = [];
70
70
  if (r['worktree_removed'] === false) {
71
71
  notes.push(String(r['worktree_remove_error'] ?? `worktree checkout at ${path} could not be removed automatically.`));
@@ -84,7 +84,7 @@ const worktreeClose = defineLeaf({
84
84
  export const nodeWorktreeBranch = defineBranch({
85
85
  name: 'worktree',
86
86
  description: 'manage a node-owned git worktree — close the one born by `node new --worktree`',
87
- whenToUse: 'you are inside a node that was spawned with `node new --worktree` and are ready to land it: rebase onto origin/main, push, and close the worktree. Symmetric with `node new --worktree`, which births it',
87
+ whenToUse: 'you are inside a node that was spawned with `node new --worktree` and are ready to land it: rebase onto local main, fast-forward local main onto it, and close the worktree. Symmetric with `node new --worktree`, which births it',
88
88
  help: {
89
89
  name: 'node worktree',
90
90
  summary: 'manage a node-owned git worktree — birth is `node new --worktree`; this branch closes it',
@@ -32,10 +32,12 @@ import { WorktreeError } from '../core/worktree.js';
32
32
  import { closeNode } from '../core/runtime/close.js';
33
33
  import { isBrokerLive, setModelLive, persistDormantModel } from '../core/runtime/model-swap.js';
34
34
  import { appendInbox } from '../core/feed/inbox.js';
35
+ import { appendSituationalContext } from '../core/runtime/situational-context.js';
35
36
  import { readMergedLaunchConfig } from '../core/config.js';
36
37
  import { listProfiles } from '../core/profiles/manifest.js';
37
38
  import { stateBlock } from '../core/help.js';
38
- import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, contextDir, reportsDir, nodeArtifacts, readContextTokens, view, armTrigger, listTriggers, cancelTrigger, TriggerArmError, fullName, } from '../core/canvas/index.js';
39
+ import { getNode, updateNode, listNodes, subscribe, unsubscribe, contextDir, reportsDir, nodeArtifacts, readContextTokens, armTrigger, listTriggers, cancelTrigger, TriggerArmError, fullName, } from '../core/canvas/index.js';
40
+ import { localCanvasSource } from '../core/canvas/source.js';
39
41
  // Past this much context, an ORCHESTRATOR that spawns a managed child is better
40
42
  // off yielding than holding its fat window open for the child's result: the
41
43
  // child's finish revives it fresh against its roadmap, so a clean window absorbs
@@ -132,10 +134,11 @@ const nodeNew = defineLeaf({
132
134
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
133
135
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
134
136
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
135
- { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on `origin/main`; the child\'s cwd is pinned there, and it lands the work serially onto origin/main later with `crtr node worktree close`. Mutually exclusive with --root.' },
137
+ { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on local `main`; the child\'s cwd is pinned there, and it lands the work serially onto local main later with `crtr node worktree close`. Mutually exclusive with --root.' },
136
138
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
137
139
  { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku). Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
138
- { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (root has none, so an uninherited child has none either). --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
140
+ { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (which may be none, e.g. a historical null-profile parent). A shell/no-spawner root with no profile coverage instead runs the startup selector and can persist the stable root profile. --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
141
+ { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context — current situation (e.g. which applet/page spawned this), NOT the visible prompt. Appended as a `<situational-context>` sibling block in the injected bearings, never as visible chat. Preserved unchanged into a deferred --at/--every/--when node_birth recipe.' },
139
142
  { kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
140
143
  { kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'DEFER the birth to a one-shot clock trigger at this future time instead of spawning now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
141
144
  { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'SPAWN-CRON: re-birth a fresh node on this cadence — a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). Combine with --at to anchor the first fire, then recur normally. Min cadence 60s. With --when, this instead sets the PREDICATE EVALUATION cadence (required), not a repeating birth.' },
@@ -161,7 +164,7 @@ const nodeNew = defineLeaf({
161
164
  dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
162
165
  outputKind: 'object',
163
166
  effects: [
164
- 'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr node worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
167
+ 'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on local main; the child closes it later with `crtr node worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
165
168
  'Armed (--at/--every/--when): inserts one detached `new`/`node_birth` triggers row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time. The daemon spawns from the stored recipe at fire time, re-deriving the launch spec live.',
166
169
  ],
167
170
  },
@@ -189,6 +192,8 @@ const nodeNew = defineLeaf({
189
192
  }
190
193
  const model = modelSpec;
191
194
  const profile = input['profile'];
195
+ const situationalContextRaw = input['situationalContext']?.trim();
196
+ const situationalContext = situationalContextRaw !== undefined && situationalContextRaw !== '' ? situationalContextRaw : undefined;
192
197
  const outputSchema = parseOutputSchemaValue(input['outputSchema']);
193
198
  const at = input['at']?.trim();
194
199
  const every = input['every']?.trim();
@@ -215,7 +220,7 @@ const nodeNew = defineLeaf({
215
220
  }
216
221
  if (!triggered) {
217
222
  try {
218
- const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile });
223
+ const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile, situationalContext });
219
224
  if (outputSchema !== null)
220
225
  writeOutputSchema(res.node.node_id, 'terminal', outputSchema);
221
226
  return {
@@ -263,6 +268,7 @@ const nodeNew = defineLeaf({
263
268
  ...(forkFrom !== undefined ? { forkFrom } : {}),
264
269
  ...(model !== undefined ? { model } : {}),
265
270
  ...(profile !== undefined ? { profile } : {}),
271
+ ...(situationalContext !== undefined ? { situationalContext } : {}),
266
272
  };
267
273
  const id = `tr-${newNodeId()}`;
268
274
  if (hasWhen) {
@@ -404,14 +410,15 @@ const nodeList = defineLeaf({
404
410
  const hangingOnly = input['hanging'] === true;
405
411
  // Graph scope (--under): self + subscription descendants. Validate the anchor
406
412
  // so a typo'd id is a structured error, not a silently-empty roster.
413
+ const canvasSource = localCanvasSource;
407
414
  let scope;
408
415
  if (under !== undefined && under !== '') {
409
- if (getNode(under) === null) {
416
+ if (await canvasSource.getNode(under) === null) {
410
417
  throw new InputError({ error: 'not_found', message: `no node: ${under}`, next: 'List nodes with `crtr node inspect list`, then pass a real id to --under.' });
411
418
  }
412
- scope = new Set([under, ...view(under)]);
419
+ scope = new Set([under, ...(await canvasSource.view(under))]);
413
420
  }
414
- const rows = listNodes(status !== undefined ? { status } : undefined).filter((row) => {
421
+ const rows = (await canvasSource.listNodes(status !== undefined ? { status } : undefined)).filter((row) => {
415
422
  if (scope !== undefined && !scope.has(row.node_id))
416
423
  return false;
417
424
  if (kinds !== undefined && !kinds.includes(row.kind))
@@ -439,8 +446,8 @@ const nodeList = defineLeaf({
439
446
  // ---------------------------------------------------------------------------
440
447
  // Enrich a bare subscription edge with the neighbor's name/kind/status so a
441
448
  // debugger reads who a report/manager IS without a second `show` per id.
442
- function enrichNeighbor(ref) {
443
- const n = getNode(ref.node_id);
449
+ async function enrichNeighborFromSource(source, ref) {
450
+ const n = await source.getNode(ref.node_id);
444
451
  return {
445
452
  node_id: ref.node_id,
446
453
  name: n ? fullName(n) : '(missing)',
@@ -478,12 +485,13 @@ const nodeShow = defineLeaf({
478
485
  },
479
486
  run: async (input) => {
480
487
  const id = input['node'];
481
- const node = getNode(id);
488
+ const canvasSource = localCanvasSource;
489
+ const node = await canvasSource.getNode(id);
482
490
  if (node === null) {
483
491
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
484
492
  }
485
- const reports = subscriptionsOf(id).map(enrichNeighbor);
486
- const managers = subscribersOf(id).map(enrichNeighbor);
493
+ const reports = await Promise.all((await canvasSource.subscriptionsOf(id)).map((ref) => enrichNeighborFromSource(canvasSource, ref)));
494
+ const managers = await Promise.all((await canvasSource.subscribersOf(id)).map((ref) => enrichNeighborFromSource(canvasSource, ref)));
487
495
  const arts = nodeArtifacts(id, ['report', 'doc', 'roadmap']);
488
496
  const artifacts = {
489
497
  report: arts.filter((a) => a.source === 'report').length,
@@ -1138,6 +1146,7 @@ const nodeMsg = defineLeaf({
1138
1146
  { kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'Self-only: bind a deadline that decides your next action — you wake on the first inbox message, or at this time if none arrives (whichever fires first cancels the other), and the body is the policy to follow in the no-message case. One-shot, urgent tier, ≤ 1 per node (a new --until replaces any prior). Mutually exclusive with --at, --every, --when, --fresh, --tier, and --timeout. Requires --self and a body.' },
1139
1147
  { kind: 'flag', name: 'timeout', type: 'string', required: false, constraint: 'Wall-clock bound for a --when predicate — a duration or absolute time after which the trigger is consumed and the owner gets one urgent notice. Required with --when; rejected otherwise.' },
1140
1148
  { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone for a calendar --every, a bare-ISO --at, or a bare-ISO --until (default: host-local). Valid only alongside --at/--every/--until; rejected with --when, and rejected when acting immediately (no trigger flags) since none of those have anything to parse it against.' },
1149
+ { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context — current situation, NOT the visible message body. Upserted onto the target\'s node-owned sidecar and delivered as a `<situational-context>` sibling block, never as visible chat/digest. Valid with a body, with --fresh, or alone (then no body is required). Immediate delivery only — rejected with --at, --every, --when, or --until.' },
1141
1150
  ],
1142
1151
  output: [
1143
1152
  { name: 'mode', type: 'string', required: true, constraint: '"inbox_message", "fresh_revive", or "deadline_message" — what this message enacts.' },
@@ -1181,7 +1190,12 @@ const nodeMsg = defineLeaf({
1181
1190
  const hasAt = at !== undefined && at !== '';
1182
1191
  const hasEvery = every !== undefined && every !== '';
1183
1192
  const outputSchema = parseOutputSchemaValue(input['outputSchema']);
1193
+ const situationalContextRaw = input['situationalContext']?.trim();
1194
+ const hasSituational = situationalContextRaw !== undefined && situationalContextRaw !== '';
1184
1195
  // --- Validation per the approved flag matrix ---
1196
+ if (hasSituational && (hasAt || hasEvery || hasWhen || hasUntil)) {
1197
+ throw new InputError({ error: 'situational_context_immediate_only', message: '--situational-context is only valid for immediate delivery (no --at, --every, --when, or --until)', field: 'situational-context', next: 'Drop the trigger flag(s), or drop --situational-context.' });
1198
+ }
1185
1199
  if (hasUntil && !isSelf) {
1186
1200
  throw new InputError({ error: 'until_self_only', message: '--until is self-only', field: 'until', next: 'Use --self, or drop --until.' });
1187
1201
  }
@@ -1374,6 +1388,10 @@ const nodeMsg = defineLeaf({
1374
1388
  next: 'Seed a goal/roadmap first, or drop --fresh so the woken context carries a message.',
1375
1389
  });
1376
1390
  }
1391
+ // Update the sidecar BEFORE the revive so buildReviveKickoff's disk read
1392
+ // picks up this update on the fresh kickoff it is about to assemble.
1393
+ if (hasSituational)
1394
+ appendSituationalContext(targetId, situationalContextRaw);
1377
1395
  reviveNode(targetId, { resume: false });
1378
1396
  return {
1379
1397
  mode: 'fresh_revive',
@@ -1383,8 +1401,8 @@ const nodeMsg = defineLeaf({
1383
1401
  guidance: `Fresh-revived ${isSelf ? 'yourself' : targetId} now — it resumes a clean window re-reading its roadmap/disk.`,
1384
1402
  };
1385
1403
  }
1386
- if (!hasBody && outputSchema === null) {
1387
- throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when / --output-schema)', field: 'body', next: 'Pass the message after the target flags or on stdin, or pass --output-schema to grant a one-off structured-output request.' });
1404
+ if (!hasBody && !hasSituational && outputSchema === null) {
1405
+ throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when / --output-schema / --situational-context)', field: 'body', next: 'Pass the message after the target flags or on stdin, pass --output-schema to grant a one-off structured-output request, or pass --situational-context alone for a hidden-only update.' });
1388
1406
  }
1389
1407
  const targetMeta = getNode(targetId);
1390
1408
  if (targetMeta === null) {
@@ -1392,15 +1410,42 @@ const nodeMsg = defineLeaf({
1392
1410
  }
1393
1411
  if (outputSchema !== null)
1394
1412
  writeOutputSchema(targetId, 'oneoff', outputSchema);
1413
+ // Update the sidecar BEFORE appending the inbox entry that will wake the
1414
+ // watcher's flush() — it reads the sidecar fresh on delivery.
1415
+ if (hasSituational)
1416
+ appendSituationalContext(targetId, situationalContextRaw);
1395
1417
  const tier = (hasTier ? tierRaw : 'normal');
1396
1418
  const from = process.env['CRTR_NODE_ID'] ?? 'human';
1397
- const messageBody = hasBody
1398
- ? body
1399
- : 'A one-off structured-output request has been granted. Call the `submit` tool with a result matching the requested schema before you stop; after submit succeeds, continue normally.';
1400
- // A body over the preview bound spills to a ref file; appendInbox clips the
1401
- // inline preview and the receiver dereferences the ref for the full text,
1402
- // same as any other report.
1403
- appendInbox(targetId, { from, tier, kind: 'message', label: messageBody.split('\n')[0].slice(0, 120), data: { body: messageBody } });
1419
+ if (hasBody || outputSchema !== null) {
1420
+ const messageBody = hasBody
1421
+ ? body
1422
+ : 'A one-off structured-output request has been granted. Call the `submit` tool with a result matching the requested schema before you stop; after submit succeeds, continue normally.';
1423
+ // A body over the preview bound spills to a ref file; appendInbox clips the
1424
+ // inline preview and the receiver dereferences the ref for the full text,
1425
+ // same as any other report. `situational: true` tells the inbox watcher's
1426
+ // flush() to ALSO deliver the hidden <situational-context> custom message
1427
+ // ahead of this visible digest — the ambient TEXT itself never rides here.
1428
+ appendInbox(targetId, {
1429
+ from,
1430
+ tier,
1431
+ kind: 'message',
1432
+ label: messageBody.split('\n')[0].slice(0, 120),
1433
+ data: { body: messageBody, ...(hasSituational ? { situational: true } : {}) },
1434
+ });
1435
+ }
1436
+ else {
1437
+ // Situational context alone: a hidden-only wake marker. `situationalOnly`
1438
+ // tells the inbox watcher to exclude this entry from the visible digest
1439
+ // entirely — it exists purely to trigger flush() into delivering the hidden
1440
+ // custom message, never as a chat line.
1441
+ appendInbox(targetId, {
1442
+ from,
1443
+ tier,
1444
+ kind: 'message',
1445
+ label: '(ambient context updated)',
1446
+ data: { situational: true, situationalOnly: true },
1447
+ });
1448
+ }
1404
1449
  // A direct message wakes any node: if the target has no live window
1405
1450
  // (done/dead/idle-released), revive it so its inbox-watcher delivers this.
1406
1451
  let woke = false;
@@ -1413,7 +1458,10 @@ const nodeMsg = defineLeaf({
1413
1458
  /* best-effort wake */
1414
1459
  }
1415
1460
  }
1416
- return { mode: 'inbox_message', armed: false, target, delivered: true, revived: woke, guidance: woke ? 'Delivered — the dormant target was revived to receive it.' : 'Delivered.' };
1461
+ const guidance = !hasBody && hasSituational
1462
+ ? (woke ? 'Situational context delivered as hidden context — the dormant target was revived to receive it; no visible message was sent.' : 'Situational context delivered as hidden context; no visible message was sent.')
1463
+ : (woke ? 'Delivered — the dormant target was revived to receive it.' : 'Delivered.');
1464
+ return { mode: 'inbox_message', armed: false, target, delivered: true, revived: woke, guidance };
1417
1465
  },
1418
1466
  render: (r) => {
1419
1467
  if (r['armed'] === true) {
@@ -72,6 +72,7 @@ function faultRemediation(fault) {
72
72
  protocol: 'fix the protocol mismatch',
73
73
  other: 'fix the underlying issue',
74
74
  wedged: 'read `crtr memory read wedged-child-on-runaway-bash` — if a runaway subprocess is to blame, kill it (not the node); if the broker had no subprocess, the daemon already SIGTERM-kicked it to resume on its own (recheck node status; nudge it if it came back idle)',
75
+ 'model-not-found': 'pass a valid `--model provider/id` or configure credentials for an authenticated provider, then revive the node (`crtr node lifecycle revive <id>`)',
75
76
  };
76
77
  const progress = fault.retry.disposition === 'auto'
77
78
  ? fault.retry.by === 'client'
@@ -31,9 +31,19 @@ import { test, before, after } from 'node:test';
31
31
  import assert from 'node:assert/strict';
32
32
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import { tmpdir } from 'node:os';
34
- import { join } from 'node:path';
34
+ import { dirname, join } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
35
36
  import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
36
37
  import { buildBrokerSession } from '../runtime/broker.js';
38
+ // M-9 (broker.ts fault-and-die model gate): buildBrokerSession with no cfg.model
39
+ // now calls the REAL SDK's modelRegistry.getAvailable() and faults if it's empty
40
+ // — which it always is on a host/CI runner with no authenticated provider. This
41
+ // test drives the fork branch only, so it registers the SAME hermetic
42
+ // fake-but-authenticated model the C3 wiring tests use, rather than depending on
43
+ // ambient host credentials (which don't exist in CI — see broker-sdk-wiring's
44
+ // c3-custom-provider-ext.ts fixture).
45
+ const HERE = dirname(fileURLToPath(import.meta.url));
46
+ const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
37
47
  // The REAL engine — bypasses the CRTR_BROKER_ENGINE fake entirely (the whole
38
48
  // point: the fake omits forkFrom, so only the real SDK can prove the branch).
39
49
  const realEngine = {
@@ -70,7 +80,12 @@ test('D2 broker fork branch: buildBrokerSession(cfg.forkFrom) yields a NEW id +
70
80
  // the same recipe a `node new --fork-from <id>` boot produces. The OLD broker
71
81
  // THREW in this branch ("--fork is not supported by the headless broker"), so
72
82
  // if broker.ts:~1106 reverts to that throw, this rejects and the test goes RED.
73
- const cfg = { cwd: dir, extensionPaths: [], forkFrom: srcFile };
83
+ const cfg = {
84
+ cwd: dir,
85
+ extensionPaths: [C3_EXT],
86
+ forkFrom: srcFile,
87
+ model: 'c3prov/c3model',
88
+ };
74
89
  let session;
75
90
  let resuming;
76
91
  await assert.doesNotReject(async () => {
@@ -27,9 +27,18 @@ import { test } from 'node:test';
27
27
  import assert from 'node:assert/strict';
28
28
  import { mkdtempSync, rmSync } from 'node:fs';
29
29
  import { tmpdir } from 'node:os';
30
- import { join } from 'node:path';
30
+ import { dirname, join } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
31
32
  import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
32
33
  import { buildBrokerSession, snapshotMessages } from '../runtime/broker.js';
34
+ // M-9 (broker.ts fault-and-die model gate): a boot with no cfg.model now calls
35
+ // the REAL SDK's modelRegistry.getAvailable() and faults if it's empty, which it
36
+ // always is on a host/CI runner with no authenticated provider. This test only
37
+ // exercises snapshot/history reconstruction, so it registers the same hermetic
38
+ // fake-but-authenticated model the C3 wiring tests use instead of depending on
39
+ // ambient host credentials (see broker-sdk-wiring's c3-custom-provider-ext.ts).
40
+ const HERE = dirname(fileURLToPath(import.meta.url));
41
+ const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
33
42
  const realEngine = {
34
43
  createAgentSessionServices,
35
44
  createAgentSessionFromServices,
@@ -73,8 +82,9 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
73
82
  // 2. Revive: the broker resumes the closed session via the SERVICES path.
74
83
  const { session } = await buildBrokerSession(realEngine, {
75
84
  cwd,
76
- extensionPaths: [],
85
+ extensionPaths: [C3_EXT],
77
86
  resumeSessionPath: sessionFile,
87
+ model: 'c3prov/c3model',
78
88
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
89
  });
80
90
  try {
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
15
15
  import { appendInbox } from '../feed/inbox.js';
16
+ import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
16
17
  // Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
17
18
  // CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the test sleeps milliseconds, not
18
19
  // seconds. SETTLE_MS still allows a resolve+seed tick, a read tick, and the
@@ -36,8 +37,10 @@ function makeFakePi() {
36
37
  const handlers = {};
37
38
  return {
38
39
  injected: [],
40
+ sentMessages: [],
39
41
  on(e, h) { handlers[e] = h; },
40
42
  sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
43
+ sendMessage(message) { this.sentMessages.push(message); },
41
44
  fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
42
45
  };
43
46
  }
@@ -105,3 +108,79 @@ describe('canvas inbox watcher — finished-node delivery', () => {
105
108
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
106
109
  });
107
110
  });
111
+ describe('canvas inbox watcher — hidden situational-context delivery', () => {
112
+ test('a situational-only entry (no visible body) never reaches the visible digest, only the hidden sendMessage', async () => {
113
+ freshNode('node-situational-only');
114
+ appendSituationalContext('node-situational-only', 'the user is mid-checkout on the store applet');
115
+ const pi = makeFakePi();
116
+ disposers.push(registerCanvasInboxWatcher(pi));
117
+ appendInbox('node-situational-only', {
118
+ from: null,
119
+ tier: 'normal',
120
+ kind: 'message',
121
+ label: '(ambient context updated)',
122
+ data: { situational: true, situationalOnly: true },
123
+ });
124
+ await wait(SETTLE_MS);
125
+ assert.equal(pi.injected.length, 0, 'a situational-only entry never triggers a visible digest');
126
+ assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
127
+ assert.match(pi.sentMessages[0].content, /<situational-context>/);
128
+ assert.match(pi.sentMessages[0].content, /the user is mid-checkout on the store applet/);
129
+ // Pins the seam the attach viewer keys its collapsed-rendering dispatch off
130
+ // (src/clients/attach/chat-view.ts): a regression here (e.g. someone routing
131
+ // the situational update through a different/generic customType) would make
132
+ // the viewer fall through to the prominent, uncollapsed CustomMessageComponent.
133
+ assert.equal(pi.sentMessages[0].customType, SITUATIONAL_CONTEXT_CUSTOM_TYPE);
134
+ });
135
+ test('a combined entry (visible body + situational flag) puts the body in the digest and the ambient text ONLY in the hidden sendMessage', async () => {
136
+ freshNode('node-situational-combined');
137
+ appendSituationalContext('node-situational-combined', 'the hidden ambient checkout state');
138
+ const pi = makeFakePi();
139
+ disposers.push(registerCanvasInboxWatcher(pi));
140
+ appendInbox('node-situational-combined', {
141
+ from: 'child-3',
142
+ tier: 'normal',
143
+ kind: 'message',
144
+ label: 'visible update',
145
+ data: { body: 'the visible message body', situational: true },
146
+ });
147
+ await wait(SETTLE_MS);
148
+ assert.equal(pi.injected.length, 1, 'the visible body still reaches the digest');
149
+ assert.match(pi.injected[0].content, /the visible message body/);
150
+ assert.doesNotMatch(pi.injected[0].content, /hidden ambient checkout state/, 'ambient text never rides the visible digest');
151
+ assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
152
+ assert.match(pi.sentMessages[0].content, /<situational-context>/);
153
+ assert.match(pi.sentMessages[0].content, /the hidden ambient checkout state/);
154
+ });
155
+ test('a situational-only entry survives a transient sendMessage failure — retried, not dropped', async () => {
156
+ // Durability regression: the cursor advances past a situational-only entry
157
+ // BEFORE flush() runs, so a swallowed sendMessage failure with no re-queue
158
+ // would drop the hidden wake permanently (never re-read from the inbox,
159
+ // never retried) — unlike the visible digest path, which already re-queues
160
+ // on failure. This pins the same durability contract for the hidden path.
161
+ freshNode('node-situational-retry');
162
+ appendSituationalContext('node-situational-retry', 'retry me please');
163
+ const pi = makeFakePi();
164
+ let calls = 0;
165
+ const realSendMessage = pi.sendMessage.bind(pi);
166
+ pi.sendMessage = (message, options) => {
167
+ calls += 1;
168
+ if (calls === 1)
169
+ throw new Error('transient failure');
170
+ realSendMessage(message, options);
171
+ };
172
+ disposers.push(registerCanvasInboxWatcher(pi));
173
+ appendInbox('node-situational-retry', {
174
+ from: null,
175
+ tier: 'normal',
176
+ kind: 'message',
177
+ label: '(ambient context updated)',
178
+ data: { situational: true, situationalOnly: true },
179
+ });
180
+ await wait(SETTLE_MS * 3);
181
+ assert.ok(calls >= 2, 'sendMessage retried after the first failure instead of being swallowed once');
182
+ assert.equal(pi.injected.length, 0, 'still never reaches the visible digest');
183
+ assert.equal(pi.sentMessages.length, 1, 'eventually delivered exactly once after the retry succeeds');
184
+ assert.match(pi.sentMessages[0].content, /retry me please/);
185
+ });
186
+ });
@@ -18,6 +18,7 @@ import { spawnNode } from '../runtime/nodes.js';
18
18
  import { promote } from '../runtime/promote.js';
19
19
  import { personaDrift } from '../runtime/persona.js';
20
20
  import { memoryDir } from '../runtime/memory.js';
21
+ import { appendSituationalContext, situationalContextBlock } from '../runtime/situational-context.js';
21
22
  import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
22
23
  let home;
23
24
  before(() => {
@@ -69,6 +70,16 @@ test('worker bearings: base framing + <knowledge> block, NO across-cycles framin
69
70
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
70
71
  assert.match(block, /<\/crtr-bearings>/);
71
72
  });
73
+ test('situational context rides the bearings as a sibling <situational-context> block; empty when unset', () => {
74
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
75
+ assert.equal(situationalContextBlock(meta.node_id), '', 'no block until something is set');
76
+ assert.doesNotMatch(buildContextIntro(meta.node_id), /<situational-context>/, 'bearings carry no block when unset');
77
+ appendSituationalContext(meta.node_id, 'user is looking at the billing applet');
78
+ const block = buildContextIntro(meta.node_id);
79
+ assert.match(block, /<situational-context>/, 'block present once situational context is set');
80
+ assert.match(block, /user is looking at the billing applet/, 'block carries the ambient text');
81
+ assert.ok(!/<situational-context>[\s\S]*<\/crtr-bearings>/.test(block), 'the block is a sibling of <crtr-bearings>, not nested inside it');
82
+ });
72
83
  test('node-local doc identity honors explicit frontmatter `name`, not the physical filename', () => {
73
84
  // node-local docs follow the one substrate identity rule (resolveDocName):
74
85
  // an explicit `name:` renders and dedups under that name, not the filename.
@@ -173,13 +173,18 @@ declare class FakeSession {
173
173
  private dispatch;
174
174
  private runDialog;
175
175
  }
176
+ interface FakeModel {
177
+ provider: string;
178
+ id: string;
179
+ }
176
180
  interface FakeServices {
177
181
  resourceLoader: ResourceLoader;
178
182
  modelRegistry: {
179
- find: (provider: string, id: string) => {
180
- provider: string;
181
- id: string;
182
- } | undefined;
183
+ find: (provider: string, id: string) => FakeModel | undefined;
184
+ getAvailable: () => FakeModel[];
185
+ getAll: () => FakeModel[];
186
+ hasConfiguredAuth: (model: FakeModel) => boolean;
187
+ refresh: () => void;
183
188
  };
184
189
  }
185
190
  export declare function createAgentSessionServices(options: {
@@ -757,6 +757,7 @@ class FakeSession {
757
757
  }
758
758
  }
759
759
  }
760
+ const FAKE_AVAILABLE_MODEL = { provider: 'fake', id: 'fake-model' };
760
761
  export async function createAgentSessionServices(options) {
761
762
  const loader = new DefaultResourceLoader({
762
763
  cwd: options.cwd,
@@ -767,10 +768,17 @@ export async function createAgentSessionServices(options) {
767
768
  await loader.reload();
768
769
  return {
769
770
  resourceLoader: loader,
770
- // A minimal model stub for any spec: the lifecycle harness passes no boot
771
- // model (so this is never hit at boot), but the set_model regression test
772
- // drives the broker's findModelSpec → session.setModel path through it.
773
- modelRegistry: { find: (provider, id) => ({ provider, id }) },
771
+ modelRegistry: {
772
+ // A minimal model stub for any spec the set_model regression test drives
773
+ // the broker's findModelSpec → session.setModel path through it.
774
+ find: (provider, id) => ({ provider, id }),
775
+ // Always-authenticated single fake model — the M-9 no-boot-model branch
776
+ // (broker.ts) picks `available[0]` and must never see an empty list here.
777
+ getAvailable: () => [FAKE_AVAILABLE_MODEL],
778
+ getAll: () => [FAKE_AVAILABLE_MODEL],
779
+ hasConfiguredAuth: () => true,
780
+ refresh: () => { },
781
+ },
774
782
  };
775
783
  }
776
784
  export async function createAgentSessionFromServices(options) {
@@ -15,6 +15,8 @@ import { reportsDir } from '../canvas/paths.js';
15
15
  import { closeDb } from '../canvas/db.js';
16
16
  import { drainBearings, buildReviveKickoff, writeYieldMessage, yieldMessagePath, } from '../runtime/kickoff.js';
17
17
  import { appendInbox, readCursor } from '../feed/inbox.js';
18
+ import { appendSituationalContext } from '../runtime/situational-context.js';
19
+ import { buildContextIntro } from '../../pi-extensions/canvas-context-intro.js';
18
20
  let home;
19
21
  function node(id) {
20
22
  return {
@@ -103,6 +105,25 @@ test('a fresh revive is pointed at its subscriptions\' on-disk report history (c
103
105
  assert.ok(/node inspect artifacts <node-id> --type report/.test(msg), 'points at full per-node expansion');
104
106
  assert.ok(/node inspect artifacts <node-id> --type inbox/.test(msg), 'points at the cursor-independent full-history replay');
105
107
  });
108
+ test('a fresh revive kickoff never carries situational context — it rides the hidden bearings seam instead', () => {
109
+ // Hiddenness regression: situational context must stay OUT of the visible
110
+ // fresh/cycling revive kickoff prompt. It is still delivered on this path, but
111
+ // hidden — canvas-context-intro's session_start handler re-fires on every
112
+ // fresh/cycling revive (a new pi process boot rooting a fresh branch) and
113
+ // re-injects buildContextIntro(), which already carries the sidecar's
114
+ // <situational-context> block as a sibling of <crtr-bearings> (see
115
+ // context-intro.test.ts for that seam's own coverage). buildReviveKickoff must
116
+ // never duplicate it into the visible prompt.
117
+ const id = 'n3';
118
+ const meta = createNode(node(id));
119
+ appendSituationalContext(id, 'user is mid-checkout on the store applet');
120
+ const kickoff = buildReviveKickoff(meta, drainBearings(meta));
121
+ assert.doesNotMatch(kickoff, /<situational-context>/, 'visible kickoff never carries the ambient block');
122
+ assert.doesNotMatch(kickoff, /mid-checkout on the store applet/, 'visible kickoff never carries the ambient text');
123
+ const hidden = buildContextIntro(id);
124
+ assert.match(hidden, /<situational-context>/, 'delivered instead via the hidden bearings/context-intro seam');
125
+ assert.match(hidden, /mid-checkout on the store applet/, 'hidden delivery carries the ambient text');
126
+ });
106
127
  test('buildReviveKickoff is pure — building twice eats nothing', () => {
107
128
  const id = 'n2';
108
129
  const meta = createNode(node(id));