@nonbot/cli 0.8.0 → 0.9.1

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.
@@ -25,6 +25,26 @@ export function buildStartHub(repoRoot, auth) {
25
25
  return hub;
26
26
  };
27
27
  }
28
+ export function registerAgentsWithHub(hub, agents, now = Date.now) {
29
+ const dispatch = hub?.dispatch;
30
+ if (typeof dispatch !== 'function')
31
+ return;
32
+ for (const a of agents) {
33
+ try {
34
+ dispatch.call(hub, {
35
+ type: 'register',
36
+ paneId: a.branch,
37
+ name: a.paneName,
38
+ branch: a.branch,
39
+ worktreePath: a.worktreePath,
40
+ nonce: a.nonce,
41
+ ts: now(),
42
+ });
43
+ }
44
+ catch {
45
+ }
46
+ }
47
+ }
28
48
  const TERMINAL_STATES = new Set(['complete', 'failed']);
29
49
  function buildSignals(agents, panes, now) {
30
50
  const byName = new Map();
@@ -227,6 +247,9 @@ export async function runChoirCommand(args = [], deps = {}) {
227
247
  errLog(`✗ choir launch failed: ${e.message}\n`);
228
248
  return 1;
229
249
  }
250
+ if (capturedHub) {
251
+ registerAgentsWithHub(capturedHub, session.agents);
252
+ }
230
253
  const cap = DEFAULT_MAX_ACTIVE_AGENTS;
231
254
  const lines = [];
232
255
  lines.push(header('nonbot choir', `session ${session.sessionName} · v${VERSION}`));
@@ -3,7 +3,9 @@ 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 { deliverAnsweredPrompts } from '../lib/run-prompt.js';
6
7
  import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
8
+ import { groupBySession, launchCoordinatedSet, } from '../lib/choir/coordinated-set.js';
7
9
  import { applyPaneTitle } from '../lib/pane-title.js';
8
10
  import { installService, uninstallService } from '../lib/service.js';
9
11
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
@@ -12,6 +14,53 @@ import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activation
12
14
  export const POLL_FAST_MS = 2000;
13
15
  export const POLL_MAX_MS = 30000;
14
16
  export const POLL_INTERVAL_MS = POLL_FAST_MS;
17
+ const TERMINAL_KIND_BY_PROFILE_ID = {
18
+ 'terminal': 'terminal.app',
19
+ 'iterm': 'iterm',
20
+ 'iterm-tab': 'iterm',
21
+ 'wezterm': 'wezterm',
22
+ 'kitty': 'kitty',
23
+ 'alacritty': 'alacritty',
24
+ 'gnome-terminal': 'gnome-terminal',
25
+ };
26
+ export function terminalKindForProfile(profile) {
27
+ if (!profile)
28
+ return 'headless';
29
+ return TERMINAL_KIND_BY_PROFILE_ID[profile.id] ?? profile.id;
30
+ }
31
+ export function probeTerminalLaunchable(profile, spawnSyncImpl = nodeSpawnSync) {
32
+ if (!profile)
33
+ return true;
34
+ let cmd;
35
+ try {
36
+ cmd = profile.launch('/tmp/nonbot-launchable-probe').cmd;
37
+ }
38
+ catch {
39
+ return true;
40
+ }
41
+ try {
42
+ const finder = process.platform === 'win32' ? 'where' : 'which';
43
+ const probe = spawnSyncImpl(finder, [cmd], { encoding: 'utf-8', timeout: 1000, windowsHide: true });
44
+ if (typeof probe.status === 'number' && probe.status !== 0)
45
+ return false;
46
+ return true;
47
+ }
48
+ catch {
49
+ return true;
50
+ }
51
+ }
52
+ export const LAST_FAILURE_REASON_MAX = 256;
53
+ export function sanitizeFailureReason(reason) {
54
+ if (typeof reason !== 'string')
55
+ return null;
56
+ const cleaned = reason
57
+ .replace(/[^\x20-\x7e]+/g, ' ')
58
+ .replace(/ {2,}/g, ' ')
59
+ .trim();
60
+ if (!cleaned)
61
+ return null;
62
+ return cleaned.slice(0, LAST_FAILURE_REASON_MAX);
63
+ }
15
64
  export function applyNonbotTmuxConfig(deps = {}) {
16
65
  const spawnSync = deps.spawnSync ?? nodeSpawnSync;
17
66
  const stdoutWrite = deps.stdoutWrite ?? ((s) => process.stdout.write(s));
@@ -131,9 +180,19 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
131
180
  if (tmuxSessionName) {
132
181
  applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
133
182
  }
183
+ const resolveTerminalFn = deps.resolveTerminal ?? resolveTerminal;
184
+ const resolvedTerminalProfile = headless ? null : resolveTerminalFn(undefined);
185
+ const terminalKind = terminalKindForProfile(resolvedTerminalProfile);
186
+ const probeLaunchable = deps.probeTerminalLaunchable
187
+ ?? ((p) => probeTerminalLaunchable(p));
188
+ const terminalLaunchable = probeLaunchable(resolvedTerminalProfile);
189
+ let lastFailureReason = null;
134
190
  const seen = new Set();
191
+ const launchedSessions = new Set();
192
+ const launchCoordinatedSetFn = deps.launchCoordinatedSet ?? launchCoordinatedSet;
135
193
  const trackedPanes = new Map();
136
194
  const killedByStop = new Set();
195
+ const injectedPrompts = new Set();
137
196
  const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
138
197
  const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
139
198
  const runHeartbeats = new Map();
@@ -194,14 +253,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
194
253
  ? `${fixedInterval}ms fixed`
195
254
  : `${Math.round(POLL_FAST_MS / 1000)}s -> ${Math.round(maxIntervalMs / 1000)}s adaptive`;
196
255
  let terminalLabel;
197
- if (headless) {
256
+ if (!resolvedTerminalProfile) {
198
257
  terminalLabel = 'headless (inline, no terminal window)';
199
258
  }
200
259
  else {
201
- const profile = resolveTerminal(undefined);
202
260
  terminalLabel = tmuxSessionName
203
- ? `${profile.displayName} · tmux pane (in session '${tmuxSessionName}')`
204
- : profile.displayName;
261
+ ? `${resolvedTerminalProfile.displayName} · tmux pane (in session '${tmuxSessionName}')`
262
+ : resolvedTerminalProfile.displayName;
205
263
  }
206
264
  const badgeSublines = [
207
265
  `listening on ${auth.baseUrl}`,
@@ -244,9 +302,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
244
302
  Authorization: `Bearer ${auth.pat}`,
245
303
  'X-Requested-With': 'ConradPM-Native',
246
304
  'X-CLI-Version': VERSION,
305
+ 'X-Terminal-Kind': terminalKind,
306
+ 'X-Terminal-Launchable': terminalLaunchable ? 'true' : 'false',
247
307
  };
248
308
  if (tmuxSessionName)
249
309
  headers['X-Tmux-Session'] = tmuxSessionName;
310
+ if (lastFailureReason)
311
+ headers['X-Last-Failure-Reason'] = lastFailureReason;
250
312
  const res = await fetchImpl(`${auth.baseUrl}/api/cli/activations/pending`, {
251
313
  headers,
252
314
  });
@@ -282,7 +344,69 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
282
344
  void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
283
345
  }
284
346
  const acts = body?.activations ?? [];
285
- for (const act of acts) {
347
+ const { singletons, sets } = groupBySession(acts);
348
+ for (const [choirSessionId, setActs] of sets) {
349
+ for (const a of setActs)
350
+ if (a?.id)
351
+ seen.add(a.id);
352
+ if (launchedSessions.has(choirSessionId))
353
+ continue;
354
+ launchedSessions.add(choirSessionId);
355
+ firedThisPoll = true;
356
+ for (const a of setActs) {
357
+ if (a.kind === 'real')
358
+ safeEmit(a.id, RUN_STAGE.LAUNCHING, seqMetrics(a));
359
+ }
360
+ try {
361
+ const result = await launchCoordinatedSetFn({
362
+ choirSessionId,
363
+ activations: setActs,
364
+ auth,
365
+ deps: deps.coordinatedSetDeps,
366
+ });
367
+ for (const pane of result.panes) {
368
+ const a = setActs.find((x) => x.id === pane.activationId);
369
+ const metrics = a && a.kind === 'real' ? seqMetrics(a) : undefined;
370
+ if (pane.tmuxPaneId) {
371
+ trackedPanes.set(pane.activationId, pane.tmuxPaneId);
372
+ trackedMeta.set(pane.activationId, {
373
+ story: pane.paneName,
374
+ provider: pane.provider,
375
+ startedAt: Date.now(),
376
+ paneId: pane.tmuxPaneId,
377
+ });
378
+ retitlePane('running', pane.tmuxPaneId, pane.paneName);
379
+ }
380
+ safeEmit(pane.activationId, RUN_STAGE.AGENT_STARTED, metrics);
381
+ try {
382
+ const hb = startRunHeartbeatFn({
383
+ baseUrl: auth.baseUrl,
384
+ pat: auth.pat,
385
+ activationId: pane.activationId,
386
+ furthestStage: RUN_STAGE.AGENT_STARTED,
387
+ lastEventSeq: 0,
388
+ });
389
+ runHeartbeats.set(pane.activationId, hb);
390
+ }
391
+ catch {
392
+ }
393
+ }
394
+ lastFailureReason = null;
395
+ emitSummary();
396
+ }
397
+ catch (e) {
398
+ errLog(statusRow('⚠', 'coordinated set failed', e.message, { stream: process.stderr }) + '\n');
399
+ lastFailureReason = sanitizeFailureReason(e.message)
400
+ ?? 'coordinated set launch failed';
401
+ for (const a of setActs) {
402
+ if (a.kind === 'real')
403
+ safeEmit(a.id, RUN_STAGE.FAILED, seqMetrics(a));
404
+ failedCount++;
405
+ }
406
+ emitSummary();
407
+ }
408
+ }
409
+ for (const act of singletons) {
286
410
  if (!act?.id || seen.has(act.id))
287
411
  continue;
288
412
  seen.add(act.id);
@@ -291,6 +415,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
291
415
  if (act.kind === 'real')
292
416
  safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
293
417
  const outcome = await fireActivation(auth, act, deps, log, errLog);
418
+ if (outcome.status === 'launched') {
419
+ lastFailureReason = null;
420
+ }
421
+ else {
422
+ lastFailureReason = sanitizeFailureReason(outcome.errorReason)
423
+ ?? 'activation launch failed';
424
+ }
294
425
  if (outcome.status === 'launched' && outcome.kind === 'real' && outcome.tmuxPaneId) {
295
426
  trackedPanes.set(outcome.id, outcome.tmuxPaneId);
296
427
  const p = (act.payload ?? {});
@@ -329,6 +460,20 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
329
460
  emitSummary();
330
461
  }
331
462
  }
463
+ if (trackedPanes.size > 0) {
464
+ try {
465
+ await deliverAnsweredPrompts({
466
+ baseUrl: auth.baseUrl,
467
+ pat: auth.pat,
468
+ injected: injectedPrompts,
469
+ fetchImpl,
470
+ spawnImpl: deps.spawnSync,
471
+ log,
472
+ });
473
+ }
474
+ catch {
475
+ }
476
+ }
332
477
  if (trackedPanes.size > 0) {
333
478
  const reported = await checkCompletions({
334
479
  tracked: trackedPanes,
@@ -0,0 +1,78 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { parsePromptMenu, mintPromptId, reportPrompt, } from '../lib/run-prompt.js';
3
+ function defaultReadStdin() {
4
+ if (process.stdin.isTTY)
5
+ return Promise.resolve('');
6
+ const chunks = [];
7
+ return new Promise((resolve) => {
8
+ let done = false;
9
+ const finish = () => {
10
+ if (done)
11
+ return;
12
+ done = true;
13
+ resolve(Buffer.concat(chunks).toString('utf-8'));
14
+ };
15
+ process.stdin.on('data', (c) => chunks.push(c));
16
+ process.stdin.on('end', finish);
17
+ process.stdin.on('error', finish);
18
+ setTimeout(finish, 1500);
19
+ });
20
+ }
21
+ function capturePane(paneId, spawnSync) {
22
+ try {
23
+ const r = spawnSync('tmux', ['capture-pane', '-p', '-t', paneId], {
24
+ encoding: 'utf-8',
25
+ timeout: 2000,
26
+ windowsHide: true,
27
+ });
28
+ return typeof r.stdout === 'string' ? r.stdout : '';
29
+ }
30
+ catch {
31
+ return '';
32
+ }
33
+ }
34
+ export async function runRunPromptHookCommand(deps = {}) {
35
+ const env = deps.env ?? process.env;
36
+ const spawnSync = deps.spawnSync ?? nodeSpawnSync;
37
+ const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
38
+ const pat = env.NONBOT_PAT;
39
+ const baseUrl = env.NONBOT_BASE_URL;
40
+ const activationId = env.NONBOT_RUN_ID;
41
+ if (!pat || !baseUrl || !activationId)
42
+ return 0;
43
+ const paneId = typeof env.TMUX_PANE === 'string' && env.TMUX_PANE.length > 0 ? env.TMUX_PANE : null;
44
+ let message = '';
45
+ try {
46
+ const raw = await (deps.readStdin ?? defaultReadStdin)();
47
+ if (raw) {
48
+ const parsed = JSON.parse(raw);
49
+ if (typeof parsed?.message === 'string')
50
+ message = parsed.message;
51
+ }
52
+ }
53
+ catch {
54
+ }
55
+ const paneText = paneId ? capturePane(paneId, spawnSync) : '';
56
+ const parsed = parsePromptMenu(paneText, message);
57
+ let question = parsed.question;
58
+ if (!question && parsed.options.length > 0) {
59
+ question = 'The agent is waiting on your input.';
60
+ }
61
+ if (!question && parsed.options.length === 0)
62
+ return 0;
63
+ const promptId = mintPromptId(activationId, paneId, { question, options: parsed.options });
64
+ const ok = await reportPrompt({
65
+ baseUrl,
66
+ pat,
67
+ activationId,
68
+ promptId,
69
+ question,
70
+ options: parsed.options,
71
+ paneId,
72
+ fetchImpl: deps.fetchImpl,
73
+ });
74
+ if (!ok) {
75
+ errLog('nonbot run-prompt-hook: prompt report failed (will retry on next notification)\n');
76
+ }
77
+ return 0;
78
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { runLogsCommand } from './commands/logs.js';
10
10
  import { runProfilesCommand } from './commands/profiles.js';
11
11
  import { runChoirCommand } from './commands/choir.js';
12
12
  import { runChoirMcpCommand } from './commands/choir-mcp.js';
13
+ import { runRunPromptHookCommand } from './commands/run-prompt-hook.js';
13
14
  import { setActiveProfile } from './lib/auth.js';
14
15
  import { header, kvRow, ANSI, isTTY } from './lib/output.js';
15
16
  const COMMANDS = [
@@ -38,6 +39,11 @@ const COMMANDS = [
38
39
  description: 'Per-pane Choir MCP server over stdio. Launched by .mcp.json — not run by hand.',
39
40
  run: () => runChoirMcpCommand(),
40
41
  },
42
+ {
43
+ name: 'run-prompt-hook',
44
+ description: "Claude Code Notification-hook handler — surfaces a Run's mid-run prompt. Not run by hand.",
45
+ run: () => runRunPromptHookCommand(),
46
+ },
41
47
  {
42
48
  name: 'status',
43
49
  description: 'Report login state + last daemon heartbeat.',
@@ -7,13 +7,13 @@ import { resolveTerminal } from './terminal.js';
7
7
  import { getActiveProfile } from './auth.js';
8
8
  import { appendActivityLog } from './activity-log.js';
9
9
  import { activationCard, clockTime } from './output.js';
10
- import { buildCommandFromParams, shellQuoteSingle } from './command-builders.js';
10
+ import { buildCommandFromParams, hookSettingsPathFor, shellQuoteSingle, } from './command-builders.js';
11
11
  import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
12
12
  export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
13
13
  export const WIRE_COMMAND_MAX_LENGTH = 4096;
14
14
  export const COMMAND_WARN_LENGTH = 8192;
15
15
  export const COMMAND_MAX_LENGTH = 16384;
16
- export function resolveExecutableCommand(act, warn = (s) => console.warn(s)) {
16
+ export function resolveExecutableCommand(act, warn = (s) => console.warn(s), opts = {}) {
17
17
  if (process.env.NONBOT_TEST_DIRECT_SHELL === '1' && act.payload) {
18
18
  const p = act.payload;
19
19
  if (p.template === 'test-direct-shell' && typeof p.shell === 'string') {
@@ -58,6 +58,13 @@ export function resolveExecutableCommand(act, warn = (s) => console.warn(s)) {
58
58
  if (typeof act.command === 'string' && act.command.length > 0 && act.command !== shell) {
59
59
  warn(`[${act.id}] note: daemon-built command differs from server-supplied command (executing daemon-built; server text is informational).\n`);
60
60
  }
61
+ if (opts.injectClaudeHook && params.template === 'real' && params.provider === 'claude') {
62
+ const withHook = {
63
+ ...params,
64
+ hookSettingsPath: hookSettingsPathFor(params.activationId),
65
+ };
66
+ shell = buildCommandFromParams(withHook);
67
+ }
61
68
  return { shell, params };
62
69
  }
63
70
  export function validateActivationEnvelope(act) {
@@ -201,7 +208,7 @@ async function captureSpawn(cmd, args) {
201
208
  export const _captureSpawnForTests = captureSpawn;
202
209
  export async function spawnTerminalDefault(act, auth) {
203
210
  validateActivationEnvelope(act);
204
- const resolved = resolveExecutableCommand(act);
211
+ const resolved = resolveExecutableCommand(act, undefined, { injectClaudeHook: true });
205
212
  const ext = process.platform === 'darwin' ? 'command' : 'sh';
206
213
  const scriptPath = path.join(tmpdir(), `nonbot-${act.id}.${ext}`);
207
214
  if (path.dirname(scriptPath) !== tmpdir()) {
@@ -428,7 +435,7 @@ export async function fireActivation(auth, act, deps = {}, log = (s) => process.
428
435
  reason,
429
436
  profile: getActiveProfile(),
430
437
  });
431
- return { id: act.id, status: 'failed', kind: act.kind };
438
+ return { id: act.id, status: 'failed', kind: act.kind, errorReason: reason };
432
439
  }
433
440
  }
434
441
  async function logActivity(entry) {
@@ -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
+ }
@@ -260,7 +260,7 @@ export function createHub(opts) {
260
260
  }
261
261
  if (msg && msg.type === 'call') {
262
262
  const res = handleCall(ident, String(msg.tool ?? ''), String(msg.paneId ?? ''), msg.args ?? {});
263
- respond(res);
263
+ respond({ type: 'response', id: msg.id, ...res });
264
264
  return;
265
265
  }
266
266
  respond({ ok: false, error: 'unknown message type' });
@@ -98,7 +98,7 @@ export function createHubClient(opts) {
98
98
  async function request(tool, args) {
99
99
  await ensureConnected();
100
100
  const id = nextId++;
101
- const frame = { type: 'request', id, tool, args };
101
+ const frame = { type: 'call', id, tool, paneId: opts.paneId, args };
102
102
  return new Promise((resolve, reject) => {
103
103
  const timer = setTimeout(() => {
104
104
  pending.delete(id);
@@ -113,6 +113,27 @@ export function buildAgentsMdHeredoc(agentsMd, activationId) {
113
113
  `NONBOT_AGENTS_EOF\n` +
114
114
  `grep -qxF .nonbot/ .gitignore 2>/dev/null || printf '%s\\n' .nonbot/ >> .gitignore`);
115
115
  }
116
+ export const RUN_PROMPT_HOOK_COMMAND = 'nonbot run-prompt-hook';
117
+ export function hookSettingsPathFor(activationId) {
118
+ const safe = String(activationId || '').replace(/[^a-zA-Z0-9_-]/g, '');
119
+ const id = safe.length > 0 ? safe : 'unknown';
120
+ return `.nonbot/hook-${id}.settings.json`;
121
+ }
122
+ export function buildHookSettingsJson() {
123
+ return JSON.stringify({
124
+ hooks: {
125
+ Notification: [
126
+ { matcher: '', hooks: [{ type: 'command', command: RUN_PROMPT_HOOK_COMMAND }] },
127
+ ],
128
+ },
129
+ });
130
+ }
131
+ export function buildHookSettingsHeredoc(settingsPath) {
132
+ return (`mkdir -p .nonbot && ` +
133
+ `cat > ${settingsPath} <<'NONBOT_HOOK_EOF'\n` +
134
+ `${buildHookSettingsJson()}\n` +
135
+ `NONBOT_HOOK_EOF`);
136
+ }
116
137
  export function buildDiagnosticCommand(params) {
117
138
  const trimmed = typeof params.repoPath === 'string' ? params.repoPath.trim() : '';
118
139
  const head = trimmed ? `cd ${shellQuoteSingle(trimmed)} && ` : '';
@@ -261,7 +282,16 @@ export function buildRealCommand(params) {
261
282
  }
262
283
  const briefPath = briefPathFor(params.activationId);
263
284
  const prompt = `Read ${briefPath} (your per-run brief) and start work on activation ${idLabel} — story: ${safeTitleLabel}`;
264
- return `${head}${agentsMdPrefix}${bannerEmit}${cli} ${shellQuoteSingle(prompt)}`;
285
+ const wantHook = cli === 'claude' &&
286
+ typeof params.hookSettingsPath === 'string' &&
287
+ params.hookSettingsPath.length > 0;
288
+ const hookHeredocPrefix = wantHook
289
+ ? `${buildHookSettingsHeredoc(params.hookSettingsPath)} && `
290
+ : '';
291
+ const settingsFlag = wantHook
292
+ ? `--settings ${shellQuoteSingle(params.hookSettingsPath)} `
293
+ : '';
294
+ return `${head}${agentsMdPrefix}${hookHeredocPrefix}${bannerEmit}${cli} ${settingsFlag}${shellQuoteSingle(prompt)}`;
265
295
  }
266
296
  export function buildCommandFromParams(params) {
267
297
  switch (params.template) {
@@ -91,6 +91,50 @@ export function validateProvider(s) {
91
91
  }
92
92
  return s;
93
93
  }
94
+ const TMUX_SAFE_DIRECTIVES = new Set([
95
+ 'set', 'set-option', 'setw', 'set-window-option',
96
+ ]);
97
+ export function isSafeTmuxConf(conf) {
98
+ for (const rawLine of conf.split(/\r?\n/)) {
99
+ const line = rawLine.trim();
100
+ if (line === '')
101
+ continue;
102
+ if (line.startsWith('#'))
103
+ continue;
104
+ if (line.includes('#(') || line.includes('$(') || line.includes('`'))
105
+ return false;
106
+ const firstToken = line.split(/\s+/)[0];
107
+ if (!TMUX_SAFE_DIRECTIVES.has(firstToken))
108
+ return false;
109
+ }
110
+ return true;
111
+ }
112
+ const ITERM_FORBIDDEN_KEYS = ['Command', 'Initial Text', 'Send Text at Start'];
113
+ export function isSafeItermProfileJson(jsonStr) {
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(jsonStr);
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ if (!parsed || typeof parsed !== 'object')
122
+ return false;
123
+ const root = parsed;
124
+ const profiles = Array.isArray(root.Profiles) ? root.Profiles : [root];
125
+ for (const prof of profiles) {
126
+ if (!prof || typeof prof !== 'object')
127
+ return false;
128
+ const p = prof;
129
+ for (const k of ITERM_FORBIDDEN_KEYS) {
130
+ if (k in p)
131
+ return false;
132
+ }
133
+ if ('Custom Command' in p && p['Custom Command'] !== 'No')
134
+ return false;
135
+ }
136
+ return true;
137
+ }
94
138
  export function validateTerminalTheme(raw) {
95
139
  if (raw === undefined || raw === null)
96
140
  return null;
@@ -107,6 +151,10 @@ export function validateTerminalTheme(raw) {
107
151
  return null;
108
152
  if (typeof t.itermProfileJson !== 'string' || t.itermProfileJson.length > THEME_ITERM_JSON_MAX)
109
153
  return null;
154
+ if (!isSafeTmuxConf(t.tmuxConf))
155
+ return null;
156
+ if (!isSafeItermProfileJson(t.itermProfileJson))
157
+ return null;
110
158
  return {
111
159
  id: t.id,
112
160
  name: t.name,
@@ -0,0 +1,204 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
3
+ import { VERSION } from '../version.js';
4
+ const QUESTION_MAX = 200;
5
+ const LABEL_MAX = 80;
6
+ const OPTIONS_MAX = 8;
7
+ const MENU_LINE_RE = /^\s*[>❯▸▶*•]?\s*(\d{1,2})[.)]\s+(\S.*)$/;
8
+ const GLYPH_RE = /[│┃|╭╮╯╰─━┄┈┌┐└┘├┤>❯▸▶*•]/g;
9
+ export function parsePromptMenu(paneText, fallbackMessage = '') {
10
+ const fallback = (fallbackMessage || '').trim().slice(0, QUESTION_MAX);
11
+ if (typeof paneText !== 'string' || paneText.length === 0) {
12
+ return { question: fallback, options: [] };
13
+ }
14
+ const lines = paneText.split('\n');
15
+ let endIdx = -1;
16
+ for (let i = lines.length - 1; i >= 0; i--) {
17
+ if (MENU_LINE_RE.test(lines[i])) {
18
+ endIdx = i;
19
+ break;
20
+ }
21
+ }
22
+ if (endIdx === -1)
23
+ return { question: fallback, options: [] };
24
+ let startIdx = endIdx;
25
+ while (startIdx - 1 >= 0 && MENU_LINE_RE.test(lines[startIdx - 1]))
26
+ startIdx--;
27
+ const options = [];
28
+ for (let i = startIdx; i <= endIdx; i++) {
29
+ if (options.length >= OPTIONS_MAX)
30
+ break;
31
+ const m = lines[i].match(MENU_LINE_RE);
32
+ if (!m)
33
+ continue;
34
+ const index = Number.parseInt(m[1], 10);
35
+ if (!Number.isFinite(index))
36
+ continue;
37
+ const label = m[2].trim().slice(0, LABEL_MAX);
38
+ if (!label)
39
+ continue;
40
+ options.push({ index, label });
41
+ }
42
+ if (options.length === 0)
43
+ return { question: fallback, options: [] };
44
+ let question = '';
45
+ for (let i = startIdx - 1; i >= 0; i--) {
46
+ if (MENU_LINE_RE.test(lines[i]))
47
+ continue;
48
+ const t = lines[i].replace(GLYPH_RE, '').trim();
49
+ if (!t)
50
+ continue;
51
+ if (!/[A-Za-z0-9]/.test(t))
52
+ continue;
53
+ question = t;
54
+ break;
55
+ }
56
+ question = (question || fallback).slice(0, QUESTION_MAX);
57
+ return { question, options };
58
+ }
59
+ export function mintPromptId(activationId, paneId, parsed) {
60
+ const sig = JSON.stringify({
61
+ q: parsed.question,
62
+ o: parsed.options.map((o) => `${o.index}:${o.label}`),
63
+ });
64
+ const hash = createHash('sha256')
65
+ .update(`${activationId}|${paneId || ''}|${sig}`)
66
+ .digest('hex')
67
+ .slice(0, 24);
68
+ return `rp_${hash}`;
69
+ }
70
+ export async function reportPrompt(args) {
71
+ const fetchImpl = args.fetchImpl ?? fetch;
72
+ try {
73
+ const body = {
74
+ promptId: args.promptId,
75
+ question: args.question,
76
+ options: args.options,
77
+ };
78
+ if (args.paneId)
79
+ body.paneId = args.paneId;
80
+ const res = await fetchImpl(`${args.baseUrl}/api/cli/activations/${args.activationId}/prompt`, {
81
+ method: 'POST',
82
+ headers: {
83
+ Authorization: `Bearer ${args.pat}`,
84
+ 'Content-Type': 'application/json',
85
+ 'X-Requested-With': 'ConradPM-Native',
86
+ 'X-CLI-Version': VERSION,
87
+ },
88
+ body: JSON.stringify(body),
89
+ });
90
+ return res.ok;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ export async function pollAnsweredPrompts(args) {
97
+ const fetchImpl = args.fetchImpl ?? fetch;
98
+ try {
99
+ const res = await fetchImpl(`${args.baseUrl}/api/cli/run-prompts/answered`, {
100
+ headers: {
101
+ Authorization: `Bearer ${args.pat}`,
102
+ 'X-Requested-With': 'ConradPM-Native',
103
+ 'X-CLI-Version': VERSION,
104
+ },
105
+ });
106
+ if (!res.ok)
107
+ return [];
108
+ const data = (await res.json());
109
+ if (!data || !Array.isArray(data.prompts))
110
+ return [];
111
+ const out = [];
112
+ for (const r of data.prompts) {
113
+ if (!r || typeof r.promptId !== 'string' || typeof r.activationId !== 'string')
114
+ continue;
115
+ out.push({
116
+ promptId: r.promptId,
117
+ activationId: r.activationId,
118
+ paneId: typeof r.paneId === 'string' && r.paneId.length > 0 ? r.paneId : null,
119
+ answerIndex: typeof r.answerIndex === 'number' ? r.answerIndex : null,
120
+ answerText: typeof r.answerText === 'string' ? r.answerText : null,
121
+ answeredAt: typeof r.answeredAt === 'number' ? r.answeredAt : undefined,
122
+ });
123
+ }
124
+ return out;
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }
130
+ export async function confirmDelivered(args) {
131
+ const fetchImpl = args.fetchImpl ?? fetch;
132
+ try {
133
+ const res = await fetchImpl(`${args.baseUrl}/api/cli/run-prompts/${args.promptId}/delivered`, {
134
+ method: 'POST',
135
+ headers: {
136
+ Authorization: `Bearer ${args.pat}`,
137
+ 'Content-Type': 'application/json',
138
+ 'X-Requested-With': 'ConradPM-Native',
139
+ 'X-CLI-Version': VERSION,
140
+ },
141
+ body: '{}',
142
+ });
143
+ return res.ok || res.status === 409;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ export function injectAnswer(paneId, answerIndex, answerText, spawnImpl = nodeSpawnSync) {
150
+ const keys = answerIndex !== null && Number.isFinite(answerIndex)
151
+ ? String(answerIndex)
152
+ : answerText ?? '';
153
+ if (keys.length === 0)
154
+ return false;
155
+ try {
156
+ spawnImpl('tmux', ['send-keys', '-l', '-t', paneId, keys], {
157
+ encoding: 'utf-8',
158
+ timeout: 2000,
159
+ windowsHide: true,
160
+ });
161
+ spawnImpl('tmux', ['send-keys', '-t', paneId, 'Enter'], {
162
+ encoding: 'utf-8',
163
+ timeout: 2000,
164
+ windowsHide: true,
165
+ });
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ export async function deliverAnsweredPrompts(opts) {
173
+ const answered = await pollAnsweredPrompts({
174
+ baseUrl: opts.baseUrl,
175
+ pat: opts.pat,
176
+ fetchImpl: opts.fetchImpl,
177
+ });
178
+ if (answered.length === 0)
179
+ return [];
180
+ const reported = [];
181
+ for (const p of answered) {
182
+ if (!p.paneId)
183
+ continue;
184
+ if (!opts.injected.has(p.promptId)) {
185
+ const sent = injectAnswer(p.paneId, p.answerIndex, p.answerText, opts.spawnImpl);
186
+ if (!sent)
187
+ continue;
188
+ opts.injected.add(p.promptId);
189
+ }
190
+ const confirmed = await confirmDelivered({
191
+ baseUrl: opts.baseUrl,
192
+ pat: opts.pat,
193
+ promptId: p.promptId,
194
+ fetchImpl: opts.fetchImpl,
195
+ });
196
+ if (confirmed) {
197
+ reported.push(p.promptId);
198
+ opts.log?.(`✓ ${p.activationId} · answer delivered to pane ${p.paneId}\n`);
199
+ }
200
+ }
201
+ if (opts.injected.size > 500)
202
+ opts.injected.clear();
203
+ return reported;
204
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.8.0';
1
+ export const VERSION = '0.9.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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",
@@ -26,8 +26,8 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.0.0",
29
- "tsx": "^4.7.0",
29
+ "tsx": "^4.22.4",
30
30
  "typescript": "^5.3.0",
31
- "vitest": "^3.0.0"
31
+ "vitest": "^4.1.8"
32
32
  }
33
33
  }