@nonbot/cli 0.8.0 → 0.9.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 +5 -0
- package/dist/commands/daemon.js +63 -1
- package/dist/lib/choir/coordinated-set.js +207 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @nonbot/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
- **Choir collapses into Run.** Launching multiple coordinated agents now comes from the canvas (select stories -> "Run together"), not a terminal command. The daemon groups activations that share a coordinated-set id and launches them with a git worktree per agent + the coordination MCP wired in, each pane on its own per-pane provider (e.g. 2 Claude + 1 Gemini). remains as internal plumbing only.
|
|
6
|
+
- The set spawn is hardened: repo path validated daemon-side, command written to a 0700 temp script (not an inline shell string), all panes asserted to share one repo, and the plan-not-API unset guard applies.
|
|
7
|
+
|
|
3
8
|
## 0.8.0
|
|
4
9
|
|
|
5
10
|
- **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.
|
package/dist/commands/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
|
|
7
|
+
import { groupBySession, launchCoordinatedSet, } from '../lib/choir/coordinated-set.js';
|
|
7
8
|
import { applyPaneTitle } from '../lib/pane-title.js';
|
|
8
9
|
import { installService, uninstallService } from '../lib/service.js';
|
|
9
10
|
import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
|
|
@@ -132,6 +133,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
132
133
|
applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
|
|
133
134
|
}
|
|
134
135
|
const seen = new Set();
|
|
136
|
+
const launchedSessions = new Set();
|
|
137
|
+
const launchCoordinatedSetFn = deps.launchCoordinatedSet ?? launchCoordinatedSet;
|
|
135
138
|
const trackedPanes = new Map();
|
|
136
139
|
const killedByStop = new Set();
|
|
137
140
|
const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
|
|
@@ -282,7 +285,66 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
282
285
|
void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
|
|
283
286
|
}
|
|
284
287
|
const acts = body?.activations ?? [];
|
|
285
|
-
|
|
288
|
+
const { singletons, sets } = groupBySession(acts);
|
|
289
|
+
for (const [choirSessionId, setActs] of sets) {
|
|
290
|
+
for (const a of setActs)
|
|
291
|
+
if (a?.id)
|
|
292
|
+
seen.add(a.id);
|
|
293
|
+
if (launchedSessions.has(choirSessionId))
|
|
294
|
+
continue;
|
|
295
|
+
launchedSessions.add(choirSessionId);
|
|
296
|
+
firedThisPoll = true;
|
|
297
|
+
for (const a of setActs) {
|
|
298
|
+
if (a.kind === 'real')
|
|
299
|
+
safeEmit(a.id, RUN_STAGE.LAUNCHING, seqMetrics(a));
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const result = await launchCoordinatedSetFn({
|
|
303
|
+
choirSessionId,
|
|
304
|
+
activations: setActs,
|
|
305
|
+
auth,
|
|
306
|
+
deps: deps.coordinatedSetDeps,
|
|
307
|
+
});
|
|
308
|
+
for (const pane of result.panes) {
|
|
309
|
+
const a = setActs.find((x) => x.id === pane.activationId);
|
|
310
|
+
const metrics = a && a.kind === 'real' ? seqMetrics(a) : undefined;
|
|
311
|
+
if (pane.tmuxPaneId) {
|
|
312
|
+
trackedPanes.set(pane.activationId, pane.tmuxPaneId);
|
|
313
|
+
trackedMeta.set(pane.activationId, {
|
|
314
|
+
story: pane.paneName,
|
|
315
|
+
provider: pane.provider,
|
|
316
|
+
startedAt: Date.now(),
|
|
317
|
+
paneId: pane.tmuxPaneId,
|
|
318
|
+
});
|
|
319
|
+
retitlePane('running', pane.tmuxPaneId, pane.paneName);
|
|
320
|
+
}
|
|
321
|
+
safeEmit(pane.activationId, RUN_STAGE.AGENT_STARTED, metrics);
|
|
322
|
+
try {
|
|
323
|
+
const hb = startRunHeartbeatFn({
|
|
324
|
+
baseUrl: auth.baseUrl,
|
|
325
|
+
pat: auth.pat,
|
|
326
|
+
activationId: pane.activationId,
|
|
327
|
+
furthestStage: RUN_STAGE.AGENT_STARTED,
|
|
328
|
+
lastEventSeq: 0,
|
|
329
|
+
});
|
|
330
|
+
runHeartbeats.set(pane.activationId, hb);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
emitSummary();
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
errLog(statusRow('⚠', 'coordinated set failed', e.message, { stream: process.stderr }) + '\n');
|
|
339
|
+
for (const a of setActs) {
|
|
340
|
+
if (a.kind === 'real')
|
|
341
|
+
safeEmit(a.id, RUN_STAGE.FAILED, seqMetrics(a));
|
|
342
|
+
failedCount++;
|
|
343
|
+
}
|
|
344
|
+
emitSummary();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const act of singletons) {
|
|
286
348
|
if (!act?.id || seen.has(act.id))
|
|
287
349
|
continue;
|
|
288
350
|
seen.add(act.id);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import nodeFs from 'node:fs';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { assertValidName } from './names.js';
|
|
6
|
+
import { addWorktree as defaultAddWorktree } from './worktree.js';
|
|
7
|
+
import { createHub as defaultCreateHub } from './hub.js';
|
|
8
|
+
import { PROVIDER_PROFILES } from '../command-builders.js';
|
|
9
|
+
import { validateRepoPath } from '../payload-validator.js';
|
|
10
|
+
const defaultFs = {
|
|
11
|
+
mkdirSync: (p, opts) => nodeFs.mkdirSync(p, opts),
|
|
12
|
+
writeFileSync: (p, data, opts) => nodeFs.writeFileSync(p, data, opts),
|
|
13
|
+
};
|
|
14
|
+
const MAX_PANES = 16;
|
|
15
|
+
const PLAN_AUTH_GUARD = `unset ANTHROPIC_API_KEY; unset ANTHROPIC_AUTH_TOKEN`;
|
|
16
|
+
function defaultRandom() {
|
|
17
|
+
return randomBytes(24).toString('hex');
|
|
18
|
+
}
|
|
19
|
+
function payloadOf(act) {
|
|
20
|
+
return (act?.payload ?? {});
|
|
21
|
+
}
|
|
22
|
+
export function choirSessionIdOf(act) {
|
|
23
|
+
const p = payloadOf(act);
|
|
24
|
+
return typeof p.choirSessionId === 'string' && p.choirSessionId.length > 0
|
|
25
|
+
? p.choirSessionId
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
export function groupBySession(activations) {
|
|
29
|
+
const singletons = [];
|
|
30
|
+
const sets = new Map();
|
|
31
|
+
for (const act of activations) {
|
|
32
|
+
const sid = choirSessionIdOf(act);
|
|
33
|
+
if (sid === null) {
|
|
34
|
+
singletons.push(act);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const arr = sets.get(sid);
|
|
38
|
+
if (arr)
|
|
39
|
+
arr.push(act);
|
|
40
|
+
else
|
|
41
|
+
sets.set(sid, [act]);
|
|
42
|
+
}
|
|
43
|
+
return { singletons, sets };
|
|
44
|
+
}
|
|
45
|
+
function resolveProvider(act) {
|
|
46
|
+
const raw = payloadOf(act).provider;
|
|
47
|
+
if (typeof raw === 'string' && raw in PROVIDER_PROFILES)
|
|
48
|
+
return raw;
|
|
49
|
+
return 'claude';
|
|
50
|
+
}
|
|
51
|
+
function deriveSessionName(choirSessionId) {
|
|
52
|
+
let s = String(choirSessionId)
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
55
|
+
.replace(/-+/g, '-')
|
|
56
|
+
.replace(/^-+|-+$/g, '')
|
|
57
|
+
.slice(0, 31);
|
|
58
|
+
if (s.length === 0 || !/^[a-z0-9]/.test(s))
|
|
59
|
+
s = `s${s}`.slice(0, 31);
|
|
60
|
+
return assertValidName(s, 'session');
|
|
61
|
+
}
|
|
62
|
+
function buildMcpJson() {
|
|
63
|
+
return JSON.stringify({
|
|
64
|
+
mcpServers: {
|
|
65
|
+
choir: {
|
|
66
|
+
command: 'nonbot',
|
|
67
|
+
args: ['choir-mcp'],
|
|
68
|
+
env: {
|
|
69
|
+
CHOIR_SESSION_TOKEN: '',
|
|
70
|
+
CHOIR_PANE_NONCE: '',
|
|
71
|
+
CHOIR_PANE_ID: '',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}, null, 2);
|
|
76
|
+
}
|
|
77
|
+
function buildPaneCommand(worktreePath, provider, storyTitle) {
|
|
78
|
+
const cli = PROVIDER_PROFILES[provider].realCli;
|
|
79
|
+
const safeWt = worktreePath.replace(/'/g, `'\\''`);
|
|
80
|
+
const title = storyTitle.replace(/[\r\n]+/g, ' ');
|
|
81
|
+
const prompt = `You are one pane of a coordinated set. Read .choir/ for your brief, ` +
|
|
82
|
+
`use the choir MCP radar to coordinate, then work on: ${title}`;
|
|
83
|
+
const safePrompt = prompt.replace(/'/g, `'\\''`);
|
|
84
|
+
return `${PLAN_AUTH_GUARD}; cd '${safeWt}' && ${cli} '${safePrompt}'`;
|
|
85
|
+
}
|
|
86
|
+
export async function launchCoordinatedSet(args) {
|
|
87
|
+
const { choirSessionId, activations, auth } = args;
|
|
88
|
+
const deps = args.deps ?? {};
|
|
89
|
+
const fsImpl = deps.fsImpl ?? defaultFs;
|
|
90
|
+
const randomImpl = deps.randomImpl ?? defaultRandom;
|
|
91
|
+
const addWorktreeImpl = deps.addWorktreeImpl ?? defaultAddWorktree;
|
|
92
|
+
const createHubImpl = deps.createHubImpl ?? defaultCreateHub;
|
|
93
|
+
const spawnPane = deps.spawnPaneImpl ?? resolveDefaultSpawnPane(auth);
|
|
94
|
+
if (!Array.isArray(activations) || activations.length === 0) {
|
|
95
|
+
throw new Error('coordinated set has no activations');
|
|
96
|
+
}
|
|
97
|
+
if (activations.length > MAX_PANES) {
|
|
98
|
+
throw new Error(`coordinated set exceeds ${MAX_PANES} panes`);
|
|
99
|
+
}
|
|
100
|
+
const sessionName = deriveSessionName(choirSessionId);
|
|
101
|
+
const repoRootRaw = typeof payloadOf(activations[0]).repoPath === 'string'
|
|
102
|
+
? payloadOf(activations[0]).repoPath
|
|
103
|
+
: (activations[0].repoPath ?? '');
|
|
104
|
+
if (!repoRootRaw)
|
|
105
|
+
throw new Error('coordinated set activation has no repoPath');
|
|
106
|
+
const repoRoot = validateRepoPath(repoRootRaw);
|
|
107
|
+
for (const act of activations) {
|
|
108
|
+
const p = payloadOf(act);
|
|
109
|
+
const actRepo = typeof p.repoPath === 'string' ? p.repoPath : (act.repoPath ?? '');
|
|
110
|
+
if (actRepo !== repoRoot) {
|
|
111
|
+
throw new Error('coordinated set spans multiple repoPaths — a set must be one repo');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const token = randomImpl();
|
|
115
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
116
|
+
throw new Error('coordinated set token minting produced an empty token');
|
|
117
|
+
}
|
|
118
|
+
const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
|
|
119
|
+
const baseBranch = 'HEAD';
|
|
120
|
+
const hub = createHubImpl({
|
|
121
|
+
sessionId,
|
|
122
|
+
repoRoot,
|
|
123
|
+
baseBranch,
|
|
124
|
+
token,
|
|
125
|
+
baseUrl: auth.baseUrl,
|
|
126
|
+
pat: auth.pat,
|
|
127
|
+
autoTimers: true,
|
|
128
|
+
});
|
|
129
|
+
hub.start();
|
|
130
|
+
const panes = [];
|
|
131
|
+
let paneIndex = 0;
|
|
132
|
+
for (const act of activations) {
|
|
133
|
+
paneIndex += 1;
|
|
134
|
+
const paneName = assertValidName(`pane${paneIndex}`, 'pane');
|
|
135
|
+
const provider = resolveProvider(act);
|
|
136
|
+
const story = typeof payloadOf(act).storyTitle === 'string'
|
|
137
|
+
? payloadOf(act).storyTitle
|
|
138
|
+
: `${act.kind} activation`;
|
|
139
|
+
const { worktreePath, branch } = addWorktreeImpl({
|
|
140
|
+
repoRoot,
|
|
141
|
+
sessionName,
|
|
142
|
+
paneName,
|
|
143
|
+
baseBranch,
|
|
144
|
+
});
|
|
145
|
+
const nonce = randomImpl();
|
|
146
|
+
const mcpPath = nodePath.join(worktreePath, '.mcp.json');
|
|
147
|
+
fsImpl.mkdirSync(worktreePath, { recursive: true });
|
|
148
|
+
fsImpl.writeFileSync(mcpPath, buildMcpJson(), { mode: 0o600 });
|
|
149
|
+
hub.dispatch({
|
|
150
|
+
type: 'register',
|
|
151
|
+
paneId: branch,
|
|
152
|
+
name: paneName,
|
|
153
|
+
branch,
|
|
154
|
+
worktreePath,
|
|
155
|
+
nonce,
|
|
156
|
+
ts: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
const command = buildPaneCommand(worktreePath, provider, story);
|
|
159
|
+
const scriptPath = nodePath.join(tmpdir(), `nonbot-choir-${sessionId}-${paneName}.sh`);
|
|
160
|
+
const scriptBody = `#!/bin/bash\n${command}\n`;
|
|
161
|
+
fsImpl.writeFileSync(scriptPath, scriptBody, { mode: 0o700 });
|
|
162
|
+
const env = {
|
|
163
|
+
...process.env,
|
|
164
|
+
CHOIR_SESSION_TOKEN: token,
|
|
165
|
+
CHOIR_PANE_NONCE: nonce,
|
|
166
|
+
CHOIR_PANE_ID: branch,
|
|
167
|
+
NONBOT_PAT: auth.pat,
|
|
168
|
+
NONBOT_RUN_ID: act.id,
|
|
169
|
+
NONBOT_BASE_URL: auth.baseUrl,
|
|
170
|
+
NONBOT_ROLE: 'lead',
|
|
171
|
+
};
|
|
172
|
+
const spawnResult = (await spawnPane({ command, scriptPath, cwd: worktreePath, env, provider, activationId: act.id, paneName })) ??
|
|
173
|
+
{};
|
|
174
|
+
const tmuxPaneId = spawnResult.tmuxPaneId ?? null;
|
|
175
|
+
panes.push({
|
|
176
|
+
activationId: act.id,
|
|
177
|
+
paneName,
|
|
178
|
+
branch,
|
|
179
|
+
worktreePath,
|
|
180
|
+
provider,
|
|
181
|
+
tmuxPaneId,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return { choirSessionId, sessionId, repoRoot, hub, panes, tokenForTest: token };
|
|
185
|
+
}
|
|
186
|
+
function resolveDefaultSpawnPane(_auth) {
|
|
187
|
+
return async (a) => {
|
|
188
|
+
const { spawn } = await import('node:child_process');
|
|
189
|
+
const proc = spawn('tmux', ['new-window', '-P', '-F', '#{pane_id}', '-n', a.paneName, `bash ${shArg(a.scriptPath)}`], {
|
|
190
|
+
cwd: a.cwd,
|
|
191
|
+
env: a.env,
|
|
192
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
193
|
+
});
|
|
194
|
+
return await new Promise((resolve) => {
|
|
195
|
+
const chunks = [];
|
|
196
|
+
proc.stdout?.on('data', (c) => chunks.push(c));
|
|
197
|
+
proc.on('error', () => resolve({ tmuxPaneId: null }));
|
|
198
|
+
proc.on('exit', () => {
|
|
199
|
+
const out = Buffer.concat(chunks).toString('utf-8').trim().split(/\s+/)[0] ?? '';
|
|
200
|
+
resolve({ tmuxPaneId: /^%\d+$/.test(out) ? out : null });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function shArg(s) {
|
|
206
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
207
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.9.0';
|
package/package.json
CHANGED