@crouton-kit/crouter 0.3.13 → 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 (233) 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/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -16,8 +16,8 @@
16
16
  // (a) stopReason is 'aborted' or 'error' → stay alive for re-steering; return.
17
17
  // (b) node.status is already 'done' (agent called `crtr push --final` this
18
18
  // turn, which sets status synchronously) → shut down; work is complete.
19
- // (c) Natural stop ('stop' | 'length') — auto-push the last assistant text
20
- // as a routine feed update, then run the stop-guard:
19
+ // (c) Natural stop ('stop' | 'length') — run the stop-guard (the node is
20
+ // NEVER auto-pushed; it reports only via its own explicit `crtr push`):
21
21
  // • 'reprompt' → pi.sendUserMessage so the node finishes or escalates.
22
22
  // • 'allow' (awaiting) → idle-release: free the tmux window and shut
23
23
  // down; the daemon watches the inbox and revives it
@@ -28,18 +28,22 @@
28
28
  // crouter's own tsc build without a dep on the pi packages.
29
29
  import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
30
30
  import { join } from 'node:path';
31
- import { getNode, jobDir, updateNode, subscribersOf } from '../core/canvas/index.js';
32
- import { push } from '../core/feed/feed.js';
31
+ import { getNode, jobDir, updateNode, recordPid, subscribersOf, closeFocusRow, setPresence } from '../core/canvas/index.js';
32
+ import { transition } from '../core/runtime/lifecycle.js';
33
33
  import { evaluateStop } from '../core/runtime/stop-guard.js';
34
- import { reviveInPlace, reviveNode } from '../core/runtime/revive.js';
35
- import { resetRoot } from '../core/runtime/reset.js';
36
- import { focusNodeInPlace, getFocus } from '../core/runtime/presence.js';
37
- import { windowAlive } from '../core/runtime/tmux.js';
34
+ import { personaDrift, commitPersonaAck } from '../core/runtime/persona.js';
35
+ import { reviveInPlace } from '../core/runtime/revive.js';
36
+ import { handleNewSession, markCleanExitDone } from '../core/runtime/reset.js';
37
+ import { setFocus } from '../core/runtime/presence.js';
38
+ import { focusOf, handFocusToManager, tearDownNode } from '../core/runtime/placement.js';
39
+ import { setRemainOnExit } from '../core/runtime/tmux.js';
38
40
  /**
39
41
  * Merge accumulated token counts into nodes/<nodeId>/job/telemetry.json.
40
42
  * Creates the directory when it doesn't yet exist. Best-effort; never throws.
43
+ * `contextTokens` is the live window gauge for THIS turn; when null (pi can't
44
+ * size the window yet) the last recorded value is preserved.
41
45
  */
42
- function flushTelemetry(jobDirPath, tokensIn, tokensOut, model) {
46
+ function flushTelemetry(jobDirPath, tokensIn, tokensOut, model, contextTokens) {
43
47
  try {
44
48
  if (!existsSync(jobDirPath))
45
49
  mkdirSync(jobDirPath, { recursive: true });
@@ -58,6 +62,7 @@ function flushTelemetry(jobDirPath, tokensIn, tokensOut, model) {
58
62
  const record = {
59
63
  tokens_in: tokensIn,
60
64
  tokens_out: tokensOut,
65
+ context_tokens: contextTokens ?? existing.context_tokens,
61
66
  model: model !== '' ? model : (existing.model ?? ''),
62
67
  updated_at: new Date().toISOString(),
63
68
  };
@@ -79,105 +84,103 @@ function lastAssistantMessage(messages) {
79
84
  }
80
85
  return undefined;
81
86
  }
82
- /** When a FOCUSED node is about to shut down (final or idle-release), bring its
83
- * manager into the visible pane it currently occupies so the view travels UP
84
- * the spine — instead of the visible window collapsing when this node's pi
85
- * exits in it. A no-op unless this node is the one the user is looking at.
86
- *
87
- * This is the swap-back guard the one-window-per-node model dropped: in-place
88
- * focus (swap-pane) reintroduced shared pane slots, so a focused leaf that
89
- * exits must hand its slot back to its manager rather than take it down.
90
- * Best-effort throughout — never throws out of agent_end. */
91
- function restoreFocusToManager(nodeId) {
92
- try {
93
- if (getFocus() !== nodeId)
94
- return; // not in view — nothing to restore
95
- const meta = getNode(nodeId);
96
- if (meta === null)
97
- return;
98
- const managerId = meta.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
99
- if (managerId === null || managerId === nodeId)
100
- return;
101
- const manager = getNode(managerId);
102
- if (manager === null)
103
- return;
104
- // Revive a dormant manager so there is a live pane to swap into view (it is
105
- // about to be woken by this node's push anyway).
106
- if (!windowAlive(manager.tmux_session, manager.window)) {
107
- try {
108
- reviveNode(managerId, { resume: true });
109
- }
110
- catch {
111
- return;
112
- }
113
- }
114
- // Swap the manager into THIS (focused, exiting) node's pane slot. focus reads
115
- // the caller pane from $TMUX_PANE — this stophook runs inside the exiting
116
- // node's pi, so that is the visible pane. When this node's pi then exits, its
117
- // pane lives on in the manager's old (background) window and closes there.
118
- focusNodeInPlace(managerId);
119
- }
120
- catch {
121
- /* best-effort; never throw out of agent_end */
122
- }
123
- }
124
- /** Concatenate all {type:'text'} content blocks from an assistant message. */
125
- function extractText(msg) {
126
- if (!msg || !Array.isArray(msg.content))
127
- return '';
128
- return msg.content
129
- .filter((c) => c != null && c.type === 'text' && typeof c.text === 'string')
130
- .map((c) => c.text)
131
- .join('\n')
132
- .trim();
133
- }
134
87
  // ---------------------------------------------------------------------------
135
- // Context-size steering bands — mode-specific schedules that ESCALATE in tone
136
- // as input context grows. The first band is a gentle "consider it"; a later
137
- // band turns firm. Past the last explicit band the firmest nudge repeats every
138
- // 50k, so a long-lived node keeps getting reminded.
88
+ // Context-size steering bands — a single shared schedule of thresholds that
89
+ // ESCALATE in tone as input context grows. The first band is a gentle
90
+ // "consider it"; a later band turns firm. The schedule TIGHTENS as it climbs:
91
+ // 130k, 150k, 170k, 185k, 200k, then every 10k (210k, 220k, …) so a long-lived
92
+ // node keeps getting reminded, more often the deeper it goes.
93
+ //
94
+ // The band schedule is shared across all node shapes; only the MESSAGE differs
95
+ // (steerNote), keyed on MODE first, then LIFECYCLE — three reachable personas:
96
+ // orchestrator (terminal OR resident): HAS a roadmap (promotion / orchestrator
97
+ // birth seeds context/roadmap.md), so it is steered to checkpoint +
98
+ // yield — 130k gentle (consider yielding) → 150k firm (do it now) →
99
+ // 185k+ pushy. Keyed on mode, NOT lifecycle: a terminal/orchestrator
100
+ // yields against its roadmap exactly like a resident one (the
101
+ // daemon's refresh-revive keys on intent='refresh', not lifecycle).
102
+ // resident/base (a root conversation): never promoted ⇒ NO roadmap on disk,
103
+ // so it is NOT told to checkpoint/yield against one. Instead: if the
104
+ // chat is outgrowing one window into a multi-phase job, promote;
105
+ // otherwise wrap up or start fresh. 130k gentle → 150k firm → 185k+
106
+ // pushy.
107
+ // terminal/base (a worker): 130k/150k suggest promote → 170k suggest promote
108
+ // (+ "ignore if nearly done") → 185k+ pushy.
139
109
  //
140
- // orchestrator: 130k gentle (consider yielding) 150k+ firm (do it now)
141
- // base worker: 130k suggest promote 160k+ suggest promote (+ "ignore if
142
- // nearly done")
110
+ // The promote/push-final guidance only makes sense for a terminal BASE worker. A
111
+ // resident node finishes by yielding or being closed, not `push final`, so it is
112
+ // never told to push final; only an ORCHESTRATOR (either lifecycle) has a roadmap
113
+ // to yield against, so only it is steered at the roadmap.
143
114
  // ---------------------------------------------------------------------------
144
- const STEER_STEP = 50_000;
145
- const ORCH_BANDS = [130_000, 150_000]; // gentle, then firm (firm repeats +50k)
146
- const WORKER_BANDS = [130_000, 160_000]; // suggest, then suggest+ignore (repeats +50k)
147
- /** The highest band threshold at or below `tokens` for `mode`. Below the first
148
- * band null. At/past the last listed band, bands continue every STEER_STEP
149
- * (so the firmest nudge keeps recurring). */
150
- function steerBand(tokens, mode) {
151
- const bands = mode === 'orchestrator' ? ORCH_BANDS : WORKER_BANDS;
152
- const first = bands[0];
153
- const last = bands[bands.length - 1];
115
+ const STEER_STEP = 10_000;
116
+ // Shared escalation schedule (both lifecycles). Tightens as it climbs:
117
+ // 130k, 150k, 170k, 185k, 200k, then every 10k (210k, 220k, …).
118
+ const BANDS = [130_000, 150_000, 170_000, 185_000, 200_000];
119
+ /** The highest band threshold at or below `tokens`. Below the first band
120
+ * null. At/past the last listed band, bands continue every STEER_STEP (so the
121
+ * firmest nudge keeps recurring). */
122
+ function steerBand(tokens) {
123
+ const first = BANDS[0];
124
+ const last = BANDS[BANDS.length - 1];
154
125
  if (tokens < first)
155
126
  return null;
156
127
  if (tokens >= last)
157
128
  return last + Math.floor((tokens - last) / STEER_STEP) * STEER_STEP;
158
129
  let chosen = first;
159
- for (const b of bands)
130
+ for (const b of BANDS)
160
131
  if (tokens >= b)
161
132
  chosen = b;
162
133
  return chosen;
163
134
  }
164
- /** The nudge text for a crossed band, specialized to the node's mode + how far
165
- * along the escalation it is. An orchestrator is steered to checkpoint its
166
- * roadmap and yield (gently first, then firmly); a non-orchestrator (base
167
- * worker) is steered to PROMOTE itself become a resident orchestrator — when
168
- * work remains, with an "ignore if nearly done" once it's deeper in. */
169
- function steerNote(at, mode) {
135
+ /** The nudge text for a crossed band, specialized to the node's (mode,
136
+ * lifecycle) persona + how far along the escalation it is.
137
+ *
138
+ * - orchestrator (terminal OR resident): checkpoint its roadmap and yield
139
+ * (gently firmly pushy). It has a context/roadmap.md to yield against.
140
+ * - resident/base (root conversation): never promoted, so NO roadmap exists —
141
+ * steer it to PROMOTE if the chat is growing into a multi-phase job (which
142
+ * seeds a roadmap), else wrap up / start fresh. Never points at roadmap.md or
143
+ * a bare `node yield`, which for a roadmap-less root just drops context.
144
+ * - terminal/base (worker): PROMOTE itself — become an orchestrator — when
145
+ * work remains, with an "ignore if nearly done, finish with push final" once
146
+ * it's deeper in.
147
+ *
148
+ * At/past 185k every persona goes PUSHY: the context is long enough that
149
+ * drifting further risks an overflow. */
150
+ export function steerNote(at, lifecycle, mode) {
170
151
  const k = Math.round(at / 1000);
152
+ const pushy = at >= 185_000;
153
+ // Keyed on MODE first: any orchestrator (terminal or resident) has a roadmap
154
+ // to checkpoint + yield against.
171
155
  if (mode === 'orchestrator') {
172
156
  if (at < 150_000) {
173
157
  return `Context ~${k}k and growing. When you reach a good stopping point, consider updating context/roadmap.md and running \`crtr node yield\` to refresh against it — no rush yet.`;
174
158
  }
175
- return `Context ~${k}k. Update context/roadmap.md so a fresh revive can continue, delegate any outstanding work, then \`crtr node yield\` to refresh.`;
159
+ if (!pushy) {
160
+ return `Context ~${k}k. Update context/roadmap.md so a fresh revive can continue, delegate any outstanding work, then \`crtr node yield\` to refresh.`;
161
+ }
162
+ return `Context ~${k}k — this is getting long. Stop taking on new work now: checkpoint context/roadmap.md, hand off anything outstanding, and \`crtr node yield\` immediately to refresh before this context overflows.`;
176
163
  }
177
- const suggest = `If much more work remains than this context can finish, consider \`crtr node promote\` to become a resident orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh).`;
178
- if (at < 160_000)
164
+ if (lifecycle === 'resident') {
165
+ // resident/base — a root conversation. It has no roadmap (only promotion
166
+ // seeds one), so steer it toward promote-or-wrap-up, never at roadmap.md.
167
+ const grow = `If this is turning into a multi-phase job, \`crtr node promote\` to become a resident orchestrator (seeds a roadmap so you can delegate and \`crtr node yield\` to refresh).`;
168
+ if (at < 150_000) {
169
+ return `Context ~${k}k and growing. ${grow} Otherwise no rush — wrap up when you reach a good stopping point.`;
170
+ }
171
+ if (!pushy) {
172
+ return `Context ~${k}k. ${grow} If you're near done, just finish here; if there's more open-ended work, start a fresh \`crtr\` rather than letting this context grow.`;
173
+ }
174
+ return `Context ~${k}k — this is getting long. Wrap up now before this context overflows: finish what's in hand, or \`crtr node promote\` immediately if substantial work remains, otherwise continue in a fresh \`crtr\`.`;
175
+ }
176
+ // terminal — a worker.
177
+ const suggest = `If much more work remains than this context can finish, consider \`crtr node promote\` to become an orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh).`;
178
+ if (at < 170_000)
179
179
  return `Context ~${k}k. ${suggest}`;
180
- return `Context ~${k}k. ${suggest} If you're nearly done, ignore this suggestion and finish with \`crtr push final\`.`;
180
+ if (!pushy) {
181
+ return `Context ~${k}k. ${suggest} If you're nearly done, ignore this suggestion and finish with \`crtr push final\`.`;
182
+ }
183
+ return `Context ~${k}k — this is getting long. Wrap up now: \`crtr push final\` if you're close, otherwise \`crtr node promote\` immediately to continue as an orchestrator instead of overflowing this context.`;
181
184
  }
182
185
  // ---------------------------------------------------------------------------
183
186
  // Extension
@@ -195,60 +198,115 @@ export function registerCanvasStophook(pi) {
195
198
  if (nodeId === undefined || nodeId.trim() === '')
196
199
  return; // not a canvas node
197
200
  const jobDirPath = jobDir(nodeId);
198
- // Running totals across all turns in this pi session. Both turn_end and
199
- // agent_end accumulate so tokens emitted in the final partial turn (if pi
200
- // fires agent_end without a preceding turn_end for it) are captured.
201
+ // Cumulative throughput across all turns in this pi session, for telemetry
202
+ // (job/telemetry.json) only both turn_end and agent_end accumulate so tokens
203
+ // emitted in the final partial turn (if pi fires agent_end without a preceding
204
+ // turn_end for it) are captured. NOT used for context-size steering: that is a
205
+ // per-turn gauge (see contextTokens), not a running sum.
201
206
  let totalIn = 0;
202
207
  let totalOut = 0;
203
208
  let model = '';
204
209
  // Context-size steering. As input context grows we nudge the node once per
205
- // band on an escalating, mode-specific schedule (see steerBand/steerNote).
206
- // Mode is read at fire time since a base worker can promote mid-session: an
207
- // orchestrator is steered to checkpoint + yield; a base worker to promote.
210
+ // band on an escalating, persona-specific schedule (see steerBand/steerNote).
211
+ // The node's (lifecycle, mode) persona is read at fire time, since a terminal
212
+ // worker can promote mid-session: a resident orchestrator is steered to
213
+ // checkpoint roadmap + yield; a resident base (root chat) to promote-or-wrap-
214
+ // up (it has no roadmap); a terminal worker to promote / push final.
208
215
  const firedBands = new Set();
209
216
  // ---------------------------------------------------------------------------
210
217
  // session_start — capture pi's session id, and detect `/new`.
211
218
  //
212
- // pi exposes the session id via ctx.sessionManager.getSessionId() on every
213
- // event context; session_start fires early, before any turns. We bind the
214
- // FIRST session_start of this process as the boot (a fresh launch and a daemon
215
- // revive are both new processes, so their first session_start is a boot, not
216
- // a `/new`). A LATER session_start with a DIFFERENT id, in this same live
217
- // process, can only mean the user ran `/new` — a brand-new conversation. For
218
- // a root that means a brand-new graph: reset it (the `crtr`-again equivalent),
219
- // then rebind. A reload reports the same id and is a no-op.
219
+ // pi tags each session_start with a `reason`: 'startup' on boot (a fresh
220
+ // launch or a daemon revive both new processes), 'new' when the user runs
221
+ // `/new` (a brand-new conversation in the SAME process), and 'resume' /
222
+ // 'reload' / 'fork' for the in-place conversation swaps that keep the same
223
+ // node. We branch on that reason NOT on a remembered session id.
224
+ //
225
+ // Why reason, not a closure flag: pi RE-ACTIVATES extensions on every session
226
+ // swap, so any id we stash on boot is reset to its initial value before the
227
+ // next session_start fires and can never observe the change — a `/new` then
228
+ // looks identical to a boot. (That is exactly the bug this replaced: `/new`
229
+ // silently fell back to an in-place reset instead of relaunching, so the node
230
+ // id + context dir never changed.) The event reason is delivered fresh on
231
+ // every fire and is immune to the re-activation.
232
+ //
233
+ // For a root, reason 'new' means a brand-new graph: relaunch (park the old
234
+ // root + boot a fresh node in this pane) or, with no pane, an in-place reset.
220
235
  // ---------------------------------------------------------------------------
221
- let boundSessionId = null;
222
- pi.on('session_start', (_event, ctx) => {
236
+ pi.on('session_start', (event, ctx) => {
223
237
  try {
224
238
  const id = ctx?.sessionManager?.getSessionId?.();
225
239
  if (typeof id !== 'string' || id === '')
226
240
  return;
227
- if (boundSessionId === null) {
228
- // Boot: bind this process to its session id.
229
- boundSessionId = id;
230
- const existing = getNode(nodeId);
231
- if (existing?.pi_session_id !== id)
232
- updateNode(nodeId, { pi_session_id: id });
241
+ // The absolute path to this session's .jsonl, captured alongside the id.
242
+ // Resuming by path is immune to a cwd discrepancy (pi opens it directly),
243
+ // whereas a bare id is resolved cwd-relative and forks across projects.
244
+ const filed = ctx?.sessionManager?.getSessionFile?.();
245
+ const sessionFile = typeof filed === 'string' && filed !== '' ? filed : null;
246
+ // `/new` — a brand-new conversation in the same process. Route it: a
247
+ // non-root child refreshes its session id; a ROOT in a tmux pane RELAUNCHES
248
+ // (parks the old root + boots a fresh node in this pane via respawn-pane
249
+ // -k, which tears down THIS pi); a root with no pane falls back to an
250
+ // in-place reset. The relaunch's detached respawn may kill this pi before
251
+ // the lines after the call run — that's fine; do not rely on anything
252
+ // after handleNewSession.
253
+ if (event?.reason === 'new') {
254
+ try {
255
+ handleNewSession(nodeId, id, process.env['TMUX_PANE'], {}, sessionFile);
256
+ }
257
+ catch { /* best-effort */ }
258
+ // Clear in-memory context-steering so the fresh conversation starts clean.
259
+ totalIn = 0;
260
+ totalOut = 0;
261
+ firedBands.clear();
233
262
  return;
234
263
  }
235
- if (id === boundSessionId)
236
- return; // reload of the same conversation
237
- // A new session id in the same process = `/new`. Brand-new graph.
238
- boundSessionId = id;
239
- try {
240
- resetRoot(nodeId, id);
241
- }
242
- catch { /* best-effort */ }
243
- // Clear in-memory context-steering so the fresh conversation starts clean.
244
- totalIn = 0;
245
- totalOut = 0;
246
- firedBands.clear();
264
+ // Boot / startup / resume / reload / fork → (re)bind this process to its
265
+ // session id, record our OS pid (the daemon's liveness signal for inline
266
+ // roots whose window outlives pi), and CONFIRM any pending refresh-yield.
267
+ // Reaching session_start proves a fresh pi actually booted, so it is now
268
+ // safe to clear intent='refresh'. reviveInPlace deliberately leaves intent
269
+ // set: the detached respawn it dispatches can't confirm itself (it kills
270
+ // the caller mid-flight), so a real boot is the only thing allowed to clear
271
+ // it otherwise a failed respawn would look identical to a successful one.
272
+ const existing = getNode(nodeId);
273
+ // Identity (session id/file) → meta; runtime (pid, intent) → atomic row setters.
274
+ updateNode(nodeId, {
275
+ pi_session_id: id,
276
+ pi_session_file: sessionFile,
277
+ });
278
+ recordPid(nodeId, process.pid);
279
+ if (existing?.intent === 'refresh')
280
+ transition(nodeId, 'revive');
247
281
  }
248
282
  catch {
249
283
  /* best-effort; never surface from an extension handler */
250
284
  }
251
285
  });
286
+ // ---------------------------------------------------------------------------
287
+ // session_shutdown — clean exit → done.
288
+ //
289
+ // pi hands us a reason as a session tears down. Only 'quit' is a node-ending
290
+ // event we record (markCleanExitDone guards against clobbering a node
291
+ // agent_end already routed to done/refresh/idle-release). 'new' is owned by
292
+ // the session_start trigger above; reload/resume/fork keep the SAME node id on
293
+ // a swapped conversation. A true crash fires NO session_shutdown and falls
294
+ // through to the daemon's window-gone 'dead'.
295
+ //
296
+ // MUST stay synchronous (no await): the synchronous DatabaseSync write then
297
+ // lands within pi's awaited shutdown emit, before pi exits.
298
+ // ---------------------------------------------------------------------------
299
+ pi.on('session_shutdown', (event, _ctx) => {
300
+ try {
301
+ // Clean /quit (reason='quit') resolves the node to done; if it held the
302
+ // user's viewport, Q1-close it (tearDownNode kills the frozen focus pane +
303
+ // closes the focus row → returns the user to a shell, §1.5/flow (e)). pi is
304
+ // already exiting here, so killing its own pane is not a self-saw.
305
+ if (markCleanExitDone(nodeId, event?.reason))
306
+ tearDownNode(nodeId);
307
+ }
308
+ catch { /* best-effort; never throw out of an extension handler */ }
309
+ });
252
310
  /** Absorb usage + model from any assistant message (turn or final batch). */
253
311
  const accumulate = (msg) => {
254
312
  if (msg?.role !== 'assistant' || msg.usage == null)
@@ -262,135 +320,193 @@ export function registerCanvasStophook(pi) {
262
320
  // turn_end — live telemetry refresh.
263
321
  // event shape: { message: AssistantMessage, ... }
264
322
  // ---------------------------------------------------------------------------
265
- pi.on('turn_end', (event) => {
323
+ pi.on('turn_end', (event, ctx) => {
266
324
  accumulate(event?.message);
325
+ // The CURRENT context size via ctx.getContextUsage() — the exact figure pi's
326
+ // footer shows. Captured once here for two consumers: the telemetry flush
327
+ // (so out-of-process readers like `crtr node new` can size this node) and
328
+ // the context-size steering below.
329
+ // .tokens is null/undefined only when pi can't know the size yet (no model,
330
+ // or right after a compaction before the next reply) — telemetry then keeps
331
+ // its last value and steering is skipped for the turn.
332
+ let contextTokens = null;
333
+ try {
334
+ const t = ctx.getContextUsage()?.tokens;
335
+ if (typeof t === 'number')
336
+ contextTokens = t;
337
+ }
338
+ catch { /* gauge unavailable this turn */ }
267
339
  // Fire-and-forget: flushTelemetry uses synchronous fs writes and never throws.
268
- flushTelemetry(jobDirPath, totalIn, totalOut, model);
269
- // Context-size steering: fire the current band once, with mode-specific
270
- // guidance (mode is read live — a worker may have promoted since launch).
340
+ flushTelemetry(jobDirPath, totalIn, totalOut, model, contextTokens);
341
+ // Context-size steering: fire the current band once, with lifecycle-specific
342
+ // guidance (lifecycle is read live — a terminal worker may have promoted to
343
+ // resident since launch).
344
+ // Delivered as a STEER, not a followUp: guidance to become an orchestrator /
345
+ // delegate / yield must redirect the node at the turn boundary, not queue
346
+ // behind whatever it does next (where it rides along, easy to ignore).
347
+ // Never the cumulative totalIn: under prompt caching that never grows (input
348
+ // is a ~2-token uncached delta each turn), so the bands were unreachable and
349
+ // the nudge never fired.
271
350
  try {
272
- const mode = getNode(nodeId)?.mode ?? 'base';
273
- const at = steerBand(totalIn, mode);
351
+ const node = getNode(nodeId);
352
+ const lifecycle = node?.lifecycle ?? 'terminal';
353
+ const mode = node?.mode ?? 'base';
354
+ const at = contextTokens !== null ? steerBand(contextTokens) : null;
274
355
  if (at !== null && !firedBands.has(at)) {
275
356
  firedBands.add(at);
276
- pi.sendUserMessage(`[crtr] ${steerNote(at, mode)}`, { deliverAs: 'followUp' });
357
+ pi.sendUserMessage(`[crtr] ${steerNote(at, lifecycle, mode)}`, { deliverAs: 'steer' });
277
358
  }
278
359
  }
279
360
  catch {
280
361
  /* steering is best-effort */
281
362
  }
363
+ // Persona-transition steering. When this node's mode or lifecycle changed
364
+ // since it was last GIVEN guidance (it ran `crtr node promote` / `node
365
+ // lifecycle` this turn, or a sibling/human flipped it while the node was
366
+ // active), inject the guidance for its NEW persona once, then commit the
367
+ // ack so the next turn sees no drift. This is the single delivery site for
368
+ // in-session transitions — state-changing commands never hand-emit guidance.
369
+ // Delivered as a STEER (like the context nudge): a persona change must
370
+ // redirect the node at the turn boundary, not queue behind its next action.
371
+ try {
372
+ const drift = personaDrift(nodeId);
373
+ if (drift !== null) {
374
+ pi.sendUserMessage(drift.guidance, { deliverAs: 'steer' });
375
+ commitPersonaAck(nodeId, drift.to);
376
+ }
377
+ }
378
+ catch {
379
+ /* persona steering is best-effort */
380
+ }
282
381
  });
283
382
  // ---------------------------------------------------------------------------
284
383
  // agent_end — routing decision when the node's pi stops.
285
384
  // event shape: { messages: AgentMessage[] }
286
385
  // ---------------------------------------------------------------------------
287
386
  pi.on('agent_end', (event, ctx) => {
288
- // Wrap in a void async IIFE so we can await the async push() call without
289
- // making the handler signature async (pi may not uniformly await async
290
- // handlers). The internal I/O (push) is all synchronous fs, so this
291
- // resolves in a single microtask tick no meaningful async delay.
292
- void (async () => {
293
- try {
294
- const messages = Array.isArray(event?.messages) ? event.messages : [];
295
- // Accumulate tokens from the final batch (edge case: a turn that fired
296
- // agent_end without a preceding turn_end for the same turn).
297
- for (const m of messages)
298
- accumulate(m);
299
- const last = lastAssistantMessage(messages);
300
- const stopReason = last?.stopReason ?? '';
301
- // (a) Interrupted or errored stay alive so the user can re-steer.
302
- if (stopReason !== 'stop' && stopReason !== 'length')
303
- return;
304
- // (b) Already done: `crtr push --final` was called this turn, which
305
- // transitions node.status 'done' synchronously. Shut down cleanly.
306
- const node = getNode(nodeId);
307
- if (node?.status === 'done') {
308
- restoreFocusToManager(nodeId);
309
- try {
310
- ctx?.shutdown?.();
311
- }
312
- catch { /* ignore */ }
313
- return;
314
- }
315
- // (b') Refresh-yield: the node ran `crtr node yield` this turn, setting
316
- // intent='refresh'. Re-exec a FRESH pi IN PLACE in this same tmux
317
- // pane (respawn-pane -k) so the node re-reads its roadmap without
318
- // churning its window critically, an interactive/foreground root
319
- // is never dropped to a shell, and no daemon round-trip is needed
320
- // (the old window-death detection silently failed whenever pi
321
- // exited into a persistent shell pane). Falls back to a clean
322
- // shutdown (daemon revives in a new window) only when we're not in
323
- // a tmux pane.
324
- if (node?.intent === 'refresh') {
325
- // Notify subscribers BEFORE refreshing. A yield is a checkpoint, not a
326
- // disappearance: the node keeps its identity and its subscription
327
- // edges across the revive, so it still owes its parent a report. Emit
328
- // one now (an `update`, not a `final` — the node isn't done) so a
329
- // yield is never silent to whoever is watching.
330
- try {
331
- const yieldText = extractText(last);
332
- const body = yieldText !== ''
333
- ? `↻ Refreshing context (yield) — still working toward my goal.\n\n${yieldText}`
334
- : '↻ Refreshing context (yield) — still working toward my goal.';
335
- await push(nodeId, { kind: 'update', body });
336
- }
337
- catch { /* notify is best-effort */ }
338
- const pane = process.env['TMUX_PANE'];
339
- if (pane !== undefined && pane.trim() !== '') {
340
- try {
341
- reviveInPlace(nodeId, pane);
342
- return; // respawn-pane -k tears down this pi and starts the fresh one
343
- }
344
- catch { /* fall through to plain shutdown */ }
345
- }
346
- try {
347
- ctx?.shutdown?.();
387
+ // All routing here is synchronous fs (status writes, telemetry, idle-release,
388
+ // steering). The stop/yield auto-pushes that needed `await push(...)` were
389
+ // removed, so the handler no longer needs to be async — the node reaches its
390
+ // subscribers ONLY through its own explicit `crtr push` calls.
391
+ try {
392
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
393
+ // Accumulate tokens from the final batch (edge case: a turn that fired
394
+ // agent_end without a preceding turn_end for the same turn).
395
+ for (const m of messages)
396
+ accumulate(m);
397
+ const last = lastAssistantMessage(messages);
398
+ const stopReason = last?.stopReason ?? '';
399
+ // (a) Interrupted or errored — stay alive so the user can re-steer.
400
+ if (stopReason !== 'stop' && stopReason !== 'length')
401
+ return;
402
+ // (b) Already done: `crtr push --final` was called this turn, which
403
+ // transitions node.status 'done' synchronously. Shut down cleanly.
404
+ const node = getNode(nodeId);
405
+ if (node?.status === 'done') {
406
+ // TRULY-DONE (pushed `final` this turn). If this node owns the user's
407
+ // viewport, its lifecycle successor takes the focus (§1.6):
408
+ // handFocusToManager hands the focus row to the manager (the node up
409
+ // the subscribes_to spine it reports to) and, when that manager's pi is
410
+ // LIVE in the backstage, synchronously swaps it into this now-frozen
411
+ // focus pane; a DORMANT manager is revived into the pane by the daemon
412
+ // on the `final` it just pushed — either way no new window, no taint.
413
+ // No manager (a root) or a manager already focused elsewhere → Q1-close
414
+ // this focus AND flip remain-on-exit OFF on %m's window so the pane
415
+ // closes when this pi exits (return-to-shell) instead of freezing into
416
+ // an orphan. We CANNOT closePane(%m) from inside %m (self-saw), but the
417
+ // pi is still alive mid-shutdown, so remain-on-exit-off is safe and
418
+ // makes tmux reap the pane on exit. An unfocused done node just shuts
419
+ // down (no pane anywhere, Invariant P). M is done → it owns no pane
420
+ // (Invariant P), so null its own presence in BOTH sub-branches before
421
+ // shutdown.
422
+ const f = focusOf(nodeId);
423
+ if (f !== null) {
424
+ const managerId = node.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
425
+ if (!handFocusToManager(f.focus_id, managerId)) {
426
+ closeFocusRow(f.focus_id);
427
+ setFocus('');
428
+ const win = getNode(nodeId)?.window; // %m's window
429
+ if (win)
430
+ setRemainOnExit(win, false); // Q1 return-to-shell
348
431
  }
349
- catch { /* ignore */ }
350
- return;
351
- }
352
- // (c) Natural stop — decide FIRST, then act. Running the stop-guard
353
- // before any auto-push is what prevents duplicate reporting: a
354
- // stalled terminal worker that narrates "done" without calling
355
- // `push final` must NOT have that prose pushed as an `update`,
356
- // because the reprompt below makes it emit a `final` next turn —
357
- // two feed entries for one completion. Only genuinely dormant
358
- // nodes ('allow') get a routine checkpoint update.
359
- const decision = evaluateStop(nodeId, { pushedFinal: false, askedHuman: false });
360
- if (decision.action === 'reprompt') {
361
- // Stalled — re-prompt so the node finishes or escalates. Its `final`
362
- // (or escalation) carries the real result, so we deliberately skip
363
- // the auto-update here. Deliver as a followUp: the turn just ended
364
- // but pi may still be flushing, so an unqualified sendUserMessage
365
- // races with 'already processing'.
366
- pi.sendUserMessage(decision.message, { deliverAs: 'followUp' });
367
- return;
368
432
  }
369
- // 'allow' the node legitimately stopped. Surface the last assistant
370
- // message as a routine feed checkpoint first.
371
- const text = extractText(last);
372
- if (text !== '') {
373
- await push(nodeId, { kind: 'update', body: text });
433
+ setPresence(nodeId, { pane: null, tmux_session: null, window: null }); // M done → owns no pane
434
+ try {
435
+ ctx?.shutdown?.();
374
436
  }
375
- // Idle-release: a node awaiting its workers (reason 'awaiting') is holding
376
- // a tmux window for nothing. Free it — mark it idle-released and shut pi
377
- // down; the daemon watches its inbox and revives it (resume) the moment a
378
- // subscribed worker delivers. An 'attended' root never releases: the human
379
- // is its wake source, so we keep its window live and dormant.
380
- if (decision.reason === 'awaiting') {
381
- updateNode(nodeId, { intent: 'idle-release', status: 'idle' });
382
- restoreFocusToManager(nodeId);
437
+ catch { /* ignore */ }
438
+ return;
439
+ }
440
+ // (b') Refresh-yield: the node ran `crtr node yield` this turn, setting
441
+ // intent='refresh'. Re-exec a FRESH pi IN PLACE in this same tmux
442
+ // pane (respawn-pane -k) so the node re-reads its roadmap without
443
+ // churning its window — critically, an interactive/foreground root
444
+ // is never dropped to a shell, and no daemon round-trip is needed
445
+ // (the old window-death detection silently failed whenever pi
446
+ // exited into a persistent shell pane). Falls back to a clean
447
+ // shutdown (daemon revives in a new window) only when we're not in
448
+ // a tmux pane.
449
+ if (node?.intent === 'refresh') {
450
+ // A yield is SILENT to subscribers: the node keeps its identity and
451
+ // subscription edges across the revive and reports only through its
452
+ // own explicit `crtr push` calls, so there is no checkpoint push here
453
+ // — just re-exec a fresh pi in place against the roadmap.
454
+ const pane = process.env['TMUX_PANE'];
455
+ if (pane !== undefined && pane.trim() !== '') {
383
456
  try {
384
- ctx?.shutdown?.();
457
+ reviveInPlace(nodeId, pane);
458
+ return; // respawn-pane -k tears down this pi and starts the fresh one
385
459
  }
386
- catch { /* ignore */ }
387
- return;
460
+ catch { /* fall through to plain shutdown */ }
388
461
  }
462
+ try {
463
+ ctx?.shutdown?.();
464
+ }
465
+ catch { /* ignore */ }
466
+ return;
389
467
  }
390
- catch {
391
- /* agent_end handler must never throw out of the extension */
468
+ // (c) Natural stop — run the stop-guard to classify this stop. Nothing
469
+ // is auto-pushed: the node reaches its subscribers only through its
470
+ // own explicit `crtr push` calls this turn. The guard decides
471
+ // whether the stop is a legitimate dormancy (idle-release, or an
472
+ // attended root staying live) or a stall to reprompt.
473
+ const decision = evaluateStop(nodeId, { pushedFinal: false, askedHuman: false });
474
+ if (decision.action === 'reprompt') {
475
+ // Stalled — re-prompt so the node finishes or escalates with an
476
+ // explicit `crtr push final` (or `crtr human ask`). Deliver as a
477
+ // followUp: the turn just ended but pi may still be flushing, so an
478
+ // unqualified sendUserMessage races with 'already processing'.
479
+ pi.sendUserMessage(decision.message, { deliverAs: 'followUp' });
480
+ return;
481
+ }
482
+ // 'allow' — the node legitimately stopped. Nothing is pushed here; any
483
+ // report it owed its subscribers was sent by an explicit `crtr push`
484
+ // during the turn.
485
+ //
486
+ // Idle-release: a node awaiting its workers (reason 'awaiting') is holding
487
+ // a tmux window for nothing. Free it — mark it idle-released and shut pi
488
+ // down; the daemon watches its inbox and revives it (resume) the moment a
489
+ // subscribed worker delivers. An 'attended' root never releases: the human
490
+ // is its wake source, so we keep its window live and dormant.
491
+ if (decision.reason === 'awaiting') {
492
+ // AWAITING = F3. transition('release') marks it idle-released. If this
493
+ // node is FOCUSED its pane FREEZES in place (remain-on-exit, armed at
494
+ // focus time) so the daemon can respawn-pane -k it back into the SAME
495
+ // focus pane when a worker pushes; if UNFOCUSED its backstage pane
496
+ // closes (dormant) and the daemon revives it into the backstage on the
497
+ // inbox. Both are the same release — tmux's per-window remain-on-exit
498
+ // decides freeze vs close. NO manager-takeover (awaiting ≠ done).
499
+ transition(nodeId, 'release');
500
+ try {
501
+ ctx?.shutdown?.();
502
+ }
503
+ catch { /* ignore */ }
504
+ return;
392
505
  }
393
- })();
506
+ }
507
+ catch {
508
+ /* agent_end handler must never throw out of the extension */
509
+ }
394
510
  });
395
511
  }
396
512
  export default registerCanvasStophook;