@vellumai/cli 0.11.7 → 0.11.8-staging.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 (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  6. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  7. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  8. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  10. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  11. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  12. package/node_modules/@vellumai/local-mode/src/hatch.ts +17 -2
  13. package/node_modules/@vellumai/local-mode/src/index.ts +5 -0
  14. package/node_modules/@vellumai/local-mode/src/lifecycle-budgets.ts +47 -0
  15. package/node_modules/@vellumai/local-mode/src/retire.ts +9 -3
  16. package/node_modules/@vellumai/local-mode/src/sleep.ts +5 -5
  17. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  18. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  21. package/package.json +4 -3
  22. package/src/__tests__/assistant-client-platform-auth.test.ts +219 -0
  23. package/src/__tests__/clean.test.ts +1 -0
  24. package/src/__tests__/nginx-ingress.test.ts +123 -8
  25. package/src/__tests__/retire-local.test.ts +32 -1
  26. package/src/__tests__/sleep.test.ts +5 -3
  27. package/src/__tests__/teleport.test.ts +1 -0
  28. package/src/__tests__/upgrade-local.test.ts +116 -0
  29. package/src/__tests__/windows-lifecycle.test.ts +77 -5
  30. package/src/adapters/openclaw-http-server.ts +5 -0
  31. package/src/commands/client.ts +35 -7
  32. package/src/commands/exec.ts +8 -2
  33. package/src/commands/logs.ts +11 -2
  34. package/src/commands/sleep.ts +6 -9
  35. package/src/commands/ssh.ts +6 -3
  36. package/src/commands/teleport.ts +6 -1
  37. package/src/commands/upgrade.ts +22 -12
  38. package/src/commands/voice.test.ts +81 -0
  39. package/src/commands/voice.ts +83 -6
  40. package/src/commands/wake.ts +7 -1
  41. package/src/lib/assistant-client.ts +145 -31
  42. package/src/lib/cloudflare-tunnel.ts +2 -1
  43. package/src/lib/detached-process.ts +1 -0
  44. package/src/lib/guardian-token.ts +4 -2
  45. package/src/lib/hatch-local.ts +5 -1
  46. package/src/lib/live-voice/client.ts +23 -2
  47. package/src/lib/live-voice/connection.ts +87 -10
  48. package/src/lib/live-voice/pcm-player.ts +16 -3
  49. package/src/lib/live-voice/velay.test.ts +99 -0
  50. package/src/lib/live-voice/velay.ts +199 -0
  51. package/src/lib/local-hatch-lock.ts +16 -3
  52. package/src/lib/local.ts +48 -13
  53. package/src/lib/nginx-ingress.ts +70 -10
  54. package/src/lib/ngrok.ts +2 -0
  55. package/src/lib/open-browser.ts +5 -1
  56. package/src/lib/process.ts +72 -10
  57. package/src/lib/provider-secrets.ts +5 -0
  58. package/src/lib/retire-local.ts +12 -5
  59. package/src/lib/step-runner.ts +3 -0
  60. package/src/lib/tailscale-tunnel.ts +6 -1
  61. package/src/lib/upgrade-lifecycle.ts +1 -0
@@ -26,6 +26,7 @@ const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
26
26
  const realProcess = { ...(await import("../lib/process.js")) };
27
27
 
28
28
  mock.module("../lib/process.js", () => ({
29
+ ...realProcess,
29
30
  isProcessAlive: isProcessAliveMock,
30
31
  stopProcessByPidFile: stopProcessByPidFileMock,
31
32
  }));
@@ -222,14 +223,15 @@ describe("sleep command", () => {
222
223
  }
223
224
 
224
225
  expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(3);
225
- // The assistant stop passes a generous 120s grace so the daemon's WAL
226
- // checkpoint completes before any SIGKILL (default 2s would truncate it).
226
+ // The assistant stop passes the daemon's own shutdown budget plus a margin,
227
+ // so its WAL checkpoint completes before any SIGKILL (the default 2s grace
228
+ // would truncate it).
227
229
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
228
230
  1,
229
231
  join(assistantRootDir, "workspace", "vellum.pid"),
230
232
  "assistant",
231
233
  undefined,
232
- 120_000,
234
+ 45_000,
233
235
  );
234
236
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
235
237
  2,
@@ -325,6 +325,7 @@ mock.module("../lib/docker.js", () => ({
325
325
  const stopProcessByPidFileMock = mock(async () => true);
326
326
 
327
327
  mock.module("../lib/process.js", () => ({
328
+ ...realProcess,
328
329
  stopProcessByPidFile: stopProcessByPidFileMock,
329
330
  }));
330
331
 
@@ -33,6 +33,8 @@ const realUpgradeLifecycle = { ...upgradeLifecycle };
33
33
 
34
34
  const findAssistantByNameMock =
35
35
  mock<typeof assistantConfig.findAssistantByName>();
36
+ const lookupAssistantByIdentifierMock =
37
+ mock<typeof assistantConfig.lookupAssistantByIdentifier>();
36
38
  const getActiveAssistantMock = mock<typeof assistantConfig.getActiveAssistant>(
37
39
  () => "local-assistant",
38
40
  );
@@ -46,6 +48,7 @@ const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
46
48
  mock.module("../lib/assistant-config", () => ({
47
49
  ...realAssistantConfig,
48
50
  findAssistantByName: findAssistantByNameMock,
51
+ lookupAssistantByIdentifier: lookupAssistantByIdentifierMock,
49
52
  getActiveAssistant: getActiveAssistantMock,
50
53
  loadAllAssistants: loadAllAssistantsMock,
51
54
  saveAssistantEntry: saveAssistantEntryMock,
@@ -228,6 +231,8 @@ beforeEach(() => {
228
231
  const entry = makeLocalEntry();
229
232
  findAssistantByNameMock.mockReset();
230
233
  findAssistantByNameMock.mockReturnValue(entry);
234
+ lookupAssistantByIdentifierMock.mockReset();
235
+ lookupAssistantByIdentifierMock.mockReturnValue({ status: "found", entry });
231
236
  getActiveAssistantMock.mockReset();
232
237
  getActiveAssistantMock.mockReturnValue("local-assistant");
233
238
  loadAllAssistantsMock.mockReset();
@@ -446,3 +451,114 @@ describe("vellum upgrade local", () => {
446
451
  expect(consoleLogSpy.mock.calls.flat().join("\n")).toContain("Already on");
447
452
  });
448
453
  });
454
+
455
+ // `cli/AGENTS.md` § "Assistant targeting convention": exact assistant ID
456
+ // matches win over display-name matches; a unique display-name match
457
+ // resolves to its assistant; an ambiguous display name is an error listing
458
+ // the matching IDs. That precedence lives in, and is tested directly against,
459
+ // `lookupAssistantByIdentifier` (see assistant-config.test.ts). These tests
460
+ // only check `vellum upgrade`'s wiring: each one stubs the exact result
461
+ // (found/ambiguous) the shared helper would return for a given argument, and
462
+ // asserts the command acts on it correctly, without reimplementing the
463
+ // lookup rules themselves.
464
+ describe("vellum upgrade assistant targeting", () => {
465
+ function makeQuillEntry(
466
+ overrides: Partial<AssistantEntry> = {},
467
+ ): AssistantEntry {
468
+ return {
469
+ ...makeLocalEntry(),
470
+ assistantId: "quill-uuid",
471
+ name: "Quill",
472
+ ...overrides,
473
+ };
474
+ }
475
+
476
+ function argvFor(...positional: string[]): string[] {
477
+ return [
478
+ "bun",
479
+ "vellum",
480
+ "upgrade",
481
+ ...positional,
482
+ "--version",
483
+ cliPkg.version ? `v${cliPkg.version}` : "v0.8.12",
484
+ ];
485
+ }
486
+
487
+ test("resolves a unique display-name match to its assistant", async () => {
488
+ const quill = makeQuillEntry();
489
+ process.argv = argvFor("Quill");
490
+ lookupAssistantByIdentifierMock.mockReturnValue({
491
+ status: "found",
492
+ entry: quill,
493
+ });
494
+
495
+ await upgrade();
496
+
497
+ expect(lookupAssistantByIdentifierMock).toHaveBeenCalledWith("Quill");
498
+ expect(exitSpy).not.toHaveBeenCalled();
499
+ expect(stopLocalProcessesMock).toHaveBeenCalledWith(
500
+ expect.objectContaining({ instanceDir: quill.resources!.instanceDir }),
501
+ );
502
+ });
503
+
504
+ test("resolves a multi-word display-name argument, unsplit by --version", async () => {
505
+ const multiWord = makeQuillEntry({ name: "My Assistant" });
506
+ process.argv = argvFor("My", "Assistant");
507
+ lookupAssistantByIdentifierMock.mockReturnValue({
508
+ status: "found",
509
+ entry: multiWord,
510
+ });
511
+
512
+ await upgrade();
513
+
514
+ expect(lookupAssistantByIdentifierMock).toHaveBeenCalledWith(
515
+ "My Assistant",
516
+ );
517
+ expect(exitSpy).not.toHaveBeenCalled();
518
+ expect(stopLocalProcessesMock).toHaveBeenCalledWith(
519
+ expect.objectContaining({
520
+ instanceDir: multiWord.resources!.instanceDir,
521
+ }),
522
+ );
523
+ });
524
+
525
+ test("an exact assistant-ID argument resolves via the shared helper's found result", async () => {
526
+ // `lookupAssistantByIdentifier` owns ID-wins-over-name precedence
527
+ // (covered in assistant-config.test.ts); this only checks that
528
+ // `vellum upgrade` passes the raw argument through unmodified and acts
529
+ // on whatever entry the shared helper resolves.
530
+ const target = makeQuillEntry({ assistantId: "quill-uuid" });
531
+ process.argv = argvFor("quill-uuid");
532
+ lookupAssistantByIdentifierMock.mockReturnValue({
533
+ status: "found",
534
+ entry: target,
535
+ });
536
+
537
+ await upgrade();
538
+
539
+ expect(lookupAssistantByIdentifierMock).toHaveBeenCalledWith("quill-uuid");
540
+ expect(exitSpy).not.toHaveBeenCalled();
541
+ expect(stopLocalProcessesMock).toHaveBeenCalledWith(
542
+ expect.objectContaining({ instanceDir: target.resources!.instanceDir }),
543
+ );
544
+ });
545
+
546
+ test("an ambiguous display name fails and lists the matching IDs", async () => {
547
+ process.argv = argvFor("Quill");
548
+ lookupAssistantByIdentifierMock.mockReturnValue({
549
+ status: "ambiguous",
550
+ matches: [
551
+ makeQuillEntry({ assistantId: "quill-uuid-1" }),
552
+ makeQuillEntry({ assistantId: "quill-uuid-2" }),
553
+ ],
554
+ });
555
+
556
+ await expect(upgrade()).rejects.toThrow("process.exit(1)");
557
+
558
+ expect(stopLocalProcessesMock).not.toHaveBeenCalled();
559
+ const errText = consoleErrorSpy.mock.calls.flat().join("\n");
560
+ expect(errText).toContain("Multiple assistants match 'Quill'");
561
+ expect(errText).toContain("quill-uuid-1");
562
+ expect(errText).toContain("quill-uuid-2");
563
+ });
564
+ });
@@ -119,10 +119,10 @@ test("reports a failed forced Windows process-tree termination", async () => {
119
119
  });
120
120
 
121
121
  test("treats a Unix exit before SIGKILL as a successful stop", async () => {
122
- const signals: Array<NodeJS.Signals | number | undefined> = [];
122
+ const calls: Array<[number, NodeJS.Signals | number | undefined]> = [];
123
123
  const originalKill = process.kill.bind(process);
124
- process.kill = ((_pid: number, signal?: NodeJS.Signals | number) => {
125
- signals.push(signal);
124
+ process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
125
+ calls.push([pid, signal]);
126
126
  if (signal === "SIGKILL") {
127
127
  throw Object.assign(new Error("no such process"), { code: "ESRCH" });
128
128
  }
@@ -130,8 +130,80 @@ test("treats a Unix exit before SIGKILL as a successful stop", async () => {
130
130
  }) as typeof process.kill;
131
131
 
132
132
  try {
133
- expect(await stopProcess(4812, "test process", 0, "darwin")).toBeTrue();
134
- expect(signals).toEqual([0, "SIGTERM", 0, "SIGKILL"]);
133
+ expect(
134
+ await stopProcess(
135
+ 4812,
136
+ "test process",
137
+ 0,
138
+ "darwin",
139
+ undefined,
140
+ () => "bun run /app/daemon/main.ts",
141
+ ),
142
+ ).toBeTrue();
143
+ // Escalation targets the process group first (the negative PID), so a
144
+ // daemon's workers die with it, then falls back to the single PID for
145
+ // processes that lead no group. ESRCH on both means it exited on its
146
+ // own, which is a successful stop.
147
+ expect(calls).toEqual([
148
+ [4812, 0],
149
+ [4812, "SIGTERM"],
150
+ [4812, 0],
151
+ [-4812, "SIGKILL"],
152
+ [4812, "SIGKILL"],
153
+ ]);
154
+ } finally {
155
+ process.kill = originalKill;
156
+ }
157
+ });
158
+
159
+ // Widening a signal to a process group requires proving the PID still names
160
+ // the same process after the grace wait. A PID recycled mid-wait must never
161
+ // receive a group-wide SIGKILL, only the single-PID kill this has always sent.
162
+ test("a PID recycled during the wait never gets a group kill", async () => {
163
+ const calls: Array<[number, NodeJS.Signals | number | undefined]> = [];
164
+ const originalKill = process.kill.bind(process);
165
+ process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
166
+ calls.push([pid, signal]);
167
+ return true;
168
+ }) as typeof process.kill;
169
+
170
+ let read = 0;
171
+ const identities = [
172
+ "bun run /app/daemon/main.ts",
173
+ "/usr/bin/postgres -D /data",
174
+ ];
175
+
176
+ try {
177
+ await stopProcess(
178
+ 4812,
179
+ "test process",
180
+ 0,
181
+ "darwin",
182
+ undefined,
183
+ () => identities[Math.min(read++, identities.length - 1)] ?? null,
184
+ );
185
+ expect(calls).not.toContainEqual([-4812, "SIGKILL"]);
186
+ expect(calls).toContainEqual([4812, "SIGKILL"]);
187
+ } finally {
188
+ process.kill = originalKill;
189
+ }
190
+ });
191
+
192
+ test("an unreadable identity after the wait never gets a group kill", async () => {
193
+ const calls: Array<[number, NodeJS.Signals | number | undefined]> = [];
194
+ const originalKill = process.kill.bind(process);
195
+ process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
196
+ calls.push([pid, signal]);
197
+ return true;
198
+ }) as typeof process.kill;
199
+
200
+ let read = 0;
201
+ try {
202
+ await stopProcess(4812, "test process", 0, "darwin", undefined, () =>
203
+ read++ === 0 ? "bun run /app/daemon/main.ts" : null,
204
+ );
205
+ expect(calls).not.toContainEqual([-4812, "SIGKILL"]);
206
+ expect(calls).toContainEqual([4812, "SIGKILL"]);
135
207
  } finally {
136
208
  process.kill = originalKill;
137
209
  }
@@ -51,11 +51,13 @@ const server = http.createServer(async (req, res) => {
51
51
  encoding: "utf-8",
52
52
  timeout: 120000,
53
53
  env: npmEnv,
54
+ windowsHide: true,
54
55
  });
55
56
  const child = spawn("vellum-openclaw-adapter", [], {
56
57
  detached: true,
57
58
  stdio: "ignore",
58
59
  env: npmEnv,
60
+ windowsHide: true,
59
61
  });
60
62
  child.unref();
61
63
  const responseBody = JSON.stringify({
@@ -91,6 +93,7 @@ const server = http.createServer(async (req, res) => {
91
93
  encoding: "utf-8",
92
94
  timeout: 10000,
93
95
  env: execEnv,
96
+ windowsHide: true,
94
97
  });
95
98
  const health = JSON.parse(output.trim()) as Record<string, unknown>;
96
99
  const result: Record<string, unknown> = {
@@ -108,6 +111,7 @@ const server = http.createServer(async (req, res) => {
108
111
  encoding: "utf-8",
109
112
  timeout: 10000,
110
113
  env: execEnv,
114
+ windowsHide: true,
111
115
  });
112
116
  result.message = `${result.message}\n\nGateway Status:\n${gatewayOutput.trim()}`;
113
117
  } catch (gatewayErr) {
@@ -138,6 +142,7 @@ const server = http.createServer(async (req, res) => {
138
142
  encoding: "utf-8",
139
143
  timeout: 10000,
140
144
  env: execEnv,
145
+ windowsHide: true,
141
146
  });
142
147
  result.message = `${result.message}\n\nGateway Status:\n${gatewayOutput.trim()}`;
143
148
  } catch (gatewayErr) {
@@ -408,6 +408,20 @@ async function maybeHydratePlatformAssistantName(
408
408
 
409
409
  const SPA_BASE = "/assistant/";
410
410
 
411
+ /**
412
+ * Cache policy for hashed build output under `dist/assets/`: the content hash
413
+ * is part of the filename, so a new build emits new names and a stored copy can
414
+ * never be stale.
415
+ */
416
+ const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
417
+
418
+ /**
419
+ * Cache policy for everything whose name is stable across builds: the HTML
420
+ * shell and unhashed `public/` files. Storing is allowed, serving without
421
+ * revalidating is not, so an assistant update is never masked by a cached copy.
422
+ */
423
+ const REVALIDATE_CACHE_CONTROL = "no-cache";
424
+
411
425
  /**
412
426
  * Locate the clients/web source directory for running the Vite dev server.
413
427
  * Only works from a source checkout (not npm-installed).
@@ -1303,6 +1317,17 @@ async function runWebInterface(
1303
1317
  `<script>window.__VELLUM_CONFIG__=${configJson}${flagOverridesSnippet}</script></head>`,
1304
1318
  );
1305
1319
 
1320
+ // The shell names this build's hashed chunks and carries the host's injected
1321
+ // config snippet, so every route that falls back to it serves the same body
1322
+ // under the same revalidating policy.
1323
+ const spaShellResponse = () =>
1324
+ new Response(indexHtml, {
1325
+ headers: {
1326
+ "Content-Type": "text/html; charset=utf-8",
1327
+ "Cache-Control": REVALIDATE_CACHE_CONTROL,
1328
+ },
1329
+ });
1330
+
1306
1331
  const fetchHandler: WebFetchHandler = async (req, server) => {
1307
1332
  const url = new URL(req.url);
1308
1333
  const { pathname } = url;
@@ -1399,19 +1424,21 @@ async function runWebInterface(
1399
1424
  const filePath = path.join(distDir, relPath);
1400
1425
  const file = Bun.file(filePath);
1401
1426
  if (await file.exists()) {
1402
- return new Response(file);
1427
+ return new Response(file, {
1428
+ headers: {
1429
+ "Cache-Control": relPath.startsWith("assets/")
1430
+ ? IMMUTABLE_CACHE_CONTROL
1431
+ : REVALIDATE_CACHE_CONTROL,
1432
+ },
1433
+ });
1403
1434
  }
1404
1435
  }
1405
- return new Response(indexHtml, {
1406
- headers: { "Content-Type": "text/html; charset=utf-8" },
1407
- });
1436
+ return spaShellResponse();
1408
1437
  }
1409
1438
 
1410
1439
  // SPA fallback for /account/* routes (login, callback, etc.)
1411
1440
  if (pathname.startsWith("/account/")) {
1412
- return new Response(indexHtml, {
1413
- headers: { "Content-Type": "text/html; charset=utf-8" },
1414
- });
1441
+ return spaShellResponse();
1415
1442
  }
1416
1443
 
1417
1444
  return new Response("Not Found", { status: 404 });
@@ -1479,6 +1506,7 @@ async function runViteDevServer(
1479
1506
  const child = spawn("bun", ["run", "dev"], {
1480
1507
  cwd: webSourceDir,
1481
1508
  stdio: "inherit",
1509
+ windowsHide: true,
1482
1510
  env: {
1483
1511
  ...process.env,
1484
1512
  ...flagEnvVars,
@@ -156,7 +156,10 @@ export async function exec(): Promise<void> {
156
156
  const cloud = entry.cloud;
157
157
 
158
158
  if (cloud === "local") {
159
- const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
159
+ const child = spawn(command[0], command.slice(1), {
160
+ stdio: "inherit",
161
+ windowsHide: true,
162
+ });
160
163
  await new Promise<void>((resolve) => {
161
164
  child.on("close", (code) => {
162
165
  process.exitCode = code ?? 0;
@@ -187,7 +190,10 @@ export async function exec(): Promise<void> {
187
190
  ? ["exec", "-it", container, ...command]
188
191
  : ["exec", container, ...command];
189
192
 
190
- const child = spawn("docker", dockerArgs, { stdio: "inherit" });
193
+ const child = spawn("docker", dockerArgs, {
194
+ stdio: "inherit",
195
+ windowsHide: true,
196
+ });
191
197
  await new Promise<void>((resolve, reject) => {
192
198
  child.on("close", (code) => {
193
199
  if (code === 0) resolve();
@@ -329,7 +329,10 @@ async function showDockerLogs(
329
329
  // Single container — stream directly to stdout/stderr
330
330
  const target = targets[0];
331
331
  const args = buildDockerArgs(target.containerName);
332
- const child = spawn("docker", args, { stdio: "inherit" });
332
+ const child = spawn("docker", args, {
333
+ stdio: "inherit",
334
+ windowsHide: true,
335
+ });
333
336
 
334
337
  await new Promise<void>((resolve, reject) => {
335
338
  child.on("close", (code) => {
@@ -356,6 +359,7 @@ async function showDockerLogs(
356
359
  const args = buildDockerArgs(target.containerName);
357
360
  const child = spawn("docker", args, {
358
361
  stdio: ["ignore", "pipe", "pipe"],
362
+ windowsHide: true,
359
363
  });
360
364
 
361
365
  const prefix = `[${target.name}] `;
@@ -466,7 +470,10 @@ async function showGcpLogs(
466
470
 
467
471
  if (opts.follow) {
468
472
  // For follow mode, stream output directly to terminal
469
- const child = spawn("gcloud", args, { stdio: "inherit" });
473
+ const child = spawn("gcloud", args, {
474
+ stdio: "inherit",
475
+ windowsHide: true,
476
+ });
470
477
  await new Promise<void>((resolve, reject) => {
471
478
  child.on("close", (code) => {
472
479
  if (code !== 0 && code !== null) {
@@ -503,6 +510,7 @@ async function showCustomLogs(
503
510
  if (opts.follow) {
504
511
  const child = spawn("ssh", [...SSH_OPTS, sshTarget, remoteCmd], {
505
512
  stdio: "inherit",
513
+ windowsHide: true,
506
514
  });
507
515
  await new Promise<void>((resolve, reject) => {
508
516
  child.on("close", (code) => {
@@ -544,6 +552,7 @@ async function showAwsLogs(
544
552
  if (opts.follow) {
545
553
  const child = spawn("ssh", [...SSH_OPTS, sshTarget, remoteCmd], {
546
554
  stdio: "inherit",
555
+ windowsHide: true,
547
556
  });
548
557
  await new Promise<void>((resolve, reject) => {
549
558
  child.on("close", (code) => {
@@ -17,7 +17,11 @@ import {
17
17
  stopContainerTunnelEdge,
18
18
  stopIngressNginx,
19
19
  } from "../lib/nginx-ingress.js";
20
- import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
20
+ import {
21
+ DAEMON_STOP_TIMEOUT_MS,
22
+ isProcessAlive,
23
+ stopProcessByPidFile,
24
+ } from "../lib/process";
21
25
  import { resolveFreshBearerToken } from "./client.js";
22
26
 
23
27
  const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
@@ -208,18 +212,11 @@ export async function sleep(): Promise<void> {
208
212
  }
209
213
  }
210
214
 
211
- // Stop assistant — use a generous timeout. On SIGTERM the daemon runs a
212
- // WAL checkpoint before exiting, which can take several seconds on a
213
- // multi-GB database. The default 2s grace in stopProcess() would SIGKILL a
214
- // healthy daemon mid-checkpoint, forcing a costly multi-minute WAL recovery
215
- // on the next start. The timeout is only a SIGKILL ceiling — stopProcess
216
- // returns as soon as the process exits, so this adds no delay in the common
217
- // case and only applies when the daemon is genuinely wedged.
218
215
  const assistantStopped = await stopProcessByPidFile(
219
216
  assistantPidFile,
220
217
  "assistant",
221
218
  undefined,
222
- 120_000,
219
+ DAEMON_STOP_TIMEOUT_MS,
223
220
  );
224
221
  if (!assistantStopped) {
225
222
  console.log("Assistant is not running.");
@@ -73,7 +73,7 @@ export async function ssh(): Promise<void> {
73
73
  child = spawn(
74
74
  "docker",
75
75
  ["exec", "-it", res.assistantContainer, "/bin/sh"],
76
- { stdio: "inherit" },
76
+ { stdio: "inherit", windowsHide: true },
77
77
  );
78
78
  } else if (cloud === "gcp") {
79
79
  const project = entry.project;
@@ -94,7 +94,7 @@ export async function ssh(): Promise<void> {
94
94
  child = spawn(
95
95
  "gcloud",
96
96
  ["compute", "ssh", sshTarget, `--project=${project}`, `--zone=${zone}`],
97
- { stdio: "inherit" },
97
+ { stdio: "inherit", windowsHide: true },
98
98
  );
99
99
  } else if (cloud === "vellum") {
100
100
  const token = readPlatformToken();
@@ -117,7 +117,10 @@ export async function ssh(): Promise<void> {
117
117
 
118
118
  console.log(`🔗 Connecting to ${entry.assistantId} via ssh...\n`);
119
119
 
120
- child = spawn("ssh", [...SSH_OPTS, sshTarget], { stdio: "inherit" });
120
+ child = spawn("ssh", [...SSH_OPTS, sshTarget], {
121
+ stdio: "inherit",
122
+ windowsHide: true,
123
+ });
121
124
  } else {
122
125
  console.error(`Error: Unknown cloud type '${cloud}'.`);
123
126
  process.exit(1);
@@ -47,7 +47,10 @@ import {
47
47
  import { hatchLocal } from "../lib/hatch-local.js";
48
48
  import { retireLocal } from "../lib/retire-local.js";
49
49
  import { validateAssistantName } from "../lib/retire-archive.js";
50
- import { stopProcessByPidFile } from "../lib/process.js";
50
+ import {
51
+ DAEMON_STOP_TIMEOUT_MS,
52
+ stopProcessByPidFile,
53
+ } from "../lib/process.js";
51
54
  import {
52
55
  fetchAssistantIngressUrl,
53
56
  fetchCurrentVersion,
@@ -1497,6 +1500,8 @@ export async function teleport(): Promise<void> {
1497
1500
  await stopProcessByPidFile(
1498
1501
  getDaemonPidPath(fromEntry.resources),
1499
1502
  "assistant",
1503
+ undefined,
1504
+ DAEMON_STOP_TIMEOUT_MS,
1500
1505
  );
1501
1506
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1502
1507
  }
@@ -6,8 +6,10 @@ import cliPkg from "../../package.json";
6
6
 
7
7
  import {
8
8
  findAssistantByName,
9
+ formatAssistantLookupError,
9
10
  getActiveAssistant,
10
11
  loadAllAssistants,
12
+ lookupAssistantByIdentifier,
11
13
  saveAssistantEntry,
12
14
  type AssistantEntry,
13
15
  } from "../lib/assistant-config";
@@ -42,6 +44,7 @@ import {
42
44
  restoreBackup,
43
45
  } from "../lib/backup-ops.js";
44
46
  import { emitCliError, categorizeUpgradeError } from "../lib/cli-error.js";
47
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
45
48
  import { exec } from "../lib/step-runner.js";
46
49
  import {
47
50
  broadcastUpgradeEvent,
@@ -89,9 +92,14 @@ interface UpgradeArgs {
89
92
  force: boolean;
90
93
  }
91
94
 
95
+ // Flags that consume the following argv token as a value, rather than a
96
+ // boolean switch. Passed to `parseAssistantTargetArg` so an unquoted
97
+ // multi-word display name is not truncated at a flag's value.
98
+ const UPGRADE_FLAGS_WITH_VALUES = ["--version"];
99
+
92
100
  function parseArgs(): UpgradeArgs {
93
101
  const args = process.argv.slice(3);
94
- let name: string | null = null;
102
+ const name = parseAssistantTargetArg(args, UPGRADE_FLAGS_WITH_VALUES) ?? null;
95
103
  let version: string | null = null;
96
104
  let latest = false;
97
105
  let prepare = false;
@@ -166,7 +174,8 @@ function parseArgs(): UpgradeArgs {
166
174
  } else if (arg === "--force") {
167
175
  force = true;
168
176
  } else if (!arg.startsWith("-")) {
169
- name = arg;
177
+ // Positional token, part of a possibly multi-word display name.
178
+ // Collected up front via `parseAssistantTargetArg` above.
170
179
  } else {
171
180
  console.error(`Error: Unknown option '${arg}'.`);
172
181
  emitCliError("UNKNOWN", `Unknown option '${arg}'`);
@@ -202,22 +211,22 @@ function parseArgs(): UpgradeArgs {
202
211
 
203
212
  /**
204
213
  * Resolve which assistant to target for the upgrade command. Priority:
205
- * 1. Explicit name argument
214
+ * 1. Explicit name argument (exact assistant ID wins over a display-name
215
+ * match; a unique display-name match resolves; an ambiguous display
216
+ * name is an error listing the matching IDs)
206
217
  * 2. Active assistant set via `vellum use`
207
218
  * 3. Sole assistant (when exactly one exists)
208
219
  */
209
220
  function resolveTargetAssistant(nameArg: string | null): AssistantEntry {
210
221
  if (nameArg) {
211
- const entry = findAssistantByName(nameArg);
212
- if (!entry) {
213
- console.error(`No assistant found with name '${nameArg}'.`);
214
- emitCliError(
215
- "ASSISTANT_NOT_FOUND",
216
- `No assistant found with name '${nameArg}'.`,
217
- );
222
+ const result = lookupAssistantByIdentifier(nameArg);
223
+ if (result.status !== "found") {
224
+ const msg = formatAssistantLookupError(nameArg, result);
225
+ console.error(msg);
226
+ emitCliError("ASSISTANT_NOT_FOUND", msg);
218
227
  process.exit(1);
219
228
  }
220
- return entry;
229
+ return result.entry;
221
230
  }
222
231
 
223
232
  const active = getActiveAssistant();
@@ -1518,7 +1527,7 @@ async function resolveLatestAndMaybeSelfUpdate(
1518
1527
  const installResult = spawnSync(
1519
1528
  "bun",
1520
1529
  ["install", "-g", `vellum@${stripVersionPrefix(latestTag)}`],
1521
- { stdio: "inherit" },
1530
+ { stdio: "inherit", windowsHide: true },
1522
1531
  );
1523
1532
  if (installResult.error || installResult.status !== 0) {
1524
1533
  const detail =
@@ -1542,6 +1551,7 @@ async function resolveLatestAndMaybeSelfUpdate(
1542
1551
  console.log(`🚀 Re-running upgrade with updated CLI...\n`);
1543
1552
  const reexecResult = spawnSync("vellum", reexecArgs, {
1544
1553
  stdio: "inherit",
1554
+ windowsHide: true,
1545
1555
  });
1546
1556
  process.exit(reexecResult.status ?? 1);
1547
1557
  }