@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 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.
@@ -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
- for (const act of acts) {
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.8.0';
1
+ export const VERSION = '0.9.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",