@nonbot/cli 0.9.12 → 0.10.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.
@@ -1,24 +1,40 @@
1
1
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
2
  import { loadAuth, getActiveProfile } from '../lib/auth.js';
3
3
  import * as activations from '../lib/activations.js';
4
- import { fireActivation, makeHeadlessSpawner, } from '../lib/activations.js';
5
- import { checkCompletions } from '../lib/completion.js';
4
+ import { fireActivation, makeHeadlessSpawner, spawnTerminalDefault, } from '../lib/activations.js';
5
+ import { checkCompletions, readExitCodeFile, removeExitFile, } from '../lib/completion.js';
6
6
  import { evictOldestToCap } from '../lib/bounded-set.js';
7
- import { deliverAnsweredPrompts, capturePane, parsePromptMenu, mintPromptId, reportPrompt, } from '../lib/run-prompt.js';
7
+ import { deliverAnsweredPrompts, capturePane, normalizeAnsweredPrompts, parsePromptMenu, mintPromptId, reportPrompt, } from '../lib/run-prompt.js';
8
8
  import { serveSnapshotRequests } from '../lib/snapshot.js';
9
+ import { createUpdateChecker } from '../lib/update-check.js';
10
+ import { refreshPaneTails, flushExitTranscripts, exitTranscriptDisabled, } from '../lib/exit-transcript.js';
11
+ import { validateActivationId } from '../lib/payload-validator.js';
12
+ import { resolveRunTimeoutMinutes, wallClockExceededReason, } from '../lib/command-builders.js';
13
+ import { prepareCloneWorkspace as defaultPrepareCloneWorkspace, pushRunBranch as defaultPushRunBranch, reapStaleWorkspaces as defaultReapStaleWorkspaces, removeCloudWorkspace as defaultRemoveCloudWorkspace, shortCloneFailureReason, touchWorkspaceLiveness as defaultTouchWorkspaceLiveness, } from '../lib/cloud-repo.js';
9
14
  import { loadOrCreateMachineId, resolveMachineName, shortMachineId } from '../lib/machine.js';
10
15
  import { emitRunStage as defaultEmitRunStage, startRunHeartbeat as defaultStartRunHeartbeat, RUN_STAGE, } from '../lib/choir/run-progress.js';
11
16
  import { groupBySession, launchCoordinatedSet, setIsIsolated, } from '../lib/choir/coordinated-set.js';
12
17
  import { IsolatedSessionTracker, reconcileAndCleanup as defaultReconcileAndCleanup, resolveBaseBranch as defaultResolveBaseBranch, } from '../lib/choir/isolated-session.js';
13
18
  import { applyPaneTitle, applyPaneState } from '../lib/pane-title.js';
14
19
  import { installService, uninstallService } from '../lib/service.js';
15
- import { probeTmuxAttached, postDaemonStopAck, requestRemoteDaemonStop, stopDaemonLocally, writePidFile, removePidFile, } from '../lib/daemon-lifecycle.js';
20
+ import { probeTmuxAttached, postDaemonStopAck, requestRemoteDaemonStop, stopDaemonLocally, attachDaemonSession, writePidFile, removePidFile, shutdownPredatesDaemon, } from '../lib/daemon-lifecycle.js';
16
21
  import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
17
22
  import { VERSION } from '../version.js';
18
23
  import { errorBlock, statusRow, daemonOpener, daemonCloser, activationCard, runSummary, formatElapsed, liveFooter, needsYouBanner, resumedLine, buildPaneBorderFormat, buildTmuxStatusLeft, buildTmuxStatusRight, WORDMARK_WIDTH, c, } from '../lib/output.js';
19
24
  export const POLL_FAST_MS = 2000;
20
25
  export const POLL_MAX_MS = 30000;
21
26
  export const POLL_INTERVAL_MS = POLL_FAST_MS;
27
+ export const MAX_CONCURRENT_RUNS_DEFAULT = 4;
28
+ export function resolveMaxConcurrentRuns(env = process.env) {
29
+ const raw = env.NONBOT_MAX_CONCURRENT_RUNS;
30
+ if (typeof raw !== 'string' || raw.trim() === '')
31
+ return MAX_CONCURRENT_RUNS_DEFAULT;
32
+ const n = Number(raw.trim());
33
+ if (!Number.isInteger(n) || n < 1)
34
+ return MAX_CONCURRENT_RUNS_DEFAULT;
35
+ return n;
36
+ }
37
+ export const CONCURRENCY_DEFERRED_REASON = 'queued: concurrency limit';
22
38
  const TERMINAL_KIND_BY_PROFILE_ID = {
23
39
  'terminal': 'terminal.app',
24
40
  'iterm': 'iterm',
@@ -66,6 +82,41 @@ export function sanitizeFailureReason(reason) {
66
82
  return null;
67
83
  return cleaned.slice(0, LAST_FAILURE_REASON_MAX);
68
84
  }
85
+ async function readErrorCode(res) {
86
+ try {
87
+ if (typeof res.json !== 'function')
88
+ return '';
89
+ const body = (await res.json());
90
+ if (!body || typeof body !== 'object')
91
+ return '';
92
+ const code = body.code;
93
+ return typeof code === 'string' ? code.slice(0, 64) : '';
94
+ }
95
+ catch {
96
+ return '';
97
+ }
98
+ }
99
+ export function describeRegistrationRejection(status, code) {
100
+ if (status === 409 && code === 'machine_in_use') {
101
+ return 'machine id already in use — set a distinct NONBOT_MACHINE_ID';
102
+ }
103
+ if (status === 400 && code === 'machine_id_mismatch') {
104
+ return 'machine id header rejected — upgrade @nonbot/cli on this runner';
105
+ }
106
+ if (status === 400 && code === 'origin_too_broad') {
107
+ return 'allowed repos too broad — scope NONBOT_ALLOWED_REPO_ORIGINS to an owner or repo';
108
+ }
109
+ return `HTTP ${status}`;
110
+ }
111
+ export function isRepoLessProbe(act) {
112
+ if (act.kind !== 'diagnostic')
113
+ return false;
114
+ const payload = act.payload;
115
+ if (!payload || typeof payload !== 'object')
116
+ return false;
117
+ const template = payload.template;
118
+ return template === 'diagnostic' || template === 'provider-test';
119
+ }
69
120
  export function applyNonbotTmuxConfig(deps = {}) {
70
121
  const spawnSync = deps.spawnSync ?? nodeSpawnSync;
71
122
  const stdoutWrite = deps.stdoutWrite ?? ((s) => process.stdout.write(s));
@@ -182,6 +233,15 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
182
233
  spawnImpl: deps.spawnSync,
183
234
  });
184
235
  }
236
+ if (args[0] === 'attach') {
237
+ const sessionFlag = args.indexOf('--session');
238
+ const sessionName = sessionFlag >= 0 ? args[sessionFlag + 1] : undefined;
239
+ return attachDaemonSession({
240
+ log: rawLog, errLog,
241
+ spawnImpl: deps.spawnSync,
242
+ ...(sessionName ? { sessionName } : {}),
243
+ });
244
+ }
185
245
  if (args.includes('--install')) {
186
246
  const install = deps.installService ?? installService;
187
247
  const res = await install();
@@ -211,11 +271,15 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
211
271
  deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log: rawLog, errLog });
212
272
  }
213
273
  }
274
+ const cloudMode = args.includes('--cloud');
214
275
  const auth = await loader();
215
276
  if (!auth) {
216
277
  errLog(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.', { stream: process.stderr }));
217
278
  return 1;
218
279
  }
280
+ if (cloudMode && !deps.spawnTerminal) {
281
+ deps.spawnTerminal = (a) => spawnTerminalDefault(a, auth, { cloud: true });
282
+ }
219
283
  const detectTmux = deps.detectTmuxSession ?? detectTmuxSession;
220
284
  const tmuxSessionName = detectTmux();
221
285
  const machineId = deps.machineId ?? loadOrCreateMachineId();
@@ -244,6 +308,26 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
244
308
  const reconcileBuildCommand = deps.reconcileBuildCommand ?? process.env.NONBOT_RECONCILE_BUILD_CMD ?? '';
245
309
  const trackedPanes = new Map();
246
310
  const killedByStop = new Set();
311
+ const cloudWorkspaces = deps.cloudWorkspaces ?? new Map();
312
+ const prepareCloneWorkspaceFn = deps.prepareCloneWorkspace ?? defaultPrepareCloneWorkspace;
313
+ const pushRunBranchFn = deps.pushRunBranch ?? defaultPushRunBranch;
314
+ const reapStaleWorkspacesFn = deps.reapStaleWorkspaces ?? defaultReapStaleWorkspaces;
315
+ const removeCloudWorkspaceFn = deps.removeCloudWorkspace ?? defaultRemoveCloudWorkspace;
316
+ const removeExitFileFn = deps.removeExitFile ?? removeExitFile;
317
+ const touchWorkspaceLivenessFn = deps.touchWorkspaceLiveness ?? defaultTouchWorkspaceLiveness;
318
+ const releaseCloudWorkspace = (activationId) => {
319
+ const ws = cloudWorkspaces.get(activationId);
320
+ cloudWorkspaces.delete(activationId);
321
+ if (!cloudMode)
322
+ return;
323
+ try {
324
+ if (ws)
325
+ removeCloudWorkspaceFn(ws.repoPath);
326
+ removeExitFileFn(activationId);
327
+ }
328
+ catch {
329
+ }
330
+ };
247
331
  const injectedPrompts = new Set();
248
332
  const servedSnapshots = new Set();
249
333
  const reportedPrompts = new Set();
@@ -485,7 +569,184 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
485
569
  }) + '\n');
486
570
  log('\n');
487
571
  pushTmuxStatus();
572
+ const cloudEnv = deps.env ?? process.env;
573
+ const allowedRepoOrigins = (cloudEnv.NONBOT_ALLOWED_REPO_ORIGINS ?? '')
574
+ .split(',')
575
+ .map((s) => s.trim())
576
+ .filter(Boolean);
577
+ const maxConcurrentRuns = deps.maxConcurrentRuns ?? resolveMaxConcurrentRuns(cloudEnv);
578
+ const runTimeoutMinutes = deps.runTimeoutMinutes ?? resolveRunTimeoutMinutes(cloudEnv);
579
+ const runTimeoutMs = runTimeoutMinutes * 60_000;
580
+ const nowMs = deps.now ?? Date.now;
581
+ const wallClockReason = wallClockExceededReason(runTimeoutMinutes);
582
+ const executeKillsFn = deps.executePendingKills ?? activations.executePendingKills;
583
+ const daemonStartedAt = nowMs();
584
+ let staleShutdownNoted = false;
585
+ const exitTranscriptsEnabled = !exitTranscriptDisabled(cloudEnv);
586
+ const paneTails = new Map();
587
+ const flushTranscripts = async (statusFor, opts = {}) => {
588
+ if (paneTails.size === 0)
589
+ return;
590
+ try {
591
+ await flushExitTranscripts({
592
+ tracked: opts.all ? new Map() : trackedPanes,
593
+ tails: paneTails,
594
+ statusFor,
595
+ profile: profileName,
596
+ now: nowMs,
597
+ log,
598
+ });
599
+ }
600
+ catch {
601
+ }
602
+ };
603
+ const updateChecker = createUpdateChecker({
604
+ env: cloudEnv,
605
+ fetchImpl: deps.updateCheckFetch === undefined
606
+ ? (process.env.VITEST ? null : fetch)
607
+ : deps.updateCheckFetch,
608
+ now: nowMs,
609
+ log: (line) => log(c.amber(line) + '\n'),
610
+ });
611
+ void updateChecker.maybeCheck();
612
+ const postActivationFailure = async (activationId, failureReason) => {
613
+ try {
614
+ validateActivationId(activationId);
615
+ }
616
+ catch {
617
+ errLog(statusRow('⚠', 'failure report skipped', 'malformed activation id', {
618
+ stream: process.stderr,
619
+ }) + '\n');
620
+ return false;
621
+ }
622
+ try {
623
+ const res = await fetchImpl(`${auth.baseUrl}/api/portfolio/activations/${activationId}`, {
624
+ method: 'PATCH',
625
+ headers: {
626
+ Authorization: `Bearer ${auth.pat}`,
627
+ 'Content-Type': 'application/json',
628
+ 'X-Requested-With': 'ConradPM-Native',
629
+ },
630
+ body: JSON.stringify({ status: 'failed', failureReason: failureReason.slice(0, 200) }),
631
+ });
632
+ return res.ok || res.status === 409;
633
+ }
634
+ catch {
635
+ return false;
636
+ }
637
+ };
638
+ if (cloudMode) {
639
+ try {
640
+ const reaped = reapStaleWorkspacesFn({ activeIds: cloudWorkspaces.keys() });
641
+ if (reaped > 0) {
642
+ log(statusRow('✓', 'stale workspaces reaped', `${reaped} older than 24h`) + '\n');
643
+ }
644
+ }
645
+ catch {
646
+ }
647
+ try {
648
+ const res = await fetchImpl(`${auth.baseUrl}/api/cli/cloud-runners`, {
649
+ method: 'POST',
650
+ headers: {
651
+ Authorization: `Bearer ${auth.pat}`,
652
+ 'Content-Type': 'application/json',
653
+ 'X-Requested-With': 'ConradPM-Native',
654
+ 'X-Machine-Id': machineId,
655
+ },
656
+ body: JSON.stringify({ machineId, kind: 'vps', allowedRepoOrigins }),
657
+ });
658
+ if (res.ok) {
659
+ log(statusRow('✓', 'cloud runner registration', shortMachineId(machineId)) + '\n');
660
+ }
661
+ else {
662
+ log(statusRow('⚠', 'cloud registration rejected', describeRegistrationRejection(res.status, await readErrorCode(res))) + '\n');
663
+ }
664
+ }
665
+ catch {
666
+ log(statusRow('⚠', 'cloud runner registration failed', 'continuing — server may be unreachable') + '\n');
667
+ }
668
+ try {
669
+ const spawnSyncImpl = deps.spawnSync ?? nodeSpawnSync;
670
+ spawnSyncImpl('tmux', ['set-environment', '-g', '-r', 'NONBOT_GIT_TOKEN'], {
671
+ timeout: 2000,
672
+ windowsHide: true,
673
+ });
674
+ log(statusRow('✓', 'tmux global env scrubbed', 'NONBOT_GIT_TOKEN') + '\n');
675
+ }
676
+ catch {
677
+ log(statusRow('⚠', 'tmux env scrub skipped', 'no tmux server reachable') + '\n');
678
+ }
679
+ }
680
+ const sweepLocalGuardrails = async () => {
681
+ if (cloudMode && cloudWorkspaces.size > 0) {
682
+ for (const ws of cloudWorkspaces.values()) {
683
+ try {
684
+ touchWorkspaceLivenessFn(ws.repoPath);
685
+ }
686
+ catch {
687
+ }
688
+ }
689
+ }
690
+ try {
691
+ const deadline = nowMs() - runTimeoutMs;
692
+ const overdue = [];
693
+ for (const [id, paneId] of trackedPanes) {
694
+ if (killedByStop.has(id))
695
+ continue;
696
+ const meta = trackedMeta.get(id);
697
+ if (!meta || meta.startedAt > deadline)
698
+ continue;
699
+ overdue.push({ activationId: id, tmuxPaneId: paneId });
700
+ }
701
+ for (const k of overdue) {
702
+ const meta = trackedMeta.get(k.activationId);
703
+ log(activationCard({
704
+ marker: '■',
705
+ color: 'amber',
706
+ id: k.activationId,
707
+ headerSuffix: 'WALL CLOCK',
708
+ kv: [
709
+ ['STORY', meta?.story ?? 'unknown'],
710
+ ['ELAPSED', meta ? formatElapsed(nowMs() - meta.startedAt) : 'unknown'],
711
+ ['PANE', `${k.tmuxPaneId} · ^C -> 2s grace -> kill-pane`],
712
+ ['REASON', wallClockReason],
713
+ ],
714
+ }) + '\n');
715
+ if (cloudMode) {
716
+ const ws = cloudWorkspaces.get(k.activationId);
717
+ if (ws) {
718
+ try {
719
+ const push = pushRunBranchFn({
720
+ repoPath: ws.repoPath,
721
+ branch: ws.branch,
722
+ activationId: k.activationId,
723
+ spawnImpl: deps.spawnSync,
724
+ });
725
+ log(statusRow(push.pushed ? '✓' : '⚠', push.pushed ? 'run branch pushed' : 'run branch not pushed', ws.branch) + '\n');
726
+ }
727
+ catch {
728
+ }
729
+ }
730
+ }
731
+ killedByStop.add(k.activationId);
732
+ retitlePane('stopping', k.tmuxPaneId, meta?.story ?? '');
733
+ safeEmit(k.activationId, RUN_STAGE.FAILED);
734
+ stopHeartbeat(k.activationId, 'wall-clock');
735
+ failedCount++;
736
+ trackedMeta.delete(k.activationId);
737
+ await postActivationFailure(k.activationId, wallClockReason);
738
+ }
739
+ if (overdue.length > 0) {
740
+ void executeKillsFn(overdue, auth.baseUrl, auth.pat);
741
+ lastFailureReason = wallClockReason;
742
+ emitSummary();
743
+ }
744
+ }
745
+ catch {
746
+ }
747
+ };
488
748
  while (running) {
749
+ void updateChecker.maybeCheck();
489
750
  let firedThisPoll = false;
490
751
  try {
491
752
  const headers = {
@@ -525,6 +786,9 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
525
786
  if (res.ok) {
526
787
  lastPollOk = true;
527
788
  const body = (await res.json());
789
+ const wireAnsweredPrompts = Array.isArray(body?.answeredPrompts)
790
+ ? normalizeAnsweredPrompts(body.answeredPrompts)
791
+ : undefined;
528
792
  const pendingKills = body?.pendingKills ?? [];
529
793
  if (pendingKills.length > 0) {
530
794
  for (const k of pendingKills) {
@@ -546,11 +810,19 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
546
810
  safeEmit(k.activationId, RUN_STAGE.STOPPED);
547
811
  stopHeartbeat(k.activationId);
548
812
  }
549
- void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
813
+ void executeKillsFn(pendingKills, auth.baseUrl, auth.pat);
550
814
  }
551
- if (body?.shutdown?.requested === true) {
815
+ const shutdownSignal = body?.shutdown?.requested === true ? body.shutdown : null;
816
+ if (shutdownSignal && shutdownPredatesDaemon(shutdownSignal, daemonStartedAt)) {
817
+ if (!staleShutdownNoted) {
818
+ staleShutdownNoted = true;
819
+ const scopeLabel = shutdownSignal.scope === 'all' ? 'all machines' : 'this machine';
820
+ log(statusRow('ℹ', 'stale stop signal ignored', `issued before this daemon started · ${scopeLabel}`) + '\n');
821
+ }
822
+ }
823
+ else if (shutdownSignal) {
552
824
  running = false;
553
- const scopeLabel = body.shutdown.scope === 'all' ? 'all machines' : 'this machine';
825
+ const scopeLabel = shutdownSignal.scope === 'all' ? 'all machines' : 'this machine';
554
826
  log('\n' + statusRow('⚠', 'daemon stop received', `remote stop · ${scopeLabel}`) + '\n');
555
827
  const kills = [...trackedPanes.entries()]
556
828
  .filter(([id]) => !killedByStop.has(id))
@@ -564,12 +836,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
564
836
  if (kills.length > 0) {
565
837
  log(statusRow('⚠', `stopping ${kills.length} run${kills.length === 1 ? '' : 's'}`, '^C -> 2s grace -> kill-pane') + '\n');
566
838
  try {
567
- await activations.executePendingKills(kills, auth.baseUrl, auth.pat);
839
+ await executeKillsFn(kills, auth.baseUrl, auth.pat);
568
840
  }
569
841
  catch { }
570
842
  }
571
843
  for (const id of [...runHeartbeats.keys()])
572
844
  stopHeartbeat(id);
845
+ await flushTranscripts(() => 'stopped', { all: true });
573
846
  await postDaemonStopAck({ baseUrl: auth.baseUrl, pat: auth.pat, machineId, fetchImpl });
574
847
  pushTmuxStatus(0, true);
575
848
  removePidFile();
@@ -688,14 +961,60 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
688
961
  emitSummary();
689
962
  }
690
963
  }
964
+ let deferredThisPoll = 0;
691
965
  for (const act of singletons) {
692
966
  if (!act?.id || seen.has(act.id))
693
967
  continue;
968
+ if (trackedPanes.size >= maxConcurrentRuns) {
969
+ deferredThisPoll = singletons.filter((a) => a?.id && !seen.has(a.id)).length;
970
+ break;
971
+ }
694
972
  seen.add(act.id);
695
973
  firedThisPoll = true;
696
974
  const metrics = act.kind === 'real' ? seqMetrics(act) : undefined;
697
975
  if (act.kind === 'real')
698
976
  safeEmit(act.id, RUN_STAGE.LAUNCHING, metrics);
977
+ if (cloudMode && !isRepoLessProbe(act)) {
978
+ if (!act.repoUrl) {
979
+ errLog(statusRow('⚠', `${act.id} · cloud workspace refused`, 'no clone url', {
980
+ stream: process.stderr,
981
+ }) + '\n');
982
+ failedCount++;
983
+ lastFailureReason = 'cloud runner requires a clone url';
984
+ if (act.kind === 'real')
985
+ safeEmit(act.id, RUN_STAGE.FAILED, metrics);
986
+ await postActivationFailure(act.id, 'cloud runner requires a clone url');
987
+ emitSummary();
988
+ continue;
989
+ }
990
+ try {
991
+ const ws = await prepareCloneWorkspaceFn({
992
+ repoUrl: act.repoUrl,
993
+ repoRef: act.repoRef ?? null,
994
+ activationId: act.id,
995
+ allowedOrigins: allowedRepoOrigins,
996
+ });
997
+ act.repoPath = ws.repoPath;
998
+ if (act.payload && typeof act.payload === 'object') {
999
+ ;
1000
+ act.payload.repoPath = ws.repoPath;
1001
+ }
1002
+ cloudWorkspaces.set(act.id, ws);
1003
+ }
1004
+ catch (e) {
1005
+ const reason = shortCloneFailureReason(e);
1006
+ errLog(statusRow('⚠', `${act.id} · cloud workspace failed`, reason, {
1007
+ stream: process.stderr,
1008
+ }) + '\n');
1009
+ failedCount++;
1010
+ lastFailureReason = sanitizeFailureReason(reason) ?? 'clone failed';
1011
+ if (act.kind === 'real')
1012
+ safeEmit(act.id, RUN_STAGE.FAILED, metrics);
1013
+ await postActivationFailure(act.id, `clone failed: ${reason}`);
1014
+ emitSummary();
1015
+ continue;
1016
+ }
1017
+ }
699
1018
  const outcome = await fireActivation(auth, act, deps, log, errLog);
700
1019
  if (outcome.status === 'launched') {
701
1020
  lastFailureReason = null;
@@ -742,6 +1061,10 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
742
1061
  emitSummary();
743
1062
  }
744
1063
  }
1064
+ if (deferredThisPoll > 0) {
1065
+ log(statusRow('⚠', CONCURRENCY_DEFERRED_REASON, `${deferredThisPoll} waiting · ${trackedPanes.size}/${maxConcurrentRuns} running`) + '\n');
1066
+ lastFailureReason = CONCURRENCY_DEFERRED_REASON;
1067
+ }
745
1068
  try {
746
1069
  await deliverAnsweredPrompts({
747
1070
  baseUrl: auth.baseUrl,
@@ -749,6 +1072,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
749
1072
  injected: injectedPrompts,
750
1073
  trackedPanes,
751
1074
  machineId,
1075
+ ...(wireAnsweredPrompts ? { prompts: wireAnsweredPrompts } : {}),
752
1076
  fetchImpl,
753
1077
  spawnImpl: deps.spawnSync,
754
1078
  log,
@@ -761,6 +1085,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
761
1085
  }
762
1086
  catch {
763
1087
  }
1088
+ if (exitTranscriptsEnabled && trackedPanes.size > 0) {
1089
+ try {
1090
+ refreshPaneTails({ tracked: trackedPanes, tails: paneTails, spawnImpl: spawnForTmux, now: nowMs });
1091
+ }
1092
+ catch {
1093
+ }
1094
+ }
1095
+ const stoppedBeforeSweep = new Set(killedByStop);
764
1096
  if (trackedPanes.size > 0) {
765
1097
  const reported = await checkCompletions({
766
1098
  tracked: trackedPanes,
@@ -788,12 +1120,39 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
788
1120
  kv,
789
1121
  }) + '\n';
790
1122
  },
1123
+ readExitCode: cloudMode ? undefined : (id) => readExitCodeFile(id),
1124
+ cloud: cloudMode
1125
+ ? {
1126
+ readExitCode: (id) => readExitCodeFile(id),
1127
+ pushWorkspace: (id) => {
1128
+ const ws = cloudWorkspaces.get(id);
1129
+ if (!ws)
1130
+ return { pushed: false, reason: 'no workspace', kind: 'no-workspace' };
1131
+ const result = pushRunBranchFn({
1132
+ repoPath: ws.repoPath,
1133
+ branch: ws.branch,
1134
+ activationId: id,
1135
+ spawnImpl: deps.spawnSync,
1136
+ });
1137
+ return {
1138
+ ...result,
1139
+ branch: ws.branch,
1140
+ ...(result.pushed ? {} : { kind: 'push-failed' }),
1141
+ };
1142
+ },
1143
+ forgetWorkspace: (id) => {
1144
+ releaseCloudWorkspace(id);
1145
+ },
1146
+ }
1147
+ : undefined,
791
1148
  });
792
1149
  if (reported.length > 0) {
793
1150
  for (const id of reported) {
794
1151
  doneCount++;
795
1152
  trackedMeta.delete(id);
796
- safeEmit(id, RUN_STAGE.FINISHED);
1153
+ releaseCloudWorkspace(id);
1154
+ const outcome = reported.outcomes?.get(id);
1155
+ safeEmit(id, outcome === 'failed' ? RUN_STAGE.FAILED : RUN_STAGE.FINISHED);
797
1156
  stopHeartbeat(id, 'finished');
798
1157
  try {
799
1158
  const session = isolatedSessions.noteTerminal(id);
@@ -812,6 +1171,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
812
1171
  }
813
1172
  emitSummary();
814
1173
  }
1174
+ await flushTranscripts((id) => {
1175
+ const outcome = reported.outcomes?.get(id);
1176
+ if (outcome)
1177
+ return outcome;
1178
+ if (reported.includes(id))
1179
+ return 'completed';
1180
+ return stoppedBeforeSweep.has(id) ? 'stopped' : 'untracked';
1181
+ });
815
1182
  }
816
1183
  if (!firedThisPoll && pendingKills.length === 0 && !tmuxChromeEnabled) {
817
1184
  log(heartbeatLine() + '\n');
@@ -823,6 +1190,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
823
1190
  lastPollOk = false;
824
1191
  errLog(statusRow('⚠', 'poll error', e.message, { stream: process.stderr }) + '\n');
825
1192
  }
1193
+ await sweepLocalGuardrails();
826
1194
  if (options.oneShot)
827
1195
  break;
828
1196
  if (fixedInterval !== undefined) {
@@ -1,4 +1,4 @@
1
- import { readActivityLog as readActivityLogDefault, } from '../lib/activity-log.js';
1
+ import { readActivityLog as readActivityLogDefault, isExitTranscriptEntry, } from '../lib/activity-log.js';
2
2
  import { header, statusRow, eventRow } from '../lib/output.js';
3
3
  const FOLLOW_INTERVAL_MS = 1000;
4
4
  function parseArgs(args) {
@@ -49,6 +49,11 @@ function relativeTime(ts, now) {
49
49
  return `${days}d ago`;
50
50
  }
51
51
  function formatEntry(e, now) {
52
+ if (isExitTranscriptEntry(e)) {
53
+ const sigil = e.status === 'completed' ? '✓' : e.status === 'failed' ? '✗' : '⚠';
54
+ const size = `${e.lines} lines${e.truncated ? ' (clamped)' : ''}`;
55
+ return eventRow([e.id, e.kind, `${e.status} · ${size}`, relativeTime(e.ts, now)], { status: sigil }) + '\n';
56
+ }
52
57
  const status = e.status === 'launched' ? '✓' : '✗';
53
58
  const when = relativeTime(e.ts, now);
54
59
  const target = `${e.mode}/${e.target}`;
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ const COMMANDS = [
21
21
  },
22
22
  {
23
23
  name: 'daemon',
24
- description: 'Long-running listener — polls /api/cli/activations/pending every 2s. `nonbot daemon stop` stops it (local; --machine <id> / --all go remote).',
24
+ description: 'Long-running listener — polls /api/cli/activations/pending every 2s. `nonbot daemon stop` stops it (local; --machine <id> / --all go remote); `nonbot daemon attach` reattaches a lost tmux window.',
25
25
  run: (args) => runDaemonCommand(args),
26
26
  },
27
27
  {