@crouton-kit/crouter 0.3.18 → 0.3.20

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 (289) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -0,0 +1,171 @@
1
+ // Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-context-intro.test.ts
2
+ //
3
+ // BUG REGRESSION (crouter/CLAUDE.md testing policy):
4
+ // `crtr node new --fork-from <node>` caused AGENT IDENTITY CONFUSION. A fork
5
+ // inherits the SOURCE node's entire first-person conversation (pi COPIES it
6
+ // into the fork's session), so on boot the forked agent impersonated the
7
+ // source — it kept acting AS the source, even "monitoring itself" as a phantom
8
+ // child, until it happened to check CRTR_NODE_ID. Two root causes, both fixed
9
+ // here and pinned by these tests:
10
+ // 1. canvas-context-intro never re-asserted the node's OWN identity, so the
11
+ // copied first-person narrative won.
12
+ // 2. the idempotency guard misfired for a fork: the copied conversation
13
+ // already carries the SOURCE's <crtr-context> entry, so the guard treated
14
+ // it as "already present" and SUPPRESSED the fork's own intro entirely —
15
+ // the fork booted with the source's bearings (source id, source dir).
16
+ // Symptom write-up: nodes/mq4ns376-da6e56c9/reports/20260608T075810-final.md.
17
+ import { test, before, after, beforeEach } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { mkdtempSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { buildContextIntro, registerCanvasContextIntro, CONTEXT_INTRO_CUSTOM_TYPE, } from '../canvas-context-intro.js';
23
+ import { createNode } from '../../core/canvas/canvas.js';
24
+ import { closeDb } from '../../core/canvas/db.js';
25
+ let home;
26
+ let origNode;
27
+ function node(id, over = {}) {
28
+ return {
29
+ node_id: id,
30
+ name: id,
31
+ created: new Date().toISOString(),
32
+ cwd: '/tmp/work',
33
+ kind: 'general',
34
+ mode: 'base',
35
+ lifecycle: 'terminal',
36
+ status: 'active',
37
+ ...over,
38
+ };
39
+ }
40
+ /** A session entry as `sessionManager.getEntries()` would return it — a custom
41
+ * message carrying a bearings block (string content). */
42
+ function introEntry(content) {
43
+ return { type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE, content };
44
+ }
45
+ function makeFakePi() {
46
+ let handler;
47
+ return {
48
+ sent: [],
49
+ on(event, h) {
50
+ if (event === 'session_start')
51
+ handler = h;
52
+ },
53
+ sendMessage(m) {
54
+ this.sent.push(m);
55
+ },
56
+ registerMessageRenderer() {
57
+ /* display-only; irrelevant here */
58
+ },
59
+ fireSessionStart(entries) {
60
+ handler?.(undefined, { sessionManager: { getEntries: () => entries } });
61
+ },
62
+ };
63
+ }
64
+ before(() => {
65
+ origNode = process.env['CRTR_NODE_ID'];
66
+ });
67
+ beforeEach(() => {
68
+ closeDb();
69
+ if (home)
70
+ rmSync(home, { recursive: true, force: true });
71
+ home = mkdtempSync(join(tmpdir(), 'crtr-context-intro-'));
72
+ process.env['CRTR_HOME'] = home;
73
+ delete process.env['CRTR_NODE_ID'];
74
+ });
75
+ after(() => {
76
+ closeDb();
77
+ if (home)
78
+ rmSync(home, { recursive: true, force: true });
79
+ delete process.env['CRTR_HOME'];
80
+ if (origNode === undefined)
81
+ delete process.env['CRTR_NODE_ID'];
82
+ else
83
+ process.env['CRTR_NODE_ID'] = origNode;
84
+ });
85
+ test('boot intro asserts the node\'s OWN identity up front (regression: forked node impersonated source)', () => {
86
+ createNode(node('alpha', { name: 'fix-auth', kind: 'developer' }));
87
+ const intro = buildContextIntro('alpha');
88
+ assert.match(intro, /<crtr-identity>/, 'opens with an explicit identity block');
89
+ assert.match(intro, /You are node alpha/, 'names the node by its own id');
90
+ assert.match(intro, /kind developer/, 'states the node kind');
91
+ // Gated (Option A): a fresh (non-fork) node's bearings are its FIRST entry, so
92
+ // there is no earlier narrative to disown — the disown framing (the fork callout
93
+ // naming the copied history "inherited reference material") is fork-only and must
94
+ // NOT appear here.
95
+ assert.ok(!/inherited reference material/.test(intro), 'a non-fork node carries no disown-the-earlier-narrative line');
96
+ // The identity block must come BEFORE the <crtr-context> bearings, so it is the
97
+ // first thing the model reads — the orienting frame, not a trailing note.
98
+ assert.ok(intro.indexOf('<crtr-identity>') < intro.indexOf('<crtr-context'), 'identity assertion precedes the context bearings');
99
+ });
100
+ test('a FORK\'s intro names the source and disowns it (regression: fork acted AS the source)', () => {
101
+ // The source node whose conversation pi copied into the fork.
102
+ createNode(node('src7', { name: 'looking-pi-crouter', description: 'monitor-telemetry' }));
103
+ // The fork: fork_from provenance persisted at spawn.
104
+ createNode(node('fork9', { fork_from: 'src7' }));
105
+ const intro = buildContextIntro('fork9');
106
+ assert.match(intro, /You are node fork9/, 'the fork is told its OWN id');
107
+ assert.match(intro, /You are a FORK of src7/, 'names the fork source explicitly');
108
+ assert.match(intro, /You are NOT src7/, '"you are NOT the source" flag is present');
109
+ // Carries the source's human label so the agent recognizes the copied persona.
110
+ assert.match(intro, /looking-pi-crouter/, 'surfaces the source label');
111
+ // The fork DOES carry the disown framing — it has a real referent (copied history).
112
+ assert.match(intro, /inherited reference material/, 'the fork disowns the copied first-person narrative');
113
+ // It must NOT tell the fork it is the source node.
114
+ assert.ok(!/You are node src7/.test(intro), 'fork is never told it is the source node');
115
+ // A non-fork sibling gets the identity assertion but NO fork callout and NO
116
+ // disown line (gated on fork_from).
117
+ createNode(node('plain2'));
118
+ const plain = buildContextIntro('plain2');
119
+ assert.match(plain, /You are node plain2/);
120
+ assert.ok(!/FORK of/.test(plain), 'a fresh (non-fork) node carries no fork callout');
121
+ assert.ok(!/inherited reference material/.test(plain), 'a non-fork node carries no disown line');
122
+ });
123
+ test('session_start INJECTS the fork\'s own bearings despite the inherited source block (the misfiring-idempotency bug)', () => {
124
+ createNode(node('srcA'));
125
+ createNode(node('forkB', { fork_from: 'srcA' }));
126
+ // A fork boots with the SOURCE's whole conversation copied in — including the
127
+ // source's <crtr-context> intro entry (which names srcA, not forkB).
128
+ const inheritedSourceBlock = introEntry(buildContextIntro('srcA'));
129
+ process.env['CRTR_NODE_ID'] = 'forkB';
130
+ const pi = makeFakePi();
131
+ registerCanvasContextIntro(pi);
132
+ pi.fireSessionStart([inheritedSourceBlock]);
133
+ // The fix: the inherited block belongs to srcA, so it must NOT suppress
134
+ // forkB's own intro. forkB's bearings (with its identity assertion) ARE sent.
135
+ assert.equal(pi.sent.length, 1, 'fork injects its OWN bearings over the inherited source block');
136
+ assert.equal(pi.sent[0].customType, CONTEXT_INTRO_CUSTOM_TYPE);
137
+ assert.match(pi.sent[0].content, /You are node forkB/, 'injected bearings name the fork, not the source');
138
+ assert.match(pi.sent[0].content, /You are a FORK of srcA/, 'injected bearings carry the fork callout');
139
+ // The exact, machine-readable resume discriminator rides along (not sent to
140
+ // the LLM): it names THIS node, so a later resume tells our block apart from
141
+ // any inherited one without depending on prose.
142
+ assert.equal(pi.sent[0].details?.nodeId, 'forkB', 'injected block carries the exact node-id stamp');
143
+ });
144
+ test('resume idempotency uses the EXACT details stamp, independent of block text (regression hardening)', () => {
145
+ createNode(node('stampD'));
146
+ // A restored block that carries OUR exact stamp but whose TEXT does not mention
147
+ // our id. The old content-substring-only guard would re-inject (and accumulate)
148
+ // here; the exact `details.nodeId` match skips correctly.
149
+ const stampedNoIdInText = {
150
+ type: 'custom_message',
151
+ customType: CONTEXT_INTRO_CUSTOM_TYPE,
152
+ content: '<crtr-context dir="/elsewhere">bearings with no id in the text</crtr-context>',
153
+ details: { nodeId: 'stampD' },
154
+ };
155
+ process.env['CRTR_NODE_ID'] = 'stampD';
156
+ const pi = makeFakePi();
157
+ registerCanvasContextIntro(pi);
158
+ pi.fireSessionStart([stampedNoIdInText]);
159
+ assert.equal(pi.sent.length, 0, 'exact details-stamp match skips re-injection even when the text omits the id');
160
+ });
161
+ test('session_start stays IDEMPOTENT on a genuine resume (our own bearings already present)', () => {
162
+ createNode(node('resumeC'));
163
+ // A `--session` relaunch restores OUR conversation — our own bearings (naming
164
+ // resumeC) are already in history.
165
+ const ownBlock = introEntry(buildContextIntro('resumeC'));
166
+ process.env['CRTR_NODE_ID'] = 'resumeC';
167
+ const pi = makeFakePi();
168
+ registerCanvasContextIntro(pi);
169
+ pi.fireSessionStart([ownBlock]);
170
+ assert.equal(pi.sent.length, 0, 'our own bearings already present → no duplicate injection');
171
+ });
@@ -113,9 +113,11 @@ test('natural stop while awaiting a live worker → idle-release with NO push (n
113
113
  // '%pane' ids below are never read by tmux). status='done' is reached by setting
114
114
  // the runtime row directly (the branch reads getNode(nodeId).status).
115
115
  // ---------------------------------------------------------------------------
116
- test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
116
+ test('§5.1.1 truly-done + focused + dormant idle-release manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
117
117
  createNode(node('root', { parent: null, lifecycle: 'resident' }));
118
- createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
118
+ // mgr is dormant + idle-release the ONLY dormant manager the daemon will
119
+ // revive INTO the frozen pane, so the ONLY dormant case that is a real takeover.
120
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator', status: 'idle', intent: 'idle-release' }));
119
121
  // M starts WITH a recorded LOCATION so the MINOR presence-null is observable.
120
122
  createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Suser', window: '@wm' }));
121
123
  subscribe('mgr', 'M', true);
@@ -126,8 +128,9 @@ test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of
126
128
  registerCanvasStophook(pi);
127
129
  let shutdown = false;
128
130
  pi.fire('agent_end', stopEvent('done — pushed final'), { shutdown: () => { shutdown = true; } });
129
- // managerId = M.parent = 'mgr' (not focused elsewhere, no live pane here → the
130
- // DORMANT-takeover path) → handFocusToManager repoints fM's occupant M→mgr. The
131
+ // managerId = M.parent = 'mgr' (idle-release, not focused elsewhere, no live
132
+ // pane here → the DORMANT-takeover path) → handFocusToManager repoints fM's
133
+ // occupant M→mgr (true ONLY because mgr is idle+idle-release). The
131
134
  // daemon later revives mgr INTO M's frozen focus pane. Non-vacuous: a no-op (no
132
135
  // handoff) impl leaves M as occupant, so getFocusByNode('mgr') is null AND
133
136
  // getFocusByNode('M') still names fM — both asserts fail.
@@ -197,22 +200,26 @@ test('§5.1.4 truly-done + UNFOCUSED → no focus row created/touched, shuts dow
197
200
  assert.equal(shutdown, true, 'an unfocused done node shuts down');
198
201
  assert.equal(listFocuses().length, 0, 'no focus row was created or touched');
199
202
  });
200
- test('§5.1.5 awaiting + focusedidle-release FREEZE: the focus row SURVIVES untouched (F3)', () => {
203
+ test('§5.1.5 awaiting + FOCUSEDSTAY ALIVE: pi keeps running (no release), focus row untouched (F3)', () => {
201
204
  createNode(node('root', { parent: null, lifecycle: 'resident' }));
202
205
  createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
203
206
  createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
204
207
  subscribe('root', 'mgr', true);
205
- subscribe('mgr', 'worker', true); // mgr awaits a live worker → idle-release
208
+ subscribe('mgr', 'worker', true); // mgr awaits a live worker → would idle-release if UNfocused
206
209
  openFocusRow('fMgr', '%g', 'Suser', 'mgr');
207
210
  process.env['CRTR_NODE_ID'] = 'mgr';
208
211
  const pi = makeFakePi();
209
212
  registerCanvasStophook(pi);
210
- pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { } });
211
- // The awaiting branch only transition('release')s it must NOT close or
212
- // repoint the focus (that is the done branch). Non-vacuous: a wrong impl that
213
- // routed an idle-release through the done-branch handoff/close would change or
214
- // remove fMgr.
215
- assert.equal(getNode('mgr')?.intent, 'idle-release', 'mgr idle-released (frozen)');
213
+ let shutdown = false;
214
+ pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { shutdown = true; } });
215
+ // A FOCUSED awaiting node holds the user's viewport, so the awaiting branch
216
+ // keeps pi LIVE and dormant (the in-process inbox-watcher wakes it) instead of
217
+ // releasing + shutting down. It must NOT release, NOT shut down, and NOT touch
218
+ // the focus row. Non-vacuous: the old freeze impl flipped intent→idle-release
219
+ // and shut pi down; this pins all three as untouched.
220
+ assert.equal(getNode('mgr')?.status, 'active', 'a focused awaiting node stays active (not released)');
221
+ assert.equal(getNode('mgr')?.intent ?? null, null, 'no idle-release intent while focused');
222
+ assert.equal(shutdown, false, 'a focused awaiting node is NOT shut down — pi stays live');
216
223
  assert.equal(getFocusByNode('mgr')?.focus_id, 'fMgr', 'the focus row is UNCHANGED — not closed, not handed off');
217
224
  });
218
225
  test('§5.1.7 resident attended (no live subs) → nothing happens; focus + status survive', () => {
@@ -8,6 +8,9 @@ interface CommandUI {
8
8
  }
9
9
  interface CommandCtx {
10
10
  ui: CommandUI;
11
+ /** Current run mode: "tui" | "rpc" | "json" | "print". Guard "tui" for the
12
+ * interactive-only path (headless brokers bind 'print'). */
13
+ mode: string;
11
14
  }
12
15
  interface CustomMessage {
13
16
  customType: string;
@@ -54,6 +54,16 @@ export function registerCanvasCommands(pi) {
54
54
  return items.length > 0 ? items : null;
55
55
  },
56
56
  handler: async (args, ctx) => {
57
+ // Interactive-only. A headless (print-mode) broker never invokes /promote
58
+ // (promotion there runs `crtr node promote` directly), so the command is
59
+ // inert. Under tmux ctx.mode is always 'tui' — byte-identical there.
60
+ if (ctx.mode !== 'tui') {
61
+ try {
62
+ ctx.ui.notify('/promote needs the interactive TUI', 'warning');
63
+ }
64
+ catch { /* best-effort */ }
65
+ return;
66
+ }
57
67
  const kind = args.trim().toLowerCase();
58
68
  ctx.ui.setStatus('crtr-promote', kind ? `promoting → ${kind}…` : 'promoting…');
59
69
  const argv = ['node', 'promote', '--json'];
@@ -4,6 +4,18 @@ export declare const CONTEXT_INTRO_CUSTOM_TYPE = "crtr-context";
4
4
  interface SessionEntryLike {
5
5
  type: string;
6
6
  customType?: string;
7
+ /** custom_message entries carry their injected content (string or blocks). */
8
+ content?: string | Array<{
9
+ type: string;
10
+ text?: string;
11
+ }>;
12
+ /** Extension metadata we stamp on the block (NOT sent to the LLM). The
13
+ * authoritative idempotency discriminator: `nodeId` is the node the block
14
+ * belongs to, so a fork (whose copied source block carries the SOURCE's id)
15
+ * is told apart from a genuine resume by an EXACT id match. */
16
+ details?: {
17
+ nodeId?: string;
18
+ };
7
19
  }
8
20
  interface SessionStartCtxLike {
9
21
  sessionManager: {
@@ -14,6 +26,11 @@ interface CustomMessageLike {
14
26
  customType: string;
15
27
  content: string;
16
28
  display?: boolean;
29
+ /** Extension-only metadata (not sent to the LLM); used as the exact
30
+ * idempotency discriminator on resume — see SessionEntryLike.details. */
31
+ details?: {
32
+ nodeId: string;
33
+ };
17
34
  }
18
35
  /** The message handed to a message renderer. `content` is normally the string we
19
36
  * sent, but pi types it as string-or-blocks, so we handle both. */
@@ -14,17 +14,26 @@
14
14
  // session_start instead.)
15
15
  //
16
16
  // The block carries: the path to the node's own context dir and the framing for
17
- // what belongs there (a shared document store for the other nodes). Resident
18
- // orchestrators additionally get the across-refresh-cycles framing + a <memory>
19
- // block merging the indexes of their three scoped memory stores (user-global,
20
- // project, node-local), each labeled with its absolute dir + index path. The
21
- // prose lives in core/runtime/bearings.ts (shared with the promotion guidance
22
- // dump), which gates the memory block on the node having a node-local store — so
23
- // a terminal worker gets no memory framing at all.
17
+ // what belongs there (a shared document store for the other nodes). EVERY node
18
+ // also gets a <memory> block merging the indexes of its three scoped memory
19
+ // stores (user-global, project, node-local), each labeled with its dir one
20
+ // consistent surface, since every node is born with all three stores. An
21
+ // orchestrator additionally gets the across-refresh-cycles framing (the one
22
+ // thing a terminal worker's bearings drop). The prose lives in
23
+ // core/runtime/bearings.ts (shared with the promotion guidance dump).
24
24
  //
25
- // IDEMPOTENT across resumes: a `--session` relaunch restores the conversation,
26
- // so the block is already in history; the session_start handler sees it via
27
- // `sessionManager.getEntries()` and skips, so it never accumulates.
25
+ // IDEMPOTENT across resumes, but FORK-AWARE: a `--session` relaunch restores OUR
26
+ // conversation (whose bearings name OUR node id), so the session_start handler
27
+ // sees it via `sessionManager.getEntries()` and skips it never accumulates. A
28
+ // `--fork-from` boot, by contrast, COPIES the source node's whole conversation
29
+ // (whose bearings name the SOURCE's node id), so the handler must NOT treat that
30
+ // inherited block as ours; it only skips when a bearings block belonging to OUR
31
+ // node is already present (exact `details.nodeId` stamp, content-match
32
+ // fallback), otherwise it injects ours — whose <crtr-identity> block reasserts
33
+ // the fork's identity over the inherited persona. This is ONE of two reinforcing
34
+ // channels: spawn.ts ALSO prepends the same identity assertion to a fork's
35
+ // kickoff prompt (the turn-triggering message), so the override does not rest on
36
+ // a single trailing custom_message.
28
37
  //
29
38
  // COLLAPSED BY DEFAULT: a `registerMessageRenderer` keyed to our customType
30
39
  // renders the block as a single one-line stub; the full body only appears when
@@ -62,6 +71,21 @@ function messageText(message) {
62
71
  .map((c) => c.text)
63
72
  .join('\n');
64
73
  }
74
+ /** Plain text of a session entry's content (string or text blocks), '' when
75
+ * absent. Used by the idempotency guard to tell OUR own injected bearings
76
+ * (which name our node id) from a SOURCE's bearings copied in by `--fork-from`. */
77
+ function entryText(e) {
78
+ const c = e.content;
79
+ if (typeof c === 'string')
80
+ return c;
81
+ if (Array.isArray(c)) {
82
+ return c
83
+ .filter((b) => typeof b?.text === 'string')
84
+ .map((b) => b.text)
85
+ .join('\n');
86
+ }
87
+ return '';
88
+ }
65
89
  /** Hard-wrap a single logical line to `width` columns (content carries no ANSI).
66
90
  * Code-point aware so wide-string slicing never splits a surrogate pair; the
67
91
  * bearings prose is plain text, so code-point count == visible columns. */
@@ -140,19 +164,35 @@ export function registerCanvasContextIntro(pi) {
140
164
  const nodeId = process.env['CRTR_NODE_ID'];
141
165
  if (nodeId === undefined || nodeId.trim() === '')
142
166
  return; // not a canvas node
143
- // Idempotent: a restored/reloaded session already carries the block.
144
- const present = ctx.sessionManager
167
+ // Idempotent on RESUME, but NOT fooled by a FORK. A `--session` relaunch
168
+ // restores OUR conversation, whose bearings name OUR node id — skip then,
169
+ // so the block never accumulates. A `--fork-from` boot instead copies the
170
+ // SOURCE node's whole conversation (its bearings name ITS node id, not
171
+ // ours), so a naive "any crtr-context present?" check would suppress our
172
+ // own intro and let the fork inherit — and impersonate — the source. So we
173
+ // only skip when a bearings block belonging to OUR node is already present.
174
+ // Primary discriminator: the EXACT `details.nodeId` stamp (machine-
175
+ // readable, copied on fork, never sent to the LLM). Fallback: a substring
176
+ // match on the block text (our id appears in the identity line AND the
177
+ // context-dir path `…/nodes/<nodeId>/context`) — covers legacy blocks
178
+ // persisted before the stamp existed. Either match ⇒ this is our own
179
+ // resume, not an inherited fork block, so skip.
180
+ const ours = ctx.sessionManager
145
181
  .getEntries()
146
- .some((e) => e.type === 'custom_message' && e.customType === CONTEXT_INTRO_CUSTOM_TYPE);
147
- if (present)
182
+ .some((e) => e.type === 'custom_message' &&
183
+ e.customType === CONTEXT_INTRO_CUSTOM_TYPE &&
184
+ (e.details?.nodeId === nodeId || entryText(e).includes(nodeId)));
185
+ if (ours)
148
186
  return;
149
187
  // No delivery options: at the idle start of a session this is pushed onto
150
188
  // the (still empty) message list and persisted immediately, so it precedes
151
- // the node's first prompt.
189
+ // the node's first prompt. The `details.nodeId` stamp rides along as the
190
+ // exact resume discriminator (not shown to the LLM).
152
191
  pi.sendMessage({
153
192
  customType: CONTEXT_INTRO_CUSTOM_TYPE,
154
193
  content: buildContextIntro(nodeId),
155
194
  display: true,
195
+ details: { nodeId },
156
196
  });
157
197
  }
158
198
  catch {
@@ -0,0 +1,44 @@
1
+ /** before_agent_start: pi hands us the assembled system prompt; returning a new
2
+ * `systemPrompt` replaces it for this agent run (pi rebuilds it each run, so
3
+ * splicing fresh every time is idempotent — never cumulative). */
4
+ interface BeforeAgentStartEventLike {
5
+ systemPrompt: string;
6
+ }
7
+ interface BeforeAgentStartResultLike {
8
+ systemPrompt: string;
9
+ }
10
+ /** tool_result: fired after a tool returns. `content` is the tool's output
11
+ * blocks; returning a new `content` replaces what the model sees. */
12
+ interface ContentBlockLike {
13
+ type: string;
14
+ text?: string;
15
+ }
16
+ interface ToolResultEventLike {
17
+ toolName: string;
18
+ isError?: boolean;
19
+ /** The tool's resolved input. For `read` it carries the file path under
20
+ * `path` (or the legacy `file_path`). Typed loosely; narrowed at use. */
21
+ input?: unknown;
22
+ content: ContentBlockLike[];
23
+ }
24
+ interface ToolResultCtxLike {
25
+ /** The session cwd — used to resolve a relative read path. */
26
+ cwd?: string;
27
+ }
28
+ type ToolResultResultLike = {
29
+ content: ContentBlockLike[];
30
+ } | void;
31
+ interface PiLike {
32
+ on(event: 'before_agent_start', handler: (event: BeforeAgentStartEventLike) => BeforeAgentStartResultLike | void | Promise<BeforeAgentStartResultLike | void>): void;
33
+ on(event: 'session_start', handler: (event: unknown, ctx: unknown) => void): void;
34
+ on(event: 'tool_result', handler: (event: ToolResultEventLike, ctx: ToolResultCtxLike) => ToolResultResultLike | Promise<ToolResultResultLike>): void;
35
+ }
36
+ /**
37
+ * Register the document substrate's two render hooks on `pi`.
38
+ *
39
+ * Returns immediately (inert) when CRTR_NODE_ID is absent — a non-canvas pi
40
+ * session gets neither hook. Each handler is independently wrapped so a failure
41
+ * degrades to a no-op (boot proceeds, the read returns) rather than bricking.
42
+ */
43
+ export declare function registerCanvasDocSubstrate(pi: PiLike): void;
44
+ export default registerCanvasDocSubstrate;
@@ -0,0 +1,112 @@
1
+ // canvas-doc-substrate.ts — the document substrate's two render hooks, owned by
2
+ // a single self-contained canvas pi extension.
3
+ //
4
+ // Loaded into a canvas node's pi process via the node's launch.extensions list
5
+ // (registered in CANVAS_EXTENSIONS at the flip — see the note at the bottom of
6
+ // this file; AUTHOR-ONLY for now, NOT yet registered). INERT when CRTR_NODE_ID
7
+ // is absent (a plain pi session or legacy job agent loads it as a no-op).
8
+ //
9
+ // It owns the substrate's two new pi hooks:
10
+ //
11
+ // 1. before_agent_start — the BOOT system-prompt half. Splices the rendered
12
+ // `## Skills` + `## Preferences` sections into `event.systemPrompt`, right
13
+ // after pi's native "Available tools" list (before the `\n\nGuidelines:`
14
+ // anchor), so the substrate's skills/preferences sit in the tool-selection
15
+ // frame the agent reads while choosing a capability — mirroring the
16
+ // personal crouter-help.ts anchor logic. Falls back to appending when the
17
+ // anchor is absent.
18
+ //
19
+ // 2. tool_result (gated on `read`) — the ON-READ half. For each non-error
20
+ // `read`, surfaces the substrate docs that should appear alongside the file
21
+ // (positional `.crouter/memory/` ancestors + `applies-to` globs) at their
22
+ // file-read-visibility rung, prepended as a `<auto-loaded-context>` block
23
+ // before the file contents. A per-session realpath set dedups so the same
24
+ // doc is not re-injected on repeat reads.
25
+ //
26
+ // Both handler bodies are wrapped in try/catch and degrade to INERT on error —
27
+ // a substrate bug must never brick node boot or break a `read`, only quietly
28
+ // stop surfacing docs. The pure render lives in core/substrate/ (render.ts +
29
+ // on-read.ts); this file is only the pi-hook plumbing.
30
+ //
31
+ // Plain TS-with-types — NO imports from @earendil-works/* (a local structural
32
+ // PiLike interface stands in), so it compiles inside crouter's own tsc build
33
+ // with no dep on the pi packages. Mirrors canvas-context-intro.ts.
34
+ import { homedir } from 'node:os';
35
+ import { join, resolve } from 'node:path';
36
+ import { renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
37
+ import { clearSessionCache } from '../core/substrate/session-cache.js';
38
+ // ---------------------------------------------------------------------------
39
+ // Extension
40
+ // ---------------------------------------------------------------------------
41
+ /** The stable seam that closes pi's native "Available tools" list. We splice the
42
+ * substrate's boot sections right before it — the agent decides which capability
43
+ * to reach for while reading the tools, so skills/preferences must sit there,
44
+ * not far below. Mirrors crouter-help.ts. Falls back to appending if absent. */
45
+ const TOOLS_ANCHOR = '\n\nGuidelines:';
46
+ /**
47
+ * Register the document substrate's two render hooks on `pi`.
48
+ *
49
+ * Returns immediately (inert) when CRTR_NODE_ID is absent — a non-canvas pi
50
+ * session gets neither hook. Each handler is independently wrapped so a failure
51
+ * degrades to a no-op (boot proceeds, the read returns) rather than bricking.
52
+ */
53
+ export function registerCanvasDocSubstrate(pi) {
54
+ const nodeId = process.env['CRTR_NODE_ID'];
55
+ if (nodeId === undefined || nodeId.trim() === '')
56
+ return; // not a canvas node — inert
57
+ // Per-session set of injected doc realpaths → a doc surfaces at most once per
58
+ // session across repeated reads. Cleared on every session_start (so a resume
59
+ // starts fresh, matching the on-read precedent).
60
+ // Also clears the per-session substrate parse cache so the corpus is re-scanned
61
+ // fresh for each new session (avoids stale docs after a skill/memory write).
62
+ const injectedDocs = new Set();
63
+ pi.on('session_start', () => {
64
+ injectedDocs.clear();
65
+ clearSessionCache();
66
+ });
67
+ // 1. BOOT system-prompt half — splice `## Skills` + `## Preferences`.
68
+ pi.on('before_agent_start', (event) => {
69
+ try {
70
+ const sections = [renderSkillsSection(nodeId), renderPreferencesSection(nodeId)].filter((s) => s !== '');
71
+ if (sections.length === 0)
72
+ return; // nothing eligible — leave the prompt untouched
73
+ const block = sections.join('\n\n');
74
+ const idx = event.systemPrompt.indexOf(TOOLS_ANCHOR);
75
+ if (idx === -1) {
76
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
77
+ }
78
+ return {
79
+ systemPrompt: `${event.systemPrompt.slice(0, idx)}\n\n${block}${event.systemPrompt.slice(idx)}`,
80
+ };
81
+ }
82
+ catch {
83
+ return; // inert on error — never brick node boot
84
+ }
85
+ });
86
+ // 2. ON-READ half — augment a non-error `read` result with surfacing docs.
87
+ pi.on('tool_result', (event, ctx) => {
88
+ try {
89
+ if (event.toolName !== 'read' || event.isError === true)
90
+ return;
91
+ const input = event.input;
92
+ const rawPath = input?.path ?? input?.file_path;
93
+ if (rawPath === undefined || rawPath === '')
94
+ return;
95
+ // Resolve to an absolute path (renderOnReadDocs realpaths it). pi's read
96
+ // tool expands a leading `~`, but event.input keeps the raw form, so
97
+ // expand it ourselves before resolving against the session cwd.
98
+ const cwd = typeof ctx?.cwd === 'string' && ctx.cwd !== '' ? ctx.cwd : process.cwd();
99
+ const expanded = rawPath.startsWith('~') ? join(homedir(), rawPath.slice(1)) : rawPath;
100
+ const absFile = resolve(cwd, expanded);
101
+ const injected = renderOnReadDocs(nodeId, absFile, injectedDocs);
102
+ if (injected === '')
103
+ return; // nothing surfaced — pass the read through unchanged
104
+ // Prepend the surfacing docs ahead of the file contents.
105
+ return { content: [{ type: 'text', text: injected }, ...event.content] };
106
+ }
107
+ catch {
108
+ return; // inert on error — a bug must degrade a read, never break it
109
+ }
110
+ });
111
+ }
112
+ export default registerCanvasDocSubstrate;
@@ -15,6 +15,9 @@ interface UIContext {
15
15
  }
16
16
  interface ExtensionCtx {
17
17
  ui: UIContext;
18
+ /** Current run mode: "tui" | "rpc" | "json" | "print". The nav chrome +
19
+ * ask-poll timer are interactive-only; headless brokers bind 'print'. */
20
+ mode: string;
18
21
  }
19
22
  interface CommandCtx {
20
23
  ui: UIContext;