@nonbot/cli 0.9.4 → 0.9.6

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.
@@ -3,16 +3,17 @@ 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';
7
- import { loadOrCreateMachineId, resolveMachineName } from '../lib/machine.js';
6
+ import { evictOldestToCap } from '../lib/bounded-set.js';
7
+ import { deliverAnsweredPrompts, capturePane, parsePromptMenu, mintPromptId, } from '../lib/run-prompt.js';
8
+ import { loadOrCreateMachineId, resolveMachineName, shortMachineId } from '../lib/machine.js';
8
9
  import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
9
10
  import { groupBySession, launchCoordinatedSet, setIsIsolated, } from '../lib/choir/coordinated-set.js';
10
11
  import { IsolatedSessionTracker, reconcileAndCleanup as defaultReconcileAndCleanup, resolveBaseBranch as defaultResolveBaseBranch, } from '../lib/choir/isolated-session.js';
11
- import { applyPaneTitle } from '../lib/pane-title.js';
12
+ import { applyPaneTitle, applyPaneState } from '../lib/pane-title.js';
12
13
  import { installService, uninstallService } from '../lib/service.js';
13
14
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
14
15
  import { VERSION } from '../version.js';
15
- import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activationCard, runSummary, formatElapsed, c, } from '../lib/output.js';
16
+ import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activationCard, runSummary, formatElapsed, liveFooter, needsYouBanner, resumedLine, buildPaneBorderFormat, buildTmuxStatusLeft, buildTmuxStatusRight, c, } from '../lib/output.js';
16
17
  export const POLL_FAST_MS = 2000;
17
18
  export const POLL_MAX_MS = 30000;
18
19
  export const POLL_INTERVAL_MS = POLL_FAST_MS;
@@ -81,6 +82,21 @@ export function applyNonbotTmuxConfig(deps = {}) {
81
82
  tmux('set-option', 'set-titles', 'on');
82
83
  tmux('set-option', 'set-titles-string', 'NONBOT TMUX │ #W');
83
84
  tmux('refresh-client', '-S');
85
+ if (deps.chrome) {
86
+ const ascii = env.NONBOT_ASCII_ONLY === '1' || env.NONBOT_ASCII_ONLY === 'true';
87
+ tmux('set-option', '-g', 'pane-border-status', 'top');
88
+ tmux('set-option', '-g', 'pane-border-format', buildPaneBorderFormat({ ascii }));
89
+ tmux('set-option', '-g', 'pane-border-lines', 'heavy');
90
+ tmux('set-option', '-g', 'status', 'on');
91
+ tmux('set-option', '-g', 'status-interval', '5');
92
+ tmux('set-option', '-g', 'status-style', 'bg=#0b0b12,fg=#8b949e');
93
+ tmux('set-option', '-g', 'status-left-length', '60');
94
+ tmux('set-option', '-g', 'status-right-length', '120');
95
+ tmux('set-option', '-g', 'status-left', buildTmuxStatusLeft({ ascii }));
96
+ tmux('set-option', '-g', 'status-right', buildTmuxStatusRight({ ascii }));
97
+ tmux('set-option', '-g', 'window-status-current-style', 'fg=#0b0b12,bg=#4fc3f7,bold');
98
+ tmux('refresh-client', '-S');
99
+ }
84
100
  if (!stdoutIsTTY)
85
101
  return;
86
102
  const insideTmux = inTmuxSession(env);
@@ -135,7 +151,7 @@ export function maybeReexecIntoTmux(deps = {}) {
135
151
  return 'reexeced';
136
152
  }
137
153
  export async function runDaemonCommand(args = [], options = {}, deps = {}) {
138
- const log = deps.log ?? ((s) => process.stdout.write(s));
154
+ const rawLog = deps.log ?? ((s) => process.stdout.write(s));
139
155
  const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
140
156
  const fetchImpl = deps.fetchImpl ?? fetch;
141
157
  const loader = deps.loadAuth ?? loadAuth;
@@ -147,7 +163,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
147
163
  const install = deps.installService ?? installService;
148
164
  const res = await install();
149
165
  if (res.ok)
150
- log(statusRow('✓', 'Service installed', res.message) + '\n');
166
+ rawLog(statusRow('✓', 'Service installed', res.message) + '\n');
151
167
  else
152
168
  errLog(statusRow('✗', 'Service install failed', res.message, { stream: process.stderr }) + '\n');
153
169
  return res.ok ? 0 : 1;
@@ -156,7 +172,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
156
172
  const uninstall = deps.uninstallService ?? uninstallService;
157
173
  const res = await uninstall();
158
174
  if (res.ok)
159
- log(statusRow('✓', 'Service removed', res.message) + '\n');
175
+ rawLog(statusRow('✓', 'Service removed', res.message) + '\n');
160
176
  else
161
177
  errLog(statusRow('✗', 'Service uninstall failed', res.message, { stream: process.stderr }) + '\n');
162
178
  return res.ok ? 0 : 1;
@@ -169,7 +185,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
169
185
  if (headless) {
170
186
  deps.headless = true;
171
187
  if (!deps.spawnTerminal) {
172
- deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log, errLog });
188
+ deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log: rawLog, errLog });
173
189
  }
174
190
  }
175
191
  const auth = await loader();
@@ -181,8 +197,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
181
197
  const tmuxSessionName = detectTmux();
182
198
  const machineId = deps.machineId ?? loadOrCreateMachineId();
183
199
  const machineName = resolveMachineName();
200
+ const tmuxChromeEnabled = tmuxSessionName !== null &&
201
+ !nonbotTmuxOptOut() &&
202
+ process.env.NONBOT_NO_TMUX_STATUS !== '1';
184
203
  if (tmuxSessionName) {
185
- applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
204
+ applyNonbotTmuxConfig({ spawnSync: deps.spawnSync, chrome: tmuxChromeEnabled });
186
205
  }
187
206
  const resolveTerminalFn = deps.resolveTerminal ?? resolveTerminal;
188
207
  const resolvedTerminalProfile = headless ? null : resolveTerminalFn(undefined);
@@ -201,6 +220,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
201
220
  const trackedPanes = new Map();
202
221
  const killedByStop = new Set();
203
222
  const injectedPrompts = new Set();
223
+ const openPrompts = new Map();
204
224
  const emitRunStageFn = deps.emitRunStage ?? defaultEmitRunStage;
205
225
  const startRunHeartbeatFn = deps.startRunHeartbeat ?? defaultStartRunHeartbeat;
206
226
  const runHeartbeats = new Map();
@@ -235,13 +255,144 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
235
255
  let doneCount = 0;
236
256
  let failedCount = 0;
237
257
  const paneTitlingEnabled = tmuxSessionName !== null && !nonbotTmuxOptOut();
258
+ const spawnForTmux = deps.spawnSync;
238
259
  const retitlePane = (state, paneId, story) => {
239
260
  if (!paneTitlingEnabled || !paneId)
240
261
  return;
241
- applyPaneTitle(paneId, state, story, deps.spawnSync);
262
+ applyPaneTitle(paneId, state, story, spawnForTmux);
263
+ applyPaneState(paneId, state, spawnForTmux);
264
+ };
265
+ const pushTmuxStatus = (needsYou = openPrompts.size, halted = false) => {
266
+ if (!tmuxChromeEnabled)
267
+ return;
268
+ const spawn = deps.spawnSync ?? nodeSpawnSync;
269
+ const set = (name, value) => {
270
+ try {
271
+ spawn('tmux', ['set-option', '-g', name, String(value)], {
272
+ encoding: 'utf-8',
273
+ timeout: 1000,
274
+ windowsHide: true,
275
+ });
276
+ }
277
+ catch {
278
+ }
279
+ };
280
+ set('@nb_running', trackedPanes.size);
281
+ set('@nb_done', doneCount);
282
+ set('@nb_failed', failedCount);
283
+ set('@nb_needsyou', needsYou);
284
+ set('@nb_poll', Math.round((fixedInterval ?? currentInterval) / 1000));
285
+ set('@nb_halted', halted ? 1 : 0);
286
+ try {
287
+ spawn('tmux', ['refresh-client', '-S'], { encoding: 'utf-8', timeout: 1000, windowsHide: true });
288
+ }
289
+ catch {
290
+ }
291
+ };
292
+ const stdoutIsTTY = deps.stdoutIsTTY ?? (process.stdout?.isTTY === true && !process.env.VITEST);
293
+ const footerEnabled = stdoutIsTTY && !headless;
294
+ let footerActive = false;
295
+ let lastPollOk = true;
296
+ const noBell = process.env.NONBOT_NO_BELL === '1' || process.env.NONBOT_NO_BELL === 'true';
297
+ const ERASE = '\r\x1b[K';
298
+ const footerHealth = () => {
299
+ if (openPrompts.size > 0)
300
+ return 'waiting';
301
+ if (!lastPollOk)
302
+ return 'reconnecting';
303
+ if (fixedInterval === undefined && currentInterval > POLL_FAST_MS)
304
+ return 'backoff';
305
+ return 'live';
306
+ };
307
+ const renderFooter = () => liveFooter({
308
+ running: trackedPanes.size,
309
+ waiting: openPrompts.size,
310
+ done: doneCount,
311
+ failed: failedCount,
312
+ nextPollMs: fixedInterval ?? currentInterval,
313
+ version: `v${VERSION}`,
314
+ machine: machineName,
315
+ health: footerHealth(),
316
+ });
317
+ const refreshFooter = () => {
318
+ if (footerEnabled && footerActive)
319
+ rawLog(ERASE + renderFooter());
320
+ };
321
+ const startFooter = () => {
322
+ if (!footerEnabled)
323
+ return;
324
+ footerActive = true;
325
+ rawLog(ERASE + renderFooter());
326
+ };
327
+ const log = (s) => {
328
+ if (!footerEnabled || !footerActive) {
329
+ rawLog(s);
330
+ return;
331
+ }
332
+ rawLog(ERASE);
333
+ rawLog(s);
334
+ rawLog(ERASE + renderFooter());
335
+ };
336
+ const dropFooter = () => {
337
+ if (footerActive) {
338
+ footerActive = false;
339
+ rawLog(ERASE);
340
+ }
242
341
  };
243
342
  const emitSummary = () => {
244
343
  log(runSummary({ running: trackedPanes.size, done: doneCount, failed: failedCount }) + '\n');
344
+ pushTmuxStatus();
345
+ };
346
+ const sweepPanePrompts = () => {
347
+ if (!paneTitlingEnabled || trackedPanes.size === 0)
348
+ return;
349
+ let changed = false;
350
+ for (const [activationId, paneId] of trackedPanes) {
351
+ let menu;
352
+ try {
353
+ menu = parsePromptMenu(capturePane(paneId, spawnForTmux), '');
354
+ }
355
+ catch {
356
+ continue;
357
+ }
358
+ const meta = trackedMeta.get(activationId);
359
+ if (menu.options.length > 0) {
360
+ const promptId = mintPromptId(activationId, paneId, menu);
361
+ if (openPrompts.get(activationId) === promptId)
362
+ continue;
363
+ openPrompts.set(activationId, promptId);
364
+ log('\n' +
365
+ needsYouBanner({
366
+ activationId,
367
+ story: meta?.story,
368
+ paneId,
369
+ provider: meta?.provider,
370
+ options: menu.options,
371
+ elapsedMs: meta ? Date.now() - meta.startedAt : undefined,
372
+ }) +
373
+ '\n');
374
+ retitlePane('waiting', paneId, meta?.story ?? '');
375
+ if (!noBell && footerEnabled)
376
+ rawLog('\x07');
377
+ changed = true;
378
+ }
379
+ else if (openPrompts.has(activationId)) {
380
+ openPrompts.delete(activationId);
381
+ log(resumedLine(activationId) + '\n');
382
+ retitlePane('running', paneId, meta?.story ?? '');
383
+ changed = true;
384
+ }
385
+ }
386
+ for (const id of [...openPrompts.keys()]) {
387
+ if (!trackedPanes.has(id)) {
388
+ openPrompts.delete(id);
389
+ changed = true;
390
+ }
391
+ }
392
+ if (changed) {
393
+ pushTmuxStatus();
394
+ refreshFooter();
395
+ }
245
396
  };
246
397
  let running = true;
247
398
  let sigHandler;
@@ -250,7 +401,9 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
250
401
  running = false;
251
402
  for (const id of [...runHeartbeats.keys()])
252
403
  stopHeartbeat(id);
253
- log('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
404
+ dropFooter();
405
+ pushTmuxStatus(0, true);
406
+ rawLog('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
254
407
  process.exit(0);
255
408
  };
256
409
  process.on('SIGINT', sigHandler);
@@ -286,6 +439,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
286
439
  log(`${headerMarker} ${c.bold(headerLabel)}\n`);
287
440
  const ctxRows = [
288
441
  ['YOU', auth.email],
442
+ ['MACHINE', `${machineName} · ${shortMachineId(machineId)}`],
289
443
  ['LISTENING', `${hostUrl}/api/cli/activations/pending`],
290
444
  ['PROFILE', profileName],
291
445
  ['LANDING', terminalLabel],
@@ -303,6 +457,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
303
457
  inTmux: tmuxSessionName !== null,
304
458
  }) + '\n');
305
459
  log('\n');
460
+ pushTmuxStatus();
461
+ startFooter();
306
462
  while (running) {
307
463
  let firedThisPoll = false;
308
464
  try {
@@ -329,6 +485,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
329
485
  return 1;
330
486
  }
331
487
  if (res.ok) {
488
+ lastPollOk = true;
332
489
  const body = (await res.json());
333
490
  const pendingKills = body?.pendingKills ?? [];
334
491
  if (pendingKills.length > 0) {
@@ -496,6 +653,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
496
653
  baseUrl: auth.baseUrl,
497
654
  pat: auth.pat,
498
655
  injected: injectedPrompts,
656
+ trackedPanes,
499
657
  machineId,
500
658
  fetchImpl,
501
659
  spawnImpl: deps.spawnSync,
@@ -504,6 +662,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
504
662
  }
505
663
  catch {
506
664
  }
665
+ try {
666
+ sweepPanePrompts();
667
+ }
668
+ catch {
669
+ }
507
670
  if (trackedPanes.size > 0) {
508
671
  const reported = await checkCompletions({
509
672
  tracked: trackedPanes,
@@ -537,7 +700,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
537
700
  doneCount++;
538
701
  trackedMeta.delete(id);
539
702
  safeEmit(id, RUN_STAGE.FINISHED);
540
- stopHeartbeat(id, 'daemon-exit');
703
+ stopHeartbeat(id, 'finished');
541
704
  try {
542
705
  const session = isolatedSessions.noteTerminal(id);
543
706
  if (session) {
@@ -556,28 +719,30 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
556
719
  emitSummary();
557
720
  }
558
721
  }
559
- if (!firedThisPoll && pendingKills.length === 0) {
722
+ if (!firedThisPoll && pendingKills.length === 0 && !footerEnabled) {
560
723
  log(pollTick({
561
724
  pending: 0,
562
725
  sleepMs: fixedInterval ?? currentInterval,
563
726
  backingOff: fixedInterval === undefined && currentInterval > POLL_FAST_MS,
564
727
  }) + '\n');
565
728
  }
566
- if (seen.size > 500)
567
- seen.clear();
729
+ evictOldestToCap(seen, 500);
568
730
  }
569
731
  }
570
732
  catch (e) {
733
+ lastPollOk = false;
571
734
  errLog(statusRow('⚠', 'poll error', e.message, { stream: process.stderr }) + '\n');
572
735
  }
573
736
  if (options.oneShot)
574
737
  break;
575
738
  if (fixedInterval !== undefined) {
739
+ refreshFooter();
576
740
  await sleep(fixedInterval);
577
741
  }
578
742
  else {
579
743
  if (firedThisPoll)
580
744
  currentInterval = POLL_FAST_MS;
745
+ refreshFooter();
581
746
  await sleep(currentInterval);
582
747
  if (!firedThisPoll) {
583
748
  currentInterval = Math.min(currentInterval * 2, maxIntervalMs);
@@ -60,6 +60,17 @@ export async function runRunPromptHookCommand(deps = {}) {
60
60
  }
61
61
  if (!question && parsed.options.length === 0)
62
62
  return 0;
63
+ if (paneId && /^%\d+$/.test(paneId)) {
64
+ try {
65
+ spawnSync('tmux', ['set-option', '-p', '-t', paneId, '@nb_state', 'waiting'], {
66
+ encoding: 'utf-8',
67
+ timeout: 1000,
68
+ windowsHide: true,
69
+ });
70
+ }
71
+ catch {
72
+ }
73
+ }
63
74
  const promptId = mintPromptId(activationId, paneId, { question, options: parsed.options });
64
75
  const ok = await reportPrompt({
65
76
  baseUrl,
@@ -9,6 +9,7 @@ import { appendActivityLog } from './activity-log.js';
9
9
  import { activationCard, clockTime } from './output.js';
10
10
  import { buildCommandFromParams, hookSettingsPathFor, shellQuoteSingle, } from './command-builders.js';
11
11
  import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
12
+ const PANE_ID_RE = /^%\d+$/;
12
13
  export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
13
14
  export const WIRE_COMMAND_MAX_LENGTH = 4096;
14
15
  export const COMMAND_WARN_LENGTH = 8192;
@@ -450,6 +451,8 @@ export async function executePendingKills(kills, baseUrl, token) {
450
451
  return;
451
452
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
452
453
  const runOne = async (k) => {
454
+ if (!PANE_ID_RE.test(k.tmuxPaneId))
455
+ return;
453
456
  try {
454
457
  spawnSync('tmux', ['send-keys', '-t', k.tmuxPaneId, 'C-c'], {
455
458
  encoding: 'utf-8',
@@ -0,0 +1,13 @@
1
+ export function evictOldestToCap(set, cap) {
2
+ if (set.size <= cap)
3
+ return;
4
+ const overflow = set.size - cap;
5
+ const victims = [];
6
+ for (const id of set) {
7
+ victims.push(id);
8
+ if (victims.length >= overflow)
9
+ break;
10
+ }
11
+ for (const id of victims)
12
+ set.delete(id);
13
+ }
@@ -7,6 +7,7 @@ import { addWorktree as defaultAddWorktree } from './worktree.js';
7
7
  import { createHub as defaultCreateHub } from './hub.js';
8
8
  import { PROVIDER_PROFILES } from '../command-builders.js';
9
9
  import { validateRepoPath } from '../payload-validator.js';
10
+ import { resolveBaseBranch as defaultResolveBaseBranch } from './isolated-session.js';
10
11
  const defaultFs = {
11
12
  mkdirSync: (p, opts) => nodeFs.mkdirSync(p, opts),
12
13
  writeFileSync: (p, data, opts) => nodeFs.writeFileSync(p, data, opts),
@@ -122,7 +123,8 @@ export async function launchCoordinatedSet(args) {
122
123
  throw new Error('coordinated set token minting produced an empty token');
123
124
  }
124
125
  const sessionId = `choir_${sessionName}_${token.slice(0, 8)}`;
125
- const baseBranch = 'HEAD';
126
+ const resolveBaseBranchFn = deps.resolveBaseBranchImpl ?? defaultResolveBaseBranch;
127
+ const baseBranch = resolveBaseBranchFn(repoRoot);
126
128
  const hub = createHubImpl({
127
129
  sessionId,
128
130
  repoRoot,
@@ -1,5 +1,5 @@
1
1
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
- import { removeWorktree as defaultRemoveWorktree } from './worktree.js';
2
+ import { removeWorktree as defaultRemoveWorktree, pollGitStatus as defaultPollGitStatus } from './worktree.js';
3
3
  import { runReconcile as defaultRunReconcile } from './reconcile.js';
4
4
  import { isValidBranch } from './names.js';
5
5
  export class IsolatedSessionTracker {
@@ -55,10 +55,21 @@ export function resolveBaseBranch(repoRoot, spawnImpl = nodeSpawnSync) {
55
55
  }
56
56
  return 'main';
57
57
  }
58
+ const LAUNCHER_ARTIFACTS = new Set(['.mcp.json']);
59
+ function worktreeHasUnmergedWork(worktreePath, baseBranch, spawnImpl, pollGitStatusImpl) {
60
+ try {
61
+ const status = pollGitStatusImpl(worktreePath, spawnImpl, baseBranch);
62
+ return status.dirtyPaths.some((p) => !LAUNCHER_ARTIFACTS.has(p));
63
+ }
64
+ catch {
65
+ return true;
66
+ }
67
+ }
58
68
  export function reconcileAndCleanup(session, deps = {}) {
59
69
  const spawnImpl = deps.spawnImpl ?? nodeSpawnSync;
60
70
  const runReconcileImpl = deps.runReconcileImpl ?? defaultRunReconcile;
61
71
  const removeWorktreeImpl = deps.removeWorktreeImpl ?? defaultRemoveWorktree;
72
+ const pollGitStatusImpl = deps.pollGitStatusImpl ?? defaultPollGitStatus;
62
73
  const log = deps.log;
63
74
  const branches = session.panes.map((p) => p.branch);
64
75
  const reconcile = runReconcileImpl({
@@ -72,9 +83,14 @@ export function reconcileAndCleanup(session, deps = {}) {
72
83
  });
73
84
  const removed = [];
74
85
  if (!reconcile.blocked) {
86
+ let kept = 0;
75
87
  for (const p of session.panes) {
88
+ if (worktreeHasUnmergedWork(p.worktreePath, session.baseBranch, spawnImpl, pollGitStatusImpl)) {
89
+ kept++;
90
+ continue;
91
+ }
76
92
  try {
77
- removeWorktreeImpl({ repoRoot: session.repoRoot, worktreePath: p.worktreePath });
93
+ removeWorktreeImpl({ repoRoot: session.repoRoot, worktreePath: p.worktreePath, force: true });
78
94
  removed.push(p.worktreePath);
79
95
  }
80
96
  catch {
@@ -82,7 +98,9 @@ export function reconcileAndCleanup(session, deps = {}) {
82
98
  }
83
99
  log?.(`[reconcile] ${session.sessionId} — merged ${reconcile.merged.length} ` +
84
100
  `branch${reconcile.merged.length === 1 ? '' : 'es'}, ` +
85
- `removed ${removed.length} worktree${removed.length === 1 ? '' : 's'}\n`);
101
+ `removed ${removed.length} worktree${removed.length === 1 ? '' : 's'}` +
102
+ (kept > 0 ? `, kept ${kept} with uncommitted work for the human` : '') +
103
+ `\n`);
86
104
  }
87
105
  else {
88
106
  log?.(`[reconcile] ${session.sessionId} — BLOCKED (${reconcile.reason}); ` +
@@ -84,8 +84,9 @@ export function listWorktrees(repoRoot, spawnImpl = nodeSpawnSync) {
84
84
  return entries;
85
85
  }
86
86
  export function removeWorktree(args) {
87
- const { repoRoot, worktreePath, spawnImpl = nodeSpawnSync } = args;
88
- const r = runGit(spawnImpl, repoRoot, ['worktree', 'remove', worktreePath]);
87
+ const { repoRoot, worktreePath, spawnImpl = nodeSpawnSync, force = false } = args;
88
+ const flags = force ? ['--force'] : [];
89
+ const r = runGit(spawnImpl, repoRoot, ['worktree', 'remove', ...flags, worktreePath]);
89
90
  assertOk(r, 'worktree remove');
90
91
  }
91
92
  export function ensureChoirGitignored(repoRoot, fsImpl = defaultFs) {
@@ -6,13 +6,13 @@ export function listLivePaneIds(spawnImpl = nodeSpawnSync) {
6
6
  timeout: 1500,
7
7
  windowsHide: true,
8
8
  });
9
+ if (r.status !== 0)
10
+ return null;
9
11
  const out = typeof r.stdout === 'string' ? r.stdout : '';
10
- if (r.status !== 0 && !out)
11
- return new Set();
12
12
  return new Set(out.split('\n').map((s) => s.trim()).filter((s) => /^%\d+$/.test(s)));
13
13
  }
14
14
  catch {
15
- return new Set();
15
+ return null;
16
16
  }
17
17
  }
18
18
  export function detectCompletedActivations(tracked, livePaneIds, killed) {
@@ -48,9 +48,11 @@ export async function checkCompletions(opts) {
48
48
  if (tracked.size === 0)
49
49
  return [];
50
50
  const live = listLivePaneIds(opts.spawnImpl);
51
+ if (live === null)
52
+ return [];
51
53
  const completed = detectCompletedActivations(tracked, live, killed);
52
54
  for (const [id, pane] of [...tracked.entries()]) {
53
- if (!live.has(pane)) {
55
+ if (!live.has(pane) && killed.has(id)) {
54
56
  tracked.delete(id);
55
57
  killed.delete(id);
56
58
  }
@@ -58,11 +60,13 @@ export async function checkCompletions(opts) {
58
60
  const reported = [];
59
61
  for (const id of completed) {
60
62
  const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
61
- if (ok) {
62
- reported.push(id);
63
- const card = opts.renderComplete?.(id);
64
- opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
63
+ if (!ok) {
64
+ continue;
65
65
  }
66
+ tracked.delete(id);
67
+ reported.push(id);
68
+ const card = opts.renderComplete?.(id);
69
+ opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
66
70
  }
67
71
  return reported;
68
72
  }
@@ -36,6 +36,12 @@ export function loadOrCreateMachineId(deps = {}) {
36
36
  }
37
37
  return machineId;
38
38
  }
39
+ export function shortMachineId(id) {
40
+ const hex = (id || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
41
+ if (hex.length === 0)
42
+ return 'm_unknown';
43
+ return `m_${hex.slice(0, 6)}`;
44
+ }
39
45
  export function resolveMachineName(env = process.env) {
40
46
  const override = env.NONBOT_MACHINE_NAME;
41
47
  if (override && override.trim().length > 0)
@@ -390,3 +390,112 @@ export function daemonCloser(opts = {}) {
390
390
  }
391
391
  return parts.join('');
392
392
  }
393
+ function stripControlBytes(s) {
394
+ return (s ?? '')
395
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
396
+ .replace(/ {2,}/g, ' ')
397
+ .trim();
398
+ }
399
+ const FOOTER_DOT_COLOR = {
400
+ live: 'green',
401
+ backoff: 'amber',
402
+ reconnecting: 'red',
403
+ waiting: 'red',
404
+ };
405
+ export function liveFooter(args) {
406
+ const stream = args.stream ?? process.stdout;
407
+ const failed = args.failed ?? 0;
408
+ const health = args.health ?? (args.waiting > 0 ? 'waiting' : 'live');
409
+ const dotGlyph = asciiOnly() ? '*' : '●';
410
+ const dotColor = FOOTER_DOT_COLOR[health];
411
+ const dot = colorize(dotGlyph, dotColor, stream);
412
+ const healthLabel = colorize(health, dotColor, stream);
413
+ const sep = c.muted('·', stream);
414
+ const segs = [`${dot} ${healthLabel}`];
415
+ segs.push(c.green(`${args.running} running`, stream));
416
+ if (args.waiting > 0)
417
+ segs.push(c.bold(c.amber(`${args.waiting} waiting`, stream), stream));
418
+ segs.push(c.muted(`${args.done} done`, stream));
419
+ if (failed > 0)
420
+ segs.push(c.red(`${failed} failed`, stream));
421
+ if (args.nextPollMs !== undefined) {
422
+ segs.push(c.muted(`next poll ${formatMsToS(args.nextPollMs)}`, stream));
423
+ }
424
+ if (args.version)
425
+ segs.push(c.muted(args.version, stream));
426
+ if (args.machine)
427
+ segs.push(c.cyan(args.machine, stream));
428
+ return segs.join(` ${sep} `);
429
+ }
430
+ export function needsYouBanner(args) {
431
+ const stream = args.stream ?? process.stdout;
432
+ const ascii = asciiOnly();
433
+ const bang = ascii ? '!!' : '‼';
434
+ const ptr = ascii ? '>' : '❯';
435
+ const tl = ascii ? '+' : '╔';
436
+ const tr = ascii ? '+' : '╗';
437
+ const bl = ascii ? '+' : '╚';
438
+ const br = ascii ? '+' : '╝';
439
+ const h = ascii ? '-' : '═';
440
+ const v = ascii ? '|' : '║';
441
+ const inner = Math.max(40, Math.min(args.width ?? 72, 100)) - 4;
442
+ const titleRaw = `${bang} NEEDS YOU · a run is waiting on your answer`;
443
+ const title = titleRaw.length > inner ? titleRaw.slice(0, inner - 1) + '…' : titleRaw.padEnd(inner, ' ');
444
+ const sep = c.muted('·', stream);
445
+ const lines = [];
446
+ lines.push(c.amber(` ${tl}${h.repeat(inner + 2)}${tr}`, stream));
447
+ lines.push(` ${c.amber(v, stream)} ${c.amber(c.bold(title, stream), stream)} ${c.amber(v, stream)}`);
448
+ lines.push(c.amber(` ${bl}${h.repeat(inner + 2)}${br}`, stream));
449
+ const story = stripControlBytes(args.story ?? '');
450
+ const idCell = c.bold(c.white(args.activationId, stream), stream);
451
+ lines.push(` ${idCell}${story ? ` ${sep} ${c.white(story, stream)}` : ''}`);
452
+ const metaParts = [c.amber('WAITING', stream)];
453
+ if (args.elapsedMs !== undefined)
454
+ metaParts.push(c.muted(formatElapsed(args.elapsedMs), stream));
455
+ metaParts.push(c.muted(`pane ${args.paneId}`, stream));
456
+ if (args.provider)
457
+ metaParts.push(c.muted(args.provider, stream));
458
+ lines.push(` ${metaParts.join(` ${sep} `)}`);
459
+ if (args.options && args.options.length > 0) {
460
+ const opts = args.options.slice(0, 4).map((o, i) => {
461
+ const label = stripControlBytes(o.label).slice(0, 40);
462
+ const lead = i === 0 ? `${c.cyan(ptr, stream)} ` : '';
463
+ return `${lead}${c.white(`${o.index}. ${label}`, stream)}`;
464
+ });
465
+ lines.push(` ${opts.join(' ')}`);
466
+ }
467
+ lines.push(` ${c.muted('answer it → on the canvas, on your phone, or', stream)} ` +
468
+ c.cyan(`tmux select-window -t ${args.paneId}`, stream));
469
+ return lines.join('\n');
470
+ }
471
+ export function resumedLine(activationId, stream = process.stdout) {
472
+ const check = asciiOnly() ? 'OK' : '✓';
473
+ return (` ${c.green(check, stream)} ${c.white(activationId, stream)} ` +
474
+ c.muted('· resumed · answer received', stream));
475
+ }
476
+ export function buildPaneBorderFormat(opts = {}) {
477
+ const warn = opts.ascii ? '!! ' : '⚠ ';
478
+ return (`#{?#{==:#{@nb_state},waiting},#[fg=colour203 bold]${warn},}` +
479
+ '#[fg=' +
480
+ '#{?#{==:#{@nb_state},waiting},colour203,' +
481
+ '#{?#{==:#{@nb_state},failed},colour203,' +
482
+ '#{?#{==:#{@nb_state},completed},colour78,' +
483
+ '#{?#{==:#{@nb_state},stopping},colour214,colour45}}}}' +
484
+ ']' +
485
+ ' #{pane_title} ');
486
+ }
487
+ export function buildTmuxStatusLeft(opts = {}) {
488
+ const dot = opts.ascii ? '* ' : '● ';
489
+ return (' #[fg=#ffffff,bold]non.bot#[default] ' +
490
+ '#{?#{==:#{@nb_halted},1},#[fg=#ff4c51]HALTED,' +
491
+ `#[fg=#28c76f]${dot}CONNECTED}#[default] `);
492
+ }
493
+ export function buildTmuxStatusRight(opts = {}) {
494
+ const warn = opts.ascii ? '!!' : '⚠';
495
+ const dot = '·';
496
+ return ('#[fg=#28c76f]#{@nb_running} run#[default] ' +
497
+ `#[fg=#8b949e]${dot} #{@nb_done} done` +
498
+ `#{?#{@nb_failed}, #[fg=#ff4c51]${dot} #{@nb_failed} fail,}#[default] ` +
499
+ `#{?#{@nb_needsyou},#[fg=#ff9f43,bold]${dot} ${warn} #{@nb_needsyou} NEEDS YOU ,}` +
500
+ `#[fg=#4fc3f7]${dot} #{host_short} ${dot} poll #{@nb_poll}s `);
501
+ }
@@ -4,12 +4,14 @@ const STATE_GLYPH = {
4
4
  completed: '✓',
5
5
  failed: '✗',
6
6
  stopping: '■',
7
+ waiting: '‼',
7
8
  };
8
9
  const STATE_GLYPH_ASCII = {
9
10
  running: '>',
10
11
  completed: 'OK',
11
12
  failed: 'XX',
12
13
  stopping: '#',
14
+ waiting: '!!',
13
15
  };
14
16
  function asciiOnly(env = process.env) {
15
17
  return env.NONBOT_ASCII_ONLY === '1' || env.NONBOT_ASCII_ONLY === 'true';
@@ -34,6 +36,26 @@ export function buildPaneTitleCommand(paneId, state, story, env = process.env) {
34
36
  const title = formatPaneTitle(state, story, env);
35
37
  return { cmd: 'tmux', args: ['select-pane', '-t', paneId, '-T', title] };
36
38
  }
39
+ export function buildPaneStateOptionCommand(paneId, state) {
40
+ if (!/^%\d+$/.test(paneId))
41
+ return null;
42
+ return { cmd: 'tmux', args: ['set-option', '-p', '-t', paneId, '@nb_state', state] };
43
+ }
44
+ export function applyPaneState(paneId, state, spawnImpl = nodeSpawnSync) {
45
+ const command = buildPaneStateOptionCommand(paneId, state);
46
+ if (!command)
47
+ return false;
48
+ try {
49
+ spawnImpl(command.cmd, command.args, {
50
+ encoding: 'utf-8',
51
+ timeout: 1000,
52
+ windowsHide: true,
53
+ });
54
+ }
55
+ catch {
56
+ }
57
+ return true;
58
+ }
37
59
  export function applyPaneTitle(paneId, state, story, spawnImpl = nodeSpawnSync, env = process.env) {
38
60
  const command = buildPaneTitleCommand(paneId, state, story, env);
39
61
  if (!command)
@@ -94,6 +94,9 @@ export function validateProvider(s) {
94
94
  const TMUX_SAFE_DIRECTIVES = new Set([
95
95
  'set', 'set-option', 'setw', 'set-window-option',
96
96
  ]);
97
+ const TMUX_FORBIDDEN_OPTIONS = new Set([
98
+ 'default-command', 'default-shell', 'command-alias',
99
+ ]);
97
100
  export function isSafeTmuxConf(conf) {
98
101
  for (const rawLine of conf.split(/\r?\n/)) {
99
102
  const line = rawLine.trim();
@@ -103,13 +106,32 @@ export function isSafeTmuxConf(conf) {
103
106
  continue;
104
107
  if (line.includes('#(') || line.includes('$(') || line.includes('`'))
105
108
  return false;
106
- const firstToken = line.split(/\s+/)[0];
107
- if (!TMUX_SAFE_DIRECTIVES.has(firstToken))
109
+ if (line.includes('\\'))
108
110
  return false;
111
+ for (const segment of line.split(';')) {
112
+ const cmd = segment.trim();
113
+ if (cmd === '')
114
+ continue;
115
+ const tokens = cmd.split(/\s+/);
116
+ if (!TMUX_SAFE_DIRECTIVES.has(tokens[0]))
117
+ return false;
118
+ for (const tok of tokens) {
119
+ if (TMUX_FORBIDDEN_OPTIONS.has(tok))
120
+ return false;
121
+ }
122
+ }
109
123
  }
110
124
  return true;
111
125
  }
112
- const ITERM_FORBIDDEN_KEYS = ['Command', 'Initial Text', 'Send Text at Start'];
126
+ const ITERM_FORBIDDEN_KEYS = [
127
+ 'Command',
128
+ 'Initial Text',
129
+ 'Send Text at Start',
130
+ 'Triggers',
131
+ 'Smart Selection Rules',
132
+ 'Semantic History',
133
+ 'Bound Hosts',
134
+ ];
113
135
  export function isSafeItermProfileJson(jsonStr) {
114
136
  let parsed;
115
137
  try {
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
3
3
  import { VERSION } from '../version.js';
4
+ import { evictOldestToCap } from './bounded-set.js';
4
5
  const QUESTION_MAX = 200;
5
6
  const LABEL_MAX = 80;
6
7
  const OPTIONS_MAX = 8;
@@ -59,6 +60,21 @@ export function parsePromptMenu(paneText, fallbackMessage = '') {
59
60
  question = (question || fallback).slice(0, QUESTION_MAX);
60
61
  return { question, options };
61
62
  }
63
+ export function capturePane(paneId, spawnImpl = nodeSpawnSync) {
64
+ if (!PANE_ID_RE.test(paneId))
65
+ return '';
66
+ try {
67
+ const r = spawnImpl('tmux', ['capture-pane', '-p', '-t', paneId], {
68
+ encoding: 'utf-8',
69
+ timeout: 2000,
70
+ windowsHide: true,
71
+ });
72
+ return typeof r.stdout === 'string' ? r.stdout : '';
73
+ }
74
+ catch {
75
+ return '';
76
+ }
77
+ }
62
78
  export function mintPromptId(activationId, paneId, parsed) {
63
79
  const sig = JSON.stringify({
64
80
  q: parsed.question,
@@ -186,13 +202,26 @@ export async function deliverAnsweredPrompts(opts) {
186
202
  return [];
187
203
  const reported = [];
188
204
  for (const p of answered) {
189
- if (!p.paneId)
205
+ const localPaneId = opts.trackedPanes?.get(p.activationId);
206
+ if (!localPaneId)
207
+ continue;
208
+ if (p.paneId && p.paneId !== localPaneId)
190
209
  continue;
191
210
  if (!opts.injected.has(p.promptId)) {
192
- const sent = injectAnswer(p.paneId, p.answerIndex, p.answerText, opts.spawnImpl);
211
+ const sent = injectAnswer(localPaneId, p.answerIndex, p.answerText, opts.spawnImpl);
193
212
  if (!sent)
194
213
  continue;
195
214
  opts.injected.add(p.promptId);
215
+ try {
216
+ const spawn = opts.spawnImpl ?? nodeSpawnSync;
217
+ spawn('tmux', ['set-option', '-p', '-t', localPaneId, '@nb_state', 'running'], {
218
+ encoding: 'utf-8',
219
+ timeout: 1000,
220
+ windowsHide: true,
221
+ });
222
+ }
223
+ catch {
224
+ }
196
225
  }
197
226
  const confirmed = await confirmDelivered({
198
227
  baseUrl: opts.baseUrl,
@@ -202,10 +231,9 @@ export async function deliverAnsweredPrompts(opts) {
202
231
  });
203
232
  if (confirmed) {
204
233
  reported.push(p.promptId);
205
- opts.log?.(`✓ ${p.activationId} · answer delivered to pane ${p.paneId}\n`);
234
+ opts.log?.(`✓ ${p.activationId} · answer delivered to pane ${localPaneId}\n`);
206
235
  }
207
236
  }
208
- if (opts.injected.size > 500)
209
- opts.injected.clear();
237
+ evictOldestToCap(opts.injected, 500);
210
238
  return reported;
211
239
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.9.4';
1
+ export const VERSION = '0.9.6';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
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",