@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.
- package/CHANGELOG.md +247 -1
- package/dist/commands/choir.js +3 -0
- package/dist/commands/daemon.js +368 -9
- package/dist/commands/logs.js +6 -1
- package/dist/lib/activations.js +46 -6
- package/dist/lib/activity-log.js +6 -0
- package/dist/lib/choir/hub.js +116 -18
- package/dist/lib/cloud-repo.js +306 -0
- package/dist/lib/command-builders.js +32 -1
- package/dist/lib/completion.js +125 -1
- package/dist/lib/daemon-lifecycle.js +15 -0
- package/dist/lib/exit-transcript.js +74 -0
- package/dist/lib/machine.js +7 -0
- package/dist/lib/payload-validator.js +51 -0
- package/dist/lib/run-prompt.js +38 -17
- package/dist/lib/snapshot.js +14 -12
- package/dist/lib/terminal.js +10 -4
- package/dist/lib/update-check.js +99 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -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
|
|
813
|
+
void executeKillsFn(pendingKills, auth.baseUrl, auth.pat);
|
|
559
814
|
}
|
|
560
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/commands/logs.js
CHANGED
|
@@ -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/lib/activations.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 {
|
package/dist/lib/activity-log.js
CHANGED
|
@@ -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)
|