@nonbot/cli 0.7.1 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.7.1
4
10
 
5
11
  - **Agents run on your Claude plan, not a metered API key.** The spawned run
@@ -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 startHub = deps.startHubImpl ?? buildStartHub(repoRoot, auth);
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('Panes', String(session.panes.length)));
237
+ lines.push(kvRow('Agents', String(session.agents.length)));
98
238
  lines.push('');
99
- for (const p of session.panes) {
100
- lines.push(statusRow('✓', p.name, `${p.branch} ${c.muted('·')} pane ${p.paneId}`));
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
- 'each pane is an autonomous Claude in its own worktree',
105
- 'advisory radar online choir_radar before touching shared areas',
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
+ }
@@ -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
  }
@@ -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
- function buildPaneShell(worktreePath, briefRelPath) {
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 (`cd '${safeWt}' && claude '${safePrompt}'` +
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 spawnPane(spawnImpl, shell, env) {
27
- const r = spawnImpl('tmux', ['split-window', '-P', '-F', '#{pane_id}', shell, ';', 'select-layout', 'tiled'], {
28
- encoding: 'utf-8',
29
- timeout: 15_000,
30
- windowsHide: true,
31
- env,
32
- });
33
- if (r.status !== 0) {
34
- throw new Error('choir pane spawn failed (tmux split-window)');
35
- }
36
- const out = typeof r.stdout === 'string' ? r.stdout : '';
37
- const first = out.trim().split(/\s+/)[0] ?? '';
38
- if (!/^%\d+$/.test(first)) {
39
- throw new Error('choir pane spawn produced no pane id');
40
- }
41
- return first;
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, spawnImpl = nodeSpawnSync, fsImpl = defaultFs, randomImpl = defaultRandom, addWorktreeImpl = defaultAddWorktree, startHub, } = args;
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 panes = [];
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 shell = buildPaneShell(worktreePath, briefRelPath);
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
- const paneId = spawnPane(spawnImpl, shell, childEnv);
103
- panes.push({ name: paneName, paneId, branch, worktreePath });
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
- panes,
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 PATH_RE = /(^|[\s'"])\/[\w.-]+\/[\w./-]+/;
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 PATH_RE.test(s);
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.7.1';
1
+ export const VERSION = '0.8.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",