@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,200 @@
|
|
|
1
|
+
// crtrd — the thin supervisor daemon. One instance per canvas.
|
|
2
|
+
//
|
|
3
|
+
// Sole responsibility: supervise tmux window exit and revive nodes. No
|
|
4
|
+
// orchestration logic lives here. The daemon is a process-lifecycle watcher.
|
|
5
|
+
//
|
|
6
|
+
// Model
|
|
7
|
+
// • Poll every intervalMs (default 2000ms).
|
|
8
|
+
// • For each active|idle node: check whether its tmux window is still alive.
|
|
9
|
+
// • Window alive → healthy, skip.
|
|
10
|
+
// • Window gone + intent==='refresh' → fresh respawn (node asked to yield).
|
|
11
|
+
// • Window gone + intent==='idle-release' → node freed its own pane while
|
|
12
|
+
// dormant; clear the stale window ref and revive (resume) when its inbox
|
|
13
|
+
// gains an unseen entry.
|
|
14
|
+
// • Window gone + any other intent → crash: mark 'dead'.
|
|
15
|
+
// • Nodes with no tmux placement (inline roots) are skipped.
|
|
16
|
+
//
|
|
17
|
+
// Single-instance guarantee
|
|
18
|
+
// A PID file at crtrHome()/crtrd.pid prevents double-runs. On start, if the
|
|
19
|
+
// file exists and the recorded pid is alive, we refuse to start (exit 0).
|
|
20
|
+
// On stop (SIGINT/SIGTERM/exit) we remove the file.
|
|
21
|
+
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { crtrHome } from '../core/canvas/paths.js';
|
|
24
|
+
import { listNodes, setStatus, getNode, updateNode, } from '../core/canvas/index.js';
|
|
25
|
+
import { windowAlive } from '../core/runtime/tmux.js';
|
|
26
|
+
import { reviveNode } from '../core/runtime/revive.js';
|
|
27
|
+
import { readInboxSince, readCursor } from '../core/feed/inbox.js';
|
|
28
|
+
const DEFAULT_INTERVAL_MS = 2000;
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Pidfile
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
function pidfilePath() {
|
|
33
|
+
return join(crtrHome(), 'crtrd.pid');
|
|
34
|
+
}
|
|
35
|
+
function writePidfile() {
|
|
36
|
+
// Ensure the canvas home exists before writing.
|
|
37
|
+
mkdirSync(crtrHome(), { recursive: true });
|
|
38
|
+
writeFileSync(pidfilePath(), String(process.pid), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
function removePidfile() {
|
|
41
|
+
try {
|
|
42
|
+
rmSync(pidfilePath());
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Already gone — nothing to do.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Read the pid stored in the pidfile, or null if absent / malformed. */
|
|
49
|
+
export function readPidfile() {
|
|
50
|
+
const p = pidfilePath();
|
|
51
|
+
if (!existsSync(p))
|
|
52
|
+
return null;
|
|
53
|
+
const raw = readFileSync(p, 'utf8').trim();
|
|
54
|
+
const n = Number(raw);
|
|
55
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
56
|
+
}
|
|
57
|
+
/** True if a process with `pid` is currently alive (signal-0 probe). */
|
|
58
|
+
export function isPidAlive(pid) {
|
|
59
|
+
try {
|
|
60
|
+
process.kill(pid, 0);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True when a crtrd process is already running (pidfile exists + pid alive). */
|
|
68
|
+
export function isDaemonRunning() {
|
|
69
|
+
const pid = readPidfile();
|
|
70
|
+
return pid !== null && isPidAlive(pid);
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Supervisor tick
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
async function superviseTick() {
|
|
76
|
+
let rows;
|
|
77
|
+
try {
|
|
78
|
+
rows = listNodes({ status: ['active', 'idle'] });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
process.stderr.write(`[crtrd] listNodes error: ${err.message}\n`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
try {
|
|
86
|
+
// listNodes returns the lightweight NodeRow; we need the full NodeMeta
|
|
87
|
+
// for tmux_session, window, intent, and pi_session_id.
|
|
88
|
+
const meta = getNode(row.node_id);
|
|
89
|
+
if (meta === null)
|
|
90
|
+
continue; // vanished between list and get
|
|
91
|
+
// Nodes without tmux placement are inline roots — not daemon-managed.
|
|
92
|
+
if (meta.tmux_session == null || meta.window == null)
|
|
93
|
+
continue;
|
|
94
|
+
if (windowAlive(meta.tmux_session, meta.window))
|
|
95
|
+
continue; // healthy
|
|
96
|
+
// Window is gone. Branch on why.
|
|
97
|
+
if (meta.intent === 'refresh') {
|
|
98
|
+
// The node set intent=refresh before stopping — a clean yield. Respawn
|
|
99
|
+
// fresh so it re-reads its roadmap/context dir.
|
|
100
|
+
process.stderr.write(`[crtrd] revive ${row.node_id} (refresh-yield)\n`);
|
|
101
|
+
reviveNode(row.node_id, { resume: false });
|
|
102
|
+
}
|
|
103
|
+
else if (meta.intent === 'idle-release') {
|
|
104
|
+
// The node freed its own window on purpose while dormant. Drop the stale
|
|
105
|
+
// window ref and keep it 'idle'; the inbox-poll pass below revives it
|
|
106
|
+
// (resume) the moment a subscribed worker delivers.
|
|
107
|
+
updateNode(row.node_id, { window: null });
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// Window vanished without the node completing or refreshing — a crash.
|
|
111
|
+
process.stderr.write(`[crtrd] dead ${row.node_id} (window gone, intent=${String(meta.intent)})\n`);
|
|
112
|
+
setStatus(row.node_id, 'dead');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// One bad node must never kill the loop.
|
|
117
|
+
process.stderr.write(`[crtrd] error supervising ${row.node_id}: ${err.message}\n`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Second pass: revive idle-released nodes whose inbox has unseen entries.
|
|
121
|
+
// The in-process inbox-watcher dies with pi, so the daemon owns wake-on-message
|
|
122
|
+
// for dormant nodes. readCursor is the cursor the watcher persisted before
|
|
123
|
+
// exit; any entry past it is undelivered work — resume the node to handle it.
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
try {
|
|
126
|
+
const meta = getNode(row.node_id);
|
|
127
|
+
if (meta === null)
|
|
128
|
+
continue;
|
|
129
|
+
if (meta.status !== 'idle' || meta.intent !== 'idle-release')
|
|
130
|
+
continue;
|
|
131
|
+
// If a window is somehow alive, the in-process watcher owns delivery.
|
|
132
|
+
if (meta.window != null && windowAlive(meta.tmux_session ?? '', meta.window)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const entries = readInboxSince(row.node_id, readCursor(row.node_id));
|
|
136
|
+
if (entries.length > 0) {
|
|
137
|
+
process.stderr.write(`[crtrd] revive ${row.node_id} (idle-release, inbox)\n`);
|
|
138
|
+
reviveNode(row.node_id, { resume: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
process.stderr.write(`[crtrd] error polling inbox ${row.node_id}: ${err.message}\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Start the supervisor loop.
|
|
147
|
+
*
|
|
148
|
+
* If a live crtrd is already running (pidfile + pid alive), exits immediately
|
|
149
|
+
* (exit 0 — idempotent, not an error). Otherwise, writes the pidfile, sets up
|
|
150
|
+
* signal handlers, and enters the poll loop.
|
|
151
|
+
*
|
|
152
|
+
* Returns a teardown callback that stops the loop and removes the pidfile.
|
|
153
|
+
* (Mainly useful for tests; in production the daemon runs until signaled.) */
|
|
154
|
+
export function runDaemon(opts = {}) {
|
|
155
|
+
if (isDaemonRunning()) {
|
|
156
|
+
const pid = readPidfile();
|
|
157
|
+
process.stderr.write(`[crtrd] already running (pid ${pid ?? '?'})\n`);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
const interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
161
|
+
writePidfile();
|
|
162
|
+
process.stderr.write(`[crtrd] started (pid ${process.pid}, interval ${interval}ms)\n`);
|
|
163
|
+
let running = true;
|
|
164
|
+
// Cleanup — idempotent.
|
|
165
|
+
const cleanup = () => {
|
|
166
|
+
removePidfile();
|
|
167
|
+
};
|
|
168
|
+
process.on('SIGINT', () => {
|
|
169
|
+
cleanup();
|
|
170
|
+
process.exit(0);
|
|
171
|
+
});
|
|
172
|
+
process.on('SIGTERM', () => {
|
|
173
|
+
cleanup();
|
|
174
|
+
process.exit(0);
|
|
175
|
+
});
|
|
176
|
+
process.on('exit', () => {
|
|
177
|
+
cleanup();
|
|
178
|
+
});
|
|
179
|
+
// Recursive setTimeout keeps ticks sequential and avoids overlap on slow
|
|
180
|
+
// canvases (a timer that fires while a prior tick is awaiting is dropped).
|
|
181
|
+
const scheduleTick = () => {
|
|
182
|
+
if (!running)
|
|
183
|
+
return;
|
|
184
|
+
superviseTick()
|
|
185
|
+
.catch((err) => {
|
|
186
|
+
process.stderr.write(`[crtrd] tick error: ${err.message}\n`);
|
|
187
|
+
})
|
|
188
|
+
.finally(() => {
|
|
189
|
+
if (running)
|
|
190
|
+
setTimeout(scheduleTick, interval);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
const initialTimer = setTimeout(scheduleTick, interval);
|
|
194
|
+
return () => {
|
|
195
|
+
running = false;
|
|
196
|
+
clearTimeout(initialTimer);
|
|
197
|
+
cleanup();
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export default runDaemon;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SpawnDaemonResult {
|
|
2
|
+
/** True when a new daemon process was spawned. */
|
|
3
|
+
started: boolean;
|
|
4
|
+
/** PID of the newly spawned process, if started. */
|
|
5
|
+
pid?: number;
|
|
6
|
+
/** PID of the already-running daemon, if it was already up. */
|
|
7
|
+
existing_pid?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Spawn crtrd detached. Returns immediately; the child outlives this process.
|
|
10
|
+
*
|
|
11
|
+
* If the daemon is already running, returns {started:false, existing_pid}.
|
|
12
|
+
* If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
|
|
13
|
+
export declare function spawnDaemon(): SpawnDaemonResult;
|
|
14
|
+
/** Start the daemon if it is not already running. No-op if already up.
|
|
15
|
+
* Silently swallows spawn errors (the canvas still works without the daemon;
|
|
16
|
+
* nodes just won't be auto-revived). */
|
|
17
|
+
export declare function ensureDaemon(): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Daemon management helpers — importable without the full command tree.
|
|
2
|
+
//
|
|
3
|
+
// spawnDaemon() is the low-level spawn call shared by `crtr canvas daemon start` and
|
|
4
|
+
// ensureDaemon(). ensureDaemon() is the silent "start if not running" front-
|
|
5
|
+
// door helper called by the canvas runtime before spawning child nodes.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { mkdirSync } from 'node:fs';
|
|
10
|
+
import { crtrHome } from '../core/canvas/paths.js';
|
|
11
|
+
import { isDaemonRunning, readPidfile } from './crtrd.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Entry point resolution
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Resolve the absolute path to the crtrd-cli entry point.
|
|
16
|
+
*
|
|
17
|
+
* At runtime this file is dist/daemon/manage.js; the entry lives at
|
|
18
|
+
* dist/daemon/crtrd-cli.js (sibling in the same directory). */
|
|
19
|
+
function resolveCrtrdEntry() {
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
return join(here, 'crtrd-cli.js');
|
|
22
|
+
}
|
|
23
|
+
/** Spawn crtrd detached. Returns immediately; the child outlives this process.
|
|
24
|
+
*
|
|
25
|
+
* If the daemon is already running, returns {started:false, existing_pid}.
|
|
26
|
+
* If spawning fails (e.g. missing dist — run `npm run build` first), throws. */
|
|
27
|
+
export function spawnDaemon() {
|
|
28
|
+
if (isDaemonRunning()) {
|
|
29
|
+
return { started: false, existing_pid: readPidfile() ?? undefined };
|
|
30
|
+
}
|
|
31
|
+
// Ensure the canvas home directory exists so the daemon can write its pidfile.
|
|
32
|
+
mkdirSync(crtrHome(), { recursive: true });
|
|
33
|
+
const entry = resolveCrtrdEntry();
|
|
34
|
+
const child = spawn(process.execPath, [entry], {
|
|
35
|
+
detached: true,
|
|
36
|
+
stdio: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
const pid = child.pid;
|
|
39
|
+
child.unref();
|
|
40
|
+
return { started: true, pid };
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// ensureDaemon — fire-and-forget front-door helper
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
/** Start the daemon if it is not already running. No-op if already up.
|
|
46
|
+
* Silently swallows spawn errors (the canvas still works without the daemon;
|
|
47
|
+
* nodes just won't be auto-revived). */
|
|
48
|
+
export function ensureDaemon() {
|
|
49
|
+
try {
|
|
50
|
+
if (!isDaemonRunning())
|
|
51
|
+
spawnDaemon();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Intentionally silent — a missing dist/daemon/crtrd-cli.js (dev mode,
|
|
55
|
+
// pre-build) must not break the calling command.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type PiEvents = 'session_start' | 'turn_end' | 'agent_start' | 'agent_end' | 'session_shutdown';
|
|
2
|
+
interface PiLike {
|
|
3
|
+
on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
|
|
4
|
+
sendUserMessage: (content: string, options?: {
|
|
5
|
+
deliverAs?: 'steer' | 'followUp';
|
|
6
|
+
}) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Register the canvas inbox watcher on `pi`.
|
|
10
|
+
*
|
|
11
|
+
* CRTR_NODE_ID is re-read each tick so late-injected env (edge case) is
|
|
12
|
+
* handled gracefully. Returns a disposer for testability; pi ignores it —
|
|
13
|
+
* the module-level liveTimer guard is the actual stacking prevention.
|
|
14
|
+
*/
|
|
15
|
+
export declare function registerCanvasInboxWatcher(pi: PiLike): () => void;
|
|
16
|
+
export default registerCanvasInboxWatcher;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// canvas-inbox-watcher.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
|
+
// The canvas model: each node is a long-lived resident that alternates between
|
|
7
|
+
// "working" (pi actively generating) and "dormant" (pi idle, waiting for a
|
|
8
|
+
// subscribed worker to push a report). This watcher bridges the dormant→working
|
|
9
|
+
// transition automatically: it polls the node's inbox.jsonl every 800ms and,
|
|
10
|
+
// when new entries arrive, coalesces them into a single digest and injects it as
|
|
11
|
+
// a pi user message — waking the node to react.
|
|
12
|
+
//
|
|
13
|
+
// Key differences from the legacy agent-inbox-watcher:
|
|
14
|
+
// • Target resolution is trivial. CRTR_NODE_ID IS the node; its inbox lives at
|
|
15
|
+
// nodes/<CRTR_NODE_ID>/inbox.jsonl. No session-dir scanning, no pi_session_id
|
|
16
|
+
// matching, no spawned-vs-top-level branching.
|
|
17
|
+
// • readInboxSince / readCursor / writeCursor from the canvas inbox primitive
|
|
18
|
+
// replace the hand-rolled JSONL scanner and cursor-file helpers.
|
|
19
|
+
// • coalesce() renders the digest (pointer list, not job-status prose).
|
|
20
|
+
// • No crtr root-init or spawnSync bootstrap — the canvas runtime wires up the
|
|
21
|
+
// node before launching pi; CRTR_NODE_ID is always present when we activate.
|
|
22
|
+
// • Deliver-as decision is driven by InboxEntry.tier (and kind): critical →
|
|
23
|
+
// true preempt (ctx.abort() the live turn, redeliver next tick), urgent →
|
|
24
|
+
// steer at the turn boundary, normal|deferred → followUp. A finished node
|
|
25
|
+
// (kind 'final') ALSO steers — a completion the subscriber may be blocked on
|
|
26
|
+
// must interrupt the current turn, not wait behind it as a follow-up.
|
|
27
|
+
//
|
|
28
|
+
// Double-notify prevention (copied from legacy watcher):
|
|
29
|
+
// A module-level `liveTimer` ensures that a /reload re-init clears the previous
|
|
30
|
+
// setInterval before starting a new one — exactly one watcher is live at a time.
|
|
31
|
+
//
|
|
32
|
+
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
33
|
+
// crouter's own tsc build without a dep on the pi packages.
|
|
34
|
+
import { readInboxSince, readCursor, writeCursor, coalesce, } from '../core/feed/inbox.js';
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Module-level timer — prevents stacking on /reload (the double-notify bug).
|
|
37
|
+
//
|
|
38
|
+
// pi ignores an extension factory's returned disposer, so a /reload re-enters
|
|
39
|
+
// this module and would ADD a new setInterval on top of any running one.
|
|
40
|
+
// N reloads → N live watchers, each with its own in-memory cursor → N deliveries
|
|
41
|
+
// of the same entry. Clearing the prior timer on each re-init ensures exactly
|
|
42
|
+
// one watcher is live. Pattern copied verbatim from agent-inbox-watcher.ts.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
let liveTimer;
|
|
45
|
+
const TICK_MS = 800; // polling cadence
|
|
46
|
+
const DEBOUNCE_MS = 1000; // flush once the burst has been quiet for this long
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Extension
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/**
|
|
51
|
+
* Register the canvas inbox watcher on `pi`.
|
|
52
|
+
*
|
|
53
|
+
* CRTR_NODE_ID is re-read each tick so late-injected env (edge case) is
|
|
54
|
+
* handled gracefully. Returns a disposer for testability; pi ignores it —
|
|
55
|
+
* the module-level liveTimer guard is the actual stacking prevention.
|
|
56
|
+
*/
|
|
57
|
+
export function registerCanvasInboxWatcher(pi) {
|
|
58
|
+
// Capture the latest event context so isIdle() is readable inside the timer
|
|
59
|
+
// callback, which has no ctx of its own.
|
|
60
|
+
let lastCtx;
|
|
61
|
+
let streaming = false;
|
|
62
|
+
const captureCtx = (_event, ctx) => {
|
|
63
|
+
if (ctx !== undefined)
|
|
64
|
+
lastCtx = ctx;
|
|
65
|
+
};
|
|
66
|
+
pi.on('session_start', captureCtx);
|
|
67
|
+
pi.on('turn_end', captureCtx);
|
|
68
|
+
pi.on('agent_start', (_e, ctx) => {
|
|
69
|
+
captureCtx(_e, ctx);
|
|
70
|
+
streaming = true;
|
|
71
|
+
});
|
|
72
|
+
pi.on('agent_end', (_e, ctx) => {
|
|
73
|
+
captureCtx(_e, ctx);
|
|
74
|
+
streaming = false;
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* True when pi is not currently streaming a response.
|
|
78
|
+
* When idle, sendUserMessage triggers a new turn immediately.
|
|
79
|
+
* When streaming, steer (interrupt) on urgency or a finished node, else follow up.
|
|
80
|
+
*/
|
|
81
|
+
const isIdle = () => {
|
|
82
|
+
try {
|
|
83
|
+
if (typeof lastCtx?.isIdle === 'function')
|
|
84
|
+
return lastCtx.isIdle() === true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* fall through to the streaming flag */
|
|
88
|
+
}
|
|
89
|
+
return !streaming;
|
|
90
|
+
};
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Debounce state
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/** Entries received since the last flush — coalesced into one message. */
|
|
95
|
+
let buffer = [];
|
|
96
|
+
/** Epoch-ms of the most recent entry arrival. Used to detect burst-quiet. */
|
|
97
|
+
let lastArrival = 0;
|
|
98
|
+
/**
|
|
99
|
+
* Durable cursor — ISO 8601 of the last entry we've consumed.
|
|
100
|
+
* Seeded from the persisted cursor file on first resolution; undefined means
|
|
101
|
+
* "read from the beginning" (no prior cursor → process all existing entries).
|
|
102
|
+
* NOT reset to `now` on first tick: that would silently drop entries that
|
|
103
|
+
* arrived between node creation and watcher startup (the startup race).
|
|
104
|
+
*/
|
|
105
|
+
let cursor;
|
|
106
|
+
let seeded = false;
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Flush: deliver the buffered entries as a single pi user message.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
const flush = () => {
|
|
111
|
+
if (buffer.length === 0)
|
|
112
|
+
return;
|
|
113
|
+
// Deferred-tier entries must never WAKE an idle node — by contract they ride
|
|
114
|
+
// the next natural turn, never interrupt. If everything buffered is deferred
|
|
115
|
+
// and the node is idle, hold them (leave buffered, cheap re-check each tick)
|
|
116
|
+
// and return without delivering. They flush the moment the node is next
|
|
117
|
+
// streaming, or a higher-tier entry joins the batch (every() turns false).
|
|
118
|
+
if (isIdle() && buffer.every((e) => e.tier === 'deferred'))
|
|
119
|
+
return;
|
|
120
|
+
const batch = buffer;
|
|
121
|
+
buffer = [];
|
|
122
|
+
const digest = coalesce(batch);
|
|
123
|
+
// Tier (and kind) drive delivery mode. Critical is a TRUE preempt; urgent —
|
|
124
|
+
// and a finished node (kind 'final') — steers at the turn boundary;
|
|
125
|
+
// normal/deferred ride the next turn (followUp). (A purely-deferred idle
|
|
126
|
+
// batch was already held above and never reaches here.) A completion a
|
|
127
|
+
// subscriber is likely blocked on must not drain as a follow-up, so 'final'
|
|
128
|
+
// steers exactly like 'urgent'.
|
|
129
|
+
const anyCritical = batch.some((e) => e.tier === 'critical');
|
|
130
|
+
const steerMidStream = anyCritical || batch.some((e) => e.tier === 'urgent' || e.kind === 'final');
|
|
131
|
+
try {
|
|
132
|
+
if (isIdle()) {
|
|
133
|
+
// Idle → trigger a new turn immediately (sendUserMessage always triggers).
|
|
134
|
+
pi.sendUserMessage(digest);
|
|
135
|
+
}
|
|
136
|
+
else if (anyCritical) {
|
|
137
|
+
// Critical mid-stream → TRUE preempt. ctx.abort() cancels the live LLM
|
|
138
|
+
// stream right now (stopReason becomes 'aborted'; the stophook stays alive
|
|
139
|
+
// on that). We then re-buffer and let the next tick deliver via the idle
|
|
140
|
+
// path — by then the turn has torn down and sendUserMessage starts a fresh
|
|
141
|
+
// turn. Relying on the proven idle path (not steer-after-abort semantics)
|
|
142
|
+
// keeps this robust; if abort hasn't settled by the next tick we simply
|
|
143
|
+
// abort again and retry — idempotent and self-healing.
|
|
144
|
+
try {
|
|
145
|
+
lastCtx?.abort?.();
|
|
146
|
+
}
|
|
147
|
+
catch { /* abort is best-effort */ }
|
|
148
|
+
buffer = batch.concat(buffer);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Mid-stream → steer on urgency or a finished node, else enqueue for the
|
|
152
|
+
// turn after this one.
|
|
153
|
+
pi.sendUserMessage(digest, { deliverAs: steerMidStream ? 'steer' : 'followUp' });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Re-queue on delivery failure so a transient error doesn't silently drop
|
|
158
|
+
// inbox entries. They will be retried on the next flush.
|
|
159
|
+
buffer = batch.concat(buffer);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Tick: poll the node's inbox and buffer new arrivals.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
const tick = () => {
|
|
166
|
+
try {
|
|
167
|
+
// Re-read env each tick: CRTR_NODE_ID could theoretically be set after the
|
|
168
|
+
// extension factory runs (e.g. the runtime injects it just before the first
|
|
169
|
+
// turn). In practice it is always present before turn_end fires, but the
|
|
170
|
+
// check is cheap and keeps the watcher robust.
|
|
171
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
172
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
173
|
+
return;
|
|
174
|
+
// Seed the cursor once, on the first tick that resolves a nodeId.
|
|
175
|
+
// readCursor returns undefined when no cursor file exists → readInboxSince
|
|
176
|
+
// with undefined returns ALL entries (no truncation to `now`).
|
|
177
|
+
if (!seeded) {
|
|
178
|
+
cursor = readCursor(nodeId);
|
|
179
|
+
seeded = true;
|
|
180
|
+
}
|
|
181
|
+
const newEntries = readInboxSince(nodeId, cursor);
|
|
182
|
+
if (newEntries.length > 0) {
|
|
183
|
+
// Advance and persist the cursor BEFORE buffering, so a crash after this
|
|
184
|
+
// point loses at most one coalesced message rather than re-injecting
|
|
185
|
+
// already-delivered entries on restart (exactly-once over restart contract).
|
|
186
|
+
const latest = newEntries.reduce((a, b) => (a.ts > b.ts ? a : b));
|
|
187
|
+
cursor = latest.ts;
|
|
188
|
+
writeCursor(nodeId, cursor);
|
|
189
|
+
buffer.push(...newEntries);
|
|
190
|
+
lastArrival = Date.now();
|
|
191
|
+
}
|
|
192
|
+
// Flush only once the burst has settled (no new entry within DEBOUNCE_MS)
|
|
193
|
+
// so near-simultaneous pushes from multiple workers arrive as one message.
|
|
194
|
+
if (buffer.length > 0 && Date.now() - lastArrival >= DEBOUNCE_MS) {
|
|
195
|
+
flush();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* watcher is best-effort; a tick must never crash the host session */
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Timer management — clear any leftover timer from a prior /reload.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
if (liveTimer !== undefined)
|
|
206
|
+
clearInterval(liveTimer);
|
|
207
|
+
const timer = setInterval(tick, TICK_MS);
|
|
208
|
+
// unref() so the watcher doesn't keep the Node process alive when everything
|
|
209
|
+
// else has finished (matches legacy watcher behaviour).
|
|
210
|
+
if (typeof timer.unref === 'function')
|
|
211
|
+
timer.unref();
|
|
212
|
+
liveTimer = timer;
|
|
213
|
+
// pi DOES fire session_shutdown — use it as the authoritative teardown so a
|
|
214
|
+
// re-init (e.g. /reload) never discovers a live sibling timer.
|
|
215
|
+
pi.on('session_shutdown', () => {
|
|
216
|
+
clearInterval(timer);
|
|
217
|
+
if (liveTimer === timer)
|
|
218
|
+
liveTimer = undefined;
|
|
219
|
+
});
|
|
220
|
+
// Disposer: returned for testability + explicit teardown in test harnesses.
|
|
221
|
+
// pi ignores the factory return value, so the module-level guard above is what
|
|
222
|
+
// actually prevents stacking in production.
|
|
223
|
+
return () => {
|
|
224
|
+
clearInterval(timer);
|
|
225
|
+
if (liveTimer === timer)
|
|
226
|
+
liveTimer = undefined;
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export default registerCanvasInboxWatcher;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
type PiEvents = 'session_start' | 'turn_end' | 'session_shutdown';
|
|
2
|
+
interface ExtensionWidgetOptions {
|
|
3
|
+
/** Where the widget is rendered. "aboveEditor" | "belowEditor" */
|
|
4
|
+
placement?: 'aboveEditor' | 'belowEditor';
|
|
5
|
+
}
|
|
6
|
+
interface UIContext {
|
|
7
|
+
setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void;
|
|
8
|
+
/** Raw key tap that fires BEFORE the editor. Return {consume:true} to swallow
|
|
9
|
+
* the key (so e.g. UP doesn't trigger pi's history recall). Returns unsub. */
|
|
10
|
+
onTerminalInput?(handler: (data: string) => {
|
|
11
|
+
consume?: boolean;
|
|
12
|
+
data?: string;
|
|
13
|
+
} | undefined): () => void;
|
|
14
|
+
/** Current editor buffer text — used to only hijack keys on an empty editor. */
|
|
15
|
+
getEditorText?(): string;
|
|
16
|
+
/** Transient toast, used to report a failed focus. */
|
|
17
|
+
notify?(message: string, type?: 'info' | 'warning' | 'error'): void;
|
|
18
|
+
}
|
|
19
|
+
interface ExtensionCtx {
|
|
20
|
+
ui: UIContext;
|
|
21
|
+
}
|
|
22
|
+
interface PiLike {
|
|
23
|
+
on(event: PiEvents, handler: (event: any, ctx: ExtensionCtx) => void | Promise<void>): void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Register the canvas nav chrome on `pi`.
|
|
27
|
+
*
|
|
28
|
+
* Returns immediately when CRTR_NODE_ID is absent — the extension is fully
|
|
29
|
+
* inert in a non-canvas pi session.
|
|
30
|
+
*/
|
|
31
|
+
export declare function registerCanvasNav(pi: PiLike): void;
|
|
32
|
+
export default registerCanvasNav;
|