@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.
- package/bin/crtrd +2 -0
- package/dist/builtin-personas/design/base.md +9 -0
- package/dist/builtin-personas/design/orchestrator.md +10 -0
- package/dist/builtin-personas/developer/base.md +9 -0
- package/dist/builtin-personas/developer/orchestrator.md +12 -0
- package/dist/builtin-personas/explore/base.md +9 -0
- package/dist/builtin-personas/explore/orchestrator.md +9 -0
- package/dist/builtin-personas/general/base.md +5 -0
- package/dist/builtin-personas/general/orchestrator.md +7 -0
- package/dist/builtin-personas/orchestration-kernel.md +71 -0
- package/dist/builtin-personas/plan/base.md +7 -0
- package/dist/builtin-personas/plan/orchestrator.md +12 -0
- package/dist/builtin-personas/review/base.md +7 -0
- package/dist/builtin-personas/review/orchestrator.md +9 -0
- package/dist/builtin-personas/runtime-base.md +39 -0
- package/dist/builtin-personas/spec/base.md +7 -0
- package/dist/builtin-personas/spec/orchestrator.md +10 -0
- package/dist/builtin-skills/skills/design/SKILL.md +51 -0
- package/dist/builtin-skills/skills/development/SKILL.md +109 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
- package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
- package/dist/cli.js +14 -6
- package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
- package/dist/commands/attention.js +152 -0
- package/dist/commands/canvas.d.ts +2 -0
- package/dist/commands/canvas.js +35 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +111 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +65 -0
- package/dist/commands/human/prompts.d.ts +5 -0
- package/dist/commands/human/prompts.js +269 -0
- package/dist/commands/human/queue.d.ts +3 -0
- package/dist/commands/human/queue.js +133 -0
- package/dist/commands/human/shared.d.ts +43 -0
- package/dist/commands/human/shared.js +107 -0
- package/dist/commands/human.js +10 -454
- package/dist/commands/node.d.ts +2 -0
- package/dist/commands/node.js +407 -0
- package/dist/commands/pkg/market-inspect.d.ts +1 -0
- package/dist/commands/pkg/market-inspect.js +157 -0
- package/dist/commands/pkg/market-manage.d.ts +1 -0
- package/dist/commands/pkg/market-manage.js +316 -0
- package/dist/commands/pkg/market.d.ts +1 -0
- package/dist/commands/pkg/market.js +16 -0
- package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
- package/dist/commands/pkg/plugin-inspect.js +142 -0
- package/dist/commands/pkg/plugin-manage.d.ts +1 -0
- package/dist/commands/pkg/plugin-manage.js +294 -0
- package/dist/commands/pkg/plugin.d.ts +1 -0
- package/dist/commands/pkg/plugin.js +16 -0
- package/dist/commands/pkg/shared.d.ts +5 -0
- package/dist/commands/pkg/shared.js +61 -0
- package/dist/commands/pkg.js +3 -1004
- package/dist/commands/push.d.ts +3 -0
- package/dist/commands/push.js +159 -0
- package/dist/commands/revive.d.ts +2 -0
- package/dist/commands/revive.js +64 -0
- package/dist/commands/skill/author.d.ts +3 -0
- package/dist/commands/skill/author.js +147 -0
- package/dist/commands/skill/find.d.ts +4 -0
- package/dist/commands/skill/find.js +254 -0
- package/dist/commands/skill/read.d.ts +1 -0
- package/dist/commands/skill/read.js +89 -0
- package/dist/commands/skill/shared.d.ts +19 -0
- package/dist/commands/skill/shared.js +207 -0
- package/dist/commands/skill/state.d.ts +3 -0
- package/dist/commands/skill/state.js +69 -0
- package/dist/commands/skill.js +6 -691
- package/dist/commands/sys/config.d.ts +1 -0
- package/dist/commands/sys/config.js +186 -0
- package/dist/commands/sys/doctor.d.ts +1 -0
- package/dist/commands/sys/doctor.js +369 -0
- package/dist/commands/sys/shared.d.ts +3 -0
- package/dist/commands/sys/shared.js +24 -0
- package/dist/commands/sys/update.d.ts +2 -0
- package/dist/commands/sys/update.js +114 -0
- package/dist/commands/sys.js +4 -694
- package/dist/core/__tests__/argv-parser.test.js +19 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
- package/dist/core/__tests__/canvas.test.js +154 -0
- package/dist/core/__tests__/reset.test.js +105 -0
- package/dist/core/canvas/attention.d.ts +24 -0
- package/dist/core/canvas/attention.js +94 -0
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +210 -0
- package/dist/core/canvas/db.d.ts +7 -0
- package/dist/core/canvas/db.js +61 -0
- package/dist/core/canvas/index.d.ts +4 -0
- package/dist/core/canvas/index.js +6 -0
- package/dist/core/canvas/paths.d.ts +16 -0
- package/dist/core/canvas/paths.js +62 -0
- package/dist/core/canvas/render.d.ts +30 -0
- package/dist/core/canvas/render.js +186 -0
- package/dist/core/canvas/types.d.ts +87 -0
- package/dist/core/canvas/types.js +8 -0
- package/dist/core/command.d.ts +5 -0
- package/dist/core/command.js +35 -10
- package/dist/core/feed/feed.d.ts +43 -0
- package/dist/core/feed/feed.js +116 -0
- package/dist/core/feed/inbox.d.ts +50 -0
- package/dist/core/feed/inbox.js +124 -0
- package/dist/core/help.js +5 -3
- package/dist/core/io.d.ts +15 -1
- package/dist/core/io.js +56 -6
- package/dist/core/personas/index.d.ts +12 -0
- package/dist/core/personas/index.js +10 -0
- package/dist/core/personas/loader.d.ts +44 -0
- package/dist/core/personas/loader.js +157 -0
- package/dist/core/personas/resolve.d.ts +36 -0
- package/dist/core/personas/resolve.js +110 -0
- package/dist/core/render.d.ts +11 -0
- package/dist/core/render.js +126 -0
- package/dist/core/resolver.d.ts +10 -0
- package/dist/core/resolver.js +109 -1
- package/dist/core/runtime/front-door.d.ts +10 -0
- package/dist/core/runtime/front-door.js +97 -0
- package/dist/core/runtime/kickoff.d.ts +23 -0
- package/dist/core/runtime/kickoff.js +134 -0
- package/dist/core/runtime/launch.d.ts +34 -0
- package/dist/core/runtime/launch.js +85 -0
- package/dist/core/runtime/nodes.d.ts +38 -0
- package/dist/core/runtime/nodes.js +95 -0
- package/dist/core/runtime/presence.d.ts +55 -0
- package/dist/core/runtime/presence.js +198 -0
- package/dist/core/runtime/promote.d.ts +30 -0
- package/dist/core/runtime/promote.js +105 -0
- package/dist/core/runtime/reset.d.ts +13 -0
- package/dist/core/runtime/reset.js +97 -0
- package/dist/core/runtime/revive.d.ts +26 -0
- package/dist/core/runtime/revive.js +87 -0
- package/dist/core/runtime/roadmap.d.ts +12 -0
- package/dist/core/runtime/roadmap.js +52 -0
- package/dist/core/runtime/spawn.d.ts +31 -0
- package/dist/core/runtime/spawn.js +123 -0
- package/dist/core/runtime/stop-guard.d.ts +18 -0
- package/dist/core/runtime/stop-guard.js +33 -0
- package/dist/core/runtime/tmux.d.ts +107 -0
- package/dist/core/runtime/tmux.js +244 -0
- package/dist/core/spawn.d.ts +17 -197
- package/dist/core/spawn.js +16 -539
- package/dist/daemon/crtrd-cli.js +4 -0
- package/dist/daemon/crtrd.d.ts +20 -0
- package/dist/daemon/crtrd.js +200 -0
- package/dist/daemon/manage.d.ts +17 -0
- package/dist/daemon/manage.js +57 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
- package/dist/pi-extensions/canvas-nav.d.ts +32 -0
- package/dist/pi-extensions/canvas-nav.js +536 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
- package/dist/pi-extensions/canvas-stophook.js +396 -0
- package/package.json +6 -5
- package/dist/commands/agent.d.ts +0 -6
- package/dist/commands/agent.js +0 -585
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -192
- package/dist/commands/job.d.ts +0 -11
- package/dist/commands/job.js +0 -384
- package/dist/commands/mode.js +0 -231
- package/dist/commands/plan.d.ts +0 -4
- package/dist/commands/plan.js +0 -322
- package/dist/commands/spec.d.ts +0 -3
- package/dist/commands/spec.js +0 -299
- package/dist/core/__tests__/flow-leaves.test.js +0 -248
- package/dist/core/__tests__/job.test.js +0 -310
- package/dist/core/__tests__/jobs.test.js +0 -98
- package/dist/core/__tests__/spawn.test.js +0 -138
- package/dist/core/__tests__/subagents.test.d.ts +0 -1
- package/dist/core/__tests__/subagents.test.js +0 -75
- package/dist/core/jobs.d.ts +0 -107
- package/dist/core/jobs.js +0 -565
- package/dist/core/subagents.d.ts +0 -18
- package/dist/core/subagents.js +0 -163
- package/dist/prompts/agent.d.ts +0 -27
- package/dist/prompts/agent.js +0 -184
- package/dist/prompts/debug.d.ts +0 -8
- package/dist/prompts/debug.js +0 -44
- /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
- /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
- /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
- /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.
|
|
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 &&
|
|
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
|
|
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.
|
|
39
|
+
"@crouton-kit/humanloop": "^0.3.14",
|
|
39
40
|
"commander": "^13.0.0"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
package/dist/commands/agent.d.ts
DELETED
|
@@ -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;
|