@crouton-kit/crouter 0.3.11 → 0.3.13

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +407 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +55 -0
  125. package/dist/core/runtime/presence.js +198 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +87 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +31 -0
  135. package/dist/core/runtime/spawn.js +123 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +107 -0
  139. package/dist/core/runtime/tmux.js +244 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +396 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -0,0 +1,396 @@
1
+ // canvas-stophook.ts — pi extension for pi-native canvas agent nodes.
2
+ //
3
+ // Loaded into every canvas node's pi process via the node's launch.extensions
4
+ // list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
5
+ //
6
+ // In the canvas model each node owns a dedicated pi window (one-window-per-node),
7
+ // so the tmux pane-relocation swap-back guard of the legacy stophook is omitted —
8
+ // there are no shared pane slots to restore.
9
+ //
10
+ // Responsibilities:
11
+ //
12
+ // turn_end — accumulate token usage and flush telemetry.json under the node's
13
+ // job/ dir so the dashboard shows live counts.
14
+ //
15
+ // agent_end — decide what happens when the node stops:
16
+ // (a) stopReason is 'aborted' or 'error' → stay alive for re-steering; return.
17
+ // (b) node.status is already 'done' (agent called `crtr push --final` this
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:
21
+ // • 'reprompt' → pi.sendUserMessage so the node finishes or escalates.
22
+ // • 'allow' (awaiting) → idle-release: free the tmux window and shut
23
+ // down; the daemon watches the inbox and revives it
24
+ // (resume) when a subscribed worker delivers.
25
+ // • 'allow' (attended root) → stay alive, dormant; the human wakes it.
26
+ //
27
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
28
+ // crouter's own tsc build without a dep on the pi packages.
29
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
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';
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';
38
+ /**
39
+ * Merge accumulated token counts into nodes/<nodeId>/job/telemetry.json.
40
+ * Creates the directory when it doesn't yet exist. Best-effort; never throws.
41
+ */
42
+ function flushTelemetry(jobDirPath, tokensIn, tokensOut, model) {
43
+ try {
44
+ if (!existsSync(jobDirPath))
45
+ mkdirSync(jobDirPath, { recursive: true });
46
+ const filePath = join(jobDirPath, 'telemetry.json');
47
+ // Merge with any existing record so concurrent readers always see a complete
48
+ // picture. Model name falls back to whatever was last recorded.
49
+ let existing = {};
50
+ if (existsSync(filePath)) {
51
+ try {
52
+ existing = JSON.parse(readFileSync(filePath, 'utf8'));
53
+ }
54
+ catch {
55
+ /* start fresh on a corrupt file */
56
+ }
57
+ }
58
+ const record = {
59
+ tokens_in: tokensIn,
60
+ tokens_out: tokensOut,
61
+ model: model !== '' ? model : (existing.model ?? ''),
62
+ updated_at: new Date().toISOString(),
63
+ };
64
+ writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
65
+ }
66
+ catch {
67
+ /* telemetry is best-effort; never surface */
68
+ }
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Message-extraction helpers
72
+ // ---------------------------------------------------------------------------
73
+ /** Walk backwards through the agent_end messages array to find the last
74
+ * assistant turn. */
75
+ function lastAssistantMessage(messages) {
76
+ for (let i = messages.length - 1; i >= 0; i--) {
77
+ if (messages[i]?.role === 'assistant')
78
+ return messages[i];
79
+ }
80
+ return undefined;
81
+ }
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
+ // ---------------------------------------------------------------------------
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.
139
+ //
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")
143
+ // ---------------------------------------------------------------------------
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];
154
+ if (tokens < first)
155
+ return null;
156
+ if (tokens >= last)
157
+ return last + Math.floor((tokens - last) / STEER_STEP) * STEER_STEP;
158
+ let chosen = first;
159
+ for (const b of bands)
160
+ if (tokens >= b)
161
+ chosen = b;
162
+ return chosen;
163
+ }
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) {
170
+ const k = Math.round(at / 1000);
171
+ if (mode === 'orchestrator') {
172
+ if (at < 150_000) {
173
+ 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
+ }
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.`;
176
+ }
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)
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\`.`;
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // Extension
184
+ // ---------------------------------------------------------------------------
185
+ /**
186
+ * Register the canvas turn_end / agent_end handlers on `pi`.
187
+ *
188
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
189
+ * inert in a non-canvas pi session. Safe to call multiple times (each call
190
+ * re-registers on the same `pi` instance, so it should only be called once
191
+ * per node lifecycle, matching how pi loads extensions).
192
+ */
193
+ export function registerCanvasStophook(pi) {
194
+ const nodeId = process.env['CRTR_NODE_ID'];
195
+ if (nodeId === undefined || nodeId.trim() === '')
196
+ return; // not a canvas node
197
+ 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
+ let totalIn = 0;
202
+ let totalOut = 0;
203
+ let model = '';
204
+ // 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
+ const firedBands = new Set();
209
+ // ---------------------------------------------------------------------------
210
+ // session_start — capture pi's session id, and detect `/new`.
211
+ //
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.
220
+ // ---------------------------------------------------------------------------
221
+ let boundSessionId = null;
222
+ pi.on('session_start', (_event, ctx) => {
223
+ try {
224
+ const id = ctx?.sessionManager?.getSessionId?.();
225
+ if (typeof id !== 'string' || id === '')
226
+ 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 });
233
+ return;
234
+ }
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();
247
+ }
248
+ catch {
249
+ /* best-effort; never surface from an extension handler */
250
+ }
251
+ });
252
+ /** Absorb usage + model from any assistant message (turn or final batch). */
253
+ const accumulate = (msg) => {
254
+ if (msg?.role !== 'assistant' || msg.usage == null)
255
+ return;
256
+ totalIn += Number(msg.usage.input ?? 0) || 0;
257
+ totalOut += Number(msg.usage.output ?? 0) || 0;
258
+ if (typeof msg.model === 'string' && msg.model !== '')
259
+ model = msg.model;
260
+ };
261
+ // ---------------------------------------------------------------------------
262
+ // turn_end — live telemetry refresh.
263
+ // event shape: { message: AssistantMessage, ... }
264
+ // ---------------------------------------------------------------------------
265
+ pi.on('turn_end', (event) => {
266
+ accumulate(event?.message);
267
+ // 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).
271
+ try {
272
+ const mode = getNode(nodeId)?.mode ?? 'base';
273
+ const at = steerBand(totalIn, mode);
274
+ if (at !== null && !firedBands.has(at)) {
275
+ firedBands.add(at);
276
+ pi.sendUserMessage(`[crtr] ${steerNote(at, mode)}`, { deliverAs: 'followUp' });
277
+ }
278
+ }
279
+ catch {
280
+ /* steering is best-effort */
281
+ }
282
+ });
283
+ // ---------------------------------------------------------------------------
284
+ // agent_end — routing decision when the node's pi stops.
285
+ // event shape: { messages: AgentMessage[] }
286
+ // ---------------------------------------------------------------------------
287
+ 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?.();
348
+ }
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
+ }
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 });
374
+ }
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);
383
+ try {
384
+ ctx?.shutdown?.();
385
+ }
386
+ catch { /* ignore */ }
387
+ return;
388
+ }
389
+ }
390
+ catch {
391
+ /* agent_end handler must never throw out of the extension */
392
+ }
393
+ })();
394
+ });
395
+ }
396
+ export default registerCanvasStophook;
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "bin": {
9
9
  "crtr": "bin/crtr",
10
- "crouter": "bin/crouter"
10
+ "crouter": "bin/crouter",
11
+ "crtrd": "bin/crtrd"
11
12
  },
12
13
  "exports": {
13
14
  ".": {
@@ -23,11 +24,11 @@
23
24
  "bin"
24
25
  ],
25
26
  "scripts": {
26
- "build": "tsc && rm -rf dist/builtin-skills && cp -R src/builtin-skills dist/builtin-skills",
27
+ "build": "rm -rf dist && tsc && cp -R src/builtin-skills dist/builtin-skills && cp -R src/builtin-personas dist/builtin-personas",
27
28
  "dev": "tsx src/cli.ts",
28
29
  "link": "npm link",
29
30
  "prepublishOnly": "npm run build",
30
- "test": "node --import tsx/esm --test 'src/core/__tests__/**/*.test.ts'"
31
+ "test": "node --import tsx/esm --test 'src/**/__tests__/**/*.test.ts'"
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",
@@ -35,7 +36,7 @@
35
36
  },
36
37
  "license": "MIT",
37
38
  "dependencies": {
38
- "@crouton-kit/humanloop": "^0.3.12",
39
+ "@crouton-kit/humanloop": "^0.3.14",
39
40
  "commander": "^13.0.0"
40
41
  },
41
42
  "devDependencies": {
@@ -1,6 +0,0 @@
1
- import type { BranchDef } from '../core/command.js';
2
- export declare const DEFAULT_KILL_SECS = 2;
3
- export declare function followUpResult(jobId: string): string;
4
- export declare function resolveMaxPanes(): number;
5
- export declare function assertTmux(): void;
6
- export declare function registerAgent(): BranchDef;