@crouton-kit/crouter 0.3.14 → 0.3.15

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 (220) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +44 -66
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -98
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +130 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +259 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +244 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +328 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +206 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +80 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +27 -10
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +24 -12
  176. package/dist/core/runtime/launch.js +75 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +32 -1
  184. package/dist/core/runtime/nodes.js +60 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +287 -0
  188. package/dist/core/runtime/placement.js +663 -0
  189. package/dist/core/runtime/presence.d.ts +7 -15
  190. package/dist/core/runtime/presence.js +90 -66
  191. package/dist/core/runtime/promote.d.ts +14 -7
  192. package/dist/core/runtime/promote.js +57 -67
  193. package/dist/core/runtime/reset.d.ts +47 -4
  194. package/dist/core/runtime/reset.js +223 -52
  195. package/dist/core/runtime/revive.d.ts +26 -2
  196. package/dist/core/runtime/revive.js +166 -39
  197. package/dist/core/runtime/spawn.d.ts +20 -5
  198. package/dist/core/runtime/spawn.js +163 -43
  199. package/dist/core/runtime/stop-guard.d.ts +1 -1
  200. package/dist/core/runtime/stop-guard.js +18 -8
  201. package/dist/core/runtime/tmux.d.ts +100 -14
  202. package/dist/core/runtime/tmux.js +201 -28
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +586 -262
  216. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  217. package/dist/pi-extensions/canvas-stophook.js +344 -228
  218. package/dist/types.d.ts +28 -0
  219. package/dist/types.js +16 -0
  220. package/package.json +1 -1
@@ -0,0 +1,266 @@
1
+ // Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-stophook-agentend.test.ts
2
+ //
3
+ // The stophook's agent_end routing no longer auto-pushes anything: a node
4
+ // reaches its subscribers ONLY through its own explicit `crtr push` calls.
5
+ // These tests pin that on the three stop outcomes:
6
+ // • natural stop while awaiting a live worker → idle-release, NO push
7
+ // • refresh-yield (intent='refresh') → re-exec/shutdown, NO push
8
+ // • stalled leaf (nothing live, no final) → reprompt still fires
9
+ // Every assertion is on DB / disk effects (report files, inbox pointers) plus
10
+ // the captured sendUserMessage — tmux is unavailable here, so the focus/respawn
11
+ // helpers no-op (TMUX_PANE is cleared) and we drive a clean shutdown path.
12
+ import { test, before, after, beforeEach } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import registerCanvasStophook from '../canvas-stophook.js';
18
+ import { createNode, subscribe, getNode, setStatus } from '../../core/canvas/canvas.js';
19
+ import { openFocusRow, getFocusByNode, getFocusById, listFocuses } from '../../core/canvas/focuses.js';
20
+ import { closeDb } from '../../core/canvas/db.js';
21
+ import { reportsDir } from '../../core/canvas/paths.js';
22
+ import { readInboxSince } from '../../core/feed/inbox.js';
23
+ import { STALL_REPROMPT } from '../../core/runtime/stop-guard.js';
24
+ let home;
25
+ let origNode;
26
+ let origPane;
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
+ function makeFakePi() {
41
+ const handlers = {};
42
+ return {
43
+ injected: [],
44
+ on(e, h) { handlers[e] = h; },
45
+ sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
46
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
47
+ };
48
+ }
49
+ /** A natural-stop agent_end event carrying one assistant text block. */
50
+ function stopEvent(text) {
51
+ return { messages: [{ role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] }] };
52
+ }
53
+ /** Count of report files written under a node's reports/ dir (0 when none). */
54
+ function reportCount(id) {
55
+ const dir = reportsDir(id);
56
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).length : 0;
57
+ }
58
+ before(() => {
59
+ origNode = process.env['CRTR_NODE_ID'];
60
+ origPane = process.env['TMUX_PANE'];
61
+ });
62
+ beforeEach(() => {
63
+ closeDb();
64
+ if (home)
65
+ rmSync(home, { recursive: true, force: true });
66
+ home = mkdtempSync(join(tmpdir(), 'crtr-stophook-end-'));
67
+ process.env['CRTR_HOME'] = home;
68
+ // Force the clean-shutdown path (no in-place respawn) so the refresh test is
69
+ // deterministic even when the suite runs inside a tmux pane.
70
+ delete process.env['TMUX_PANE'];
71
+ });
72
+ after(() => {
73
+ closeDb();
74
+ if (home)
75
+ rmSync(home, { recursive: true, force: true });
76
+ delete process.env['CRTR_HOME'];
77
+ if (origNode === undefined)
78
+ delete process.env['CRTR_NODE_ID'];
79
+ else
80
+ process.env['CRTR_NODE_ID'] = origNode;
81
+ if (origPane === undefined)
82
+ delete process.env['TMUX_PANE'];
83
+ else
84
+ process.env['TMUX_PANE'] = origPane;
85
+ });
86
+ test('natural stop while awaiting a live worker → idle-release with NO push (no report, no inbox pointer)', () => {
87
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
88
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
89
+ createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
90
+ subscribe('root', 'mgr', true); // root would receive any push mgr emits
91
+ subscribe('mgr', 'worker', true); // mgr holds an active live subscription → "awaiting"
92
+ process.env['CRTR_NODE_ID'] = 'mgr';
93
+ const pi = makeFakePi();
94
+ registerCanvasStophook(pi);
95
+ let shutdown = false;
96
+ pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { shutdown = true; } });
97
+ const m = getNode('mgr');
98
+ assert.equal(m?.intent, 'idle-release', 'mgr idle-released');
99
+ assert.equal(m?.status, 'idle', 'mgr marked idle');
100
+ assert.equal(shutdown, true, 'pi shut down');
101
+ assert.equal(reportCount('mgr'), 0, 'NO report file written');
102
+ assert.equal(readInboxSince('root').length, 0, 'NO inbox pointer fanned to subscriber');
103
+ assert.equal(pi.injected.length, 0, 'no reprompt on a legitimate idle-release');
104
+ // §5.1 case 6 (awaiting + UNFOCUSED → idle-release, no focus): the awaiting
105
+ // branch must never create/touch a focus row. Non-vacuous: an impl that ran
106
+ // the done-branch handoff/openFocus on an idle-release would leave a row here.
107
+ assert.equal(listFocuses().length, 0, 'awaiting+unfocused leaves the focuses table empty');
108
+ });
109
+ // ---------------------------------------------------------------------------
110
+ // §5.1 — the §1.7 agent_end branch map on the focuses table. Every assertion is
111
+ // on the canvas focuses/runtime rows after firing agent_end (TMUX_PANE is
112
+ // cleared in beforeEach, so the focus helpers in the handler are pure DB and the
113
+ // '%pane' ids below are never read by tmux). status='done' is reached by setting
114
+ // the runtime row directly (the branch reads getNode(nodeId).status).
115
+ // ---------------------------------------------------------------------------
116
+ test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
117
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
118
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
119
+ // M starts WITH a recorded LOCATION so the MINOR presence-null is observable.
120
+ createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Suser', window: '@wm' }));
121
+ subscribe('mgr', 'M', true);
122
+ openFocusRow('fM', '%m', 'Suser', 'M');
123
+ process.env['CRTR_NODE_ID'] = 'M';
124
+ setStatus('M', 'done'); // pushed final this turn
125
+ const pi = makeFakePi();
126
+ registerCanvasStophook(pi);
127
+ let shutdown = false;
128
+ 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
+ // daemon later revives mgr INTO M's frozen focus pane. Non-vacuous: a no-op (no
132
+ // handoff) impl leaves M as occupant, so getFocusByNode('mgr') is null AND
133
+ // getFocusByNode('M') still names fM — both asserts fail.
134
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fM', 'focus row taken over by the manager');
135
+ assert.equal(getFocusByNode('M'), null, 'the finished node no longer occupies any focus');
136
+ assert.equal(shutdown, true, 'pi shut down after the handoff');
137
+ // MINOR: after a successful takeover M (done) owns no pane (Invariant P) — its
138
+ // own presence is nulled so two rows never reference %m. Non-vacuous: an impl
139
+ // that skips the done-path setPresence-null leaves getNode('M').pane === '%m'.
140
+ assert.equal(getNode('M')?.pane ?? null, null, 'the finished node\'s own LOCATION pane is nulled');
141
+ assert.equal(getNode('M')?.window ?? null, null, 'the finished node\'s window presence is nulled too');
142
+ });
143
+ test('§5.1.2 truly-done + focused + NO manager (root) → focus row CLOSED (Q1)', () => {
144
+ // R carries a LOCATION so the close-path presence-null is observable.
145
+ createNode(node('R', { parent: null, lifecycle: 'terminal', pane: '%r', tmux_session: 'Suser', window: '@wr' }));
146
+ openFocusRow('fR', '%r', 'Suser', 'R');
147
+ process.env['CRTR_NODE_ID'] = 'R';
148
+ setStatus('R', 'done');
149
+ const pi = makeFakePi();
150
+ registerCanvasStophook(pi);
151
+ pi.fire('agent_end', stopEvent('root done'), { shutdown: () => { } });
152
+ // managerId = R.parent(null) ?? subscribersOf(R)[0](none) = null →
153
+ // handFocusToManager returns false → the close path: closeFocusRow(fR) +
154
+ // setRemainOnExit(%r's window, false) (return-to-shell) + null R's presence.
155
+ // Non-vacuous: a takeover-instead-of-close impl would leave the row present;
156
+ // an impl that skips the MINOR presence-null leaves getNode('R').pane === '%r'.
157
+ assert.equal(getFocusById('fR'), null, 'a manager-less finished focus is closed, not handed off');
158
+ assert.equal(listFocuses().length, 0, 'no focus rows survive');
159
+ assert.equal(getNode('R')?.pane ?? null, null, 'the finished root\'s own LOCATION pane is nulled (close path reaps)');
160
+ });
161
+ test('§5.1.3 truly-done + focused + manager ALREADY focused elsewhere → focus CLOSED, manager UNMOVED', () => {
162
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
163
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
164
+ createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Sa', window: '@wm' }));
165
+ subscribe('mgr', 'M', true);
166
+ openFocusRow('fOther', '%o', 'Sb', 'mgr'); // mgr already on its OWN viewport
167
+ openFocusRow('fM', '%m', 'Sa', 'M');
168
+ process.env['CRTR_NODE_ID'] = 'M';
169
+ setStatus('M', 'done');
170
+ const pi = makeFakePi();
171
+ registerCanvasStophook(pi);
172
+ pi.fire('agent_end', stopEvent('M done'), { shutdown: () => { } });
173
+ // handFocusToManager sees getFocusByNode('mgr') != null → returns false →
174
+ // closeFocusRow(fM). Non-vacuous: moving mgr would either repoint its focus_id
175
+ // to fM (and a wrong impl that didn't close fM would leave it present) or throw
176
+ // UNIQUE(node_id); this pins mgr's OTHER focus untouched and M's focus gone.
177
+ assert.equal(getFocusById('fM'), null, "M's focus is closed");
178
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fOther', "the manager's other viewport is NOT stolen");
179
+ // MINOR: M (done) is reaped on the close path — its own presence nulled.
180
+ // Non-vacuous: an impl that skips the done-path setPresence-null leaves
181
+ // getNode('M').pane === '%m'.
182
+ assert.equal(getNode('M')?.pane ?? null, null, "the finished node's own LOCATION pane is nulled");
183
+ });
184
+ test('§5.1.4 truly-done + UNFOCUSED → no focus row created/touched, shuts down (Invariant P)', () => {
185
+ createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
186
+ createNode(node('M', { parent: 'mgr', lifecycle: 'terminal' }));
187
+ subscribe('mgr', 'M', true);
188
+ process.env['CRTR_NODE_ID'] = 'M';
189
+ setStatus('M', 'done');
190
+ const pi = makeFakePi();
191
+ registerCanvasStophook(pi);
192
+ let shutdown = false;
193
+ pi.fire('agent_end', stopEvent('done, never had a viewport'), { shutdown: () => { shutdown = true; } });
194
+ // focusOf(M) is null → the focus block is skipped entirely → just shutdown.
195
+ // Non-vacuous: an impl that created or handed off a focus row would leave
196
+ // listFocuses non-empty.
197
+ assert.equal(shutdown, true, 'an unfocused done node shuts down');
198
+ assert.equal(listFocuses().length, 0, 'no focus row was created or touched');
199
+ });
200
+ test('§5.1.5 awaiting + focused → idle-release FREEZE: the focus row SURVIVES untouched (F3)', () => {
201
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
202
+ createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
203
+ createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
204
+ subscribe('root', 'mgr', true);
205
+ subscribe('mgr', 'worker', true); // mgr awaits a live worker → idle-release
206
+ openFocusRow('fMgr', '%g', 'Suser', 'mgr');
207
+ process.env['CRTR_NODE_ID'] = 'mgr';
208
+ const pi = makeFakePi();
209
+ 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)');
216
+ assert.equal(getFocusByNode('mgr')?.focus_id, 'fMgr', 'the focus row is UNCHANGED — not closed, not handed off');
217
+ });
218
+ test('§5.1.7 resident attended (no live subs) → nothing happens; focus + status survive', () => {
219
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
220
+ openFocusRow('fR', '%r', 'Suser', 'root');
221
+ process.env['CRTR_NODE_ID'] = 'root';
222
+ const pi = makeFakePi();
223
+ registerCanvasStophook(pi);
224
+ let shutdown = false;
225
+ pi.fire('agent_end', stopEvent('I have wrapped up'), { shutdown: () => { shutdown = true; } });
226
+ // evaluateStop on a resident → reason 'dormant' (NOT 'awaiting'), so the
227
+ // awaiting branch is skipped and the handler does nothing: no release, no
228
+ // shutdown, no focus touch. Non-vacuous: an impl that idle-released a resident
229
+ // would flip status→idle / intent→idle-release; one that touched focus would
230
+ // change/remove fR.
231
+ assert.equal(getNode('root')?.status, 'active', 'a resident is never forced dormant');
232
+ assert.equal(getNode('root')?.intent ?? null, null, 'no idle-release intent on a resident');
233
+ assert.equal(getFocusByNode('root')?.focus_id, 'fR', 'focus row survives untouched');
234
+ assert.equal(shutdown, false, 'a resident attended node is not shut down');
235
+ });
236
+ test('refresh-yield (intent=refresh) writes NO push — silent to subscribers', () => {
237
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
238
+ createNode(node('orch', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator', intent: 'refresh' }));
239
+ subscribe('root', 'orch', true);
240
+ process.env['CRTR_NODE_ID'] = 'orch';
241
+ const pi = makeFakePi();
242
+ registerCanvasStophook(pi);
243
+ let shutdown = false;
244
+ pi.fire('agent_end', stopEvent('checkpoint before refreshing'), { shutdown: () => { shutdown = true; } });
245
+ assert.equal(shutdown, true, 'pi shut down (no tmux pane → clean shutdown)');
246
+ assert.equal(reportCount('orch'), 0, 'a yield is silent: NO report file');
247
+ assert.equal(readInboxSince('root').length, 0, 'a yield is silent: NO inbox pointer');
248
+ assert.equal(pi.injected.length, 0, 'no reprompt on a refresh-yield');
249
+ });
250
+ test('stalled leaf (nothing live to await, no final) is still reprompted', () => {
251
+ createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
252
+ createNode(node('leaf', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
253
+ subscribe('mgr', 'leaf', true); // mgr subscribes to leaf; leaf itself awaits nothing
254
+ process.env['CRTR_NODE_ID'] = 'leaf';
255
+ const pi = makeFakePi();
256
+ registerCanvasStophook(pi);
257
+ let shutdown = false;
258
+ pi.fire('agent_end', stopEvent('I think I am basically done here'), { shutdown: () => { shutdown = true; } });
259
+ assert.equal(pi.injected.length, 1, 'the stall reprompt fired');
260
+ assert.equal(pi.injected[0].content, STALL_REPROMPT, 'reprompt carries the stall nudge to push final / ask');
261
+ assert.equal(pi.injected[0].deliverAs, 'followUp', 'reprompt delivered as a followUp');
262
+ assert.equal(shutdown, false, 'a stalled leaf is NOT shut down — it is re-prompted to finish');
263
+ assert.notEqual(getNode('leaf')?.intent, 'idle-release', 'a stalled leaf does not idle-release');
264
+ assert.equal(reportCount('leaf'), 0, 'NO report file written on a stall');
265
+ assert.equal(readInboxSince('mgr').length, 0, 'NO inbox pointer fanned on a stall');
266
+ });
@@ -1,11 +1,11 @@
1
1
  // canvas-commands.ts — pi extension registering canvas slash-commands on nodes.
2
2
  //
3
- // /promote [kind] — promote THIS node to a resident orchestrator. Runs
3
+ // /promote [kind] — promote THIS node to an orchestrator. Runs
4
4
  // `crtr node promote --json` for CRTR_NODE_ID (optionally specializing its
5
- // kind), then injects the orchestration guidance the command returns into
6
- // context and triggers a turn, so the node authors its roadmap immediately.
7
- // This is the same mid-turn guidance dump the node would get by running the
8
- // command itself by hand — surfaced as a one-keystroke affordance.
5
+ // kind), then triggers a turn. The orchestration guidance is injected
6
+ // CENTRALLY by the persona injector (canvas-stophook turn_end) at the turn
7
+ // boundary the same path the node gets by running the command itself by
8
+ // hand — surfaced as a one-keystroke affordance.
9
9
  //
10
10
  // The Alt+C tmux action menu's "promote to orchestrator" item (key `o`) simply
11
11
  // send-keys `/promote` into the active pane, so the menu and the slash command
@@ -46,7 +46,7 @@ export function registerCanvasCommands(pi) {
46
46
  if (nodeId === undefined || nodeId.trim() === '')
47
47
  return; // not a canvas node
48
48
  pi.registerCommand('promote', {
49
- description: 'Promote this node to a resident orchestrator — /promote, or /promote <kind> to specialize',
49
+ description: 'Promote this node to an orchestrator — /promote, or /promote <kind> to specialize',
50
50
  getArgumentCompletions: (prefix) => {
51
51
  const items = kinds()
52
52
  .filter((k) => k.startsWith(prefix))
@@ -87,13 +87,16 @@ export function registerCanvasCommands(pi) {
87
87
  }
88
88
  const rmPath = (result.roadmap_path ?? '').trim();
89
89
  ctx.ui.notify(`Promoted to ${result.kind ?? 'orchestrator'} orchestrator — authoring roadmap${rmPath !== '' ? ` (${rmPath})` : ''}.`, 'info');
90
- // The guidance is operating instructions for the node, not the user.
91
- // Inject it silently and trigger a turn so the node acts on it now —
92
- // exactly what happens when the node runs `crtr node promote` by hand.
93
- const guidance = (result.guidance ?? '').trim();
94
- if (guidance === '')
95
- return;
96
- pi.sendMessage({ customType: 'crtr-promote', content: guidance, display: false }, { triggerTurn: true });
90
+ // The orchestration guidance is no longer returned by the command the
91
+ // persona injector (canvas-stophook turn_end) is the single source and
92
+ // steers it in at the next turn boundary. Trigger a turn so the node wakes
93
+ // and the injector fires, exactly as when the node runs `crtr node
94
+ // promote` itself by hand.
95
+ pi.sendMessage({
96
+ customType: 'crtr-promote',
97
+ content: 'You have just been promoted to an orchestrator. Your new-role guidance is arriving — read it, author your roadmap, and start delegating.',
98
+ display: false,
99
+ }, { triggerTurn: true });
97
100
  },
98
101
  });
99
102
  }
@@ -0,0 +1,70 @@
1
+ /** The `customType` stamped on the injected session message. Used both to write
2
+ * the entry and to detect it on resume (the idempotency guard). */
3
+ export declare const CONTEXT_INTRO_CUSTOM_TYPE = "crtr-context";
4
+ interface SessionEntryLike {
5
+ type: string;
6
+ customType?: string;
7
+ }
8
+ interface SessionStartCtxLike {
9
+ sessionManager: {
10
+ getEntries: () => SessionEntryLike[];
11
+ };
12
+ }
13
+ interface CustomMessageLike {
14
+ customType: string;
15
+ content: string;
16
+ display?: boolean;
17
+ }
18
+ /** The message handed to a message renderer. `content` is normally the string we
19
+ * sent, but pi types it as string-or-blocks, so we handle both. */
20
+ interface RenderedMessageLike {
21
+ customType: string;
22
+ content: string | Array<{
23
+ type: string;
24
+ text?: string;
25
+ }>;
26
+ }
27
+ /** Minimal structural match for pi-tui's `Component` (render + invalidate). A
28
+ * plain object of this shape is a valid child for pi's Container. */
29
+ interface ComponentLike {
30
+ render: (width: number) => string[];
31
+ invalidate: () => void;
32
+ }
33
+ /** Subset of pi's `Theme` we touch — `fg(color, text)` wraps text in ANSI. Used
34
+ * defensively (falls back to plain text if absent). */
35
+ interface ThemeLike {
36
+ fg?: (color: string, text: string) => string;
37
+ }
38
+ interface PiLike {
39
+ on: (event: 'session_start', handler: (event: unknown, ctx: SessionStartCtxLike) => void | Promise<void>) => void;
40
+ sendMessage: (message: CustomMessageLike, options?: {
41
+ deliverAs?: string;
42
+ triggerTurn?: boolean;
43
+ }) => void;
44
+ registerMessageRenderer: (customType: string, renderer: (message: RenderedMessageLike, options: {
45
+ expanded?: boolean;
46
+ }, theme: ThemeLike) => ComponentLike | undefined) => void;
47
+ }
48
+ /** Build the <crtr-context> bearings block for `nodeId`. Thin wrapper over the
49
+ * shared builder in core/runtime/bearings.ts (the single source of truth, also
50
+ * used by the promotion guidance dump). Exported for testing. */
51
+ export declare function buildContextIntro(nodeId: string): string;
52
+ /**
53
+ * Renderer for `crtr-context` messages. Collapsed (default) shows a one-line
54
+ * stub; expanded (Ctrl+O) shows the label + full body. Returns a plain object
55
+ * matching pi's structural `Component` interface — no pi-tui import. Exported for
56
+ * testing.
57
+ */
58
+ export declare function renderContextMessage(message: RenderedMessageLike, options: {
59
+ expanded?: boolean;
60
+ }, theme: ThemeLike): ComponentLike;
61
+ /**
62
+ * Register the context-intro preamble on `pi`.
63
+ *
64
+ * Returns immediately (inert) when CRTR_NODE_ID is absent. On `session_start`
65
+ * it injects the <crtr-context> block as the first message of a brand-new chat
66
+ * — but only when the session does not already carry it, so a `--session <id>`
67
+ * relaunch (which restores the conversation) never duplicates the block.
68
+ */
69
+ export declare function registerCanvasContextIntro(pi: PiLike): void;
70
+ export default registerCanvasContextIntro;
@@ -0,0 +1,164 @@
1
+ // canvas-context-intro.ts — pi extension for pi-native canvas agent nodes.
2
+ //
3
+ // Loaded into every canvas node's pi process via the node's launch.extensions
4
+ // list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
5
+ //
6
+ // The bearings preamble. On `session_start` — which fires BEFORE the node's
7
+ // first user message enters the session — this injects ONE <crtr-context>
8
+ // message via `pi.sendMessage` (no delivery options, so at the idle start it is
9
+ // pushed straight onto the message list and persisted). Because the session is
10
+ // still empty at that point, the bearings land as the FIRST entry, ahead of the
11
+ // node's first prompt — the orienting frame, not a trailing afterthought.
12
+ // (before_agent_start / deliverAs:"nextTurn" both append AFTER the user
13
+ // message — see agent-session's submit path — which is why we use
14
+ // session_start instead.)
15
+ //
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.
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.
28
+ //
29
+ // COLLAPSED BY DEFAULT: a `registerMessageRenderer` keyed to our customType
30
+ // renders the block as a single one-line stub; the full body only appears when
31
+ // the user expands tool output (Ctrl+O / `app.tools.expand`). pi drives this via
32
+ // `CustomMessageComponent.setExpanded(toolOutputExpanded)`, so the same toggle
33
+ // that expands tool results expands the bearings. The renderer returns a plain
34
+ // object satisfying pi's structural `Component` interface ({ render, invalidate })
35
+ // — no pi-tui class needed. The LLM always sees the full `content` regardless of
36
+ // how it renders; the renderer is display-only.
37
+ //
38
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
39
+ // crouter's own tsc build without a dep on the pi packages.
40
+ import { buildContextBearings } from '../core/runtime/bearings.js';
41
+ /** The `customType` stamped on the injected session message. Used both to write
42
+ * the entry and to detect it on resume (the idempotency guard). */
43
+ export const CONTEXT_INTRO_CUSTOM_TYPE = 'crtr-context';
44
+ // ---------------------------------------------------------------------------
45
+ // Block builder
46
+ // ---------------------------------------------------------------------------
47
+ /** Build the <crtr-context> bearings block for `nodeId`. Thin wrapper over the
48
+ * shared builder in core/runtime/bearings.ts (the single source of truth, also
49
+ * used by the promotion guidance dump). Exported for testing. */
50
+ export function buildContextIntro(nodeId) {
51
+ return buildContextBearings(nodeId);
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // Collapsed-by-default rendering
55
+ // ---------------------------------------------------------------------------
56
+ /** Pull the plain text out of a custom message's content (string or blocks). */
57
+ function messageText(message) {
58
+ if (typeof message.content === 'string')
59
+ return message.content;
60
+ return message.content
61
+ .filter((c) => c.type === 'text' && typeof c.text === 'string')
62
+ .map((c) => c.text)
63
+ .join('\n');
64
+ }
65
+ /** Hard-wrap a single logical line to `width` columns (content carries no ANSI).
66
+ * Code-point aware so wide-string slicing never splits a surrogate pair; the
67
+ * bearings prose is plain text, so code-point count == visible columns. */
68
+ function wrapLine(line, width) {
69
+ if (width <= 0)
70
+ return [''];
71
+ const chars = Array.from(line);
72
+ if (chars.length <= width)
73
+ return [line];
74
+ const out = [];
75
+ for (let i = 0; i < chars.length; i += width)
76
+ out.push(chars.slice(i, i + width).join(''));
77
+ return out;
78
+ }
79
+ /** Truncate plain text to at most `width` columns, appending an ellipsis when it
80
+ * would overflow. Content here is ANSI-free plain text (label + prose), so a
81
+ * code-point count stands in for visible width. The renderer MUST keep every
82
+ * emitted line within the terminal width or pi's TUI aborts the whole render. */
83
+ function truncateToWidth(text, width) {
84
+ if (width <= 0)
85
+ return '';
86
+ const chars = Array.from(text);
87
+ if (chars.length <= width)
88
+ return text;
89
+ if (width === 1)
90
+ return '…';
91
+ return chars.slice(0, width - 1).join('') + '…';
92
+ }
93
+ /**
94
+ * Renderer for `crtr-context` messages. Collapsed (default) shows a one-line
95
+ * stub; expanded (Ctrl+O) shows the label + full body. Returns a plain object
96
+ * matching pi's structural `Component` interface — no pi-tui import. Exported for
97
+ * testing.
98
+ */
99
+ export function renderContextMessage(message, options, theme) {
100
+ const expanded = options?.expanded === true;
101
+ const paint = (color, text) => typeof theme?.fg === 'function' ? theme.fg(color, text) : text;
102
+ return {
103
+ render(width) {
104
+ const w = typeof width === 'number' && width > 0 ? width : 80;
105
+ if (!expanded) {
106
+ // Truncate BEFORE painting so the ANSI wrapper never inflates the
107
+ // measured width; an over-wide line aborts pi's entire TUI render.
108
+ const stub = `[${CONTEXT_INTRO_CUSTOM_TYPE}] orienting bearings — ctrl+o to expand`;
109
+ return [paint('dim', truncateToWidth(stub, w))];
110
+ }
111
+ const lines = [paint('customMessageLabel', truncateToWidth(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`, w)), ''];
112
+ for (const raw of messageText(message).split('\n')) {
113
+ for (const wrapped of wrapLine(raw, w))
114
+ lines.push(paint('customMessageText', wrapped));
115
+ }
116
+ return lines;
117
+ },
118
+ invalidate() {
119
+ /* stateless — nothing to clear */
120
+ },
121
+ };
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Extension
125
+ // ---------------------------------------------------------------------------
126
+ /**
127
+ * Register the context-intro preamble on `pi`.
128
+ *
129
+ * Returns immediately (inert) when CRTR_NODE_ID is absent. On `session_start`
130
+ * it injects the <crtr-context> block as the first message of a brand-new chat
131
+ * — but only when the session does not already carry it, so a `--session <id>`
132
+ * relaunch (which restores the conversation) never duplicates the block.
133
+ */
134
+ export function registerCanvasContextIntro(pi) {
135
+ // Collapse the block to a one-liner until the user expands tool output (Ctrl+O).
136
+ // Harmless to register outside TUI mode (it's only consulted while rendering).
137
+ pi.registerMessageRenderer(CONTEXT_INTRO_CUSTOM_TYPE, renderContextMessage);
138
+ pi.on('session_start', (_event, ctx) => {
139
+ try {
140
+ const nodeId = process.env['CRTR_NODE_ID'];
141
+ if (nodeId === undefined || nodeId.trim() === '')
142
+ return; // not a canvas node
143
+ // Idempotent: a restored/reloaded session already carries the block.
144
+ const present = ctx.sessionManager
145
+ .getEntries()
146
+ .some((e) => e.type === 'custom_message' && e.customType === CONTEXT_INTRO_CUSTOM_TYPE);
147
+ if (present)
148
+ return;
149
+ // No delivery options: at the idle start of a session this is pushed onto
150
+ // the (still empty) message list and persisted immediately, so it precedes
151
+ // the node's first prompt.
152
+ pi.sendMessage({
153
+ customType: CONTEXT_INTRO_CUSTOM_TYPE,
154
+ content: buildContextIntro(nodeId),
155
+ display: true,
156
+ });
157
+ }
158
+ catch {
159
+ // Best-effort: a failure here must never break session startup.
160
+ return;
161
+ }
162
+ });
163
+ }
164
+ export default registerCanvasContextIntro;
@@ -6,6 +6,9 @@ interface InputEventLike {
6
6
  }
7
7
  interface PiLike {
8
8
  on: (event: 'input', handler: (event: InputEventLike, ctx: any) => void) => void;
9
+ /** Update the live session display name (pi's editor label). Present in
10
+ * interactive mode; optional so the extension stays inert where it's not. */
11
+ setSessionName?: (name: string) => void;
9
12
  }
10
13
  /**
11
14
  * Register the goal-capture handler on `pi`.
@@ -19,6 +19,8 @@
19
19
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
20
20
  // crouter's own tsc build without a dep on the pi packages.
21
21
  import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
22
+ import { generateAndPersistName } from '../core/runtime/naming.js';
23
+ import { editorLabel } from '../core/canvas/index.js';
22
24
  /**
23
25
  * Register the goal-capture handler on `pi`.
24
26
  *
@@ -43,7 +45,19 @@ export function registerCanvasGoalCapture(pi) {
43
45
  // masquerade as the user's first mandate.
44
46
  if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
45
47
  return;
46
- captureGoalIfAbsent(nodeId, text);
48
+ // First mandate for a bare root: persist it as the goal, and ask pi
49
+ // (async, non-blocking) to name the session from it. The name lands on
50
+ // meta.description; the onNamed callback pushes the new editor label into
51
+ // THIS live session via setSessionName, so it updates immediately instead
52
+ // of only on the next cycle.
53
+ if (captureGoalIfAbsent(nodeId, text)) {
54
+ generateAndPersistName(nodeId, text, (meta) => {
55
+ try {
56
+ pi.setSessionName?.(editorLabel(meta));
57
+ }
58
+ catch { /* best-effort */ }
59
+ });
60
+ }
47
61
  }
48
62
  catch {
49
63
  // Best-effort: a capture failure must never drop or alter the message.
@@ -32,6 +32,7 @@
32
32
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
33
33
  // crouter's own tsc build without a dep on the pi packages.
34
34
  import { readInboxSince, readCursor, writeCursor, coalesce, } from '../core/feed/inbox.js';
35
+ import { getNode } from '../core/canvas/index.js';
35
36
  // ---------------------------------------------------------------------------
36
37
  // Module-level timer — prevents stacking on /reload (the double-notify bug).
37
38
  //
@@ -179,6 +180,16 @@ export function registerCanvasInboxWatcher(pi) {
179
180
  seeded = true;
180
181
  }
181
182
  const newEntries = readInboxSince(nodeId, cursor);
183
+ // Refresh-yield in flight: the node ran `crtr node yield` and is about to be
184
+ // torn down and revived fresh. Hold everything — don't consume the cursor
185
+ // (advancing it past these entries would drop them on tear-down) and don't
186
+ // deliver (steering a child's `final` into the yielding turn hijacks the
187
+ // clean stop the refresh path depends on, which is how a yield got derailed
188
+ // mid-flight). The fresh pi re-reads the feed on boot. getNode only when
189
+ // there's actual work pending, so idle ticks stay cheap.
190
+ if ((newEntries.length > 0 || buffer.length > 0) && getNode(nodeId)?.intent === 'refresh') {
191
+ return;
192
+ }
182
193
  if (newEntries.length > 0) {
183
194
  // Advance and persist the cursor BEFORE buffering, so a crash after this
184
195
  // point loses at most one coalesced message rather than re-injecting