@crouton-kit/crouter 0.3.8 → 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.
- 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 +25 -27
- package/dist/commands/{job.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/{agent.d.ts → daemon.d.ts} +1 -1
- 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 +15 -427
- package/dist/commands/node.d.ts +2 -0
- package/dist/commands/node.js +354 -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 +8 -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 +12 -681
- 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 +9 -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/__tests__/resolver.test.js +69 -1
- package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
- package/dist/core/__tests__/unknown-path.test.js +52 -0
- package/dist/core/bootstrap.d.ts +2 -0
- package/dist/core/bootstrap.js +66 -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 +63 -2
- package/dist/core/command.js +97 -24
- 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/frontmatter.d.ts +10 -0
- package/dist/core/frontmatter.js +24 -9
- package/dist/core/help.d.ts +39 -8
- package/dist/core/help.js +69 -35
- 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 +160 -2
- 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 +38 -0
- package/dist/core/runtime/presence.js +152 -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 +89 -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 +33 -0
- package/dist/core/runtime/spawn.js +118 -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 +88 -0
- package/dist/core/runtime/tmux.js +198 -0
- package/dist/core/spawn.d.ts +17 -80
- package/dist/core/spawn.js +15 -219
- package/dist/daemon/crtrd-cli.d.ts +1 -0
- 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 +373 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +3 -0
- package/package.json +6 -5
- package/dist/commands/agent.js +0 -384
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -179
- package/dist/commands/job.js +0 -344
- package/dist/commands/plan.d.ts +0 -4
- package/dist/commands/plan.js +0 -309
- package/dist/commands/spec.d.ts +0 -3
- package/dist/commands/spec.js +0 -286
- 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 -66
- package/dist/core/jobs.d.ts +0 -101
- package/dist/core/jobs.js +0 -462
- package/dist/prompts/agent.d.ts +0 -18
- package/dist/prompts/agent.js +0 -153
- 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
|
@@ -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/dist/types.d.ts
CHANGED
|
@@ -90,6 +90,26 @@ export interface Skill {
|
|
|
90
90
|
enabled: boolean;
|
|
91
91
|
disabledIn?: Scope;
|
|
92
92
|
}
|
|
93
|
+
export interface SubagentFrontmatter {
|
|
94
|
+
name: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
/** Tool allow-list (pi tool names). Passed through to pi via `--tools`. */
|
|
97
|
+
tools?: string[];
|
|
98
|
+
/** Model pattern/id passed to the agent CLI via `--model`. */
|
|
99
|
+
model?: string;
|
|
100
|
+
}
|
|
101
|
+
export interface Subagent {
|
|
102
|
+
name: string;
|
|
103
|
+
/** Plugin the subagent belongs to, or SCOPE_SKILL_PLUGIN ('_') for a
|
|
104
|
+
* scope-root agent stored at `<scope-root>/agents/<name>.md`. */
|
|
105
|
+
plugin: string;
|
|
106
|
+
scope: Scope;
|
|
107
|
+
/** Absolute path to the agent's .md file. */
|
|
108
|
+
path: string;
|
|
109
|
+
frontmatter: SubagentFrontmatter;
|
|
110
|
+
/** Markdown body — used as the spawned agent's appended system prompt. */
|
|
111
|
+
systemPrompt: string;
|
|
112
|
+
}
|
|
93
113
|
export interface InstalledPlugin {
|
|
94
114
|
name: string;
|
|
95
115
|
scope: Scope;
|
|
@@ -117,6 +137,7 @@ export declare const CONFIG_FILE = "config.json";
|
|
|
117
137
|
export declare const STATE_FILE = "state.json";
|
|
118
138
|
export declare const SKILL_ENTRY_FILE = "SKILL.md";
|
|
119
139
|
export declare const SKILLS_DIR = "skills";
|
|
140
|
+
export declare const AGENTS_DIR = "agents";
|
|
120
141
|
export declare const SCOPE_SKILL_PLUGIN = "_";
|
|
121
142
|
export declare const DEFAULT_MAX_PANES_PER_WINDOW = 3;
|
|
122
143
|
export declare function defaultScopeConfig(): ScopeConfig;
|
package/dist/types.js
CHANGED
|
@@ -20,6 +20,9 @@ export const CONFIG_FILE = 'config.json';
|
|
|
20
20
|
export const STATE_FILE = 'state.json';
|
|
21
21
|
export const SKILL_ENTRY_FILE = 'SKILL.md';
|
|
22
22
|
export const SKILLS_DIR = 'skills';
|
|
23
|
+
// Subagent definitions live as flat `<name>.md` files under `<root>/agents/`,
|
|
24
|
+
// for both scope roots and plugins. Mirrors SKILLS_DIR.
|
|
25
|
+
export const AGENTS_DIR = 'agents';
|
|
23
26
|
// Sentinel plugin name for skills that live at a scope root (no plugin wrapper).
|
|
24
27
|
// Stored as `<scope-root>/skills/<name>/SKILL.md`. Shown in listings without the
|
|
25
28
|
// `_/` prefix.
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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 &&
|
|
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": {
|