@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.
@@ -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
- rawLog('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
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
+ }
@@ -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.10';
1
+ export const VERSION = '0.9.12';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",