@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
package/dist/core/spawn.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
// Tmux pane
|
|
1
|
+
// Tmux pane detach helpers.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// Crash detection: the wrapper shell command is:
|
|
11
|
-
// `claude --dangerously-skip-permissions <prompt>; crtr job _fail <job_id>`
|
|
12
|
-
// If the worker calls `crtr job submit` before claude exits, result.json is
|
|
13
|
-
// written and `_fail` is a no-op (writeResult is idempotent for done status).
|
|
14
|
-
// If claude dies without a submit, `_fail` writes status 'failed'. Either way
|
|
15
|
-
// `job read result` sees a terminal result.json.
|
|
3
|
+
// A small set of tmux primitives used by the `human` command tree to put the
|
|
4
|
+
// humanloop TUI in a detached pane: spawnAndDetach (open a pane running a given
|
|
5
|
+
// command), countPanesInCurrentWindow (placement decision), plus shellQuote and
|
|
6
|
+
// isInTmux. The canvas runtime has its own one-window-per-node machinery in
|
|
7
|
+
// core/runtime/tmux.ts; this module is only the pane-split path the human TUI
|
|
8
|
+
// needs.
|
|
16
9
|
import { spawnSync } from 'node:child_process';
|
|
17
10
|
export function isInTmux() {
|
|
18
11
|
return Boolean(process.env.TMUX);
|
|
@@ -20,6 +13,7 @@ export function isInTmux() {
|
|
|
20
13
|
export function shellQuote(s) {
|
|
21
14
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
22
15
|
}
|
|
16
|
+
/** Count panes in the current tmux window (0 outside tmux / on error). */
|
|
23
17
|
export function countPanesInCurrentWindow() {
|
|
24
18
|
const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
|
|
25
19
|
encoding: 'utf8',
|
|
@@ -28,126 +22,10 @@ export function countPanesInCurrentWindow() {
|
|
|
28
22
|
return 0;
|
|
29
23
|
return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
|
|
30
24
|
}
|
|
31
|
-
function listWindowsInCurrentSession() {
|
|
32
|
-
const result = spawnSync('tmux', ['list-windows', '-F', '#{window_id} #{window_panes} #{window_active}'], { encoding: 'utf8' });
|
|
33
|
-
if (result.status !== 0)
|
|
34
|
-
return [];
|
|
35
|
-
return result.stdout
|
|
36
|
-
.split('\n')
|
|
37
|
-
.filter((line) => line.trim() !== '')
|
|
38
|
-
.map((line) => {
|
|
39
|
-
const [id, count, active] = line.split(' ');
|
|
40
|
-
return {
|
|
41
|
-
windowId: id,
|
|
42
|
-
paneCount: Number.parseInt(count, 10),
|
|
43
|
-
isActive: active === '1',
|
|
44
|
-
};
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Map of window_id → list of pane TTYs (basename, e.g. `ttys008`) for every
|
|
49
|
-
* pane in the current tmux session. Used as the bridge between tmux's pane
|
|
50
|
-
* model and the system process table for foreground-command lookup.
|
|
51
|
-
*
|
|
52
|
-
* tmux's `#{pane_current_command}` is unreliable on macOS because the Claude
|
|
53
|
-
* Code CLI sets `process.title` to its version (e.g. `2.1.143`), which is what
|
|
54
|
-
* tmux then reports. Going through the TTY + `ps` gives us the real binary
|
|
55
|
-
* name (`claude`) from the kernel.
|
|
56
|
-
*/
|
|
57
|
-
function paneTtysByWindow() {
|
|
58
|
-
const result = spawnSync('tmux', ['list-panes', '-s', '-F', '#{window_id} #{pane_tty}'], { encoding: 'utf8' });
|
|
59
|
-
const out = new Map();
|
|
60
|
-
if (result.status !== 0)
|
|
61
|
-
return out;
|
|
62
|
-
for (const line of result.stdout.split('\n')) {
|
|
63
|
-
if (line.trim() === '')
|
|
64
|
-
continue;
|
|
65
|
-
const idx = line.indexOf(' ');
|
|
66
|
-
if (idx === -1)
|
|
67
|
-
continue;
|
|
68
|
-
const windowId = line.slice(0, idx);
|
|
69
|
-
const tty = line.slice(idx + 1);
|
|
70
|
-
const ttyBase = tty.startsWith('/dev/') ? tty.slice(5) : tty;
|
|
71
|
-
const existing = out.get(windowId);
|
|
72
|
-
if (existing === undefined) {
|
|
73
|
-
out.set(windowId, [ttyBase]);
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
existing.push(ttyBase);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Map of tty basename → set of foreground process `comm` names on that tty.
|
|
83
|
-
* A process is "foreground" if its STAT field includes `+` (member of the
|
|
84
|
-
* terminal's foreground process group). Built from one `ps -axo ...` call.
|
|
85
|
-
*/
|
|
86
|
-
function foregroundCommsByTty() {
|
|
87
|
-
const result = spawnSync('ps', ['-axo', 'stat=,comm=,tty='], { encoding: 'utf8' });
|
|
88
|
-
const out = new Map();
|
|
89
|
-
if (result.status !== 0)
|
|
90
|
-
return out;
|
|
91
|
-
for (const line of result.stdout.split('\n')) {
|
|
92
|
-
if (line.trim() === '')
|
|
93
|
-
continue;
|
|
94
|
-
const m = line.match(/^(\S+)\s+(.+?)\s+(\S+)\s*$/);
|
|
95
|
-
if (m === null)
|
|
96
|
-
continue;
|
|
97
|
-
const [, stat, comm, tty] = m;
|
|
98
|
-
if (!stat.includes('+'))
|
|
99
|
-
continue;
|
|
100
|
-
if (tty === '??' || tty === '?')
|
|
101
|
-
continue;
|
|
102
|
-
const existing = out.get(tty);
|
|
103
|
-
if (existing === undefined) {
|
|
104
|
-
out.set(tty, new Set([comm.trim()]));
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
existing.add(comm.trim());
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return out;
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Find a window in the current tmux session with fewer than `maxPanesPerWindow`
|
|
114
|
-
* panes AND where every existing pane has `claude` as a foreground process.
|
|
115
|
-
* Prefers the active window so the spawned pane is visible to the user;
|
|
116
|
-
* otherwise falls back to the first other eligible window. Returns the tmux
|
|
117
|
-
* window id (e.g. `@5`) to pass via `-t`, or null if no window qualifies.
|
|
118
|
-
*
|
|
119
|
-
* Windows holding non-agent panes (dashboards, log tails, idle shells, editors,
|
|
120
|
-
* REPLs, etc.) are skipped so spawning never disrupts those workflows. A pane
|
|
121
|
-
* qualifies as long as `claude` is among its foreground commands — co-resident
|
|
122
|
-
* helpers like `caffeinate` don't disqualify it.
|
|
123
|
-
*/
|
|
124
|
-
export function findWindowWithSpace(maxPanesPerWindow) {
|
|
125
|
-
const windows = listWindowsInCurrentSession();
|
|
126
|
-
const ttysByWindow = paneTtysByWindow();
|
|
127
|
-
const fgByTty = foregroundCommsByTty();
|
|
128
|
-
const isClaudeOnly = (windowId) => {
|
|
129
|
-
const ttys = ttysByWindow.get(windowId);
|
|
130
|
-
if (ttys === undefined || ttys.length === 0)
|
|
131
|
-
return false;
|
|
132
|
-
return ttys.every((tty) => fgByTty.get(tty)?.has('claude') === true);
|
|
133
|
-
};
|
|
134
|
-
const eligible = windows.filter((w) => w.paneCount < maxPanesPerWindow && isClaudeOnly(w.windowId));
|
|
135
|
-
const active = eligible.find((w) => w.isActive);
|
|
136
|
-
if (active !== undefined)
|
|
137
|
-
return active.windowId;
|
|
138
|
-
const first = eligible[0];
|
|
139
|
-
if (first === undefined)
|
|
140
|
-
return null;
|
|
141
|
-
return first.windowId;
|
|
142
|
-
}
|
|
143
25
|
/**
|
|
144
26
|
* Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
|
|
145
|
-
* so the caller can return normally before the pane dies. No-op outside tmux
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* Used by `crtr job submit` (kill_pane=true) so a reviewer agent can self-close
|
|
149
|
-
* its pane after delivering its verdict, and by `spawnAndDetach` for handoff
|
|
150
|
-
* self-kill.
|
|
27
|
+
* so the caller can return normally before the pane dies. No-op outside tmux,
|
|
28
|
+
* when TMUX_PANE is unset, or when delaySeconds <= 0.
|
|
151
29
|
*/
|
|
152
30
|
export function scheduleKillCurrentPane(delaySeconds) {
|
|
153
31
|
const currentPane = process.env.TMUX_PANE;
|
|
@@ -161,24 +39,9 @@ export function scheduleKillCurrentPane(delaySeconds) {
|
|
|
161
39
|
return true;
|
|
162
40
|
}
|
|
163
41
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* If the worker submits via `crtr job submit` before claude exits,
|
|
169
|
-
* result.json is already written (`done`); `_fail` sees it and is a no-op.
|
|
170
|
-
* If claude crashes/exits without submitting, `_fail` writes status `failed`
|
|
171
|
-
* so `job read result` can distinguish completion from crash.
|
|
172
|
-
*/
|
|
173
|
-
function wrapperCmd(claudeCmd, jobId) {
|
|
174
|
-
return `${claudeCmd}; crtr job _fail ${shellQuote(jobId)}`;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Fire-and-forget: launch an interactive `claude` in a new pane (or window),
|
|
178
|
-
* then schedule the originating pane to be killed after `killAfterSeconds`.
|
|
179
|
-
*
|
|
180
|
-
* No custom system prompt — the task is delivered as the first user message.
|
|
181
|
-
* Returns as soon as the new pane is up; does NOT wait for claude to finish.
|
|
42
|
+
* Fire-and-forget: launch `opts.command` in a new pane (or window), then
|
|
43
|
+
* schedule the originating pane to be killed after `killAfterSeconds`. Returns
|
|
44
|
+
* as soon as the new pane is up; does NOT wait for the command to finish.
|
|
182
45
|
*/
|
|
183
46
|
export function spawnAndDetach(opts) {
|
|
184
47
|
if (!isInTmux()) {
|
|
@@ -187,17 +50,6 @@ export function spawnAndDetach(opts) {
|
|
|
187
50
|
message: 'handoff requires tmux (TMUX env var not set)',
|
|
188
51
|
};
|
|
189
52
|
}
|
|
190
|
-
const buildClaudeInner = () => {
|
|
191
|
-
const parts = ['claude'];
|
|
192
|
-
if (opts.name !== undefined && opts.name !== '') {
|
|
193
|
-
parts.push('-n', shellQuote(opts.name));
|
|
194
|
-
}
|
|
195
|
-
parts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
|
|
196
|
-
return parts.join(' ');
|
|
197
|
-
};
|
|
198
|
-
const inner = opts.command !== undefined ? opts.command : buildClaudeInner();
|
|
199
|
-
const useFailGuard = opts.failGuard !== false;
|
|
200
|
-
const fullCmd = useFailGuard ? wrapperCmd(inner, opts.jobId) : inner;
|
|
201
53
|
const splitArgs = [];
|
|
202
54
|
if (opts.placement === 'new-window') {
|
|
203
55
|
splitArgs.push('new-window');
|
|
@@ -218,7 +70,7 @@ export function spawnAndDetach(opts) {
|
|
|
218
70
|
if (opts.jobId !== undefined) {
|
|
219
71
|
splitArgs.push('-e', `CRTR_JOB_ID=${opts.jobId}`);
|
|
220
72
|
}
|
|
221
|
-
splitArgs.push(
|
|
73
|
+
splitArgs.push(opts.command);
|
|
222
74
|
const split = spawnSync('tmux', splitArgs, { encoding: 'utf8' });
|
|
223
75
|
if (split.status !== 0) {
|
|
224
76
|
const stderrText = split.stderr.trim();
|
|
@@ -231,62 +83,6 @@ export function spawnAndDetach(opts) {
|
|
|
231
83
|
return {
|
|
232
84
|
status: 'spawned',
|
|
233
85
|
paneId,
|
|
234
|
-
message: `handed off to pane ${paneId}
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Async sibling spawn. Launches a claude session in a tmux pane, progressively
|
|
239
|
-
* filling existing windows up to `maxPanesPerWindow` before creating a new
|
|
240
|
-
* window. Returns immediately with the pane id; the parent stays alive.
|
|
241
|
-
*
|
|
242
|
-
* Placement order:
|
|
243
|
-
* 1. Current window, if it has space.
|
|
244
|
-
* 2. Any other window in the session with space.
|
|
245
|
-
* 3. New window (every existing window at capacity).
|
|
246
|
-
*
|
|
247
|
-
* If `fork` is set, uses `claude --resume <id> --fork-session`.
|
|
248
|
-
*/
|
|
249
|
-
export function spawnAgent(opts) {
|
|
250
|
-
if (!isInTmux()) {
|
|
251
|
-
return {
|
|
252
|
-
status: 'not-in-tmux',
|
|
253
|
-
message: 'crtr job requires tmux (TMUX env var not set)',
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
const claudeParts = ['claude'];
|
|
257
|
-
if (opts.name !== undefined && opts.name !== '') {
|
|
258
|
-
claudeParts.push('-n', shellQuote(opts.name));
|
|
259
|
-
}
|
|
260
|
-
if (opts.fork !== undefined) {
|
|
261
|
-
claudeParts.push('--resume', opts.fork.sessionId, '--fork-session');
|
|
262
|
-
}
|
|
263
|
-
claudeParts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
|
|
264
|
-
const claudeCmd = claudeParts.join(' ');
|
|
265
|
-
const fullCmd = wrapperCmd(claudeCmd, opts.jobId);
|
|
266
|
-
const targetWindow = findWindowWithSpace(opts.maxPanesPerWindow);
|
|
267
|
-
const placement = targetWindow === null ? 'new-window' : 'split-window';
|
|
268
|
-
const tmuxArgs = [placement];
|
|
269
|
-
if (placement === 'split-window') {
|
|
270
|
-
tmuxArgs.push('-h', '-t', targetWindow);
|
|
271
|
-
}
|
|
272
|
-
tmuxArgs.push('-P', '-F', '#{pane_id}', '-c', opts.cwd, '-e', `CRTR_JOB_ID=${opts.jobId}`, fullCmd);
|
|
273
|
-
const split = spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
|
|
274
|
-
if (split.status !== 0) {
|
|
275
|
-
const stderrText = split.stderr.trim();
|
|
276
|
-
const msg = stderrText === '' ? `tmux ${placement} failed` : stderrText;
|
|
277
|
-
return { status: 'spawn-failed', message: msg };
|
|
278
|
-
}
|
|
279
|
-
const paneId = split.stdout.trim();
|
|
280
|
-
// Re-balance the target window's panes evenly so the new pane doesn't end up
|
|
281
|
-
// half the size of its siblings. -t <pane_id> resolves to the window it lives
|
|
282
|
-
// in for both placements (split + new-window).
|
|
283
|
-
spawnSync('tmux', ['select-layout', '-t', paneId, 'even-horizontal'], {
|
|
284
|
-
encoding: 'utf8',
|
|
285
|
-
});
|
|
286
|
-
return {
|
|
287
|
-
status: 'spawned',
|
|
288
|
-
paneId,
|
|
289
|
-
placement,
|
|
290
|
-
message: `agent spawned in pane ${paneId} (${placement})`,
|
|
86
|
+
message: `handed off to pane ${paneId}`,
|
|
291
87
|
};
|
|
292
88
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Read the pid stored in the pidfile, or null if absent / malformed. */
|
|
2
|
+
export declare function readPidfile(): number | null;
|
|
3
|
+
/** True if a process with `pid` is currently alive (signal-0 probe). */
|
|
4
|
+
export declare function isPidAlive(pid: number): boolean;
|
|
5
|
+
/** True when a crtrd process is already running (pidfile exists + pid alive). */
|
|
6
|
+
export declare function isDaemonRunning(): boolean;
|
|
7
|
+
export interface DaemonOpts {
|
|
8
|
+
/** Milliseconds between supervision polls. Default 2000. */
|
|
9
|
+
intervalMs?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Start the supervisor loop.
|
|
12
|
+
*
|
|
13
|
+
* If a live crtrd is already running (pidfile + pid alive), exits immediately
|
|
14
|
+
* (exit 0 — idempotent, not an error). Otherwise, writes the pidfile, sets up
|
|
15
|
+
* signal handlers, and enters the poll loop.
|
|
16
|
+
*
|
|
17
|
+
* Returns a teardown callback that stops the loop and removes the pidfile.
|
|
18
|
+
* (Mainly useful for tests; in production the daemon runs until signaled.) */
|
|
19
|
+
export declare function runDaemon(opts?: DaemonOpts): () => void;
|
|
20
|
+
export default runDaemon;
|
|
@@ -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;
|