@nonbot/cli 0.9.11 → 0.9.13
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 +89 -4
- package/dist/index.js +1 -1
- package/dist/lib/daemon-lifecycle.js +185 -0
- 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, attachDaemonSession, 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,36 @@ 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
|
+
}
|
|
185
|
+
if (args[0] === 'attach') {
|
|
186
|
+
const sessionFlag = args.indexOf('--session');
|
|
187
|
+
const sessionName = sessionFlag >= 0 ? args[sessionFlag + 1] : undefined;
|
|
188
|
+
return attachDaemonSession({
|
|
189
|
+
log: rawLog, errLog,
|
|
190
|
+
spawnImpl: deps.spawnSync,
|
|
191
|
+
...(sessionName ? { sessionName } : {}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
163
194
|
if (args.includes('--install')) {
|
|
164
195
|
const install = deps.installService ?? installService;
|
|
165
196
|
const res = await install();
|
|
@@ -198,6 +229,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
198
229
|
const tmuxSessionName = detectTmux();
|
|
199
230
|
const machineId = deps.machineId ?? loadOrCreateMachineId();
|
|
200
231
|
const machineName = resolveMachineName();
|
|
232
|
+
if (!options.oneShot)
|
|
233
|
+
writePidFile(process.pid);
|
|
201
234
|
const tmuxChromeEnabled = tmuxSessionName !== null &&
|
|
202
235
|
!nonbotTmuxOptOut() &&
|
|
203
236
|
process.env.NONBOT_NO_TMUX_STATUS !== '1';
|
|
@@ -399,10 +432,12 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
399
432
|
for (const id of [...runHeartbeats.keys()])
|
|
400
433
|
stopHeartbeat(id);
|
|
401
434
|
pushTmuxStatus(0, true);
|
|
402
|
-
|
|
435
|
+
removePidFile();
|
|
436
|
+
rawLog('\n' + statusRow('✓', 'daemon stopped', 'signal received') + '\n');
|
|
403
437
|
process.exit(0);
|
|
404
438
|
};
|
|
405
439
|
process.on('SIGINT', sigHandler);
|
|
440
|
+
process.on('SIGTERM', sigHandler);
|
|
406
441
|
}
|
|
407
442
|
const hostUrl = new URL(auth.baseUrl).host;
|
|
408
443
|
const profileName = getActiveProfile();
|
|
@@ -476,8 +511,12 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
476
511
|
'X-Prompts-Open': String(openPrompts.size),
|
|
477
512
|
'X-Poll-Interval-Ms': String(fixedInterval ?? currentInterval),
|
|
478
513
|
};
|
|
479
|
-
if (tmuxSessionName)
|
|
514
|
+
if (tmuxSessionName) {
|
|
480
515
|
headers['X-Tmux-Session'] = tmuxSessionName;
|
|
516
|
+
const attached = (deps.probeTmuxAttached ?? probeTmuxAttached)(deps.spawnSync);
|
|
517
|
+
if (attached !== null)
|
|
518
|
+
headers['X-Tmux-Attached'] = attached ? 'true' : 'false';
|
|
519
|
+
}
|
|
481
520
|
if (lastFailureReason)
|
|
482
521
|
headers['X-Last-Failure-Reason'] = lastFailureReason;
|
|
483
522
|
const res = await fetchImpl(`${auth.baseUrl}/api/cli/activations/pending`, {
|
|
@@ -485,8 +524,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
485
524
|
});
|
|
486
525
|
if (res.status === 401) {
|
|
487
526
|
errLog(errorBlock('Auth failed (HTTP 401)', 'Re-run: nonbot login', { stream: process.stderr }));
|
|
488
|
-
if (sigHandler)
|
|
527
|
+
if (sigHandler) {
|
|
489
528
|
process.off('SIGINT', sigHandler);
|
|
529
|
+
process.off('SIGTERM', sigHandler);
|
|
530
|
+
}
|
|
531
|
+
removePidFile();
|
|
490
532
|
return 1;
|
|
491
533
|
}
|
|
492
534
|
if (res.ok) {
|
|
@@ -515,6 +557,45 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
515
557
|
}
|
|
516
558
|
void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
|
|
517
559
|
}
|
|
560
|
+
if (body?.shutdown?.requested === true) {
|
|
561
|
+
running = false;
|
|
562
|
+
const scopeLabel = body.shutdown.scope === 'all' ? 'all machines' : 'this machine';
|
|
563
|
+
log('\n' + statusRow('⚠', 'daemon stop received', `remote stop · ${scopeLabel}`) + '\n');
|
|
564
|
+
const kills = [...trackedPanes.entries()]
|
|
565
|
+
.filter(([id]) => !killedByStop.has(id))
|
|
566
|
+
.map(([activationId, tmuxPaneId]) => ({ activationId, tmuxPaneId }));
|
|
567
|
+
for (const k of kills) {
|
|
568
|
+
killedByStop.add(k.activationId);
|
|
569
|
+
retitlePane('stopping', k.tmuxPaneId, trackedMeta.get(k.activationId)?.story ?? '');
|
|
570
|
+
safeEmit(k.activationId, RUN_STAGE.STOPPED);
|
|
571
|
+
stopHeartbeat(k.activationId);
|
|
572
|
+
}
|
|
573
|
+
if (kills.length > 0) {
|
|
574
|
+
log(statusRow('⚠', `stopping ${kills.length} run${kills.length === 1 ? '' : 's'}`, '^C -> 2s grace -> kill-pane') + '\n');
|
|
575
|
+
try {
|
|
576
|
+
await activations.executePendingKills(kills, auth.baseUrl, auth.pat);
|
|
577
|
+
}
|
|
578
|
+
catch { }
|
|
579
|
+
}
|
|
580
|
+
for (const id of [...runHeartbeats.keys()])
|
|
581
|
+
stopHeartbeat(id);
|
|
582
|
+
await postDaemonStopAck({ baseUrl: auth.baseUrl, pat: auth.pat, machineId, fetchImpl });
|
|
583
|
+
pushTmuxStatus(0, true);
|
|
584
|
+
removePidFile();
|
|
585
|
+
if (sigHandler) {
|
|
586
|
+
process.off('SIGINT', sigHandler);
|
|
587
|
+
process.off('SIGTERM', sigHandler);
|
|
588
|
+
}
|
|
589
|
+
rawLog('\n' + statusRow('✓', 'daemon stopped', `remote stop · ${scopeLabel}`) + '\n');
|
|
590
|
+
if (tmuxSessionName) {
|
|
591
|
+
const sp = (deps.spawnSync ?? nodeSpawnSync);
|
|
592
|
+
try {
|
|
593
|
+
sp('tmux', ['kill-session', '-t', tmuxSessionName], { timeout: 2000 });
|
|
594
|
+
}
|
|
595
|
+
catch { }
|
|
596
|
+
}
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
518
599
|
const snapshotRequests = body?.snapshotRequests ?? [];
|
|
519
600
|
if (Array.isArray(snapshotRequests) && snapshotRequests.length > 0) {
|
|
520
601
|
try {
|
|
@@ -765,7 +846,11 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
|
|
|
765
846
|
}
|
|
766
847
|
}
|
|
767
848
|
}
|
|
768
|
-
if (sigHandler)
|
|
849
|
+
if (sigHandler) {
|
|
769
850
|
process.off('SIGINT', sigHandler);
|
|
851
|
+
process.off('SIGTERM', sigHandler);
|
|
852
|
+
}
|
|
853
|
+
if (!options.oneShot)
|
|
854
|
+
removePidFile();
|
|
770
855
|
return 0;
|
|
771
856
|
}
|
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); `nonbot daemon attach` reattaches a lost tmux window.',
|
|
25
25
|
run: (args) => runDaemonCommand(args),
|
|
26
26
|
},
|
|
27
27
|
{
|
|
@@ -0,0 +1,185 @@
|
|
|
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 function attachDaemonSession(deps) {
|
|
110
|
+
const spawn = deps.spawnImpl ?? nodeSpawnSync;
|
|
111
|
+
const sessionName = deps.sessionName ?? 'nonbot';
|
|
112
|
+
try {
|
|
113
|
+
const probe = spawn('tmux', ['has-session', '-t', sessionName], {
|
|
114
|
+
encoding: 'utf-8', timeout: 2000, windowsHide: true,
|
|
115
|
+
});
|
|
116
|
+
if (probe.status !== 0) {
|
|
117
|
+
deps.errLog(`✗ no tmux session '${sessionName}' to attach to.\n Start the daemon first: nonbot daemon\n`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
deps.errLog('✗ tmux not found — install it (brew install tmux) or run the daemon without tmux.\n');
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
deps.log(`↳ attaching to tmux session '${sessionName}' — press Ctrl-B then D to detach again.\n`);
|
|
126
|
+
try {
|
|
127
|
+
const res = spawn('tmux', ['attach', '-t', sessionName], {
|
|
128
|
+
...{ stdio: 'inherit' },
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
});
|
|
131
|
+
return res.status === 0 ? 0 : 1;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
deps.errLog(`✗ could not attach to '${sessionName}'.\n`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function stopDaemonLocally(deps) {
|
|
139
|
+
const kill = deps.kill ?? ((pid, sig) => process.kill(pid, sig));
|
|
140
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
141
|
+
const spawn = deps.spawnImpl ?? nodeSpawnSync;
|
|
142
|
+
const sessionName = deps.sessionName ?? 'nonbot';
|
|
143
|
+
const alive = (pid) => {
|
|
144
|
+
try {
|
|
145
|
+
kill(pid, 0);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const pid = readPidFile(deps);
|
|
153
|
+
if (pid !== null && alive(pid)) {
|
|
154
|
+
try {
|
|
155
|
+
kill(pid, 'SIGTERM');
|
|
156
|
+
}
|
|
157
|
+
catch { }
|
|
158
|
+
for (let i = 0; i < 6; i++) {
|
|
159
|
+
await sleep(500);
|
|
160
|
+
if (!alive(pid)) {
|
|
161
|
+
removePidFile(deps);
|
|
162
|
+
deps.log(`✓ daemon stopped (pid ${pid})\n`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
deps.errLog(`⚠ pid ${pid} did not exit after SIGTERM — try: kill -9 ${pid}\n`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
if (pid !== null)
|
|
170
|
+
removePidFile(deps);
|
|
171
|
+
try {
|
|
172
|
+
const result = spawn('tmux', ['kill-session', '-t', sessionName], {
|
|
173
|
+
encoding: 'utf-8',
|
|
174
|
+
timeout: 2000,
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
});
|
|
177
|
+
if (result.status === 0) {
|
|
178
|
+
deps.log(`✓ tmux session '${sessionName}' killed (no PID file — pre-0.9.12 daemon?)\n`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
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`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.9.
|
|
1
|
+
export const VERSION = '0.9.13';
|
package/package.json
CHANGED