byterover-cli 2.2.0 → 2.3.1

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 (99) hide show
  1. package/dist/agent/infra/llm/providers/openai.d.ts +12 -0
  2. package/dist/agent/infra/llm/providers/openai.js +52 -1
  3. package/dist/oclif/commands/curate/index.js +2 -2
  4. package/dist/oclif/commands/model/switch.js +14 -3
  5. package/dist/oclif/commands/providers/connect.d.ts +9 -0
  6. package/dist/oclif/commands/providers/connect.js +110 -14
  7. package/dist/oclif/commands/providers/list.js +3 -5
  8. package/dist/oclif/commands/query.js +2 -2
  9. package/dist/oclif/commands/restart.d.ts +34 -50
  10. package/dist/oclif/commands/restart.js +122 -209
  11. package/dist/oclif/hooks/init/block-command-update-npm.d.ts +11 -0
  12. package/dist/oclif/hooks/init/block-command-update-npm.js +15 -0
  13. package/dist/oclif/hooks/init/update-notifier.d.ts +3 -0
  14. package/dist/oclif/hooks/init/update-notifier.js +17 -4
  15. package/dist/oclif/hooks/postrun/restart-after-update.d.ts +22 -0
  16. package/dist/oclif/hooks/postrun/restart-after-update.js +40 -0
  17. package/dist/oclif/lib/daemon-client.d.ts +4 -0
  18. package/dist/oclif/lib/daemon-client.js +13 -3
  19. package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
  20. package/dist/server/core/domain/entities/provider-config.js +4 -3
  21. package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
  22. package/dist/server/core/domain/entities/provider-registry.js +24 -1
  23. package/dist/server/core/domain/errors/task-error.d.ts +2 -0
  24. package/dist/server/core/domain/errors/task-error.js +6 -1
  25. package/dist/server/core/domain/transport/schemas.d.ts +2 -0
  26. package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
  27. package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
  28. package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
  29. package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
  30. package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
  31. package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
  32. package/dist/server/infra/daemon/agent-process.js +22 -4
  33. package/dist/server/infra/daemon/brv-server.js +13 -2
  34. package/dist/server/infra/http/models-dev-client.d.ts +29 -0
  35. package/dist/server/infra/http/models-dev-client.js +133 -0
  36. package/dist/server/infra/http/openrouter-api-client.js +1 -1
  37. package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
  38. package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
  39. package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
  40. package/dist/server/infra/http/provider-model-fetchers.js +88 -10
  41. package/dist/server/infra/process/feature-handlers.d.ts +3 -1
  42. package/dist/server/infra/process/feature-handlers.js +3 -1
  43. package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
  44. package/dist/server/infra/provider/provider-config-resolver.js +59 -4
  45. package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
  46. package/dist/server/infra/provider-oauth/callback-server.js +203 -0
  47. package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
  48. package/dist/server/infra/provider-oauth/errors.js +76 -0
  49. package/dist/server/infra/provider-oauth/index.d.ts +9 -0
  50. package/dist/server/infra/provider-oauth/index.js +9 -0
  51. package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
  52. package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
  53. package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
  54. package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
  55. package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
  56. package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
  57. package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
  58. package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
  59. package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
  60. package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
  61. package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
  62. package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
  63. package/dist/server/infra/provider-oauth/types.d.ts +55 -0
  64. package/dist/server/infra/provider-oauth/types.js +22 -0
  65. package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
  66. package/dist/server/infra/storage/file-provider-config-store.js +1 -3
  67. package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
  68. package/dist/server/infra/transport/handlers/model-handler.js +53 -11
  69. package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
  70. package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
  71. package/dist/shared/constants/oauth.d.ts +14 -0
  72. package/dist/shared/constants/oauth.js +14 -0
  73. package/dist/shared/transport/events/index.d.ts +5 -0
  74. package/dist/shared/transport/events/model-events.d.ts +2 -0
  75. package/dist/shared/transport/events/provider-events.d.ts +36 -0
  76. package/dist/shared/transport/events/provider-events.js +5 -0
  77. package/dist/shared/transport/types/dto.d.ts +4 -0
  78. package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
  79. package/dist/tui/features/model/api/set-active-model.js +12 -4
  80. package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
  81. package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
  82. package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
  83. package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
  84. package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
  85. package/dist/tui/features/provider/api/start-oauth.js +15 -0
  86. package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
  87. package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
  88. package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
  89. package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
  90. package/dist/tui/features/provider/components/provider-dialog.js +1 -1
  91. package/dist/tui/features/provider/components/provider-flow.js +54 -4
  92. package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
  93. package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
  94. package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
  95. package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
  96. package/dist/tui/providers/app-providers.js +2 -1
  97. package/dist/tui/utils/error-messages.js +6 -1
  98. package/oclif.manifest.json +191 -168
  99. package/package.json +7 -5
@@ -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;
@@ -0,0 +1,22 @@
1
+ import type { Hook } from '@oclif/core';
2
+ export type RestartAfterUpdateDeps = {
3
+ argv: string[];
4
+ commandId: string | undefined;
5
+ log: (msg: string) => void;
6
+ spawnRestartFn: () => {
7
+ unref(): void;
8
+ };
9
+ };
10
+ /**
11
+ * Restart daemon/agent processes after a manual `brv update`.
12
+ *
13
+ * Fires after every command via the oclif postrun hook; early-returns for
14
+ * anything other than `brv update`.
15
+ *
16
+ * Skips background auto-updates: @oclif/plugin-update passes `--autoupdate`
17
+ * when spawning `brv update` in the background. Manual `brv update` runs
18
+ * in the user's shell without that flag.
19
+ */
20
+ export declare function handleRestartAfterUpdate(deps: RestartAfterUpdateDeps): Promise<void>;
21
+ declare const hook: Hook<'postrun'>;
22
+ export default hook;
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Restart daemon/agent processes after a manual `brv update`.
4
+ *
5
+ * Fires after every command via the oclif postrun hook; early-returns for
6
+ * anything other than `brv update`.
7
+ *
8
+ * Skips background auto-updates: @oclif/plugin-update passes `--autoupdate`
9
+ * when spawning `brv update` in the background. Manual `brv update` runs
10
+ * in the user's shell without that flag.
11
+ */
12
+ export async function handleRestartAfterUpdate(deps) {
13
+ if (deps.commandId !== 'update')
14
+ return;
15
+ if (deps.argv.includes('--autoupdate'))
16
+ return;
17
+ try {
18
+ const child = deps.spawnRestartFn();
19
+ child.unref();
20
+ deps.log('Restarting ByteRover in the background. Please wait a few seconds before running brv again.');
21
+ }
22
+ catch {
23
+ deps.log('Failed to restart ByteRover. Please restart it manually by running `brv restart`.');
24
+ // best-effort — update already succeeded
25
+ }
26
+ }
27
+ const hook = async function (opts) {
28
+ await handleRestartAfterUpdate({
29
+ argv: opts.argv,
30
+ commandId: opts.Command.id,
31
+ log: this.log.bind(this),
32
+ spawnRestartFn: () => spawn('brv restart', {
33
+ detached: true,
34
+ shell: true,
35
+ stdio: 'ignore',
36
+ windowsHide: true,
37
+ }),
38
+ });
39
+ };
40
+ export default hook;
@@ -30,6 +30,10 @@ export declare function isRetryableError(error: unknown): boolean;
30
30
  * Checks if an error left leaked Socket.IO handles that prevent Node.js from exiting.
31
31
  */
32
32
  export declare function hasLeakedHandles(error: unknown): boolean;
33
+ /**
34
+ * Builds a user-friendly message when provider credentials are missing from storage.
35
+ */
36
+ export declare function providerMissingMessage(activeProvider: string, authMethod?: 'api-key' | 'oauth'): string;
33
37
  export interface ProviderErrorContext {
34
38
  activeModel?: string;
35
39
  activeProvider?: string;
@@ -12,6 +12,8 @@ const USER_FRIENDLY_MESSAGES = {
12
12
  [TaskErrorCode.CONTEXT_TREE_NOT_INITIALIZED]: 'Context tree not initialized.',
13
13
  [TaskErrorCode.LOCAL_CHANGES_EXIST]: 'You have local changes. Run "brv push" to save your changes before pulling.',
14
14
  [TaskErrorCode.NOT_AUTHENTICATED]: 'Not authenticated. Cloud sync features (push/pull/space) require login — local query and curate work without authentication.',
15
+ [TaskErrorCode.OAUTH_REFRESH_FAILED]: 'OAuth token refresh failed. Run "brv providers connect <provider> --oauth" to reconnect.',
16
+ [TaskErrorCode.OAUTH_TOKEN_EXPIRED]: 'OAuth token has expired. Run "brv providers connect <provider> --oauth" to reconnect.',
15
17
  [TaskErrorCode.PROJECT_NOT_INIT]: 'Project not initialized. Run "brv restart" to reinitialize.',
16
18
  [TaskErrorCode.PROVIDER_NOT_CONFIGURED]: 'No provider connected. Run "brv providers connect byterover" to use the free built-in provider, or connect another provider.',
17
19
  [TaskErrorCode.SPACE_NOT_CONFIGURED]: 'No space configured. Run "brv space list" to see available spaces, then "brv space switch --team <team> --name <space>" to select one.',
@@ -83,6 +85,14 @@ export function hasLeakedHandles(error) {
83
85
  return false;
84
86
  return error.code === TaskErrorCode.AGENT_DISCONNECTED || error.code === TaskErrorCode.AGENT_NOT_AVAILABLE;
85
87
  }
88
+ /**
89
+ * Builds a user-friendly message when provider credentials are missing from storage.
90
+ */
91
+ export function providerMissingMessage(activeProvider, authMethod) {
92
+ return authMethod === 'oauth'
93
+ ? `${activeProvider} authentication has expired.\nPlease reconnect: brv providers connect ${activeProvider} --oauth`
94
+ : `${activeProvider} API key is missing from storage.\nPlease reconnect: brv providers connect ${activeProvider} --api-key <your-key>`;
95
+ }
86
96
  /**
87
97
  * Formats a connection error into a user-friendly message.
88
98
  */
@@ -131,9 +141,9 @@ export function formatConnectionError(error, providerContext) {
131
141
  const provider = providerContext?.activeProvider ?? '<provider>';
132
142
  const model = providerContext?.activeModel;
133
143
  const currentInfo = model ? `Provider: ${provider} Model: ${model}\n\n` : `Provider: ${provider}\n\n`;
134
- return (`LLM provider API key is missing or invalid.\n${currentInfo}` +
135
- ' Reconnect with your API key:\n' +
136
- ` brv providers connect ${provider} --api-key <key>\n\n` +
144
+ return (`LLM provider credentials are missing or invalid.\n${currentInfo}` +
145
+ ' Reconnect your provider:\n' +
146
+ ` brv providers connect ${provider}\n\n` +
137
147
  ' Switch to a different provider:\n' +
138
148
  ' brv providers switch <provider>\n\n' +
139
149
  ' See all options: brv providers --help');