@crouton-kit/crouter 0.3.14 → 0.3.16

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