@nonbot/cli 0.9.13 → 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, attachDaemonSession, 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));
@@ -220,11 +271,15 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
220
271
  deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log: rawLog, errLog });
221
272
  }
222
273
  }
274
+ const cloudMode = args.includes('--cloud');
223
275
  const auth = await loader();
224
276
  if (!auth) {
225
277
  errLog(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.', { stream: process.stderr }));
226
278
  return 1;
227
279
  }
280
+ if (cloudMode && !deps.spawnTerminal) {
281
+ deps.spawnTerminal = (a) => spawnTerminalDefault(a, auth, { cloud: true });
282
+ }
228
283
  const detectTmux = deps.detectTmuxSession ?? detectTmuxSession;
229
284
  const tmuxSessionName = detectTmux();
230
285
  const machineId = deps.machineId ?? loadOrCreateMachineId();
@@ -253,6 +308,26 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
253
308
  const reconcileBuildCommand = deps.reconcileBuildCommand ?? process.env.NONBOT_RECONCILE_BUILD_CMD ?? '';
254
309
  const trackedPanes = new Map();
255
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
+ };
256
331
  const injectedPrompts = new Set();
257
332
  const servedSnapshots = new Set();
258
333
  const reportedPrompts = new Set();
@@ -494,7 +569,184 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
494
569
  }) + '\n');
495
570
  log('\n');
496
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
+ };
497
748
  while (running) {
749
+ void updateChecker.maybeCheck();
498
750
  let firedThisPoll = false;
499
751
  try {
500
752
  const headers = {
@@ -534,6 +786,9 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
534
786
  if (res.ok) {
535
787
  lastPollOk = true;
536
788
  const body = (await res.json());
789
+ const wireAnsweredPrompts = Array.isArray(body?.answeredPrompts)
790
+ ? normalizeAnsweredPrompts(body.answeredPrompts)
791
+ : undefined;
537
792
  const pendingKills = body?.pendingKills ?? [];
538
793
  if (pendingKills.length > 0) {
539
794
  for (const k of pendingKills) {
@@ -555,11 +810,19 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
555
810
  safeEmit(k.activationId, RUN_STAGE.STOPPED);
556
811
  stopHeartbeat(k.activationId);
557
812
  }
558
- void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
813
+ void executeKillsFn(pendingKills, auth.baseUrl, auth.pat);
559
814
  }
560
- 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) {
561
824
  running = false;
562
- const scopeLabel = body.shutdown.scope === 'all' ? 'all machines' : 'this machine';
825
+ const scopeLabel = shutdownSignal.scope === 'all' ? 'all machines' : 'this machine';
563
826
  log('\n' + statusRow('⚠', 'daemon stop received', `remote stop · ${scopeLabel}`) + '\n');
564
827
  const kills = [...trackedPanes.entries()]
565
828
  .filter(([id]) => !killedByStop.has(id))
@@ -573,12 +836,13 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
573
836
  if (kills.length > 0) {
574
837
  log(statusRow('⚠', `stopping ${kills.length} run${kills.length === 1 ? '' : 's'}`, '^C -> 2s grace -> kill-pane') + '\n');
575
838
  try {
576
- await activations.executePendingKills(kills, auth.baseUrl, auth.pat);
839
+ await executeKillsFn(kills, auth.baseUrl, auth.pat);
577
840
  }
578
841
  catch { }
579
842
  }
580
843
  for (const id of [...runHeartbeats.keys()])
581
844
  stopHeartbeat(id);
845
+ await flushTranscripts(() => 'stopped', { all: true });
582
846
  await postDaemonStopAck({ baseUrl: auth.baseUrl, pat: auth.pat, machineId, fetchImpl });
583
847
  pushTmuxStatus(0, true);
584
848
  removePidFile();
@@ -697,14 +961,60 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
697
961
  emitSummary();
698
962
  }
699
963
  }
964
+ let deferredThisPoll = 0;
700
965
  for (const act of singletons) {
701
966
  if (!act?.id || seen.has(act.id))
702
967
  continue;
968
+ if (trackedPanes.size >= maxConcurrentRuns) {
969
+ deferredThisPoll = singletons.filter((a) => a?.id && !seen.has(a.id)).length;
970
+ break;
971
+ }
703
972
  seen.add(act.id);
704
973
  firedThisPoll = true;
705
974
  const metrics = act.kind === 'real' ? seqMetrics(act) : undefined;
706
975
  if (act.kind === 'real')
707
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
+ }
708
1018
  const outcome = await fireActivation(auth, act, deps, log, errLog);
709
1019
  if (outcome.status === 'launched') {
710
1020
  lastFailureReason = null;
@@ -751,6 +1061,10 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
751
1061
  emitSummary();
752
1062
  }
753
1063
  }
1064
+ if (deferredThisPoll > 0) {
1065
+ log(statusRow('⚠', CONCURRENCY_DEFERRED_REASON, `${deferredThisPoll} waiting · ${trackedPanes.size}/${maxConcurrentRuns} running`) + '\n');
1066
+ lastFailureReason = CONCURRENCY_DEFERRED_REASON;
1067
+ }
754
1068
  try {
755
1069
  await deliverAnsweredPrompts({
756
1070
  baseUrl: auth.baseUrl,
@@ -758,6 +1072,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
758
1072
  injected: injectedPrompts,
759
1073
  trackedPanes,
760
1074
  machineId,
1075
+ ...(wireAnsweredPrompts ? { prompts: wireAnsweredPrompts } : {}),
761
1076
  fetchImpl,
762
1077
  spawnImpl: deps.spawnSync,
763
1078
  log,
@@ -770,6 +1085,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
770
1085
  }
771
1086
  catch {
772
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);
773
1096
  if (trackedPanes.size > 0) {
774
1097
  const reported = await checkCompletions({
775
1098
  tracked: trackedPanes,
@@ -797,12 +1120,39 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
797
1120
  kv,
798
1121
  }) + '\n';
799
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,
800
1148
  });
801
1149
  if (reported.length > 0) {
802
1150
  for (const id of reported) {
803
1151
  doneCount++;
804
1152
  trackedMeta.delete(id);
805
- 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);
806
1156
  stopHeartbeat(id, 'finished');
807
1157
  try {
808
1158
  const session = isolatedSessions.noteTerminal(id);
@@ -821,6 +1171,14 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
821
1171
  }
822
1172
  emitSummary();
823
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
+ });
824
1182
  }
825
1183
  if (!firedThisPoll && pendingKills.length === 0 && !tmuxChromeEnabled) {
826
1184
  log(heartbeatLine() + '\n');
@@ -832,6 +1190,7 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
832
1190
  lastPollOk = false;
833
1191
  errLog(statusRow('⚠', 'poll error', e.message, { stream: process.stderr }) + '\n');
834
1192
  }
1193
+ await sweepLocalGuardrails();
835
1194
  if (options.oneShot)
836
1195
  break;
837
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}`;
@@ -7,8 +7,9 @@ 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 { scrubLine } from './snapshot.js';
10
11
  import { buildCommandFromParams, hookSettingsPathFor, shellQuoteSingle, } from './command-builders.js';
11
- import { validatePayload, validateActivationId, validateRepoPath, ValidationError, extractTerminalTheme, } from './payload-validator.js';
12
+ import { validatePayload, validateActivationId, validateRepoPath, validateRepoUrl, validateRepoRef, ValidationError, extractTerminalTheme, } from './payload-validator.js';
12
13
  const PANE_ID_RE = /^%\d+$/;
13
14
  export const BUILT_COMMAND_MAX_LENGTH = 32 * 1024;
14
15
  export const WIRE_COMMAND_MAX_LENGTH = 4096;
@@ -56,7 +57,8 @@ export function resolveExecutableCommand(act, warn = (s) => console.warn(s), opt
56
57
  if (shell.length > BUILT_COMMAND_MAX_LENGTH) {
57
58
  throw new Error(`[${act.id}] built command is too large (${shell.length} chars, cap ${BUILT_COMMAND_MAX_LENGTH}). This indicates an oversized AGENTS.md or a builder bug.`);
58
59
  }
59
- if (typeof act.command === 'string' && act.command.length > 0 && act.command !== shell) {
60
+ const driftBase = params.template === 'real' ? buildCommandFromParams({ ...params, wallClock: false }) : shell;
61
+ if (typeof act.command === 'string' && act.command.length > 0 && act.command !== driftBase) {
60
62
  warn(`[${act.id}] note: daemon-built command differs from server-supplied command (executing daemon-built; server text is informational).\n`);
61
63
  }
62
64
  if (opts.injectClaudeHook && params.template === 'real' && params.provider === 'claude') {
@@ -89,6 +91,16 @@ export function validateActivationEnvelope(act) {
89
91
  throw e;
90
92
  }
91
93
  }
94
+ try {
95
+ validateRepoUrl(act.repoUrl);
96
+ validateRepoRef(act.repoRef);
97
+ }
98
+ catch (e) {
99
+ if (e instanceof ValidationError) {
100
+ throw new Error(`[${act.id}] outer envelope validation failed (act.${e.field}): ${e.reason}`);
101
+ }
102
+ throw e;
103
+ }
92
104
  if (act.payload &&
93
105
  typeof act.payload === 'object' &&
94
106
  'repoPath' in act.payload) {
@@ -122,6 +134,23 @@ function truncate(s, max) {
122
134
  return s;
123
135
  return s.slice(0, max - 32) + `\n…[truncated to ${max}B]`;
124
136
  }
137
+ const CHILD_ENV_ALLOW = new Set([
138
+ 'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'USER', 'LOGNAME', 'TMPDIR',
139
+ 'TMUX', 'TMUX_PANE', 'NODE_ENV', 'NONBOT_PAT', 'NONBOT_BASE_URL', 'NONBOT_RUN_ID',
140
+ 'NONBOT_ROLE', 'NONBOT_CONFIG_DIR', 'CLAUDE_CODE_OAUTH_TOKEN',
141
+ 'NONBOT_RUN_TIMEOUT_MIN',
142
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
143
+ ]);
144
+ export function buildChildEnv(base, opts) {
145
+ const out = {};
146
+ for (const [k, v] of Object.entries(base)) {
147
+ if (CHILD_ENV_ALLOW.has(k))
148
+ out[k] = v;
149
+ else if (opts.byok && (k === 'ANTHROPIC_API_KEY' || k === 'ANTHROPIC_AUTH_TOKEN'))
150
+ out[k] = v;
151
+ }
152
+ return out;
153
+ }
125
154
  export function makeHeadlessSpawner(opts) {
126
155
  return (act) => new Promise((resolve, reject) => {
127
156
  try {
@@ -140,8 +169,12 @@ export function makeHeadlessSpawner(opts) {
140
169
  return;
141
170
  }
142
171
  const cwd = act.repoPath || undefined;
172
+ const childEnv = buildChildEnv(process.env, {
173
+ byok: !process.env.CLAUDE_CODE_OAUTH_TOKEN &&
174
+ (!!process.env.ANTHROPIC_API_KEY || !!process.env.ANTHROPIC_AUTH_TOKEN),
175
+ });
143
176
  if (opts.wait) {
144
- const proc = spawn('bash', ['-c', resolved.shell], { cwd, stdio: 'inherit' });
177
+ const proc = spawn('bash', ['-c', resolved.shell], { cwd, env: childEnv, stdio: 'inherit' });
145
178
  proc.on('error', (e) => {
146
179
  const err = e;
147
180
  reject(new Error(`failed to run headless: ${err.message}`));
@@ -151,13 +184,14 @@ export function makeHeadlessSpawner(opts) {
151
184
  }
152
185
  const proc = spawn('bash', ['-c', resolved.shell], {
153
186
  cwd,
187
+ env: childEnv,
154
188
  stdio: ['ignore', 'pipe', 'pipe'],
155
189
  });
156
190
  const prefixLines = (chunk, sink) => {
157
191
  const text = chunk.toString('utf-8');
158
192
  for (const line of text.split('\n')) {
159
193
  if (line.length > 0)
160
- sink(`[${act.id}] ${line}\n`);
194
+ sink(`[${act.id}] ${scrubLine(line)}\n`);
161
195
  }
162
196
  };
163
197
  proc.stdout?.on('data', (c) => prefixLines(c, opts.log));
@@ -207,7 +241,11 @@ async function captureSpawn(cmd, args) {
207
241
  });
208
242
  }
209
243
  export const _captureSpawnForTests = captureSpawn;
210
- export async function spawnTerminalDefault(act, auth) {
244
+ export function exitFilePathFor(activationId) {
245
+ const ext = process.platform === 'darwin' ? 'command' : 'sh';
246
+ return path.join(tmpdir(), `nonbot-${activationId}.${ext}.exit`);
247
+ }
248
+ export async function spawnTerminalDefault(act, auth, opts) {
211
249
  validateActivationEnvelope(act);
212
250
  const resolved = resolveExecutableCommand(act, undefined, { injectClaudeHook: true });
213
251
  const ext = process.platform === 'darwin' ? 'command' : 'sh';
@@ -253,7 +291,9 @@ export async function spawnTerminalDefault(act, auth) {
253
291
  `Switch to iTerm for theme support. Launching un-themed.`);
254
292
  }
255
293
  }
256
- const { cmd, args } = profile.launch(scriptPath, itermProfileName ? { itermProfileName } : undefined);
294
+ const cloudExitFile = opts?.cloud && profile.id === 'tmux' ? exitFilePathFor(act.id) : undefined;
295
+ const launchOpts = itermProfileName || cloudExitFile ? { itermProfileName, cloudExitFile } : undefined;
296
+ const { cmd, args } = profile.launch(scriptPath, launchOpts);
257
297
  const result = await captureSpawn(cmd, args);
258
298
  const unlinkBest = async () => {
259
299
  try {
@@ -1,6 +1,12 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
+ export function isExitTranscriptEntry(e) {
5
+ return (!!e &&
6
+ typeof e === 'object' &&
7
+ e.kind === 'exit-transcript' &&
8
+ typeof e.tail === 'string');
9
+ }
4
10
  function configDir() {
5
11
  const override = process.env.NONBOT_CONFIG_DIR;
6
12
  if (override && override.length > 0)