@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.
@@ -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
- rawLog('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
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.11';
1
+ export const VERSION = '0.9.13';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
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",