@crouton-kit/crouter 0.3.11 → 0.3.12

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 +354 -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 +38 -0
  125. package/dist/core/runtime/presence.js +152 -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 +89 -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 +33 -0
  135. package/dist/core/runtime/spawn.js +118 -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 +88 -0
  139. package/dist/core/runtime/tmux.js +198 -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 +373 -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,373 @@
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 — first nudge at 100k input tokens, then one
136
+ // every 50k thereafter (150k, 200k, 250k, …). Unbounded: a long-lived node
137
+ // keeps getting reminded as it grows.
138
+ // ---------------------------------------------------------------------------
139
+ const STEER_FLOOR = 100_000;
140
+ const STEER_STEP = 50_000;
141
+ /** The highest band boundary at or below `tokens` (100k, 150k, 200k, …), or
142
+ * null below the floor. */
143
+ function steerBand(tokens) {
144
+ if (tokens < STEER_FLOOR)
145
+ return null;
146
+ return STEER_FLOOR + Math.floor((tokens - STEER_FLOOR) / STEER_STEP) * STEER_STEP;
147
+ }
148
+ /** The nudge text for a crossed band, specialized to the node's mode. An
149
+ * orchestrator is steered to checkpoint its roadmap and yield; a non-
150
+ * orchestrator (base worker) is steered to PROMOTE itself — become a resident
151
+ * orchestrator — when work remains, or finish if it's nearly done. */
152
+ function steerNote(at, mode) {
153
+ const k = Math.round(at / 1000);
154
+ if (mode === 'orchestrator') {
155
+ return `Context ~${k}k. Update context/roadmap.md so a fresh revive can continue, delegate any outstanding work, then \`crtr node yield\` to refresh.`;
156
+ }
157
+ return `Context ~${k}k and climbing. If more work remains than this context can finish, \`crtr node promote\` to become a resident orchestrator (seeds a roadmap, lets you delegate and \`crtr node yield\` to refresh). If you're nearly done, finish with \`crtr push final\`.`;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Extension
161
+ // ---------------------------------------------------------------------------
162
+ /**
163
+ * Register the canvas turn_end / agent_end handlers on `pi`.
164
+ *
165
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
166
+ * inert in a non-canvas pi session. Safe to call multiple times (each call
167
+ * re-registers on the same `pi` instance, so it should only be called once
168
+ * per node lifecycle, matching how pi loads extensions).
169
+ */
170
+ export function registerCanvasStophook(pi) {
171
+ const nodeId = process.env['CRTR_NODE_ID'];
172
+ if (nodeId === undefined || nodeId.trim() === '')
173
+ return; // not a canvas node
174
+ const jobDirPath = jobDir(nodeId);
175
+ // Running totals across all turns in this pi session. Both turn_end and
176
+ // agent_end accumulate so tokens emitted in the final partial turn (if pi
177
+ // fires agent_end without a preceding turn_end for it) are captured.
178
+ let totalIn = 0;
179
+ let totalOut = 0;
180
+ let model = '';
181
+ // Context-size steering. As input context grows we nudge the node once per
182
+ // band (100k, then every 50k). The nudge depends on the node's CURRENT mode,
183
+ // read at fire time since a base worker can promote mid-session: an
184
+ // orchestrator checkpoints + yields; a base worker is steered to promote.
185
+ const firedBands = new Set();
186
+ // ---------------------------------------------------------------------------
187
+ // session_start — capture pi's session id, and detect `/new`.
188
+ //
189
+ // pi exposes the session id via ctx.sessionManager.getSessionId() on every
190
+ // event context; session_start fires early, before any turns. We bind the
191
+ // FIRST session_start of this process as the boot (a fresh launch and a daemon
192
+ // revive are both new processes, so their first session_start is a boot, not
193
+ // a `/new`). A LATER session_start with a DIFFERENT id, in this same live
194
+ // process, can only mean the user ran `/new` — a brand-new conversation. For
195
+ // a root that means a brand-new graph: reset it (the `crtr`-again equivalent),
196
+ // then rebind. A reload reports the same id and is a no-op.
197
+ // ---------------------------------------------------------------------------
198
+ let boundSessionId = null;
199
+ pi.on('session_start', (_event, ctx) => {
200
+ try {
201
+ const id = ctx?.sessionManager?.getSessionId?.();
202
+ if (typeof id !== 'string' || id === '')
203
+ return;
204
+ if (boundSessionId === null) {
205
+ // Boot: bind this process to its session id.
206
+ boundSessionId = id;
207
+ const existing = getNode(nodeId);
208
+ if (existing?.pi_session_id !== id)
209
+ updateNode(nodeId, { pi_session_id: id });
210
+ return;
211
+ }
212
+ if (id === boundSessionId)
213
+ return; // reload of the same conversation
214
+ // A new session id in the same process = `/new`. Brand-new graph.
215
+ boundSessionId = id;
216
+ try {
217
+ resetRoot(nodeId, id);
218
+ }
219
+ catch { /* best-effort */ }
220
+ // Clear in-memory context-steering so the fresh conversation starts clean.
221
+ totalIn = 0;
222
+ totalOut = 0;
223
+ firedBands.clear();
224
+ }
225
+ catch {
226
+ /* best-effort; never surface from an extension handler */
227
+ }
228
+ });
229
+ /** Absorb usage + model from any assistant message (turn or final batch). */
230
+ const accumulate = (msg) => {
231
+ if (msg?.role !== 'assistant' || msg.usage == null)
232
+ return;
233
+ totalIn += Number(msg.usage.input ?? 0) || 0;
234
+ totalOut += Number(msg.usage.output ?? 0) || 0;
235
+ if (typeof msg.model === 'string' && msg.model !== '')
236
+ model = msg.model;
237
+ };
238
+ // ---------------------------------------------------------------------------
239
+ // turn_end — live telemetry refresh.
240
+ // event shape: { message: AssistantMessage, ... }
241
+ // ---------------------------------------------------------------------------
242
+ pi.on('turn_end', (event) => {
243
+ accumulate(event?.message);
244
+ // Fire-and-forget: flushTelemetry uses synchronous fs writes and never throws.
245
+ flushTelemetry(jobDirPath, totalIn, totalOut, model);
246
+ // Context-size steering: fire the current band once, with mode-specific
247
+ // guidance (mode is read live — a worker may have promoted since launch).
248
+ try {
249
+ const at = steerBand(totalIn);
250
+ if (at !== null && !firedBands.has(at)) {
251
+ firedBands.add(at);
252
+ const mode = getNode(nodeId)?.mode ?? 'base';
253
+ pi.sendUserMessage(`[crtr] ${steerNote(at, mode)}`, { deliverAs: 'followUp' });
254
+ }
255
+ }
256
+ catch {
257
+ /* steering is best-effort */
258
+ }
259
+ });
260
+ // ---------------------------------------------------------------------------
261
+ // agent_end — routing decision when the node's pi stops.
262
+ // event shape: { messages: AgentMessage[] }
263
+ // ---------------------------------------------------------------------------
264
+ pi.on('agent_end', (event, ctx) => {
265
+ // Wrap in a void async IIFE so we can await the async push() call without
266
+ // making the handler signature async (pi may not uniformly await async
267
+ // handlers). The internal I/O (push) is all synchronous fs, so this
268
+ // resolves in a single microtask tick — no meaningful async delay.
269
+ void (async () => {
270
+ try {
271
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
272
+ // Accumulate tokens from the final batch (edge case: a turn that fired
273
+ // agent_end without a preceding turn_end for the same turn).
274
+ for (const m of messages)
275
+ accumulate(m);
276
+ const last = lastAssistantMessage(messages);
277
+ const stopReason = last?.stopReason ?? '';
278
+ // (a) Interrupted or errored — stay alive so the user can re-steer.
279
+ if (stopReason !== 'stop' && stopReason !== 'length')
280
+ return;
281
+ // (b) Already done: `crtr push --final` was called this turn, which
282
+ // transitions node.status → 'done' synchronously. Shut down cleanly.
283
+ const node = getNode(nodeId);
284
+ if (node?.status === 'done') {
285
+ restoreFocusToManager(nodeId);
286
+ try {
287
+ ctx?.shutdown?.();
288
+ }
289
+ catch { /* ignore */ }
290
+ return;
291
+ }
292
+ // (b') Refresh-yield: the node ran `crtr node yield` this turn, setting
293
+ // intent='refresh'. Re-exec a FRESH pi IN PLACE in this same tmux
294
+ // pane (respawn-pane -k) so the node re-reads its roadmap without
295
+ // churning its window — critically, an interactive/foreground root
296
+ // is never dropped to a shell, and no daemon round-trip is needed
297
+ // (the old window-death detection silently failed whenever pi
298
+ // exited into a persistent shell pane). Falls back to a clean
299
+ // shutdown (daemon revives in a new window) only when we're not in
300
+ // a tmux pane.
301
+ if (node?.intent === 'refresh') {
302
+ // Notify subscribers BEFORE refreshing. A yield is a checkpoint, not a
303
+ // disappearance: the node keeps its identity and its subscription
304
+ // edges across the revive, so it still owes its parent a report. Emit
305
+ // one now (an `update`, not a `final` — the node isn't done) so a
306
+ // yield is never silent to whoever is watching.
307
+ try {
308
+ const yieldText = extractText(last);
309
+ const body = yieldText !== ''
310
+ ? `↻ Refreshing context (yield) — still working toward my goal.\n\n${yieldText}`
311
+ : '↻ Refreshing context (yield) — still working toward my goal.';
312
+ await push(nodeId, { kind: 'update', body });
313
+ }
314
+ catch { /* notify is best-effort */ }
315
+ const pane = process.env['TMUX_PANE'];
316
+ if (pane !== undefined && pane.trim() !== '') {
317
+ try {
318
+ reviveInPlace(nodeId, pane);
319
+ return; // respawn-pane -k tears down this pi and starts the fresh one
320
+ }
321
+ catch { /* fall through to plain shutdown */ }
322
+ }
323
+ try {
324
+ ctx?.shutdown?.();
325
+ }
326
+ catch { /* ignore */ }
327
+ return;
328
+ }
329
+ // (c) Natural stop — decide FIRST, then act. Running the stop-guard
330
+ // before any auto-push is what prevents duplicate reporting: a
331
+ // stalled terminal worker that narrates "done" without calling
332
+ // `push final` must NOT have that prose pushed as an `update`,
333
+ // because the reprompt below makes it emit a `final` next turn —
334
+ // two feed entries for one completion. Only genuinely dormant
335
+ // nodes ('allow') get a routine checkpoint update.
336
+ const decision = evaluateStop(nodeId, { pushedFinal: false, askedHuman: false });
337
+ if (decision.action === 'reprompt') {
338
+ // Stalled — re-prompt so the node finishes or escalates. Its `final`
339
+ // (or escalation) carries the real result, so we deliberately skip
340
+ // the auto-update here. Deliver as a followUp: the turn just ended
341
+ // but pi may still be flushing, so an unqualified sendUserMessage
342
+ // races with 'already processing'.
343
+ pi.sendUserMessage(decision.message, { deliverAs: 'followUp' });
344
+ return;
345
+ }
346
+ // 'allow' — the node legitimately stopped. Surface the last assistant
347
+ // message as a routine feed checkpoint first.
348
+ const text = extractText(last);
349
+ if (text !== '') {
350
+ await push(nodeId, { kind: 'update', body: text });
351
+ }
352
+ // Idle-release: a node awaiting its workers (reason 'awaiting') is holding
353
+ // a tmux window for nothing. Free it — mark it idle-released and shut pi
354
+ // down; the daemon watches its inbox and revives it (resume) the moment a
355
+ // subscribed worker delivers. An 'attended' root never releases: the human
356
+ // is its wake source, so we keep its window live and dormant.
357
+ if (decision.reason === 'awaiting') {
358
+ updateNode(nodeId, { intent: 'idle-release', status: 'idle' });
359
+ restoreFocusToManager(nodeId);
360
+ try {
361
+ ctx?.shutdown?.();
362
+ }
363
+ catch { /* ignore */ }
364
+ return;
365
+ }
366
+ }
367
+ catch {
368
+ /* agent_end handler must never throw out of the extension */
369
+ }
370
+ })();
371
+ });
372
+ }
373
+ 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.12",
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;