byterover-cli 2.3.0 → 2.3.2

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.
Files changed (56) hide show
  1. package/dist/oclif/commands/restart.d.ts +34 -50
  2. package/dist/oclif/commands/restart.js +122 -209
  3. package/dist/oclif/hooks/init/block-command-update-npm.d.ts +11 -0
  4. package/dist/oclif/hooks/init/block-command-update-npm.js +15 -0
  5. package/dist/oclif/hooks/init/update-notifier.d.ts +3 -0
  6. package/dist/oclif/hooks/init/update-notifier.js +17 -4
  7. package/dist/oclif/hooks/postrun/restart-after-update.d.ts +22 -0
  8. package/dist/oclif/hooks/postrun/restart-after-update.js +40 -0
  9. package/dist/server/infra/http/openrouter-api-client.js +1 -1
  10. package/node_modules/@campfirein/brv-transport-client/package.json +1 -1
  11. package/node_modules/{socket.io-parser/node_modules/debug → debug}/package.json +7 -3
  12. package/node_modules/{socket.io-parser/node_modules/debug → debug}/src/browser.js +2 -1
  13. package/node_modules/{socket.io-client/node_modules/debug → debug}/src/common.js +57 -39
  14. package/node_modules/socket.io-client/build/cjs/index.d.ts +2 -2
  15. package/node_modules/socket.io-client/build/cjs/socket.js +2 -3
  16. package/node_modules/socket.io-client/build/esm/index.d.ts +2 -2
  17. package/node_modules/socket.io-client/build/esm/package.json +1 -1
  18. package/node_modules/socket.io-client/build/esm/socket.js +1 -3
  19. package/node_modules/socket.io-client/build/esm-debug/index.d.ts +2 -2
  20. package/node_modules/socket.io-client/build/esm-debug/package.json +1 -1
  21. package/node_modules/socket.io-client/build/esm-debug/socket.js +2 -3
  22. package/node_modules/socket.io-client/dist/socket.io.esm.min.js +3 -3
  23. package/node_modules/socket.io-client/dist/socket.io.esm.min.js.map +1 -1
  24. package/node_modules/socket.io-client/dist/socket.io.js +89 -42
  25. package/node_modules/socket.io-client/dist/socket.io.js.map +1 -1
  26. package/node_modules/socket.io-client/dist/socket.io.min.js +3 -3
  27. package/node_modules/socket.io-client/dist/socket.io.min.js.map +1 -1
  28. package/node_modules/socket.io-client/dist/socket.io.msgpack.min.js +3 -3
  29. package/node_modules/socket.io-client/dist/socket.io.msgpack.min.js.map +1 -1
  30. package/node_modules/socket.io-client/package.json +3 -3
  31. package/node_modules/socket.io-parser/LICENSE +1 -1
  32. package/node_modules/socket.io-parser/build/cjs/binary.js +2 -3
  33. package/node_modules/socket.io-parser/build/cjs/index.d.ts +15 -4
  34. package/node_modules/socket.io-parser/build/cjs/index.js +62 -16
  35. package/node_modules/socket.io-parser/build/cjs/is-binary.d.ts +1 -1
  36. package/node_modules/socket.io-parser/build/cjs/is-binary.js +2 -3
  37. package/node_modules/socket.io-parser/build/esm/index.d.ts +15 -4
  38. package/node_modules/socket.io-parser/build/esm/index.js +60 -15
  39. package/node_modules/socket.io-parser/build/esm/is-binary.d.ts +1 -1
  40. package/node_modules/socket.io-parser/build/esm-debug/index.d.ts +15 -4
  41. package/node_modules/socket.io-parser/build/esm-debug/index.js +60 -15
  42. package/node_modules/socket.io-parser/build/esm-debug/is-binary.d.ts +1 -1
  43. package/node_modules/socket.io-parser/package.json +8 -22
  44. package/oclif.manifest.json +107 -100
  45. package/package.json +7 -7
  46. package/node_modules/socket.io-client/node_modules/debug/package.json +0 -60
  47. package/node_modules/socket.io-client/node_modules/debug/src/browser.js +0 -271
  48. package/node_modules/socket.io-parser/node_modules/debug/LICENSE +0 -20
  49. package/node_modules/socket.io-parser/node_modules/debug/README.md +0 -481
  50. package/node_modules/socket.io-parser/node_modules/debug/src/common.js +0 -274
  51. package/node_modules/socket.io-parser/node_modules/debug/src/index.js +0 -10
  52. package/node_modules/socket.io-parser/node_modules/debug/src/node.js +0 -263
  53. /package/node_modules/{socket.io-client/node_modules/debug → debug}/LICENSE +0 -0
  54. /package/node_modules/{socket.io-client/node_modules/debug → debug}/README.md +0 -0
  55. /package/node_modules/{socket.io-client/node_modules/debug → debug}/src/index.js +0 -0
  56. /package/node_modules/{socket.io-client/node_modules/debug → debug}/src/node.js +0 -0
@@ -1,10 +1,13 @@
1
- import { type EnsureDaemonResult } from '@campfirein/brv-transport-client';
2
1
  import { Command } from '@oclif/core';
3
2
  export default class Restart extends Command {
4
3
  static description: string;
5
4
  static examples: string[];
5
+ /** Commands whose processes must not be killed (e.g. `brv update` calls `brv restart`). */
6
+ private static readonly PROTECTED_COMMANDS;
7
+ /** Server/agent patterns — cannot match CLI processes, no self-kill risk. */
8
+ private static readonly SERVER_AGENT_PATTERNS;
6
9
  /**
7
- * Builds the list of file-path patterns used to identify brv processes for pattern kill.
10
+ * Builds the list of CLI script patterns used to identify brv client processes.
8
11
  *
9
12
  * All patterns are absolute paths or specific filenames to avoid false-positive matches
10
13
  * against other oclif CLIs (which also use bin/run.js and bin/dev.js conventions).
@@ -17,32 +20,14 @@ export default class Restart extends Command {
17
20
  * nvm / system global: cmdline = node .../bin/brv ← caught by 'bin/brv' substring
18
21
  * curl install (/.brv-cli/): join(brvBinDir, 'run') — entry point named 'run' without .js
19
22
  *
20
- * Relative patterns (./bin/run.js, ./bin/dev.js) are intentionally excluded: they would
21
- * match any oclif CLI running in dev mode, not just brv.
22
- *
23
23
  * Set deduplicates when paths overlap (e.g. process.argv[1] is already run.js).
24
24
  */
25
- static buildKillPatterns(brvBinDir: string, argv1: string): string[];
26
- /**
27
- * Build a pid→cwd map from `lsof -d cwd -Fn` output.
28
- *
29
- * On macOS, `-p <pid>` is ignored and lsof returns ALL processes.
30
- * Output format per process: `p<pid>\nfcwd\nn<cwd_path>`.
31
- * Returns empty map if lsof is unavailable.
32
- */
33
- private static buildCwdByPid;
25
+ static buildCliPatterns(): string[];
34
26
  /**
35
- * Kill matching brv processes on macOS by scanning all processes via `ps`.
36
- *
37
- * For processes started with a relative path (e.g. `./bin/dev.js`), the literal
38
- * relative path is in the OS cmdline — absolute-path patterns won't match.
39
- * Resolves relative .js paths using buildCwdByPid() to avoid false positives
40
- * (e.g. `byterover-cli-clone/bin/dev.js` must not match `byterover-cli/bin/dev.js`).
41
- *
42
- * When cwd is resolved: check only absolute patterns (precise, no false positives).
43
- * When cwd is unavailable: also check relative fallback patterns (./bin/dev.js).
27
+ * Returns true if the cmdline contains a protected command as an argument.
28
+ * Handles both /proc null-byte delimiters (Linux) and space delimiters (macOS ps).
44
29
  */
45
- private static killByMacOsProcScan;
30
+ private static isProtectedCommand;
46
31
  /**
47
32
  * Kill a process by PID.
48
33
  * - Unix: SIGKILL via process.kill() — immediate, no graceful shutdown
@@ -51,46 +36,45 @@ export default class Restart extends Command {
51
36
  private static killByPid;
52
37
  /**
53
38
  * Kill matching brv processes on Linux by scanning /proc/<pid>/cmdline.
54
- *
55
- * For processes started with a relative path (e.g. `./bin/dev.js`), resolves
56
- * the path using /proc/<pid>/cwd so absolute-path patterns match correctly
57
- * without false positives.
58
- *
59
- * When cwd is resolved: check only absolute patterns (precise, no false positives).
60
- * When cwd is unavailable: also check relative fallback patterns (./bin/dev.js).
61
- * Mirrors the macOS killByMacOsProcScan behavior.
62
- *
63
- * Works on all Linux distros including Alpine — /proc is a kernel feature,
64
- * no userspace tools required.
39
+ * Simple substring match — no cwd resolution needed.
40
+ * Works on all Linux distros including Alpine /proc is a kernel feature.
65
41
  */
66
42
  private static killByProcScan;
67
43
  /**
68
- * Best-effort pattern kill for all brv processes (daemon, agents, TUI sessions, MCP servers,
69
- * headless commands). Errors are silently ignored.
44
+ * Kill matching brv processes on macOS by scanning all processes via `ps`.
45
+ * Simple substring match no cwd resolution needed because patterns
46
+ * are either unique filenames (brv-server.js) or absolute paths.
47
+ */
48
+ private static killByPsScan;
49
+ /**
50
+ * Pattern-kill brv processes matching the given patterns.
51
+ *
52
+ * Self-exclusion: own PID and parent PID are always filtered out.
53
+ * The parent PID exclusion protects the oclif bin/brv bash wrapper
54
+ * on bundled installs (it does not use exec, so bash remains as parent).
70
55
  *
71
- * Relative paths (e.g. `./bin/dev.js`) are resolved via cwd before pattern matching,
72
- * ensuring accuracy without false positives from other oclif CLIs.
56
+ * When skipProtected is true, processes running protected commands
57
+ * (e.g. `brv update`) are spared prevents `brv restart` from killing
58
+ * the `brv update` process that invoked it.
73
59
  *
74
60
  * OS dispatch:
75
- * Linux (incl. Alpine, WSL2): /proc scan + /proc/<pid>/cwd resolution
76
- * macOS: ps -A scan + lsof cwd resolution
61
+ * Linux (incl. Alpine, WSL2): /proc scan
62
+ * macOS: ps -A scan
77
63
  * Windows: PowerShell Get-CimInstance — available Windows 8+ / PS 3.0+
78
- *
79
- * Self-exclusion: own PID filtered on Unix; excluded explicitly in PowerShell query.
80
64
  */
81
65
  private static patternKill;
82
66
  private static sleep;
83
67
  /**
84
- * Polls until the process with the given PID is no longer alive.
68
+ * Polls until the process is dead, returning true if it exited within the timeout.
85
69
  * Uses `process.kill(pid, 0)` — sends no signal, just checks existence.
86
- * On ESRCH the PID is confirmed dead. Silently times out if the process
87
- * outlives timeoutMs (e.g. zombie held by parent).
88
- * Unix only — on Windows, taskkill /f is synchronous so no polling needed.
70
+ * On ESRCH the PID is confirmed dead.
89
71
  */
90
- private static waitForPidToDie;
72
+ private static waitForProcessExit;
91
73
  protected cleanupAllDaemonFiles(dataDir: string): void;
92
74
  protected exitProcess(code: number): void;
93
- protected killAllBrvProcesses(dataDir: string): Promise<void>;
75
+ protected loadDaemonInfo(dataDir: string): undefined | {
76
+ pid: number;
77
+ port: number;
78
+ };
94
79
  run(): Promise<void>;
95
- protected startDaemon(serverPath: string): Promise<EnsureDaemonResult>;
96
80
  }
@@ -1,22 +1,25 @@
1
- import { DAEMON_INSTANCE_FILE, ensureDaemonRunning, getGlobalDataDir, GlobalInstanceManager, HEARTBEAT_FILE, SPAWN_LOCK_FILE, } from '@campfirein/brv-transport-client';
1
+ import { DAEMON_INSTANCE_FILE, getGlobalDataDir, GlobalInstanceManager, HEARTBEAT_FILE, SPAWN_LOCK_FILE, } from '@campfirein/brv-transport-client';
2
2
  import { Command } from '@oclif/core';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { readdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs';
5
- import { dirname, join } from 'node:path';
6
- import { resolveLocalServerMainPath } from '../../server/utils/server-main-resolver.js';
7
- const MAX_ATTEMPTS = 3;
4
+ import { readdirSync, readFileSync, unlinkSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
8
6
  const KILL_SETTLE_MS = 500;
9
- const DAEMON_START_TIMEOUT_MS = 15_000;
10
- const KILL_VERIFY_TIMEOUT_MS = 2000;
7
+ const KILL_VERIFY_TIMEOUT_MS = 5000;
11
8
  const KILL_VERIFY_POLL_MS = 100;
9
+ const SIGTERM_BUDGET_MS = 8000;
12
10
  export default class Restart extends Command {
13
11
  static description = `Restart ByteRover — stop everything and start fresh.
14
12
 
15
13
  Run this when ByteRover is unresponsive, stuck, or after installing an update.
16
- All open sessions and background processes are stopped before the fresh start.`;
14
+ All open sessions and background processes are stopped.
15
+ The daemon will restart automatically on the next brv command.`;
17
16
  static examples = ['<%= config.bin %> <%= command.id %>'];
17
+ /** Commands whose processes must not be killed (e.g. `brv update` calls `brv restart`). */
18
+ static PROTECTED_COMMANDS = ['update'];
19
+ /** Server/agent patterns — cannot match CLI processes, no self-kill risk. */
20
+ static SERVER_AGENT_PATTERNS = ['brv-server.js', 'agent-process.js'];
18
21
  /**
19
- * Builds the list of file-path patterns used to identify brv processes for pattern kill.
22
+ * Builds the list of CLI script patterns used to identify brv client processes.
20
23
  *
21
24
  * All patterns are absolute paths or specific filenames to avoid false-positive matches
22
25
  * against other oclif CLIs (which also use bin/run.js and bin/dev.js conventions).
@@ -29,21 +32,12 @@ All open sessions and background processes are stopped before the fresh start.`;
29
32
  * nvm / system global: cmdline = node .../bin/brv ← caught by 'bin/brv' substring
30
33
  * curl install (/.brv-cli/): join(brvBinDir, 'run') — entry point named 'run' without .js
31
34
  *
32
- * Relative patterns (./bin/run.js, ./bin/dev.js) are intentionally excluded: they would
33
- * match any oclif CLI running in dev mode, not just brv.
34
- *
35
35
  * Set deduplicates when paths overlap (e.g. process.argv[1] is already run.js).
36
36
  */
37
- static buildKillPatterns(brvBinDir, argv1) {
38
- // Patterns ordered from most specific to broadest:
39
- // bin/brv — nvm/bundled binary (.../bin/brv)
40
- // byterover-cli/bin/run.js — npm global/nvm install (package name is always the folder name
41
- // in node_modules): /usr/local/.../byterover-cli/bin/run.js,
42
- // .nvm/.../byterover-cli/bin/run.js. NOT used for dev.js because
43
- // dev clones can have any directory name — covered by brvBinDir.
44
- // exact sibling paths — current installation's run.js / dev.js / run (any dir name)
45
- // process.argv[1] — current executable (bundled binary / dev entry)
46
- const brvScripts = [
37
+ static buildCliPatterns() {
38
+ const argv1 = resolve(process.argv[1]);
39
+ const brvBinDir = dirname(argv1);
40
+ return [
47
41
  ...new Set([
48
42
  argv1,
49
43
  join('bin', 'brv'),
@@ -53,90 +47,19 @@ All open sessions and background processes are stopped before the fresh start.`;
53
47
  join(brvBinDir, 'run.js'),
54
48
  ]),
55
49
  ];
56
- return ['brv-server.js', 'agent-process.js', ...brvScripts];
57
50
  }
58
51
  /**
59
- * Build a pid→cwd map from `lsof -d cwd -Fn` output.
60
- *
61
- * On macOS, `-p <pid>` is ignored and lsof returns ALL processes.
62
- * Output format per process: `p<pid>\nfcwd\nn<cwd_path>`.
63
- * Returns empty map if lsof is unavailable.
52
+ * Returns true if the cmdline contains a protected command as an argument.
53
+ * Handles both /proc null-byte delimiters (Linux) and space delimiters (macOS ps).
64
54
  */
65
- static buildCwdByPid() {
66
- const cwdByPid = new Map();
67
- try {
68
- const lsofResult = spawnSync('lsof', ['-d', 'cwd', '-Fn'], { encoding: 'utf8' });
69
- if (!lsofResult.stdout)
70
- return cwdByPid;
71
- const lines = lsofResult.stdout.split('\n');
72
- let curPid = -1;
73
- for (let i = 0; i < lines.length; i++) {
74
- if (lines[i].startsWith('p')) {
75
- curPid = Number.parseInt(lines[i].slice(1), 10);
76
- }
77
- else if (lines[i] === 'fcwd' && curPid > 0 && lines[i + 1]?.startsWith('n')) {
78
- cwdByPid.set(curPid, lines[i + 1].slice(1));
79
- }
80
- }
81
- }
82
- catch {
83
- // lsof unavailable — caller falls back to relative-path patterns
84
- }
85
- return cwdByPid;
86
- }
87
- /**
88
- * Kill matching brv processes on macOS by scanning all processes via `ps`.
89
- *
90
- * For processes started with a relative path (e.g. `./bin/dev.js`), the literal
91
- * relative path is in the OS cmdline — absolute-path patterns won't match.
92
- * Resolves relative .js paths using buildCwdByPid() to avoid false positives
93
- * (e.g. `byterover-cli-clone/bin/dev.js` must not match `byterover-cli/bin/dev.js`).
94
- *
95
- * When cwd is resolved: check only absolute patterns (precise, no false positives).
96
- * When cwd is unavailable: also check relative fallback patterns (./bin/dev.js).
97
- */
98
- static killByMacOsProcScan(patterns, excludePids) {
99
- const psResult = spawnSync('ps', ['-A', '-o', 'pid,args'], { encoding: 'utf8' });
100
- if (!psResult.stdout)
101
- return;
102
- const cwdByPid = Restart.buildCwdByPid();
103
- for (const line of psResult.stdout.split('\n').slice(1)) {
104
- const match = /^\s*(\d+)\s+(.+)$/.exec(line);
105
- if (!match)
106
- continue;
107
- const pid = Number.parseInt(match[1], 10);
108
- const rawCmdline = match[2].trim();
109
- if (Number.isNaN(pid) || excludePids.has(pid))
110
- continue;
111
- // Resolve relative .js path using cwd map to get an absolute path for matching.
112
- let cmdline = rawCmdline;
113
- let cwdResolved = false;
114
- const relativeJs = rawCmdline.split(/\s+/).find((a) => a.endsWith('.js') && !a.startsWith('/'));
115
- if (relativeJs) {
116
- const cwd = cwdByPid.get(pid);
117
- if (cwd) {
118
- cmdline = rawCmdline.replace(relativeJs, join(cwd, relativeJs));
119
- cwdResolved = true;
120
- }
121
- }
122
- for (const pattern of patterns) {
123
- // When cwd resolved to absolute path, skip relative fallback patterns (those starting with
124
- // './') — the resolved cmdline no longer contains relative paths, so these won't match.
125
- // Prevents false positives against other projects (e.g. byterover-cli-clone) that also
126
- // run ./bin/dev.js when lsof is unavailable and cwd cannot be resolved.
127
- if (cwdResolved && pattern.startsWith('./'))
128
- continue;
129
- if (cmdline.includes(pattern)) {
130
- try {
131
- process.kill(pid, 'SIGKILL');
132
- }
133
- catch {
134
- // Process already dead — ignore
135
- }
136
- break;
137
- }
138
- }
139
- }
55
+ static isProtectedCommand(cmdline) {
56
+ return Restart.PROTECTED_COMMANDS.some((cmd) =>
57
+ // Linux /proc/cmdline: null-byte delimited
58
+ cmdline.includes(`\0${cmd}\0`) ||
59
+ cmdline.endsWith(`\0${cmd}`) ||
60
+ // macOS ps / Windows: space delimited
61
+ cmdline.endsWith(` ${cmd}`) ||
62
+ cmdline.includes(` ${cmd} `));
140
63
  }
141
64
  /**
142
65
  * Kill a process by PID.
@@ -158,19 +81,10 @@ All open sessions and background processes are stopped before the fresh start.`;
158
81
  }
159
82
  /**
160
83
  * Kill matching brv processes on Linux by scanning /proc/<pid>/cmdline.
161
- *
162
- * For processes started with a relative path (e.g. `./bin/dev.js`), resolves
163
- * the path using /proc/<pid>/cwd so absolute-path patterns match correctly
164
- * without false positives.
165
- *
166
- * When cwd is resolved: check only absolute patterns (precise, no false positives).
167
- * When cwd is unavailable: also check relative fallback patterns (./bin/dev.js).
168
- * Mirrors the macOS killByMacOsProcScan behavior.
169
- *
170
- * Works on all Linux distros including Alpine — /proc is a kernel feature,
171
- * no userspace tools required.
84
+ * Simple substring match — no cwd resolution needed.
85
+ * Works on all Linux distros including Alpine /proc is a kernel feature.
172
86
  */
173
- static killByProcScan(patterns, excludePids) {
87
+ static killByProcScan(patterns, excludePids, skipProtected) {
174
88
  let entries;
175
89
  try {
176
90
  entries = readdirSync('/proc');
@@ -183,34 +97,11 @@ All open sessions and background processes are stopped before the fresh start.`;
183
97
  if (Number.isNaN(pid) || excludePids.has(pid))
184
98
  continue;
185
99
  try {
186
- const args = readFileSync(join('/proc', entry, 'cmdline'), 'utf8')
187
- .split('\0')
188
- .filter(Boolean);
189
- let cmdline = args.join(' ');
190
- // Resolve relative .js path using /proc/<pid>/cwd to match against absolute-path patterns.
191
- // Without this, `./bin/dev.js` would not match `byterover-cli/bin/dev.js`.
192
- let cwdResolved = false;
193
- const relativeJs = args.find((a) => a.endsWith('.js') && !a.startsWith('/'));
194
- if (relativeJs) {
195
- try {
196
- const cwd = readlinkSync(join('/proc', entry, 'cwd'));
197
- cmdline = cmdline.replace(relativeJs, join(cwd, relativeJs));
198
- cwdResolved = true;
199
- }
200
- catch {
201
- // cwd unreadable — use original cmdline
202
- }
203
- }
204
- for (const pattern of patterns) {
205
- // When cwd resolved to absolute path, skip relative fallback patterns (those starting with
206
- // './') — the resolved cmdline no longer contains relative paths, so these won't match.
207
- // Prevents false positives against other oclif CLIs that also run ./bin/dev.js.
208
- if (cwdResolved && pattern.startsWith('./'))
100
+ const cmdline = readFileSync(join('/proc', entry, 'cmdline'), 'utf8');
101
+ if (patterns.some((p) => cmdline.includes(p))) {
102
+ if (skipProtected && Restart.isProtectedCommand(cmdline))
209
103
  continue;
210
- if (cmdline.includes(pattern)) {
211
- process.kill(pid, 'SIGKILL');
212
- break; // Already killing this PID — no need to check remaining patterns
213
- }
104
+ process.kill(pid, 'SIGKILL');
214
105
  }
215
106
  }
216
107
  catch {
@@ -219,36 +110,66 @@ All open sessions and background processes are stopped before the fresh start.`;
219
110
  }
220
111
  }
221
112
  /**
222
- * Best-effort pattern kill for all brv processes (daemon, agents, TUI sessions, MCP servers,
223
- * headless commands). Errors are silently ignored.
113
+ * Kill matching brv processes on macOS by scanning all processes via `ps`.
114
+ * Simple substring match no cwd resolution needed because patterns
115
+ * are either unique filenames (brv-server.js) or absolute paths.
116
+ */
117
+ static killByPsScan(patterns, excludePids, skipProtected) {
118
+ const psResult = spawnSync('ps', ['-A', '-o', 'pid,args'], { encoding: 'utf8' });
119
+ if (!psResult.stdout)
120
+ return;
121
+ for (const line of psResult.stdout.split('\n').slice(1)) {
122
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
123
+ if (!match)
124
+ continue;
125
+ const pid = Number.parseInt(match[1], 10);
126
+ const cmdline = match[2];
127
+ if (Number.isNaN(pid) || excludePids.has(pid))
128
+ continue;
129
+ if (patterns.some((p) => cmdline.includes(p))) {
130
+ if (skipProtected && Restart.isProtectedCommand(cmdline))
131
+ continue;
132
+ try {
133
+ process.kill(pid, 'SIGKILL');
134
+ }
135
+ catch {
136
+ // Process already dead — ignore
137
+ }
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * Pattern-kill brv processes matching the given patterns.
143
+ *
144
+ * Self-exclusion: own PID and parent PID are always filtered out.
145
+ * The parent PID exclusion protects the oclif bin/brv bash wrapper
146
+ * on bundled installs (it does not use exec, so bash remains as parent).
224
147
  *
225
- * Relative paths (e.g. `./bin/dev.js`) are resolved via cwd before pattern matching,
226
- * ensuring accuracy without false positives from other oclif CLIs.
148
+ * When skipProtected is true, processes running protected commands
149
+ * (e.g. `brv update`) are spared prevents `brv restart` from killing
150
+ * the `brv update` process that invoked it.
227
151
  *
228
152
  * OS dispatch:
229
- * Linux (incl. Alpine, WSL2): /proc scan + /proc/<pid>/cwd resolution
230
- * macOS: ps -A scan + lsof cwd resolution
153
+ * Linux (incl. Alpine, WSL2): /proc scan
154
+ * macOS: ps -A scan
231
155
  * Windows: PowerShell Get-CimInstance — available Windows 8+ / PS 3.0+
232
- *
233
- * Self-exclusion: own PID filtered on Unix; excluded explicitly in PowerShell query.
234
156
  */
235
- static patternKill() {
236
- const brvBinDir = dirname(process.argv[1]);
237
- const allPatterns = Restart.buildKillPatterns(brvBinDir, process.argv[1]);
238
- // Exclude both the current node process and its parent shell wrapper (install.sh installs
239
- // use a shell script that forks node — killing the wrapper garbles terminal output).
157
+ static patternKill(patterns, skipProtected = false) {
240
158
  const excludePids = new Set([process.pid, process.ppid]);
241
159
  if (process.platform === 'win32') {
242
- const whereClause = allPatterns.map((p) => `$_.CommandLine -like '*${p}*'`).join(' -or ');
243
- const script = `Get-CimInstance Win32_Process | Where-Object { (${whereClause}) -and $_.ProcessId -ne ${process.pid} -and $_.ProcessId -ne ${process.ppid} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`;
160
+ const whereClause = patterns.map((p) => `$_.CommandLine -like '*${p.replaceAll("'", "''")}*'`).join(' -or ');
161
+ const protectedClause = skipProtected
162
+ ? ` -and ${Restart.PROTECTED_COMMANDS.map((cmd) => `$_.CommandLine -notlike '* ${cmd} *' -and $_.CommandLine -notlike '* ${cmd}'`).join(' -and ')}`
163
+ : '';
164
+ const script = `Get-CimInstance Win32_Process | Where-Object { (${whereClause}) -and $_.ProcessId -ne ${process.pid} -and $_.ProcessId -ne ${process.ppid}${protectedClause} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`;
244
165
  spawnSync('powershell', ['-Command', script], { stdio: 'ignore' });
245
166
  }
246
167
  else if (process.platform === 'linux') {
247
- Restart.killByProcScan(allPatterns, excludePids);
168
+ Restart.killByProcScan(patterns, excludePids, skipProtected);
248
169
  }
249
170
  else {
250
- // macOS (and other Unix): ps -A scan with lsof cwd resolution for relative paths
251
- Restart.killByMacOsProcScan(allPatterns, excludePids);
171
+ // macOS (and other Unix): ps -A scan
172
+ Restart.killByPsScan(patterns, excludePids, skipProtected);
252
173
  }
253
174
  }
254
175
  static sleep(ms) {
@@ -257,25 +178,23 @@ All open sessions and background processes are stopped before the fresh start.`;
257
178
  });
258
179
  }
259
180
  /**
260
- * Polls until the process with the given PID is no longer alive.
181
+ * Polls until the process is dead, returning true if it exited within the timeout.
261
182
  * Uses `process.kill(pid, 0)` — sends no signal, just checks existence.
262
- * On ESRCH the PID is confirmed dead. Silently times out if the process
263
- * outlives timeoutMs (e.g. zombie held by parent).
264
- * Unix only — on Windows, taskkill /f is synchronous so no polling needed.
183
+ * On ESRCH the PID is confirmed dead.
265
184
  */
266
- static async waitForPidToDie(pid, timeoutMs) {
185
+ static async waitForProcessExit(pid, timeoutMs) {
267
186
  const deadline = Date.now() + timeoutMs;
268
187
  while (Date.now() < deadline) {
269
188
  try {
270
- process.kill(pid, 0); // throws ESRCH if dead
189
+ process.kill(pid, 0);
271
190
  }
272
191
  catch {
273
- return; // process confirmed dead
192
+ return true; // ESRCH = dead
274
193
  }
275
194
  // eslint-disable-next-line no-await-in-loop -- intentional poll loop
276
195
  await Restart.sleep(KILL_VERIFY_POLL_MS);
277
196
  }
278
- // Timed out — continue anyway; retry loop will kill again if still alive
197
+ return false;
279
198
  }
280
199
  cleanupAllDaemonFiles(dataDir) {
281
200
  for (const file of [DAEMON_INSTANCE_FILE, HEARTBEAT_FILE, SPAWN_LOCK_FILE]) {
@@ -291,55 +210,49 @@ All open sessions and background processes are stopped before the fresh start.`;
291
210
  // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
292
211
  process.exit(code);
293
212
  }
294
- async killAllBrvProcesses(dataDir) {
295
- this.log('Stopping processes...');
296
- // Read PID directly from daemon.json — no health-check filtering.
297
- const info = new GlobalInstanceManager({ dataDir }).load();
298
- if (info !== undefined) {
299
- Restart.killByPid(info.pid);
300
- // Verify the daemon PID is dead before pattern-killing the rest.
301
- // taskkill /f on Windows is synchronous so polling is only needed on Unix.
302
- if (process.platform !== 'win32') {
303
- await Restart.waitForPidToDie(info.pid, KILL_VERIFY_TIMEOUT_MS);
304
- }
305
- }
306
- // Always run pattern kill — catches processes not in daemon.json
307
- // (agents, TUI sessions, MCP servers, headless commands).
308
- Restart.patternKill();
309
- await Restart.sleep(KILL_SETTLE_MS);
213
+ loadDaemonInfo(dataDir) {
214
+ return new GlobalInstanceManager({ dataDir }).load();
310
215
  }
311
216
  async run() {
312
- const serverPath = resolveLocalServerMainPath();
313
217
  const dataDir = getGlobalDataDir();
314
- /* eslint-disable no-await-in-loop -- intentional sequential retry loop */
315
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
316
- if (attempt > 1) {
317
- this.log(`Attempt ${attempt}/${MAX_ATTEMPTS}...`);
318
- }
319
- await this.killAllBrvProcesses(dataDir);
320
- this.cleanupAllDaemonFiles(dataDir);
321
- this.log('Starting daemon...');
322
- const result = await this.startDaemon(serverPath);
323
- if (result.success) {
324
- this.log(`Daemon started (PID ${result.info.pid}, port ${result.info.port})`);
325
- break;
218
+ // Phase 1: Kill all client processes first (TUI, MCP, headless commands).
219
+ // Must happen BEFORE daemon kill clients have reconnectors that will
220
+ // respawn the daemon via ensureDaemonRunning() if they detect disconnection.
221
+ // Self excluded by process.pid / process.ppid.
222
+ // Protected commands (e.g. `brv update`) are spared.
223
+ this.log('Stopping clients...');
224
+ Restart.patternKill(Restart.buildCliPatterns(), true);
225
+ await Restart.sleep(KILL_SETTLE_MS);
226
+ // Phase 2: Graceful daemon kill via daemon.json PID.
227
+ // SIGTERM triggers ShutdownHandler → stops agents, transport, releases daemon.json.
228
+ // Safe now because all clients are dead — no one can respawn daemon.
229
+ const info = this.loadDaemonInfo(dataDir);
230
+ if (info !== undefined) {
231
+ this.log(`Stopping daemon (PID ${info.pid})...`);
232
+ let stopped = false;
233
+ try {
234
+ process.kill(info.pid, 'SIGTERM');
235
+ stopped = await Restart.waitForProcessExit(info.pid, SIGTERM_BUDGET_MS);
326
236
  }
327
- const detail = result.spawnError ? ` (${result.spawnError})` : '';
328
- if (attempt < MAX_ATTEMPTS) {
329
- this.log(`Daemon did not start (${result.reason}${detail}). Retrying...`);
237
+ catch {
238
+ stopped = true; // ESRCH = already dead
330
239
  }
331
- else {
332
- this.error(`Failed to start daemon after ${MAX_ATTEMPTS} attempts: ${result.reason}${detail}`);
240
+ if (!stopped) {
241
+ Restart.killByPid(info.pid);
242
+ if (process.platform !== 'win32') {
243
+ await Restart.waitForProcessExit(info.pid, KILL_VERIFY_TIMEOUT_MS);
244
+ }
333
245
  }
334
246
  }
335
- /* eslint-enable no-await-in-loop */
247
+ // Phase 3: Kill orphaned server/agent processes not tracked in daemon.json.
248
+ Restart.patternKill(Restart.SERVER_AGENT_PATTERNS);
249
+ await Restart.sleep(KILL_SETTLE_MS);
250
+ // Phase 4: Clean state files.
251
+ this.cleanupAllDaemonFiles(dataDir);
252
+ this.log('All ByteRover processes stopped.');
336
253
  // Force exit — oclif does not call process.exit() after run() returns,
337
- // relying on the event loop to drain. In production, third-party plugin
338
- // hooks (e.g. @oclif/plugin-update) can leave open handles that prevent
339
- // the process from exiting naturally. mcp.ts uses the same pattern.
254
+ // relying on the event loop to drain. Third-party plugin hooks (e.g.
255
+ // @oclif/plugin-update) can leave open handles that prevent exit.
340
256
  this.exitProcess(0);
341
257
  }
342
- async startDaemon(serverPath) {
343
- return ensureDaemonRunning({ serverPath, timeoutMs: DAEMON_START_TIMEOUT_MS });
344
- }
345
258
  }
@@ -0,0 +1,11 @@
1
+ import type { Hook } from '@oclif/core';
2
+ export type BlockCommandUpdateNpmDeps = {
3
+ commandId: string | undefined;
4
+ errorFn: (message: string, options: {
5
+ exit: number;
6
+ }) => void;
7
+ isNpmGlobalInstalled: boolean;
8
+ };
9
+ export declare function handleBlockCommandUpdateNpm(deps: BlockCommandUpdateNpmDeps): void;
10
+ declare const hook: Hook<'init'>;
11
+ export default hook;
@@ -0,0 +1,15 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { isNpmGlobalInstall } from './update-notifier.js';
3
+ export function handleBlockCommandUpdateNpm(deps) {
4
+ if (deps.commandId === 'update' && deps.isNpmGlobalInstalled) {
5
+ deps.errorFn('brv was installed via npm. Use `npm update -g byterover-cli` to update.', { exit: 1 });
6
+ }
7
+ }
8
+ const hook = async function (opts) {
9
+ handleBlockCommandUpdateNpm({
10
+ commandId: opts.id,
11
+ errorFn: this.error.bind(this),
12
+ isNpmGlobalInstalled: isNpmGlobalInstall(execSync),
13
+ });
14
+ };
15
+ export default hook;
@@ -33,6 +33,9 @@ export type UpdateNotifierDeps = {
33
33
  isTTY: boolean;
34
34
  log: (message: string) => void;
35
35
  notifier: NarrowedUpdateNotifier;
36
+ spawnRestartFn: () => {
37
+ unref(): void;
38
+ };
36
39
  };
37
40
  /**
38
41
  * Check whether byterover-cli is installed as a npm global package.
@@ -1,5 +1,5 @@
1
1
  import { confirm } from '@inquirer/prompts';
2
- import { execSync } from 'node:child_process';
2
+ import { execSync, spawn } from 'node:child_process';
3
3
  import updateNotifier from 'update-notifier';
4
4
  /**
5
5
  * Check interval for update notifications (1 hour)
@@ -34,16 +34,23 @@ export async function handleUpdateNotification(deps) {
34
34
  }
35
35
  const shouldUpdate = await confirmPrompt({
36
36
  default: true,
37
- message: `Update available: ${current} → ${latest}. Would you like to update now?`,
37
+ message: `Update available: ${current} → ${latest}. Update now? (active sessions will be restarted)`,
38
38
  });
39
39
  if (shouldUpdate) {
40
40
  log('Updating byterover-cli...');
41
41
  try {
42
42
  execSyncFn('npm update -g byterover-cli', { stdio: 'inherit' });
43
43
  log('');
44
- log(`✓ Successfully updated to ${latest}`);
44
+ log(`✓ Updated to ${latest}.`);
45
45
  log('');
46
- log(`The update will take effect on next launch. Run 'brv' when ready.`);
46
+ try {
47
+ const child = deps.spawnRestartFn();
48
+ child.unref();
49
+ log('Restarting ByteRover in the background. Please wait a few seconds before running brv again.');
50
+ }
51
+ catch {
52
+ log('Failed to restart ByteRover. Please restart it manually by running `brv restart`.');
53
+ }
47
54
  exitFn(0);
48
55
  }
49
56
  catch {
@@ -63,6 +70,12 @@ const hook = async function () {
63
70
  isTTY: process.stdout.isTTY ?? false,
64
71
  log: this.log.bind(this),
65
72
  notifier,
73
+ spawnRestartFn: () => spawn('brv restart', {
74
+ detached: true,
75
+ shell: true,
76
+ stdio: 'ignore',
77
+ windowsHide: true,
78
+ }),
66
79
  });
67
80
  };
68
81
  export default hook;