@vellumai/cli 0.11.3 → 0.11.4-dev.202608190019.b94dbf2

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 (41) hide show
  1. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
  2. package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
  3. package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
  4. package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
  5. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
  6. package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
  7. package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
  8. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  12. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  13. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  14. package/package.json +1 -1
  15. package/src/__tests__/assistant-config.test.ts +35 -0
  16. package/src/__tests__/nginx-ingress-command.test.ts +4 -23
  17. package/src/__tests__/nginx-ingress.test.ts +59 -215
  18. package/src/__tests__/pair.test.ts +11 -197
  19. package/src/__tests__/retire-archive.test.ts +13 -1
  20. package/src/__tests__/retire-local.test.ts +58 -4
  21. package/src/__tests__/tunnel.test.ts +0 -28
  22. package/src/__tests__/wake.test.ts +91 -69
  23. package/src/__tests__/windows-lifecycle.test.ts +157 -0
  24. package/src/commands/client.ts +9 -31
  25. package/src/commands/nginx-ingress.ts +0 -15
  26. package/src/commands/pair.ts +0 -39
  27. package/src/commands/wake.ts +39 -12
  28. package/src/lib/__tests__/web-dist.test.ts +86 -0
  29. package/src/lib/assistant-config.ts +64 -27
  30. package/src/lib/local.ts +60 -20
  31. package/src/lib/nginx-ingress.ts +35 -108
  32. package/src/lib/orphan-detection.test.ts +3 -0
  33. package/src/lib/orphan-detection.ts +33 -11
  34. package/src/lib/pgrep.ts +20 -2
  35. package/src/lib/process.ts +191 -18
  36. package/src/lib/retire-archive.ts +38 -8
  37. package/src/lib/retire-local.ts +75 -9
  38. package/src/lib/tunnel-edge.ts +14 -22
  39. package/src/lib/web-dist.ts +48 -0
  40. package/src/lib/feature-flags.test.ts +0 -157
  41. package/src/lib/feature-flags.ts +0 -38
@@ -14,6 +14,8 @@ export interface RemoteProcess {
14
14
  command: string;
15
15
  }
16
16
 
17
+ const VELLUM_PROCESS_MARKER = /vellum|qdrant|openclaw/;
18
+
17
19
  export function classifyProcess(command: string): string {
18
20
  if (/qdrant/.test(command)) return "qdrant";
19
21
  if (/vellum-gateway/.test(command)) return "gateway";
@@ -23,9 +25,9 @@ export function classifyProcess(command: string): string {
23
25
  )
24
26
  )
25
27
  return "openclaw-adapter";
26
- if (/vellum-daemon/.test(command)) return "assistant";
28
+ if (/vellum-daemon|[\\/]daemon[\\/]main/.test(command)) return "assistant";
27
29
  if (/daemon\s+(start|restart)/.test(command)) return "assistant";
28
- if (/vellum-cli/.test(command)) return "vellum";
30
+ if (/vellum-cli|[\\/]vellum(?:-cli)?\.exe/.test(command)) return "vellum";
29
31
  // Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
30
32
  // but they are not background service processes.
31
33
  if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
@@ -56,7 +58,8 @@ export function classifyProcess(command: string): string {
56
58
  * logs in the terminal).
57
59
  */
58
60
  export function isInteractiveCliSession(command: string): boolean {
59
- const vellumToken = /(?:^|\/)vellum(?:-cli)?(?:\s+--(?:no-color|plain))*/;
61
+ const vellumToken =
62
+ /(?:^|[\\/])vellum(?:-cli)?(?:\.exe)?["']?(?:\s+--(?:no-color|plain))*/;
60
63
  const interactiveSubcommand = new RegExp(
61
64
  vellumToken.source +
62
65
  String.raw`\s+(?:tunnel|events|logs|client|terminal|ssh|exec|message|workflows)\b`,
@@ -154,6 +157,28 @@ export interface DetectOrphansOptions {
154
157
  * avoid touching the real on-host lockfiles.
155
158
  */
156
159
  excludePids?: Set<string>;
160
+ platform?: NodeJS.Platform;
161
+ }
162
+
163
+ const WINDOWS_PROCESS_LIST_SCRIPT =
164
+ 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CommandLine)" }';
165
+
166
+ export function processTableCommand(hostPlatform: NodeJS.Platform): {
167
+ command: string;
168
+ args: string[];
169
+ } {
170
+ if (hostPlatform === "win32") {
171
+ return {
172
+ command: "powershell.exe",
173
+ args: [
174
+ "-NoProfile",
175
+ "-NonInteractive",
176
+ "-Command",
177
+ WINDOWS_PROCESS_LIST_SCRIPT,
178
+ ],
179
+ };
180
+ }
181
+ return { command: "ps", args: ["ax", "-o", "pid=,ppid=,args="] };
157
182
  }
158
183
 
159
184
  export async function detectOrphanedProcesses(
@@ -173,20 +198,17 @@ export async function detectOrphanedProcesses(
173
198
  // Process table scan — discover orphaned processes by scanning the OS
174
199
  // process table rather than reading PID files from the workspace.
175
200
  try {
176
- const output = await execOutput(
177
- "sh",
178
- [
179
- "-c",
180
- "ps ax -o pid=,ppid=,args= | grep -E 'vellum|qdrant|openclaw' | grep -v grep",
181
- ],
182
- { timeoutMs: 5_000 },
183
- );
201
+ const table = processTableCommand(options.platform ?? process.platform);
202
+ const output = await execOutput(table.command, table.args, {
203
+ timeoutMs: 5_000,
204
+ });
184
205
  const procs = parseRemotePs(output);
185
206
  const ownPid = String(process.pid);
186
207
 
187
208
  for (const p of procs) {
188
209
  if (p.pid === ownPid || seenPids.has(p.pid)) continue;
189
210
  if (knownPids.has(p.pid)) continue;
211
+ if (!VELLUM_PROCESS_MARKER.test(p.command)) continue;
190
212
  // Live interactive sessions are spared before classification so that
191
213
  // service substrings in their argv cannot mark them as orphans.
192
214
  if (isInteractiveCliSession(p.command)) {
package/src/lib/pgrep.ts CHANGED
@@ -1,12 +1,30 @@
1
1
  import { execOutput } from "./step-runner";
2
+ import { executableName, parseTasklistCsv } from "./process.js";
2
3
 
3
4
  const PGREP_TIMEOUT_MS = 5_000;
4
5
 
5
- export async function pgrepExact(name: string): Promise<string[]> {
6
+ export async function pgrepExact(
7
+ name: string,
8
+ hostPlatform: NodeJS.Platform = process.platform,
9
+ ): Promise<string[]> {
6
10
  try {
7
- const output = await execOutput("pgrep", ["-x", name], {
11
+ const command = hostPlatform === "win32" ? "tasklist.exe" : "pgrep";
12
+ const args =
13
+ hostPlatform === "win32"
14
+ ? [
15
+ "/FI",
16
+ `IMAGENAME eq ${executableName(name, hostPlatform)}`,
17
+ "/FO",
18
+ "CSV",
19
+ "/NH",
20
+ ]
21
+ : ["-x", name];
22
+ const output = await execOutput(command, args, {
8
23
  timeoutMs: PGREP_TIMEOUT_MS,
9
24
  });
25
+ if (hostPlatform === "win32") {
26
+ return parseTasklistCsv(output).map(({ pid }) => String(pid));
27
+ }
10
28
  return output.trim().split("\n").filter(Boolean);
11
29
  } catch {
12
30
  return [];
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { existsSync, readFileSync, unlinkSync } from "fs";
3
+ import { platform } from "os";
3
4
 
4
5
  import {
5
6
  httpHealthCheck,
@@ -8,21 +9,115 @@ import {
8
9
  waitForDaemonReady,
9
10
  } from "./http-client.js";
10
11
 
12
+ const VELLUM_COMMAND_PATTERN =
13
+ /vellum-daemon|vellum-cli|vellum-gateway|credential-executor|@vellumai|[\\/]\.?vellum[\\/]|[\\/]daemon[\\/]main|[\\/]\.vellum[\\/].*qdrant[\\/]bin[\\/]qdrant/;
14
+
15
+ export const isVellumCommandLine = (command: string): boolean =>
16
+ VELLUM_COMMAND_PATTERN.test(command);
17
+
11
18
  /**
12
19
  * Verify that a PID belongs to a vellum-related process by inspecting its
13
20
  * command line via `ps`. Prevents killing unrelated processes when a PID file
14
21
  * is stale and the OS has reused the PID.
15
22
  */
16
- export function isVellumProcess(pid: number): boolean {
23
+ export function executableName(
24
+ name: string,
25
+ hostPlatform: NodeJS.Platform = platform(),
26
+ ): string {
27
+ return hostPlatform === "win32" && !name.endsWith(".exe")
28
+ ? `${name}.exe`
29
+ : name;
30
+ }
31
+
32
+ export function pathListDelimiter(
33
+ hostPlatform: NodeJS.Platform = platform(),
34
+ ): string {
35
+ return hostPlatform === "win32" ? ";" : ":";
36
+ }
37
+
38
+ export interface TasklistProcess {
39
+ imageName: string;
40
+ pid: number;
41
+ }
42
+
43
+ export function parseTasklistCsv(output: string): TasklistProcess[] {
44
+ const processes: TasklistProcess[] = [];
45
+ for (const line of output.split(/\r?\n/)) {
46
+ const match = line.match(/^"([^"]+)","(\d+)"/);
47
+ if (match) {
48
+ processes.push({ imageName: match[1], pid: Number(match[2]) });
49
+ }
50
+ }
51
+ return processes;
52
+ }
53
+
54
+ function readWindowsProcesses(pid?: number): TasklistProcess[] {
55
+ const args = pid
56
+ ? ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]
57
+ : ["/FO", "CSV", "/NH"];
58
+ const output = execFileSync("tasklist.exe", args, {
59
+ encoding: "utf-8",
60
+ timeout: 5000,
61
+ stdio: ["ignore", "pipe", "ignore"],
62
+ });
63
+ return parseTasklistCsv(output);
64
+ }
65
+
66
+ export function windowsCommandLineLookupArgs(pid: number): string[] {
67
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
68
+ throw new Error(`Invalid process ID: ${pid}`);
69
+ }
70
+ return [
71
+ "-NoProfile",
72
+ "-NonInteractive",
73
+ "-Command",
74
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`,
75
+ ];
76
+ }
77
+
78
+ function readWindowsCommandLine(pid: number): string {
79
+ return execFileSync("powershell.exe", windowsCommandLineLookupArgs(pid), {
80
+ encoding: "utf8",
81
+ timeout: 3000,
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ });
84
+ }
85
+
86
+ export function isVellumWindowsProcess(
87
+ imageName: string,
88
+ commandLine = "",
89
+ ): boolean {
90
+ if (/^qdrant\.exe$/i.test(imageName)) {
91
+ return isVellumCommandLine(commandLine);
92
+ }
93
+ if (
94
+ /^(?:vellum|vellum-cli|vellum-daemon|vellum-gateway|credential-executor)\.exe$/i.test(
95
+ imageName,
96
+ )
97
+ ) {
98
+ return true;
99
+ }
100
+ return /^bun\.exe$/i.test(imageName) && isVellumCommandLine(commandLine);
101
+ }
102
+
103
+ export function isVellumProcess(
104
+ pid: number,
105
+ hostPlatform: NodeJS.Platform = platform(),
106
+ ): boolean {
17
107
  try {
108
+ if (hostPlatform === "win32") {
109
+ const imageName = readWindowsProcesses(pid)[0]?.imageName ?? "";
110
+ const commandLine = /^(?:bun|qdrant)\.exe$/i.test(imageName)
111
+ ? readWindowsCommandLine(pid)
112
+ : "";
113
+ return isVellumWindowsProcess(imageName, commandLine);
114
+ }
18
115
  const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
19
116
  encoding: "utf-8",
20
117
  timeout: 3000,
21
118
  stdio: ["ignore", "pipe", "ignore"],
22
119
  }).trim();
23
- return /vellum-daemon|vellum-cli|vellum-gateway|credential-executor|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
24
- output,
25
- );
120
+ return isVellumCommandLine(output);
26
121
  } catch {
27
122
  return false;
28
123
  }
@@ -89,6 +184,7 @@ export async function isProcessHealthy(
89
184
  * - `"migration_failed"` — process is alive and healthy, but its DB migrations
90
185
  * failed: a terminal state that never recovers without a restart. The
91
186
  * process is kept alive (same keep-alive rule as `"unready"`).
187
+ * - `"stuck"`: process is unresponsive and could not be terminated.
92
188
  * - `"needs_start"` — process was dead, hung (and killed), or a stale PID
93
189
  * was cleaned up. Caller should start a fresh process.
94
190
  */
@@ -96,6 +192,7 @@ export type ProcessState =
96
192
  | { status: "healthy"; pid: number }
97
193
  | { status: "unready"; pid: number }
98
194
  | { status: "migration_failed"; pid: number }
195
+ | { status: "stuck"; pid: number }
99
196
  | { status: "needs_start"; pid: number | null };
100
197
 
101
198
  /**
@@ -140,7 +237,10 @@ export async function resolveProcessState(
140
237
  console.log(
141
238
  `${label} process alive (pid ${result.pid}) but not responding — killing and restarting...`,
142
239
  );
143
- await stopProcess(result.pid, label);
240
+ const stopped = await stopProcess(result.pid, label);
241
+ if (!stopped && isProcessAlive(pidFile).alive) {
242
+ return { status: "stuck", pid: result.pid };
243
+ }
144
244
  } else {
145
245
  console.log(
146
246
  `Stale PID file (pid ${result.pid} is not a Vellum process) — cleaning up...`,
@@ -177,12 +277,20 @@ export async function resolveProcessState(
177
277
 
178
278
  /**
179
279
  * Stop a process by PID: SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
180
- * Returns true if the process was stopped, false if it wasn't alive.
280
+ * Returns true if the process was stopped, false if it wasn't alive or
281
+ * termination failed.
181
282
  */
182
283
  export async function stopProcess(
183
284
  pid: number,
184
285
  label: string,
185
286
  timeoutMs: number = 2000,
287
+ hostPlatform: NodeJS.Platform = platform(),
288
+ runTaskkill: (args: string[], timeout: number) => void = (args, timeout) => {
289
+ execFileSync("taskkill.exe", args, {
290
+ timeout,
291
+ stdio: "ignore",
292
+ });
293
+ },
186
294
  ): Promise<boolean> {
187
295
  try {
188
296
  process.kill(pid, 0);
@@ -191,10 +299,19 @@ export async function stopProcess(
191
299
  }
192
300
 
193
301
  console.log(`Stopping ${label} (pid ${pid})...`);
194
- process.kill(pid, "SIGTERM");
302
+ let waitForGracefulExit = true;
303
+ if (hostPlatform === "win32") {
304
+ try {
305
+ runTaskkill(["/PID", String(pid), "/T"], timeoutMs);
306
+ } catch {
307
+ waitForGracefulExit = false;
308
+ }
309
+ } else {
310
+ process.kill(pid, "SIGTERM");
311
+ }
195
312
 
196
313
  const deadline = Date.now() + timeoutMs;
197
- while (Date.now() < deadline) {
314
+ while (waitForGracefulExit && Date.now() < deadline) {
198
315
  try {
199
316
  process.kill(pid, 0);
200
317
  await new Promise((r) => setTimeout(r, 100));
@@ -205,13 +322,33 @@ export async function stopProcess(
205
322
 
206
323
  try {
207
324
  process.kill(pid, 0);
208
- console.log(`${label} did not exit after SIGTERM, sending SIGKILL...`);
209
- process.kill(pid, "SIGKILL");
210
325
  } catch {
211
- // Already dead
326
+ return true;
327
+ }
328
+ if (hostPlatform === "win32") {
329
+ console.log(`${label} did not exit, terminating its process tree...`);
330
+ try {
331
+ runTaskkill(["/PID", String(pid), "/T", "/F"], timeoutMs);
332
+ } catch {
333
+ return false;
334
+ }
335
+ try {
336
+ process.kill(pid, 0);
337
+ return false;
338
+ } catch {
339
+ return true;
340
+ }
341
+ }
342
+ console.log(`${label} did not exit after SIGTERM, sending SIGKILL...`);
343
+ try {
344
+ process.kill(pid, "SIGKILL");
345
+ return true;
346
+ } catch (error) {
347
+ return (
348
+ error instanceof Error &&
349
+ (error as NodeJS.ErrnoException).code === "ESRCH"
350
+ );
212
351
  }
213
-
214
- return true;
215
352
  }
216
353
 
217
354
  /** Remove one or more files, ignoring missing-file errors. */
@@ -235,6 +372,12 @@ export async function stopProcessByPidFile(
235
372
  label: string,
236
373
  extraCleanupFiles?: string[],
237
374
  timeoutMs?: number,
375
+ stop: (
376
+ pid: number,
377
+ label: string,
378
+ timeoutMs?: number,
379
+ ) => Promise<boolean> = stopProcess,
380
+ ownsProcess: (pid: number) => boolean = isVellumProcess,
238
381
  ): Promise<boolean> {
239
382
  const { alive, pid } = isProcessAlive(pidFile);
240
383
 
@@ -246,7 +389,7 @@ export async function stopProcessByPidFile(
246
389
  // Verify the PID actually belongs to a vellum process before killing.
247
390
  // If the PID file is stale and the OS reused the PID, skip the kill
248
391
  // and clean up the stale files instead.
249
- if (!isVellumProcess(pid)) {
392
+ if (!ownsProcess(pid)) {
250
393
  console.log(
251
394
  `PID ${pid} is not a vellum process — cleaning up stale ${label} PID file.`,
252
395
  );
@@ -254,8 +397,10 @@ export async function stopProcessByPidFile(
254
397
  return false;
255
398
  }
256
399
 
257
- const stopped = await stopProcess(pid, label, timeoutMs);
258
- removeFiles(pidFile, extraCleanupFiles);
400
+ const stopped = await stop(pid, label, timeoutMs);
401
+ if (stopped || !isProcessAlive(pidFile).alive) {
402
+ removeFiles(pidFile, extraCleanupFiles);
403
+ }
259
404
  return stopped;
260
405
  }
261
406
 
@@ -265,7 +410,33 @@ export async function stopProcessByPidFile(
265
410
  *
266
411
  * Returns true if at least one process was stopped.
267
412
  */
268
- export async function stopOrphanedDaemonProcesses(): Promise<boolean> {
413
+ export async function stopOrphanedDaemonProcesses(
414
+ excludePids: ReadonlySet<string> = new Set(),
415
+ hostPlatform: NodeJS.Platform = platform(),
416
+ ): Promise<boolean> {
417
+ if (hostPlatform === "win32") {
418
+ try {
419
+ const results = await Promise.all(
420
+ readWindowsProcesses()
421
+ .filter(
422
+ ({ imageName, pid }) =>
423
+ pid !== process.pid &&
424
+ !excludePids.has(String(pid)) &&
425
+ (/^vellum-daemon\.exe$/i.test(imageName) ||
426
+ (/^bun\.exe$/i.test(imageName) &&
427
+ /vellum-daemon|[\\/]assistant[\\/]src[\\/](?:index|daemon[\\/]main)\.ts/i.test(
428
+ readWindowsCommandLine(pid),
429
+ ))),
430
+ )
431
+ .map(({ pid }) =>
432
+ stopProcess(pid, "orphaned assistant", 2000, hostPlatform),
433
+ ),
434
+ );
435
+ return results.some(Boolean);
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
269
440
  let output: string;
270
441
  try {
271
442
  output = execFileSync("ps", ["-axww", "-o", "pid=,command="], {
@@ -284,7 +455,9 @@ export async function stopOrphanedDaemonProcesses(): Promise<boolean> {
284
455
  const spaceIdx = trimmed.indexOf(" ");
285
456
  if (spaceIdx === -1) continue;
286
457
  const pid = parseInt(trimmed.slice(0, spaceIdx), 10);
287
- if (isNaN(pid) || pid === process.pid) continue;
458
+ if (isNaN(pid) || pid === process.pid || excludePids.has(String(pid))) {
459
+ continue;
460
+ }
288
461
  const cmd = trimmed.slice(spaceIdx + 1);
289
462
 
290
463
  if (cmd.includes("vellum-daemon")) {
@@ -1,6 +1,20 @@
1
1
  import { mkdirSync } from "fs";
2
2
  import { homedir } from "os";
3
- import { basename, join, resolve } from "path";
3
+ import { join, posix, win32 } from "path";
4
+
5
+ function isPathInside(
6
+ parent: string,
7
+ candidate: string,
8
+ pathApi: typeof posix,
9
+ ): boolean {
10
+ const relativePath = pathApi.relative(parent, candidate);
11
+ return (
12
+ relativePath !== "" &&
13
+ relativePath !== ".." &&
14
+ !relativePath.startsWith(`..${pathApi.sep}`) &&
15
+ !pathApi.isAbsolute(relativePath)
16
+ );
17
+ }
4
18
 
5
19
  export function getRetiredDir(): string {
6
20
  const xdgData =
@@ -23,21 +37,37 @@ export function validateAssistantName(name: string): void {
23
37
  }
24
38
  }
25
39
 
26
- function safeName(assistantId: string): string {
40
+ function safeName(
41
+ assistantId: string,
42
+ retiredDir: string,
43
+ pathApi: typeof posix,
44
+ ): string {
27
45
  validateAssistantName(assistantId);
28
46
  // Canonicalize and verify the result stays inside the retired directory
29
- const retiredDir = getRetiredDir();
30
- const candidate = resolve(retiredDir, basename(assistantId));
31
- if (!candidate.startsWith(retiredDir + "/")) {
47
+ const candidate = pathApi.resolve(retiredDir, pathApi.basename(assistantId));
48
+ if (!isPathInside(retiredDir, candidate, pathApi)) {
32
49
  throw new Error(`Invalid assistant name: '${assistantId}'`);
33
50
  }
34
- return basename(assistantId);
51
+ return pathApi.basename(assistantId);
52
+ }
53
+
54
+ export function resolveRetiredFilePath(
55
+ assistantId: string,
56
+ extension: "tar.gz" | "json",
57
+ retiredDir: string,
58
+ hostPlatform: NodeJS.Platform = process.platform,
59
+ ): string {
60
+ const pathApi = hostPlatform === "win32" ? win32 : posix;
61
+ return pathApi.join(
62
+ retiredDir,
63
+ `${safeName(assistantId, retiredDir, pathApi)}.${extension}`,
64
+ );
35
65
  }
36
66
 
37
67
  export function getArchivePath(assistantId: string): string {
38
- return join(getRetiredDir(), `${safeName(assistantId)}.tar.gz`);
68
+ return resolveRetiredFilePath(assistantId, "tar.gz", getRetiredDir());
39
69
  }
40
70
 
41
71
  export function getMetadataPath(assistantId: string): string {
42
- return join(getRetiredDir(), `${safeName(assistantId)}.json`);
72
+ return resolveRetiredFilePath(assistantId, "json", getRetiredDir());
43
73
  }
@@ -1,11 +1,16 @@
1
1
  import { spawn } from "child_process";
2
2
  import { homedir } from "os";
3
3
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
4
- import { basename, dirname, join } from "path";
4
+ import { basename, dirname, join, win32 } from "path";
5
5
 
6
- import { getDaemonPidPath, loadAllAssistants } from "./assistant-config.js";
6
+ import {
7
+ getDaemonPidPath,
8
+ loadAllAssistants,
9
+ loadAllAssistantsAcrossEnvs,
10
+ } from "./assistant-config.js";
7
11
  import type { AssistantEntry } from "./assistant-config.js";
8
12
  import { stopIngressNginx } from "./nginx-ingress.js";
13
+ import { getKnownPidsFromAssistants } from "./orphan-detection.js";
9
14
  import {
10
15
  stopOrphanedDaemonProcesses,
11
16
  stopProcessByPidFile,
@@ -26,6 +31,61 @@ export interface RetireLocalResult {
26
31
  sharedDataDir?: boolean;
27
32
  }
28
33
 
34
+ interface RetireArchiveCommand {
35
+ command: string;
36
+ args: string[];
37
+ env?: Record<string, string>;
38
+ }
39
+
40
+ const WINDOWS_RETIRE_ARCHIVE_SCRIPT = [
41
+ "$ErrorActionPreference = 'Stop'",
42
+ "& tar.exe -czf $env:VELLUM_RETIRE_ARCHIVE_PATH -C $env:VELLUM_RETIRE_ARCHIVE_PARENT $env:VELLUM_RETIRE_STAGING_NAME",
43
+ "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
44
+ "Remove-Item -LiteralPath $env:VELLUM_RETIRE_STAGING_DIR -Recurse -Force",
45
+ ].join("; ");
46
+
47
+ export function getRetireArchiveCommand(
48
+ archivePath: string,
49
+ stagingDir: string,
50
+ hostPlatform: NodeJS.Platform = process.platform,
51
+ ): RetireArchiveCommand {
52
+ const archiveParent =
53
+ hostPlatform === "win32" ? win32.dirname(stagingDir) : dirname(stagingDir);
54
+ const stagingName =
55
+ hostPlatform === "win32"
56
+ ? win32.basename(stagingDir)
57
+ : basename(stagingDir);
58
+ if (hostPlatform === "win32") {
59
+ return {
60
+ command: "powershell.exe",
61
+ args: [
62
+ "-NoProfile",
63
+ "-NonInteractive",
64
+ "-Command",
65
+ WINDOWS_RETIRE_ARCHIVE_SCRIPT,
66
+ ],
67
+ env: {
68
+ VELLUM_RETIRE_ARCHIVE_PATH: archivePath,
69
+ VELLUM_RETIRE_ARCHIVE_PARENT: archiveParent,
70
+ VELLUM_RETIRE_STAGING_NAME: stagingName,
71
+ VELLUM_RETIRE_STAGING_DIR: stagingDir,
72
+ },
73
+ };
74
+ }
75
+ return {
76
+ command: "sh",
77
+ args: [
78
+ "-c",
79
+ 'tar czf "$1" -C "$2" "$3" && rm -rf "$4"',
80
+ "vellum-retire",
81
+ archivePath,
82
+ archiveParent,
83
+ stagingName,
84
+ stagingDir,
85
+ ],
86
+ };
87
+ }
88
+
29
89
  export async function retireLocal(
30
90
  name: string,
31
91
  entry: AssistantEntry,
@@ -99,7 +159,14 @@ export async function retireLocal(
99
159
  // If the PID file didn't track a running daemon, scan for orphaned
100
160
  // daemon processes that may have been started without writing a PID.
101
161
  if (!daemonStopped) {
102
- await stopOrphanedDaemonProcesses();
162
+ const otherAssistantPids = getKnownPidsFromAssistants(
163
+ [...loadAllAssistantsAcrossEnvs(), ...loadAllAssistants()].filter(
164
+ (other) =>
165
+ other.assistantId !== name ||
166
+ other.resources?.instanceDir !== resources.instanceDir,
167
+ ),
168
+ );
169
+ await stopOrphanedDaemonProcesses(otherAssistantPids);
103
170
  }
104
171
 
105
172
  // For named instances (instanceDir differs from the base directory),
@@ -140,14 +207,13 @@ export async function retireLocal(
140
207
 
141
208
  // Spawn tar + cleanup in the background and detach so the CLI can exit
142
209
  // immediately. The staging directory is removed once the archive is written.
143
- const tarCmd = [
144
- `tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(dirname(stagingDir))} ${JSON.stringify(basename(stagingDir))}`,
145
- `rm -rf ${JSON.stringify(stagingDir)}`,
146
- ].join(" && ");
147
-
148
- const child = spawn("sh", ["-c", tarCmd], {
210
+ const archiveCommand = getRetireArchiveCommand(archivePath, stagingDir);
211
+ const child = spawn(archiveCommand.command, archiveCommand.args, {
149
212
  stdio: "ignore",
150
213
  detached: true,
214
+ ...(archiveCommand.env
215
+ ? { env: { ...process.env, ...archiveCommand.env } }
216
+ : {}),
151
217
  });
152
218
  child.unref();
153
219
 
@@ -10,17 +10,6 @@ import {
10
10
  } from "./nginx-ingress.js";
11
11
  import { hasWebhookIntegrations, maybeStartNgrokTunnel } from "./ngrok.js";
12
12
 
13
- /**
14
- * Retry policy for the flag lookup inside the tunnel-edge restore. The gateway
15
- * has typically been up for milliseconds at this point and answers
16
- * `503 {"status":"starting"}` (or refuses connections) until its startup
17
- * completes, so a single lookup races it.
18
- */
19
- export const WEB_INGRESS_FLAG_RETRY = {
20
- attempts: 15,
21
- intervalMs: 2_000,
22
- };
23
-
24
13
  /**
25
14
  * Whether the workspace ingress config wants the remote-web edge: explicitly
26
15
  * enabled with a saved public URL.
@@ -53,14 +42,14 @@ function wantsTunnelEdge(workspaceDir: string): boolean {
53
42
  * Bring the nginx edge back up after a wake or local upgrade and point the
54
43
  * webhook auto-tunnel at it. The edge is wanted when webhook integrations are
55
44
  * configured or the workspace ingress config is enabled with a saved public
56
- * URL; `ensureTunnelEdge` picks SPA vs webhooks-only mode off the
57
- * `web-remote-ingress` flag, retrying the lookup through the gateway's startup
58
- * window. A healthy edge whose recorded state already targets the requested
59
- * gateway port is reused without the flag lookup or the `remoteWebConfigHash`
60
- * comparison `startRemoteWebIngress` performs; both flag-driven mode drift
61
- * and injected-config drift (a renamed assistant, a changed hub URL) are
62
- * repaired by the next explicit `vellum tunnel` or `vellum nginx-ingress up`,
63
- * not by background wakes. Edge failures warn
45
+ * URL. A healthy SPA edge whose recorded state already targets the requested
46
+ * gateway port is reused without the `remoteWebConfigHash` comparison
47
+ * `startRemoteWebIngress` performs; injected-config drift (a renamed
48
+ * assistant, a changed hub URL) is repaired by the next explicit
49
+ * `vellum tunnel` or `vellum nginx-ingress up`, not by background wakes.
50
+ * A recorded webhooks-only edge is never reused: it goes through
51
+ * `ensureTunnelEdge` so the wake upgrades it to the SPA edge.
52
+ * Edge failures warn
64
53
  * (with the error's install or diagnostic text) and fall back to tunneling the
65
54
  * gateway port directly, which `maybeStartNgrokTunnel` only does when webhook
66
55
  * integrations are configured, so webhook channels on nginx-less machines
@@ -79,11 +68,15 @@ export async function restoreTunnelEdgeAndAutoTunnel(
79
68
  ? readIngressState(workspaceDir)
80
69
  : null;
81
70
  let edge: TunnelEdge | null = null;
82
- if (recorded !== null && recorded.gatewayPort === gatewayPort) {
71
+ if (
72
+ recorded !== null &&
73
+ recorded.gatewayPort === gatewayPort &&
74
+ recorded.includeWebApp
75
+ ) {
83
76
  edge = {
84
77
  port: recorded.listenPort,
85
78
  started: false,
86
- includesWebApp: recorded.includeWebApp,
79
+ includesWebApp: true,
87
80
  };
88
81
  } else {
89
82
  try {
@@ -91,7 +84,6 @@ export async function restoreTunnelEdgeAndAutoTunnel(
91
84
  assistantId,
92
85
  workspaceDir,
93
86
  gatewayPort,
94
- flagRetry: WEB_INGRESS_FLAG_RETRY,
95
87
  });
96
88
  } catch (err) {
97
89
  console.warn(