@nonbot/cli 0.9.10 → 0.9.12
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/dist/commands/daemon.js +80 -4
- package/dist/index.js +1 -1
- package/dist/lib/daemon-lifecycle.js +156 -0
- package/dist/lib/snapshot.js +2 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -12,6 +12,7 @@ import { groupBySession, launchCoordinatedSet, setIsIsolated, } from '../lib/cho
|
|
|
12
12
|
import { IsolatedSessionTracker, reconcileAndCleanup as defaultReconcileAndCleanup, resolveBaseBranch as defaultResolveBaseBranch, } from '../lib/choir/isolated-session.js';
|
|
13
13
|
import { applyPaneTitle, applyPaneState } from '../lib/pane-title.js';
|
|
14
14
|
import { installService, uninstallService } from '../lib/service.js';
|
|
15
|
+
import { probeTmuxAttached, postDaemonStopAck, requestRemoteDaemonStop, stopDaemonLocally, writePidFile, removePidFile, } from '../lib/daemon-lifecycle.js';
|
|
15
16
|
import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
|
|
16
17
|
import { VERSION } from '../version.js';
|
|
17
18
|
import { errorBlock, statusRow, daemonOpener, daemonCloser, activationCard, runSummary, formatElapsed, liveFooter, needsYouBanner, resumedLine, buildPaneBorderFormat, buildTmuxStatusLeft, buildTmuxStatusRight, WORDMARK_WIDTH, c, } from '../lib/output.js';
|
|
@@ -160,6 +161,27 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
160
161
|
const fixedInterval = options.pollIntervalMs;
|
|
161
162
|
const maxIntervalMs = options.maxPollIntervalMs ?? POLL_MAX_MS;
|
|
162
163
|
let currentInterval = fixedInterval ?? POLL_FAST_MS;
|
|
164
|
+
if (args[0] === 'stop') {
|
|
165
|
+
const stopArgs = args.slice(1);
|
|
166
|
+
const all = stopArgs.includes('--all');
|
|
167
|
+
const machineFlag = stopArgs.indexOf('--machine');
|
|
168
|
+
const targetMachine = machineFlag >= 0 ? stopArgs[machineFlag + 1] : undefined;
|
|
169
|
+
if (all || targetMachine) {
|
|
170
|
+
const auth = await loader();
|
|
171
|
+
if (!auth) {
|
|
172
|
+
errLog(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.', { stream: process.stderr }));
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
return requestRemoteDaemonStop({
|
|
176
|
+
auth, machineId: targetMachine ?? null, all, fetchImpl,
|
|
177
|
+
log: rawLog, errLog,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return stopDaemonLocally({
|
|
181
|
+
log: rawLog, errLog,
|
|
182
|
+
spawnImpl: deps.spawnSync,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
163
185
|
if (args.includes('--install')) {
|
|
164
186
|
const install = deps.installService ?? installService;
|
|
165
187
|
const res = await install();
|
|
@@ -198,6 +220,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
198
220
|
const tmuxSessionName = detectTmux();
|
|
199
221
|
const machineId = deps.machineId ?? loadOrCreateMachineId();
|
|
200
222
|
const machineName = resolveMachineName();
|
|
223
|
+
if (!options.oneShot)
|
|
224
|
+
writePidFile(process.pid);
|
|
201
225
|
const tmuxChromeEnabled = tmuxSessionName !== null &&
|
|
202
226
|
!nonbotTmuxOptOut() &&
|
|
203
227
|
process.env.NONBOT_NO_TMUX_STATUS !== '1';
|
|
@@ -399,10 +423,12 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
399
423
|
for (const id of [...runHeartbeats.keys()])
|
|
400
424
|
stopHeartbeat(id);
|
|
401
425
|
pushTmuxStatus(0, true);
|
|
402
|
-
|
|
426
|
+
removePidFile();
|
|
427
|
+
rawLog('\n' + statusRow('✓', 'daemon stopped', 'signal received') + '\n');
|
|
403
428
|
process.exit(0);
|
|
404
429
|
};
|
|
405
430
|
process.on('SIGINT', sigHandler);
|
|
431
|
+
process.on('SIGTERM', sigHandler);
|
|
406
432
|
}
|
|
407
433
|
const hostUrl = new URL(auth.baseUrl).host;
|
|
408
434
|
const profileName = getActiveProfile();
|
|
@@ -476,8 +502,12 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
476
502
|
'X-Prompts-Open': String(openPrompts.size),
|
|
477
503
|
'X-Poll-Interval-Ms': String(fixedInterval ?? currentInterval),
|
|
478
504
|
};
|
|
479
|
-
if (tmuxSessionName)
|
|
505
|
+
if (tmuxSessionName) {
|
|
480
506
|
headers['X-Tmux-Session'] = tmuxSessionName;
|
|
507
|
+
const attached = (deps.probeTmuxAttached ?? probeTmuxAttached)(deps.spawnSync);
|
|
508
|
+
if (attached !== null)
|
|
509
|
+
headers['X-Tmux-Attached'] = attached ? 'true' : 'false';
|
|
510
|
+
}
|
|
481
511
|
if (lastFailureReason)
|
|
482
512
|
headers['X-Last-Failure-Reason'] = lastFailureReason;
|
|
483
513
|
const res = await fetchImpl(`${auth.baseUrl}/api/cli/activations/pending`, {
|
|
@@ -485,8 +515,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
485
515
|
});
|
|
486
516
|
if (res.status === 401) {
|
|
487
517
|
errLog(errorBlock('Auth failed (HTTP 401)', 'Re-run: nonbot login', { stream: process.stderr }));
|
|
488
|
-
if (sigHandler)
|
|
518
|
+
if (sigHandler) {
|
|
489
519
|
process.off('SIGINT', sigHandler);
|
|
520
|
+
process.off('SIGTERM', sigHandler);
|
|
521
|
+
}
|
|
522
|
+
removePidFile();
|
|
490
523
|
return 1;
|
|
491
524
|
}
|
|
492
525
|
if (res.ok) {
|
|
@@ -515,6 +548,45 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
515
548
|
}
|
|
516
549
|
void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
|
|
517
550
|
}
|
|
551
|
+
if (body?.shutdown?.requested === true) {
|
|
552
|
+
running = false;
|
|
553
|
+
const scopeLabel = body.shutdown.scope === 'all' ? 'all machines' : 'this machine';
|
|
554
|
+
log('\n' + statusRow('⚠', 'daemon stop received', `remote stop · ${scopeLabel}`) + '\n');
|
|
555
|
+
const kills = [...trackedPanes.entries()]
|
|
556
|
+
.filter(([id]) => !killedByStop.has(id))
|
|
557
|
+
.map(([activationId, tmuxPaneId]) => ({ activationId, tmuxPaneId }));
|
|
558
|
+
for (const k of kills) {
|
|
559
|
+
killedByStop.add(k.activationId);
|
|
560
|
+
retitlePane('stopping', k.tmuxPaneId, trackedMeta.get(k.activationId)?.story ?? '');
|
|
561
|
+
safeEmit(k.activationId, RUN_STAGE.STOPPED);
|
|
562
|
+
stopHeartbeat(k.activationId);
|
|
563
|
+
}
|
|
564
|
+
if (kills.length > 0) {
|
|
565
|
+
log(statusRow('⚠', `stopping ${kills.length} run${kills.length === 1 ? '' : 's'}`, '^C -> 2s grace -> kill-pane') + '\n');
|
|
566
|
+
try {
|
|
567
|
+
await activations.executePendingKills(kills, auth.baseUrl, auth.pat);
|
|
568
|
+
}
|
|
569
|
+
catch { }
|
|
570
|
+
}
|
|
571
|
+
for (const id of [...runHeartbeats.keys()])
|
|
572
|
+
stopHeartbeat(id);
|
|
573
|
+
await postDaemonStopAck({ baseUrl: auth.baseUrl, pat: auth.pat, machineId, fetchImpl });
|
|
574
|
+
pushTmuxStatus(0, true);
|
|
575
|
+
removePidFile();
|
|
576
|
+
if (sigHandler) {
|
|
577
|
+
process.off('SIGINT', sigHandler);
|
|
578
|
+
process.off('SIGTERM', sigHandler);
|
|
579
|
+
}
|
|
580
|
+
rawLog('\n' + statusRow('✓', 'daemon stopped', `remote stop · ${scopeLabel}`) + '\n');
|
|
581
|
+
if (tmuxSessionName) {
|
|
582
|
+
const sp = (deps.spawnSync ?? nodeSpawnSync);
|
|
583
|
+
try {
|
|
584
|
+
sp('tmux', ['kill-session', '-t', tmuxSessionName], { timeout: 2000 });
|
|
585
|
+
}
|
|
586
|
+
catch { }
|
|
587
|
+
}
|
|
588
|
+
return 0;
|
|
589
|
+
}
|
|
518
590
|
const snapshotRequests = body?.snapshotRequests ?? [];
|
|
519
591
|
if (Array.isArray(snapshotRequests) && snapshotRequests.length > 0) {
|
|
520
592
|
try {
|
|
@@ -765,7 +837,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
765
837
|
}
|
|
766
838
|
}
|
|
767
839
|
}
|
|
768
|
-
if (sigHandler)
|
|
840
|
+
if (sigHandler) {
|
|
769
841
|
process.off('SIGINT', sigHandler);
|
|
842
|
+
process.off('SIGTERM', sigHandler);
|
|
843
|
+
}
|
|
844
|
+
if (!options.oneShot)
|
|
845
|
+
removePidFile();
|
|
770
846
|
return 0;
|
|
771
847
|
}
|
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.',
|
|
24
|
+
description: 'Long-running listener — polls /api/cli/activations/pending every 2s. `nonbot daemon stop` stops it (local; --machine <id> / --all go remote).',
|
|
25
25
|
run: (args) => runDaemonCommand(args),
|
|
26
26
|
},
|
|
27
27
|
{
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import * as nodeFs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
5
|
+
import { VERSION } from '../version.js';
|
|
6
|
+
function configDir() {
|
|
7
|
+
const override = process.env.NONBOT_CONFIG_DIR;
|
|
8
|
+
if (override && override.length > 0)
|
|
9
|
+
return override;
|
|
10
|
+
return path.join(homedir(), '.config', 'nonbot');
|
|
11
|
+
}
|
|
12
|
+
export function pidFilePath(dir = configDir()) {
|
|
13
|
+
return path.join(dir, 'daemon.pid');
|
|
14
|
+
}
|
|
15
|
+
export function writePidFile(pid, deps = {}) {
|
|
16
|
+
const fs = deps.fs ?? nodeFs;
|
|
17
|
+
const dir = deps.dir ?? configDir();
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
20
|
+
fs.writeFileSync(pidFilePath(dir), String(pid), { mode: 0o600 });
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
}
|
|
24
|
+
export function readPidFile(deps = {}) {
|
|
25
|
+
const fs = deps.fs ?? nodeFs;
|
|
26
|
+
const dir = deps.dir ?? configDir();
|
|
27
|
+
try {
|
|
28
|
+
const file = pidFilePath(dir);
|
|
29
|
+
if (!fs.existsSync(file))
|
|
30
|
+
return null;
|
|
31
|
+
const pid = Number.parseInt(fs.readFileSync(file, 'utf-8').trim(), 10);
|
|
32
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function removePidFile(deps = {}) {
|
|
39
|
+
const fs = deps.fs ?? nodeFs;
|
|
40
|
+
const dir = deps.dir ?? configDir();
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(pidFilePath(dir));
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
}
|
|
46
|
+
export function probeTmuxAttached(spawnImpl) {
|
|
47
|
+
const spawn = spawnImpl ?? nodeSpawnSync;
|
|
48
|
+
try {
|
|
49
|
+
const result = spawn('tmux', ['display-message', '-p', '#{session_attached}'], {
|
|
50
|
+
encoding: 'utf-8',
|
|
51
|
+
timeout: 1000,
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
if (result.status === 0 && typeof result.stdout === 'string') {
|
|
55
|
+
const n = Number.parseInt(result.stdout.trim(), 10);
|
|
56
|
+
if (Number.isInteger(n) && n >= 0)
|
|
57
|
+
return n > 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
export async function postDaemonStopAck(opts) {
|
|
64
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetchImpl(`${opts.baseUrl}/api/cli/daemon/stop-ack`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${opts.pat}`,
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
72
|
+
'X-CLI-Version': VERSION,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ machineId: opts.machineId }),
|
|
75
|
+
signal: AbortSignal.timeout(5000),
|
|
76
|
+
});
|
|
77
|
+
return res.ok;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function requestRemoteDaemonStop(opts) {
|
|
84
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetchImpl(`${opts.auth.baseUrl}/api/run/daemon/stop`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${opts.auth.pat}`,
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
'X-Requested-With': 'ConradPM-Native',
|
|
92
|
+
'X-CLI-Version': VERSION,
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify(opts.all ? { all: true } : { machineId: opts.machineId }),
|
|
95
|
+
});
|
|
96
|
+
if (res.ok) {
|
|
97
|
+
const target = opts.all ? 'every machine' : `machine ${opts.machineId}`;
|
|
98
|
+
opts.log(`✓ stop queued for ${target} — the daemon exits on its next poll (≤30s)\n`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
opts.errLog(`✗ server refused the stop (HTTP ${res.status})\n`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
opts.errLog(`✗ could not reach the server: ${e.message}\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function stopDaemonLocally(deps) {
|
|
110
|
+
const kill = deps.kill ?? ((pid, sig) => process.kill(pid, sig));
|
|
111
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
112
|
+
const spawn = deps.spawnImpl ?? nodeSpawnSync;
|
|
113
|
+
const sessionName = deps.sessionName ?? 'nonbot';
|
|
114
|
+
const alive = (pid) => {
|
|
115
|
+
try {
|
|
116
|
+
kill(pid, 0);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const pid = readPidFile(deps);
|
|
124
|
+
if (pid !== null && alive(pid)) {
|
|
125
|
+
try {
|
|
126
|
+
kill(pid, 'SIGTERM');
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
for (let i = 0; i < 6; i++) {
|
|
130
|
+
await sleep(500);
|
|
131
|
+
if (!alive(pid)) {
|
|
132
|
+
removePidFile(deps);
|
|
133
|
+
deps.log(`✓ daemon stopped (pid ${pid})\n`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
deps.errLog(`⚠ pid ${pid} did not exit after SIGTERM — try: kill -9 ${pid}\n`);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
if (pid !== null)
|
|
141
|
+
removePidFile(deps);
|
|
142
|
+
try {
|
|
143
|
+
const result = spawn('tmux', ['kill-session', '-t', sessionName], {
|
|
144
|
+
encoding: 'utf-8',
|
|
145
|
+
timeout: 2000,
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
});
|
|
148
|
+
if (result.status === 0) {
|
|
149
|
+
deps.log(`✓ tmux session '${sessionName}' killed (no PID file — pre-0.9.12 daemon?)\n`);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
deps.errLog(`✗ nothing to stop — no PID file and no tmux session '${sessionName}'.\n If a daemon is running elsewhere: nonbot daemon stop --machine <id> (see /run)\n`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
package/dist/lib/snapshot.js
CHANGED
|
@@ -3,6 +3,7 @@ import { VERSION } from '../version.js';
|
|
|
3
3
|
import { evictOldestToCap } from './bounded-set.js';
|
|
4
4
|
export const PANE_ID_RE = /^%\d+$/;
|
|
5
5
|
export const ACTIVATION_ID_RE = /^act_[a-f0-9]{8,32}$/i;
|
|
6
|
+
export const SNAPSHOT_REQUESTS_MAX = 16;
|
|
6
7
|
export const SNAPSHOT_MAX_BYTES = 16384;
|
|
7
8
|
const MAX_LINES_DEFAULT = 100;
|
|
8
9
|
const MAX_LINES_CAP = 2000;
|
|
@@ -74,7 +75,7 @@ async function postSnapshot(opts, activationId, body) {
|
|
|
74
75
|
}
|
|
75
76
|
export async function serveSnapshotRequests(opts) {
|
|
76
77
|
const servedNow = [];
|
|
77
|
-
for (const req of opts.requests ?? []) {
|
|
78
|
+
for (const req of (opts.requests ?? []).slice(0, SNAPSHOT_REQUESTS_MAX)) {
|
|
78
79
|
if (!req || typeof req.requestId !== 'string' || req.requestId.length === 0)
|
|
79
80
|
continue;
|
|
80
81
|
if (typeof req.activationId !== 'string' || !ACTIVATION_ID_RE.test(req.activationId)) {
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.9.
|
|
1
|
+
export const VERSION = '0.9.12';
|
package/package.json
CHANGED