@nonbot/cli 0.7.0 → 0.8.0
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/CHANGELOG.md +15 -0
- package/dist/commands/choir.js +193 -6
- package/dist/commands/daemon.js +55 -0
- package/dist/lib/activations.js +2 -1
- package/dist/lib/choir/conductor.js +92 -0
- package/dist/lib/choir/launcher.js +39 -28
- package/dist/lib/choir/materialize.js +97 -0
- package/dist/lib/choir/progress-events.js +3 -2
- package/dist/lib/choir/run-progress.js +101 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @nonbot/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- **Conductor: Choir runs headless, panes appear only when needed.** `nonbot choir` no longer opens N terminal panes. Each agent runs headless in its own git worktree; the live radar is the web dashboard at /choir. A tmux pane is summoned (kill + `claude --resume` in the same worktree) ONLY when an agent hits awaiting-input or an escalated stall, and torn back down when resolved — so the steady state is zero extra windows. A tunable concurrency cap (`choir.maxActiveAgents`, default 2) keeps only K agents working at once.
|
|
6
|
+
- **Runs light up the dashboard.** A ▶ Run now emits sanitized, metadata-only progress + a heartbeat to /choir (it is a 1-pane Choir), inheriting health/stall detection, the Needs-You queue, the dead-man's-switch and the digest — fire-and-forget, never affecting the Run itself.
|
|
7
|
+
- Agents run on your Claude plan, not a metered API key (carried from 0.7.1; the headless + summon spawns both unset ANTHROPIC_API_KEY/AUTH_TOKEN).
|
|
8
|
+
|
|
9
|
+
## 0.7.1
|
|
10
|
+
|
|
11
|
+
- **Agents run on your Claude plan, not a metered API key.** The spawned run
|
|
12
|
+
script now `unset`s `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` before
|
|
13
|
+
launching, so a key sitting in your shell profile can't silently switch an
|
|
14
|
+
agent onto pay-per-token billing — it uses your `claude login` subscription.
|
|
15
|
+
A BYOK command that explicitly sets a key still wins (the unset only clears an
|
|
16
|
+
ambient key). Applies to both Run and Choir agents.
|
|
17
|
+
|
|
3
18
|
## 0.7.0
|
|
4
19
|
|
|
5
20
|
- The per-pane Choir coordination MCP is now **bundled** as the `nonbot
|
package/dist/commands/choir.js
CHANGED
|
@@ -5,6 +5,9 @@ import { header, kvRow, statusRow, statusBadge, c } from '../lib/output.js';
|
|
|
5
5
|
import { assertValidName } from '../lib/choir/names.js';
|
|
6
6
|
import { launchChoir, } from '../lib/choir/launcher.js';
|
|
7
7
|
import { createHub } from '../lib/choir/hub.js';
|
|
8
|
+
import { planConductorTick, DEFAULT_MAX_ACTIVE_AGENTS, } from '../lib/choir/conductor.js';
|
|
9
|
+
import { summonPane, dematerialize } from '../lib/choir/materialize.js';
|
|
10
|
+
import { resolveThresholds } from '../lib/choir/health.js';
|
|
8
11
|
export function buildStartHub(repoRoot, auth) {
|
|
9
12
|
return ({ sessionId, baseBranch, token }) => {
|
|
10
13
|
const opts = {
|
|
@@ -22,6 +25,133 @@ export function buildStartHub(repoRoot, auth) {
|
|
|
22
25
|
return hub;
|
|
23
26
|
};
|
|
24
27
|
}
|
|
28
|
+
const TERMINAL_STATES = new Set(['complete', 'failed']);
|
|
29
|
+
function buildSignals(agents, panes, now) {
|
|
30
|
+
const byName = new Map();
|
|
31
|
+
for (const p of Object.values(panes)) {
|
|
32
|
+
if (p && typeof p === 'object') {
|
|
33
|
+
const rec = p;
|
|
34
|
+
const name = typeof rec.name === 'string' ? rec.name : null;
|
|
35
|
+
if (name)
|
|
36
|
+
byName.set(name, rec);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const a of agents) {
|
|
41
|
+
const rec = byName.get(a.paneName);
|
|
42
|
+
if (!rec)
|
|
43
|
+
continue;
|
|
44
|
+
const num = (v, fallback) => typeof v === 'number' ? v : fallback;
|
|
45
|
+
out[a.paneName] = {
|
|
46
|
+
lastToolUse: num(rec.lastToolUse, now),
|
|
47
|
+
lastGitDelta: num(rec.lastGitDelta, now),
|
|
48
|
+
lastPaneActivity: num(rec.lastPaneActivity, now),
|
|
49
|
+
awaitingInput: rec.status === 'awaiting-input',
|
|
50
|
+
crashed: rec.status === 'failed',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function allTerminal(agents) {
|
|
56
|
+
return agents.every((a) => TERMINAL_STATES.has(a.state));
|
|
57
|
+
}
|
|
58
|
+
export function runConductorLoop(args, deps) {
|
|
59
|
+
const { agents, hub, cap } = args;
|
|
60
|
+
const thresholds = deps.thresholds ?? resolveThresholds('normal');
|
|
61
|
+
const tickMs = deps.tickMs ?? 5000;
|
|
62
|
+
let cancel = null;
|
|
63
|
+
let tornDown = false;
|
|
64
|
+
const teardown = async () => {
|
|
65
|
+
if (tornDown)
|
|
66
|
+
return;
|
|
67
|
+
tornDown = true;
|
|
68
|
+
if (cancel) {
|
|
69
|
+
try {
|
|
70
|
+
cancel();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const a of agents) {
|
|
76
|
+
if (a.state === 'summoned' && a.tmuxPaneId != null) {
|
|
77
|
+
try {
|
|
78
|
+
await deps.dematerializeImpl(a);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
}
|
|
82
|
+
a.tmuxPaneId = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
hub.stop();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
}
|
|
90
|
+
logWorktreeNote();
|
|
91
|
+
};
|
|
92
|
+
const logWorktreeNote = () => {
|
|
93
|
+
deps.log('\n' + c.muted('worktrees kept (review before removing — unmerged work is never auto-deleted):') + '\n');
|
|
94
|
+
for (const a of agents) {
|
|
95
|
+
deps.log(` ${c.cyan(a.branch)} ${c.muted('·')} ${c.muted(a.worktreePath)}\n`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const tick = async () => {
|
|
99
|
+
if (tornDown)
|
|
100
|
+
return;
|
|
101
|
+
const now = deps.now();
|
|
102
|
+
const signalsByPane = buildSignals(agents, hub.getState().panes, now);
|
|
103
|
+
const decisions = deps.planTick({
|
|
104
|
+
agents,
|
|
105
|
+
signalsByPane,
|
|
106
|
+
cap,
|
|
107
|
+
now,
|
|
108
|
+
thresholds,
|
|
109
|
+
});
|
|
110
|
+
for (const d of decisions) {
|
|
111
|
+
const a = agents.find((x) => x.paneName === d.paneName);
|
|
112
|
+
if (!a)
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
switch (d.action) {
|
|
116
|
+
case 'start': {
|
|
117
|
+
deps.spawnHeadlessImpl(a);
|
|
118
|
+
a.state = 'headless';
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'summon': {
|
|
122
|
+
const paneId = await deps.summonPaneImpl(a);
|
|
123
|
+
a.tmuxPaneId = paneId;
|
|
124
|
+
a.state = 'summoned';
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'dematerialize': {
|
|
128
|
+
await deps.dematerializeImpl(a);
|
|
129
|
+
a.tmuxPaneId = null;
|
|
130
|
+
deps.spawnHeadlessImpl(a);
|
|
131
|
+
a.state = 'headless';
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'park':
|
|
135
|
+
case 'noop':
|
|
136
|
+
default:
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (allTerminal(agents)) {
|
|
144
|
+
await teardown();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
cancel = deps.timerImpl(() => tick(), tickMs);
|
|
148
|
+
if (deps.onSigint) {
|
|
149
|
+
deps.onSigint(() => teardown());
|
|
150
|
+
}
|
|
151
|
+
return () => {
|
|
152
|
+
void teardown();
|
|
153
|
+
};
|
|
154
|
+
}
|
|
25
155
|
function flagValue(args, name) {
|
|
26
156
|
for (let i = 0; i < args.length; i++) {
|
|
27
157
|
if (args[i] === name)
|
|
@@ -72,7 +202,16 @@ export async function runChoirCommand(args = [], deps = {}) {
|
|
|
72
202
|
return 1;
|
|
73
203
|
}
|
|
74
204
|
const repoRoot = process.cwd();
|
|
75
|
-
const
|
|
205
|
+
const innerStartHub = deps.startHubImpl ?? buildStartHub(repoRoot, auth);
|
|
206
|
+
let capturedHub = null;
|
|
207
|
+
const startHub = innerStartHub === false
|
|
208
|
+
? false
|
|
209
|
+
: (a) => {
|
|
210
|
+
const hub = innerStartHub(a);
|
|
211
|
+
if (hub && typeof hub === 'object')
|
|
212
|
+
capturedHub = hub;
|
|
213
|
+
return hub;
|
|
214
|
+
};
|
|
76
215
|
let session;
|
|
77
216
|
try {
|
|
78
217
|
session = launch({
|
|
@@ -88,23 +227,71 @@ export async function runChoirCommand(args = [], deps = {}) {
|
|
|
88
227
|
errLog(`✗ choir launch failed: ${e.message}\n`);
|
|
89
228
|
return 1;
|
|
90
229
|
}
|
|
230
|
+
const cap = DEFAULT_MAX_ACTIVE_AGENTS;
|
|
91
231
|
const lines = [];
|
|
92
232
|
lines.push(header('nonbot choir', `session ${session.sessionName} · v${VERSION}`));
|
|
93
233
|
lines.push('');
|
|
94
234
|
lines.push(kvRow('Repo', repoRoot));
|
|
95
235
|
lines.push(kvRow('Base', session.baseBranch));
|
|
96
236
|
lines.push(kvRow('Join mode', session.joinMode));
|
|
97
|
-
lines.push(kvRow('
|
|
237
|
+
lines.push(kvRow('Agents', String(session.agents.length)));
|
|
98
238
|
lines.push('');
|
|
99
|
-
for (const
|
|
100
|
-
lines.push(statusRow('✓',
|
|
239
|
+
for (const a of session.agents) {
|
|
240
|
+
lines.push(statusRow('✓', a.paneName, `${a.branch} ${c.muted('·')} ${a.state}`));
|
|
101
241
|
}
|
|
102
242
|
lines.push('');
|
|
103
243
|
lines.push(statusBadge('green', 'choir live', [
|
|
104
|
-
|
|
105
|
-
'
|
|
244
|
+
`▶ ${session.agents.length} agents running headless · watch the radar at /choir`,
|
|
245
|
+
'a pane only opens when one needs you',
|
|
246
|
+
`max ${cap} active at once`,
|
|
106
247
|
]));
|
|
107
248
|
lines.push('');
|
|
108
249
|
log(lines.join('\n') + '\n');
|
|
250
|
+
const runLoop = deps.runLoopImpl ?? runConductorLoop;
|
|
251
|
+
if (capturedHub) {
|
|
252
|
+
runLoop({ agents: session.agents, hub: capturedHub, cap }, {
|
|
253
|
+
timerImpl: (cb, ms) => {
|
|
254
|
+
const t = setInterval(() => void cb(), ms);
|
|
255
|
+
t.unref?.();
|
|
256
|
+
return () => clearInterval(t);
|
|
257
|
+
},
|
|
258
|
+
now: () => Date.now(),
|
|
259
|
+
planTick: planConductorTick,
|
|
260
|
+
summonPaneImpl: (a) => summonPane(a),
|
|
261
|
+
dematerializeImpl: (a) => dematerialize(a),
|
|
262
|
+
spawnHeadlessImpl: makeRespawnHeadless(session.tokenForTest),
|
|
263
|
+
log,
|
|
264
|
+
onSigint: (handler) => process.once('SIGINT', () => void handler()),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
109
267
|
return 0;
|
|
110
268
|
}
|
|
269
|
+
export function buildRespawnEnv(sessionToken, agent) {
|
|
270
|
+
return {
|
|
271
|
+
...process.env,
|
|
272
|
+
CHOIR_SESSION_TOKEN: sessionToken,
|
|
273
|
+
CHOIR_PANE_ID: agent.branch,
|
|
274
|
+
CHOIR_PANE_NONCE: agent.nonce,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export function makeRespawnHeadless(sessionToken, spawnImpl) {
|
|
278
|
+
return (agent) => {
|
|
279
|
+
const safeWt = agent.worktreePath.replace(/'/g, `'\\''`);
|
|
280
|
+
const command = `unset ANTHROPIC_API_KEY; unset ANTHROPIC_AUTH_TOKEN; ` +
|
|
281
|
+
`cd '${safeWt}' && claude --resume '${agent.resumeSessionId.replace(/'/g, `'\\''`)}'`;
|
|
282
|
+
const env = buildRespawnEnv(sessionToken, agent);
|
|
283
|
+
if (spawnImpl) {
|
|
284
|
+
const { pid } = spawnImpl({ command, cwd: agent.worktreePath, env });
|
|
285
|
+
agent.headlessPid = typeof pid === 'number' ? pid : null;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
void import('node:child_process').then(({ spawn }) => {
|
|
289
|
+
const proc = spawn('bash', ['-c', command], {
|
|
290
|
+
cwd: agent.worktreePath,
|
|
291
|
+
env,
|
|
292
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
293
|
+
});
|
|
294
|
+
agent.headlessPid = typeof proc.pid === 'number' ? proc.pid : null;
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
}
|
package/dist/commands/daemon.js
CHANGED
|
@@ -3,6 +3,7 @@ import { loadAuth, getActiveProfile } from '../lib/auth.js';
|
|
|
3
3
|
import * as activations from '../lib/activations.js';
|
|
4
4
|
import { fireActivation, makeHeadlessSpawner, } from '../lib/activations.js';
|
|
5
5
|
import { checkCompletions } from '../lib/completion.js';
|
|
6
|
+
import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
|
|
6
7
|
import { applyPaneTitle } from '../lib/pane-title.js';
|
|
7
8
|
import { installService, uninstallService } from '../lib/service.js';
|
|
8
9
|
import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
|
|
@@ -133,6 +134,36 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
133
134
|
const seen = new Set();
|
|
134
135
|
const trackedPanes = new Map();
|
|
135
136
|
const killedByStop = new Set();
|
|
137
|
+
const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
|
|
138
|
+
const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
|
|
139
|
+
const runHeartbeats = new Map();
|
|
140
|
+
const safeEmit = (activationId, stage, metrics) => {
|
|
141
|
+
try {
|
|
142
|
+
void Promise.resolve(emitRunStageFn({ baseUrl: auth.baseUrl, pat: auth.pat, activationId, stage, metrics })).catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const stopHeartbeat = (activationId, reason) => {
|
|
148
|
+
const hb = runHeartbeats.get(activationId);
|
|
149
|
+
if (!hb)
|
|
150
|
+
return;
|
|
151
|
+
runHeartbeats.delete(activationId);
|
|
152
|
+
try {
|
|
153
|
+
void Promise.resolve(hb.stop(reason)).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const seqMetrics = (act) => {
|
|
159
|
+
const p = (act.payload ?? {});
|
|
160
|
+
const m = {};
|
|
161
|
+
if (typeof p.storyIndex === 'number')
|
|
162
|
+
m.storyIndex = p.storyIndex;
|
|
163
|
+
if (typeof p.storyTotal === 'number')
|
|
164
|
+
m.storyTotal = p.storyTotal;
|
|
165
|
+
return m.storyIndex !== undefined || m.storyTotal !== undefined ? m : undefined;
|
|
166
|
+
};
|
|
136
167
|
const trackedMeta = new Map();
|
|
137
168
|
let doneCount = 0;
|
|
138
169
|
let failedCount = 0;
|
|
@@ -150,6 +181,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
150
181
|
if (!options.oneShot) {
|
|
151
182
|
sigHandler = () => {
|
|
152
183
|
running = false;
|
|
184
|
+
for (const id of [...runHeartbeats.keys()])
|
|
185
|
+
stopHeartbeat(id);
|
|
153
186
|
log('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
|
|
154
187
|
process.exit(0);
|
|
155
188
|
};
|
|
@@ -243,6 +276,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
243
276
|
killedByStop.add(k.activationId);
|
|
244
277
|
const meta = trackedMeta.get(k.activationId);
|
|
245
278
|
retitlePane('stopping', k.tmuxPaneId, meta?.story ?? '');
|
|
279
|
+
safeEmit(k.activationId, RUN_STAGE.STOPPED);
|
|
280
|
+
stopHeartbeat(k.activationId);
|
|
246
281
|
}
|
|
247
282
|
void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
|
|
248
283
|
}
|
|
@@ -252,6 +287,9 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
252
287
|
continue;
|
|
253
288
|
seen.add(act.id);
|
|
254
289
|
firedThisPoll = true;
|
|
290
|
+
const metrics = act.kind === 'real' ? seqMetrics(act) : undefined;
|
|
291
|
+
if (act.kind === 'real')
|
|
292
|
+
safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
|
|
255
293
|
const outcome = await fireActivation(auth, act, deps, log, errLog);
|
|
256
294
|
if (outcome.status === 'launched' && outcome.kind === 'real' && outcome.tmuxPaneId) {
|
|
257
295
|
trackedPanes.set(outcome.id, outcome.tmuxPaneId);
|
|
@@ -269,10 +307,25 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
269
307
|
paneId: outcome.tmuxPaneId,
|
|
270
308
|
});
|
|
271
309
|
retitlePane('running', outcome.tmuxPaneId, story);
|
|
310
|
+
safeEmit(act.id, RUN_STAGE.AGENT_STARTED, metrics);
|
|
311
|
+
try {
|
|
312
|
+
const hb = startRunHeartbeatFn({
|
|
313
|
+
baseUrl: auth.baseUrl,
|
|
314
|
+
pat: auth.pat,
|
|
315
|
+
activationId: outcome.id,
|
|
316
|
+
furthestStage: RUN_STAGE.AGENT_STARTED,
|
|
317
|
+
lastEventSeq: 0,
|
|
318
|
+
});
|
|
319
|
+
runHeartbeats.set(outcome.id, hb);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
}
|
|
272
323
|
emitSummary();
|
|
273
324
|
}
|
|
274
325
|
else if (outcome.status === 'failed') {
|
|
275
326
|
failedCount++;
|
|
327
|
+
if (act.kind === 'real')
|
|
328
|
+
safeEmit(act.id, RUN_STAGE.FAILED, metrics);
|
|
276
329
|
emitSummary();
|
|
277
330
|
}
|
|
278
331
|
}
|
|
@@ -308,6 +361,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
308
361
|
for (const id of reported) {
|
|
309
362
|
doneCount++;
|
|
310
363
|
trackedMeta.delete(id);
|
|
364
|
+
safeEmit(id, RUN_STAGE.FINISHED);
|
|
365
|
+
stopHeartbeat(id, 'daemon-exit');
|
|
311
366
|
}
|
|
312
367
|
emitSummary();
|
|
313
368
|
}
|
package/dist/lib/activations.js
CHANGED
|
@@ -215,7 +215,8 @@ export async function spawnTerminalDefault(act, auth) {
|
|
|
215
215
|
const roleLine = `export NONBOT_ROLE='lead'`;
|
|
216
216
|
envPrefix = `${patLine}\n${runIdLine}\n${baseUrlLine}\n${roleLine}\n`;
|
|
217
217
|
}
|
|
218
|
-
const
|
|
218
|
+
const planAuthGuard = `unset ANTHROPIC_API_KEY\nunset ANTHROPIC_AUTH_TOKEN\n`;
|
|
219
|
+
const body = `#!/bin/bash\n${planAuthGuard}${envPrefix}${resolved.shell}\n`;
|
|
219
220
|
await fs.writeFile(scriptPath, body, { mode: 0o700 });
|
|
220
221
|
await fs.chmod(scriptPath, 0o700);
|
|
221
222
|
const profile = resolveTerminal(act.terminal);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { deriveHealth, shouldEscalateToPush } from './health.js';
|
|
2
|
+
export const DEFAULT_MAX_ACTIVE_AGENTS = 2;
|
|
3
|
+
const ACTIVE_STATES = new Set(['headless', 'summoned']);
|
|
4
|
+
const TERMINAL_STATES = new Set(['complete', 'failed']);
|
|
5
|
+
function isActive(state) {
|
|
6
|
+
return ACTIVE_STATES.has(state);
|
|
7
|
+
}
|
|
8
|
+
function isTerminal(state) {
|
|
9
|
+
return TERMINAL_STATES.has(state);
|
|
10
|
+
}
|
|
11
|
+
function needsHuman(health, signals, args) {
|
|
12
|
+
if (signals.crashed)
|
|
13
|
+
return 'agent-crashed';
|
|
14
|
+
if (health === 'awaiting-input')
|
|
15
|
+
return 'awaiting-input';
|
|
16
|
+
const escalate = shouldEscalateToPush(health, {
|
|
17
|
+
thresholds: args.thresholds,
|
|
18
|
+
paneTotal: args.total,
|
|
19
|
+
liveNonTerminalPanes: args.activeCount,
|
|
20
|
+
slowDwellMs: 0,
|
|
21
|
+
});
|
|
22
|
+
if (escalate)
|
|
23
|
+
return health === 'slow' ? 'stalled' : health;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function planConductorTick(args) {
|
|
27
|
+
const { agents, signalsByPane, now, thresholds } = args;
|
|
28
|
+
const cap = typeof args.cap === 'number' && args.cap >= 0 ? args.cap : DEFAULT_MAX_ACTIVE_AGENTS;
|
|
29
|
+
let activeCount = agents.reduce((n, a) => (isActive(a.state) ? n + 1 : n), 0);
|
|
30
|
+
const totalNonTerminal = agents.reduce((n, a) => (isTerminal(a.state) ? n : n + 1), 0);
|
|
31
|
+
const decisions = [];
|
|
32
|
+
for (const a of agents) {
|
|
33
|
+
if (isTerminal(a.state)) {
|
|
34
|
+
decisions.push({ paneName: a.paneName, action: 'noop', reason: a.state });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (a.state === 'pending')
|
|
38
|
+
continue;
|
|
39
|
+
const signals = signalsByPane[a.paneName];
|
|
40
|
+
if (!signals) {
|
|
41
|
+
decisions.push({ paneName: a.paneName, action: 'noop' });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const { state: health } = deriveHealth({
|
|
45
|
+
now,
|
|
46
|
+
thresholds,
|
|
47
|
+
lastToolUse: signals.lastToolUse,
|
|
48
|
+
lastGitDelta: signals.lastGitDelta,
|
|
49
|
+
lastPaneActivity: signals.lastPaneActivity,
|
|
50
|
+
awaitingInput: signals.awaitingInput,
|
|
51
|
+
crashed: signals.crashed,
|
|
52
|
+
churning: signals.churning,
|
|
53
|
+
loopFlips: signals.loopFlips,
|
|
54
|
+
commitsAdvancedInWindow: signals.commitsAdvancedInWindow,
|
|
55
|
+
expectSlow: signals.expectSlow,
|
|
56
|
+
});
|
|
57
|
+
const reason = needsHuman(health, signals, {
|
|
58
|
+
now,
|
|
59
|
+
thresholds,
|
|
60
|
+
activeCount,
|
|
61
|
+
total: totalNonTerminal,
|
|
62
|
+
});
|
|
63
|
+
if (a.state === 'headless') {
|
|
64
|
+
if (reason) {
|
|
65
|
+
decisions.push({ paneName: a.paneName, action: 'summon', reason });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
decisions.push({ paneName: a.paneName, action: 'noop' });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
if (reason) {
|
|
73
|
+
decisions.push({ paneName: a.paneName, action: 'noop', reason });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
decisions.push({ paneName: a.paneName, action: 'dematerialize', reason: 'resolved' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const a of agents) {
|
|
81
|
+
if (a.state !== 'pending')
|
|
82
|
+
continue;
|
|
83
|
+
if (activeCount < cap) {
|
|
84
|
+
decisions.push({ paneName: a.paneName, action: 'start', reason: 'slot-free' });
|
|
85
|
+
activeCount += 1;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
decisions.push({ paneName: a.paneName, action: 'noop', reason: 'cap-reached' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return decisions;
|
|
92
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
2
1
|
import nodeFs from 'node:fs';
|
|
3
2
|
import nodePath from 'node:path';
|
|
4
3
|
import { randomBytes } from 'node:crypto';
|
|
@@ -13,32 +12,33 @@ const MAX_PANES = 16;
|
|
|
13
12
|
function defaultRandom() {
|
|
14
13
|
return randomBytes(24).toString('hex');
|
|
15
14
|
}
|
|
16
|
-
|
|
15
|
+
const PLAN_AUTH_GUARD = `unset ANTHROPIC_API_KEY; unset ANTHROPIC_AUTH_TOKEN`;
|
|
16
|
+
function buildHeadlessCommand(worktreePath, briefRelPath) {
|
|
17
17
|
const safeWt = worktreePath.replace(/'/g, `'\\''`);
|
|
18
18
|
const safeBrief = briefRelPath.replace(/'/g, `'\\''`);
|
|
19
19
|
const prompt = `Read ${safeBrief} (your Choir brief) and start work in this worktree.`;
|
|
20
20
|
const safePrompt = prompt.replace(/'/g, `'\\''`);
|
|
21
|
-
return
|
|
22
|
-
`; printf '\\n%s\\n' 'Choir pane finished — press enter to close'` +
|
|
23
|
-
`; read _` +
|
|
24
|
-
`; tmux kill-pane`);
|
|
21
|
+
return `${PLAN_AUTH_GUARD}; cd '${safeWt}' && claude '${safePrompt}'`;
|
|
25
22
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
function resolveDefaultSpawnHeadless() {
|
|
24
|
+
return (args) => {
|
|
25
|
+
void import('node:child_process').then(({ spawn }) => {
|
|
26
|
+
const proc = spawn('bash', ['-c', args.command], {
|
|
27
|
+
cwd: args.cwd,
|
|
28
|
+
env: args.env,
|
|
29
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
const prefix = (chunk, sink) => {
|
|
32
|
+
const text = chunk.toString('utf-8');
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
if (line.length > 0)
|
|
35
|
+
sink.write(`[choir:${args.paneName}] ${line}\n`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
proc.stdout?.on('data', (c) => prefix(c, process.stdout));
|
|
39
|
+
proc.stderr?.on('data', (c) => prefix(c, process.stderr));
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
42
|
}
|
|
43
43
|
function buildMcpJson() {
|
|
44
44
|
const obj = {
|
|
@@ -57,7 +57,8 @@ function buildMcpJson() {
|
|
|
57
57
|
return JSON.stringify(obj, null, 2);
|
|
58
58
|
}
|
|
59
59
|
export function launchChoir(args) {
|
|
60
|
-
const { repoRoot, sessionName: rawSession, paneCount, baseBranch: rawBase, joinMode,
|
|
60
|
+
const { repoRoot, sessionName: rawSession, paneCount, baseBranch: rawBase, joinMode, spawnHeadlessImpl, fsImpl = defaultFs, randomImpl = defaultRandom, addWorktreeImpl = defaultAddWorktree, startHub, } = args;
|
|
61
|
+
const spawnHeadless = spawnHeadlessImpl ?? resolveDefaultSpawnHeadless();
|
|
61
62
|
const sessionName = assertValidName(rawSession, 'session');
|
|
62
63
|
assertValidBranch(rawBase, 'base branch');
|
|
63
64
|
const baseBranch = rawBase;
|
|
@@ -72,7 +73,7 @@ export function launchChoir(args) {
|
|
|
72
73
|
throw new Error('choir token minting produced an empty token');
|
|
73
74
|
}
|
|
74
75
|
const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
|
|
75
|
-
const
|
|
76
|
+
const agents = [];
|
|
76
77
|
for (const paneName of paneNames) {
|
|
77
78
|
const { worktreePath, branch } = addWorktreeImpl({
|
|
78
79
|
repoRoot,
|
|
@@ -92,15 +93,25 @@ export function launchChoir(args) {
|
|
|
92
93
|
}
|
|
93
94
|
else {
|
|
94
95
|
}
|
|
95
|
-
const
|
|
96
|
+
const command = buildHeadlessCommand(worktreePath, briefRelPath);
|
|
96
97
|
const childEnv = {
|
|
97
98
|
...process.env,
|
|
98
99
|
CHOIR_SESSION_TOKEN: token,
|
|
99
100
|
CHOIR_PANE_NONCE: nonce,
|
|
100
101
|
CHOIR_PANE_ID: branch,
|
|
101
102
|
};
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
spawnHeadless({ command, cwd: worktreePath, env: childEnv, paneName });
|
|
104
|
+
agents.push({
|
|
105
|
+
paneName,
|
|
106
|
+
worktreePath,
|
|
107
|
+
branch,
|
|
108
|
+
resumeSessionId: branch,
|
|
109
|
+
nonce,
|
|
110
|
+
provider: 'claude',
|
|
111
|
+
state: 'headless',
|
|
112
|
+
tmuxPaneId: null,
|
|
113
|
+
headlessPid: null,
|
|
114
|
+
});
|
|
104
115
|
}
|
|
105
116
|
if (startHub !== false) {
|
|
106
117
|
const start = startHub ?? resolveDefaultStartHub();
|
|
@@ -112,7 +123,7 @@ export function launchChoir(args) {
|
|
|
112
123
|
repoRoot,
|
|
113
124
|
baseBranch,
|
|
114
125
|
joinMode,
|
|
115
|
-
|
|
126
|
+
agents,
|
|
116
127
|
tokenForTest: token,
|
|
117
128
|
};
|
|
118
129
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
2
|
+
import { isValidBranch, isValidPaneId } from './names.js';
|
|
3
|
+
import { shellQuoteSingle } from '../command-builders.js';
|
|
4
|
+
const defaultKill = (pid, signal) => {
|
|
5
|
+
try {
|
|
6
|
+
return process.kill(pid, signal);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
const PRESS_ENTER_TRAILER = `; printf '\\n%s\\n' 'Resolved — press enter to send this agent back headless'` +
|
|
14
|
+
`; read _` +
|
|
15
|
+
`; tmux kill-pane`;
|
|
16
|
+
function buildResumeInvocation(agent) {
|
|
17
|
+
const sid = shellQuoteSingle(agent.resumeSessionId);
|
|
18
|
+
switch (agent.provider) {
|
|
19
|
+
case 'claude':
|
|
20
|
+
return `claude --resume ${sid}`;
|
|
21
|
+
case 'codex': {
|
|
22
|
+
const brief = shellQuoteSingle('You were working in this git worktree — inspect `git status` / `git log` to see your state, then continue.');
|
|
23
|
+
return `codex ${brief}`;
|
|
24
|
+
}
|
|
25
|
+
case 'gemini-cli': {
|
|
26
|
+
const brief = shellQuoteSingle('You were working in this git worktree — inspect `git status` / `git log` to see your state, then continue.');
|
|
27
|
+
return `gemini ${brief}`;
|
|
28
|
+
}
|
|
29
|
+
default: {
|
|
30
|
+
const _never = agent.provider;
|
|
31
|
+
throw new Error(`choir materialize: unknown provider`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function buildResumeShell(agent) {
|
|
36
|
+
const safeWt = shellQuoteSingle(agent.worktreePath);
|
|
37
|
+
const invocation = buildResumeInvocation(agent);
|
|
38
|
+
return (`unset ANTHROPIC_API_KEY` +
|
|
39
|
+
`; unset ANTHROPIC_AUTH_TOKEN` +
|
|
40
|
+
`; cd ${safeWt} && ${invocation}` +
|
|
41
|
+
PRESS_ENTER_TRAILER);
|
|
42
|
+
}
|
|
43
|
+
const SHELL_UNSAFE_RE = /[\n\r\0;`$"\\]/;
|
|
44
|
+
function assertSafeWorktreePath(p) {
|
|
45
|
+
if (typeof p !== 'string' || p.length === 0 || p.length > 1024 || SHELL_UNSAFE_RE.test(p)) {
|
|
46
|
+
throw new Error('choir materialize: invalid worktree path');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function summonPane(agent, deps = {}) {
|
|
50
|
+
const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
|
|
51
|
+
const killImpl = deps.killImpl ?? defaultKill;
|
|
52
|
+
const sleepImpl = deps.sleepImpl ?? defaultSleep;
|
|
53
|
+
const graceMs = deps.graceMs ?? 2000;
|
|
54
|
+
assertSafeWorktreePath(agent.worktreePath);
|
|
55
|
+
if (!isValidBranch(agent.branch)) {
|
|
56
|
+
throw new Error('choir materialize: invalid branch');
|
|
57
|
+
}
|
|
58
|
+
if (typeof agent.headlessPid === 'number') {
|
|
59
|
+
const pid = agent.headlessPid;
|
|
60
|
+
killImpl(pid, 'SIGINT');
|
|
61
|
+
await sleepImpl(graceMs);
|
|
62
|
+
killImpl(pid, 'SIGKILL');
|
|
63
|
+
}
|
|
64
|
+
const shell = buildResumeShell(agent);
|
|
65
|
+
const r = spawnImpl('tmux', ['split-window', '-P', '-F', '#{pane_id}', shell, ';', 'select-layout', 'tiled'], {
|
|
66
|
+
encoding: 'utf-8',
|
|
67
|
+
timeout: 15_000,
|
|
68
|
+
windowsHide: true,
|
|
69
|
+
});
|
|
70
|
+
if (r.status !== 0) {
|
|
71
|
+
throw new Error('choir summon pane spawn failed (tmux split-window)');
|
|
72
|
+
}
|
|
73
|
+
const out = typeof r.stdout === 'string' ? r.stdout : '';
|
|
74
|
+
const first = out.trim().split(/\s+/)[0] ?? '';
|
|
75
|
+
if (!/^%\d+$/.test(first)) {
|
|
76
|
+
throw new Error('choir summon produced no pane id');
|
|
77
|
+
}
|
|
78
|
+
return first;
|
|
79
|
+
}
|
|
80
|
+
export async function dematerialize(agent, deps = {}) {
|
|
81
|
+
const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
|
|
82
|
+
const paneId = agent.tmuxPaneId;
|
|
83
|
+
if (paneId == null)
|
|
84
|
+
return;
|
|
85
|
+
if (!isValidPaneId(paneId)) {
|
|
86
|
+
throw new Error('choir materialize: invalid pane id');
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
spawnImpl('tmux', ['kill-pane', '-t', paneId], {
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
timeout: 2000,
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -185,9 +185,10 @@ export function sanitizeForEgress(obj) {
|
|
|
185
185
|
}
|
|
186
186
|
const SECRET_RE = /\b(pat_[A-Za-z0-9_-]{4,}|sk_[A-Za-z0-9_-]{4,})/;
|
|
187
187
|
const DIFF_RE = /^(diff --git |@@ |[+-]{3} )|(\n[+-])/;
|
|
188
|
-
const
|
|
188
|
+
const PATH_MULTI_SEGMENT_RE = /(^|[\s'"])\/[\w.-]+\/[\w./-]+/;
|
|
189
|
+
const PATH_SINGLE_SEGMENT_RE = /(^|[\s'"])\/[\w.-]{4,}/;
|
|
189
190
|
function looksLikePath(s) {
|
|
190
|
-
return
|
|
191
|
+
return PATH_MULTI_SEGMENT_RE.test(s) || PATH_SINGLE_SEGMENT_RE.test(s);
|
|
191
192
|
}
|
|
192
193
|
function looksLikeDiff(s) {
|
|
193
194
|
return DIFF_RE.test(s);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { makeEvent, sanitizeForEgress, assertNoForbiddenFields } from './progress-events.js';
|
|
2
|
+
const X_REQUESTED_WITH = 'ConradPM-Native';
|
|
3
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
4
|
+
export const RUN_STAGE = Object.freeze({
|
|
5
|
+
QUEUED: 'queued',
|
|
6
|
+
ACCEPTED: 'accepted',
|
|
7
|
+
LAUNCHING: 'launching',
|
|
8
|
+
AGENT_STARTED: 'agent-started',
|
|
9
|
+
WORKING: 'working',
|
|
10
|
+
FINISHED: 'finished',
|
|
11
|
+
FAILED: 'failed',
|
|
12
|
+
STOPPED: 'stopped',
|
|
13
|
+
});
|
|
14
|
+
export function runLifecycleStage(phase) {
|
|
15
|
+
return phase;
|
|
16
|
+
}
|
|
17
|
+
export async function emitRunStage(opts) {
|
|
18
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
19
|
+
const now = opts.now ?? Date.now;
|
|
20
|
+
let body;
|
|
21
|
+
try {
|
|
22
|
+
const ts = Math.floor(now());
|
|
23
|
+
const event = makeEvent({
|
|
24
|
+
sessionId: opts.activationId,
|
|
25
|
+
engine: 'run',
|
|
26
|
+
paneId: null,
|
|
27
|
+
stage: opts.stage,
|
|
28
|
+
summary: opts.summary ?? '',
|
|
29
|
+
ts,
|
|
30
|
+
metrics: opts.metrics,
|
|
31
|
+
refs: opts.refs,
|
|
32
|
+
}, { now, random: opts.randomImpl });
|
|
33
|
+
const safe = sanitizeForEgress(event);
|
|
34
|
+
assertNoForbiddenFields(safe);
|
|
35
|
+
body = JSON.stringify({ events: [safe] });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await fetchImpl(`${opts.baseUrl}/api/cli/choir/events`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${opts.pat}`,
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
'X-Requested-With': X_REQUESTED_WITH,
|
|
47
|
+
},
|
|
48
|
+
body,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const defaultTimerImpl = {
|
|
55
|
+
setInterval: (h, ms) => setInterval(h, ms),
|
|
56
|
+
clearInterval: (handle) => clearInterval(handle),
|
|
57
|
+
};
|
|
58
|
+
export function startRunHeartbeat(opts) {
|
|
59
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
60
|
+
const timerImpl = opts.timerImpl ?? defaultTimerImpl;
|
|
61
|
+
const beat = async (reason) => {
|
|
62
|
+
const body = {
|
|
63
|
+
sessionId: opts.activationId,
|
|
64
|
+
paneCount: 1,
|
|
65
|
+
furthestStage: opts.furthestStage,
|
|
66
|
+
lastEventSeq: opts.lastEventSeq,
|
|
67
|
+
};
|
|
68
|
+
if (reason)
|
|
69
|
+
body.last_beat_reason = reason;
|
|
70
|
+
try {
|
|
71
|
+
await fetchImpl(`${opts.baseUrl}/api/cli/choir/heartbeat`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Bearer ${opts.pat}`,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'X-Requested-With': X_REQUESTED_WITH,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const handle = timerImpl.setInterval(() => {
|
|
85
|
+
void beat();
|
|
86
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
87
|
+
if (handle && typeof handle.unref === 'function') {
|
|
88
|
+
;
|
|
89
|
+
handle.unref();
|
|
90
|
+
}
|
|
91
|
+
let stopped = false;
|
|
92
|
+
return {
|
|
93
|
+
async stop(reason) {
|
|
94
|
+
if (stopped)
|
|
95
|
+
return;
|
|
96
|
+
stopped = true;
|
|
97
|
+
timerImpl.clearInterval(handle);
|
|
98
|
+
await beat(reason ?? 'daemon-exit');
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.8.0';
|
package/package.json
CHANGED