@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,7 +14,6 @@ import { join } from "node:path";
14
14
 
15
15
  import * as assistantConfig from "../lib/assistant-config.js";
16
16
  import * as docker from "../lib/docker.js";
17
- import * as featureFlags from "../lib/feature-flags.js";
18
17
  import * as guardianToken from "../lib/guardian-token.js";
19
18
  import * as ingressConfig from "../lib/ingress-config.js";
20
19
  import * as local from "../lib/local.js";
@@ -141,19 +140,9 @@ mock.module("../lib/ngrok", () => ({
141
140
  maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
142
141
  }));
143
142
 
144
- const realFeatureFlags = { ...featureFlags };
145
143
  const realIngressConfig = { ...ingressConfig };
146
144
  const realNginxIngress = { ...nginxIngress };
147
145
 
148
- const isAssistantFeatureFlagEnabledMock = mock<
149
- typeof featureFlags.isAssistantFeatureFlagEnabled
150
- >(async () => true);
151
-
152
- mock.module("../lib/feature-flags.js", () => ({
153
- ...realFeatureFlags,
154
- isAssistantFeatureFlagEnabled: isAssistantFeatureFlagEnabledMock,
155
- }));
156
-
157
146
  const loadRawConfigMock = mock<typeof ingressConfig.loadRawConfig>(() => ({}));
158
147
 
159
148
  mock.module("../lib/ingress-config.js", () => ({
@@ -179,12 +168,12 @@ mock.module("../lib/nginx-ingress.js", () => ({
179
168
  }));
180
169
 
181
170
  const { wake } = await import("../commands/wake.js");
182
- const { WEB_INGRESS_FLAG_RETRY } = await import("../lib/tunnel-edge.js");
183
171
 
184
172
  let tempDir: string;
185
173
  let originalArgv: string[];
186
174
  let logSpy: ReturnType<typeof spyOn>;
187
175
  let warnSpy: ReturnType<typeof spyOn>;
176
+ let localEntry: AssistantEntry;
188
177
 
189
178
  function makeLocalEntry(): AssistantEntry {
190
179
  tempDir = mkdtempSync(join(tmpdir(), "vellum-wake-test-"));
@@ -211,9 +200,9 @@ beforeEach(() => {
211
200
  logSpy = spyOn(console, "log").mockImplementation(() => {});
212
201
  warnSpy = spyOn(console, "warn").mockImplementation(() => {});
213
202
 
214
- const entry = makeLocalEntry();
203
+ localEntry = makeLocalEntry();
215
204
  resolveTargetAssistantMock.mockReset();
216
- resolveTargetAssistantMock.mockReturnValue(entry);
205
+ resolveTargetAssistantMock.mockReturnValue(localEntry);
217
206
  saveAssistantEntryMock.mockReset();
218
207
  getDaemonPidPathMock.mockReset();
219
208
  getDaemonPidPathMock.mockImplementation((resources) =>
@@ -256,8 +245,6 @@ beforeEach(() => {
256
245
  );
257
246
  maybeStartNgrokTunnelMock.mockReset();
258
247
  maybeStartNgrokTunnelMock.mockResolvedValue(null);
259
- isAssistantFeatureFlagEnabledMock.mockReset();
260
- isAssistantFeatureFlagEnabledMock.mockResolvedValue(true);
261
248
  loadRawConfigMock.mockReset();
262
249
  loadRawConfigMock.mockReturnValue({});
263
250
  ensureTunnelEdgeMock.mockReset();
@@ -288,7 +275,6 @@ afterAll(() => {
288
275
  mock.module("../lib/process", () => realProcessLib);
289
276
  mock.module("../lib/local", () => realLocal);
290
277
  mock.module("../lib/ngrok", () => realNgrok);
291
- mock.module("../lib/feature-flags.js", () => realFeatureFlags);
292
278
  mock.module("../lib/ingress-config.js", () => realIngressConfig);
293
279
  mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
294
280
  });
@@ -402,8 +388,73 @@ describe("vellum wake", () => {
402
388
  // startLocalDaemon should NOT be called (daemon already running).
403
389
  expect(startLocalDaemonMock).not.toHaveBeenCalled();
404
390
  });
405
- });
406
391
 
392
+ test("does not duplicate an assistant already classified as stuck", async () => {
393
+ localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
394
+ resolveProcessStateMock.mockImplementation(
395
+ async (_pidFile, _port, label) =>
396
+ label === "Assistant"
397
+ ? { status: "stuck", pid: 123 }
398
+ : { status: "healthy", pid: 456 },
399
+ );
400
+
401
+ await wake();
402
+
403
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
404
+ expect(logSpy).toHaveBeenCalledWith(
405
+ expect.stringContaining("could not be stopped"),
406
+ );
407
+ });
408
+
409
+ test("does not duplicate an assistant when a watch restart cannot stop it", async () => {
410
+ localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
411
+ isAssistantWatchModeAvailableMock.mockReturnValue(true);
412
+ stopProcessByPidFileMock.mockResolvedValue(false);
413
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 123 });
414
+
415
+ await wake();
416
+
417
+ expect(startLocalDaemonMock).not.toHaveBeenCalled();
418
+ expect(logSpy).toHaveBeenCalledWith(
419
+ expect.stringContaining("could not be stopped for watch mode"),
420
+ );
421
+ });
422
+
423
+ test("does not duplicate a gateway already classified as stuck", async () => {
424
+ localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
425
+ resolveProcessStateMock.mockImplementation(
426
+ async (_pidFile, _port, label) =>
427
+ label === "Gateway"
428
+ ? { status: "stuck", pid: 456 }
429
+ : { status: "healthy", pid: 123 },
430
+ );
431
+
432
+ await wake();
433
+
434
+ expect(startGatewayMock).not.toHaveBeenCalled();
435
+ expect(logSpy).toHaveBeenCalledWith(
436
+ expect.stringContaining("Gateway running"),
437
+ );
438
+ });
439
+
440
+ test("does not duplicate a gateway when a watch restart cannot stop it", async () => {
441
+ localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
442
+ isGatewayWatchModeAvailableMock.mockReturnValue(true);
443
+ stopProcessByPidFileMock.mockResolvedValue(false);
444
+ isProcessAliveMock.mockImplementation((pidFile) =>
445
+ pidFile.endsWith("gateway.pid")
446
+ ? { alive: true, pid: 456 }
447
+ : { alive: false, pid: null },
448
+ );
449
+
450
+ await wake();
451
+
452
+ expect(startGatewayMock).not.toHaveBeenCalled();
453
+ expect(logSpy).toHaveBeenCalledWith(
454
+ expect.stringContaining("Gateway running"),
455
+ );
456
+ });
457
+ });
407
458
 
408
459
  describe("vellum wake — tunnel edge restore", () => {
409
460
  const webhookConfig = { telegram: { botUsername: "bot" } };
@@ -416,7 +467,7 @@ describe("vellum wake — tunnel edge restore", () => {
416
467
  process.argv = ["bun", "vellum", "wake", "local-assistant"];
417
468
  });
418
469
 
419
- test("webhook-configured wake ensures the edge with the startup flag retry and tunnels the edge port", async () => {
470
+ test("webhook-configured wake ensures the edge and tunnels the edge port", async () => {
420
471
  loadRawConfigMock.mockReturnValue(webhookConfig);
421
472
 
422
473
  await wake();
@@ -425,7 +476,6 @@ describe("vellum wake — tunnel edge restore", () => {
425
476
  assistantId: "local-assistant",
426
477
  workspaceDir: workspaceDirOf(tempDir),
427
478
  gatewayPort: 7830,
428
- flagRetry: WEB_INGRESS_FLAG_RETRY,
429
479
  });
430
480
  expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
431
481
  7840,
@@ -443,7 +493,6 @@ describe("vellum wake — tunnel edge restore", () => {
443
493
  assistantId: "local-assistant",
444
494
  workspaceDir: workspaceDirOf(tempDir),
445
495
  gatewayPort: 7830,
446
- flagRetry: WEB_INGRESS_FLAG_RETRY,
447
496
  });
448
497
  expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
449
498
  7840,
@@ -455,7 +504,6 @@ describe("vellum wake — tunnel edge restore", () => {
455
504
  test("skips the edge and tunnels the gateway port when nothing wants an edge", async () => {
456
505
  await wake();
457
506
 
458
- expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
459
507
  expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
460
508
  expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
461
509
  7830,
@@ -483,7 +531,7 @@ describe("vellum wake — tunnel edge restore", () => {
483
531
  expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
484
532
  });
485
533
 
486
- test("a running edge recorded against this gateway port is reused with zero flag calls", async () => {
534
+ test("a running edge recorded against this gateway port is reused without ensureTunnelEdge", async () => {
487
535
  loadRawConfigMock.mockReturnValue(webhookConfig);
488
536
  isIngressRunningMock.mockReturnValue(true);
489
537
  readIngressStateMock.mockReturnValue({
@@ -494,7 +542,6 @@ describe("vellum wake — tunnel edge restore", () => {
494
542
 
495
543
  await wake();
496
544
 
497
- expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
498
545
  expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
499
546
  expect(logSpy).toHaveBeenCalledWith(
500
547
  " Tunnel edge already running on 127.0.0.1:7845 (remote web + webhooks).",
@@ -505,6 +552,26 @@ describe("vellum wake — tunnel edge restore", () => {
505
552
  );
506
553
  });
507
554
 
555
+ test("a running webhooks-only edge goes through ensureTunnelEdge to upgrade to the SPA edge", async () => {
556
+ loadRawConfigMock.mockReturnValue(webhookConfig);
557
+ isIngressRunningMock.mockReturnValue(true);
558
+ readIngressStateMock.mockReturnValue({
559
+ listenPort: 7845,
560
+ includeWebApp: false,
561
+ gatewayPort: 7830,
562
+ });
563
+
564
+ await wake();
565
+
566
+ expect(ensureTunnelEdgeMock).toHaveBeenCalledWith(
567
+ expect.objectContaining({ gatewayPort: 7830 }),
568
+ );
569
+ expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
570
+ 7840,
571
+ workspaceDirOf(tempDir),
572
+ );
573
+ });
574
+
508
575
  test("a running edge recorded against a different gateway port goes through ensureTunnelEdge", async () => {
509
576
  loadRawConfigMock.mockReturnValue(webhookConfig);
510
577
  isIngressRunningMock.mockReturnValue(true);
@@ -563,28 +630,6 @@ describe("vellum wake — tunnel edge restore", () => {
563
630
  expect(logSpy).toHaveBeenCalledWith("Wake complete.");
564
631
  });
565
632
 
566
- test("flag-off + webhooks-on produces a webhooks-only edge fronting the tunnel", async () => {
567
- loadRawConfigMock.mockReturnValue(webhookConfig);
568
- ensureTunnelEdgeMock.mockResolvedValue({
569
- port: 7840,
570
- started: true,
571
- includesWebApp: false,
572
- });
573
-
574
- await wake();
575
-
576
- expect(ensureTunnelEdgeMock).toHaveBeenCalledWith(
577
- expect.objectContaining({ assistantId: "local-assistant" }),
578
- );
579
- expect(logSpy).toHaveBeenCalledWith(
580
- expect.stringContaining("webhooks only"),
581
- );
582
- expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
583
- 7840,
584
- workspaceDirOf(tempDir),
585
- );
586
- });
587
-
588
633
  test("a reused edge still points the tunnel at its listen port", async () => {
589
634
  loadRawConfigMock.mockReturnValue(enabledConfig);
590
635
  ensureTunnelEdgeMock.mockResolvedValue({
@@ -600,27 +645,4 @@ describe("vellum wake — tunnel edge restore", () => {
600
645
  workspaceDirOf(tempDir),
601
646
  );
602
647
  });
603
-
604
- test("an exhausted flag lookup warns and falls back to the gateway-port tunnel", async () => {
605
- loadRawConfigMock.mockReturnValue(webhookConfig);
606
- ensureTunnelEdgeMock.mockRejectedValue(
607
- new Error(
608
- "Could not verify the `web-remote-ingress` feature flag before starting the edge. Is the assistant running? Try `vellum wake` and retry. gateway unreachable",
609
- ),
610
- );
611
-
612
- await wake();
613
-
614
- expect(warnSpy).toHaveBeenCalledWith(
615
- expect.stringContaining("web-remote-ingress"),
616
- );
617
- expect(warnSpy).toHaveBeenCalledWith(
618
- expect.stringContaining("vellum nginx-ingress up"),
619
- );
620
- expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
621
- 7830,
622
- workspaceDirOf(tempDir),
623
- );
624
- expect(logSpy).toHaveBeenCalledWith("Wake complete.");
625
- });
626
648
  });
@@ -0,0 +1,157 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ executableName,
8
+ isProcessAlive,
9
+ isVellumCommandLine,
10
+ isVellumWindowsProcess,
11
+ pathListDelimiter,
12
+ parseTasklistCsv,
13
+ stopProcess,
14
+ stopProcessByPidFile,
15
+ windowsCommandLineLookupArgs,
16
+ } from "../lib/process";
17
+ import {
18
+ isInteractiveCliSession,
19
+ parseRemotePs,
20
+ processTableCommand,
21
+ } from "../lib/orphan-detection";
22
+
23
+ test("uses Windows executable suffixes without changing Unix names", () => {
24
+ expect(executableName("vellum-daemon", "win32")).toBe("vellum-daemon.exe");
25
+ expect(executableName("vellum.exe", "win32")).toBe("vellum.exe");
26
+ expect(executableName("vellum-daemon", "darwin")).toBe("vellum-daemon");
27
+ expect(pathListDelimiter("win32")).toBe(";");
28
+ expect(pathListDelimiter("linux")).toBe(":");
29
+ });
30
+
31
+ test("parses tasklist rows and ignores informational output", () => {
32
+ const output =
33
+ '"vellum-daemon.exe","4812","Console","1","20,000 K"\r\nINFO: No tasks match.';
34
+ expect(parseTasklistCsv(output)).toEqual([
35
+ { imageName: "vellum-daemon.exe", pid: 4812 },
36
+ ]);
37
+ });
38
+
39
+ test("passes the PID inside the PowerShell command", () => {
40
+ const args = windowsCommandLineLookupArgs(4812);
41
+ expect(args).toHaveLength(4);
42
+ expect(args[3]).toContain("ProcessId = 4812");
43
+ expect(args[3]).not.toContain("$args");
44
+ });
45
+
46
+ test("selects a direct process-table command with fixed arguments", () => {
47
+ const windows = processTableCommand("win32");
48
+ expect(windows.command).toBe("powershell.exe");
49
+ expect(windows.args.join(" ")).toContain("Win32_Process");
50
+ expect(processTableCommand("linux")).toEqual({
51
+ command: "ps",
52
+ args: ["ax", "-o", "pid=,ppid=,args="],
53
+ });
54
+ });
55
+
56
+ test("preserves command lines containing spaces and Unicode usernames", () => {
57
+ const [parsed] = parseRemotePs(
58
+ "42 1 C:\\Users\\Example User 用户\\Vellum\\vellum-daemon.exe --port 3030",
59
+ );
60
+ expect(parsed.command).toContain(
61
+ "Example User 用户\\Vellum\\vellum-daemon.exe",
62
+ );
63
+ expect(
64
+ isInteractiveCliSession(
65
+ '"C:\\Users\\Example User 用户\\Vellum\\vellum.exe" exec -it --service vellum-gateway',
66
+ ),
67
+ ).toBeTrue();
68
+ });
69
+
70
+ test("rejects a stale PID file", () => {
71
+ const dir = mkdtempSync(path.join(tmpdir(), "vellum stale pid "));
72
+ const pidFile = path.join(dir, "assistant.pid");
73
+ writeFileSync(pidFile, "2147483647\n", "utf8");
74
+ expect(isProcessAlive(pidFile)).toEqual({ alive: false, pid: null });
75
+ rmSync(dir, { recursive: true, force: true });
76
+ });
77
+
78
+ test("recognizes only Vellum Bun-hosted process command lines", () => {
79
+ expect(
80
+ isVellumCommandLine(
81
+ "bun.exe C:\\Users\\Example User 用户\\.vellum\\runtime\\assistant\\src\\daemon\\main.ts",
82
+ ),
83
+ ).toBeTrue();
84
+ expect(
85
+ isVellumCommandLine("bun.exe C:\\work\\unrelated\\server.ts"),
86
+ ).toBeFalse();
87
+ });
88
+
89
+ test("verifies Windows Qdrant ownership from its command line", () => {
90
+ expect(
91
+ isVellumWindowsProcess(
92
+ "qdrant.exe",
93
+ "C:\\Users\\Example User\\.vellum\\workspace\\data\\qdrant\\bin\\qdrant.exe",
94
+ ),
95
+ ).toBeTrue();
96
+ expect(
97
+ isVellumWindowsProcess("qdrant.exe", "C:\\Databases\\qdrant.exe"),
98
+ ).toBeFalse();
99
+ });
100
+
101
+ test("reports a failed forced Windows process-tree termination", async () => {
102
+ const calls: string[][] = [];
103
+ const stopped = await stopProcess(
104
+ process.pid,
105
+ "test process",
106
+ 0,
107
+ "win32",
108
+ (args) => {
109
+ calls.push(args);
110
+ throw new Error("access denied");
111
+ },
112
+ );
113
+
114
+ expect(stopped).toBeFalse();
115
+ expect(calls).toEqual([
116
+ ["/PID", String(process.pid), "/T"],
117
+ ["/PID", String(process.pid), "/T", "/F"],
118
+ ]);
119
+ });
120
+
121
+ test("treats a Unix exit before SIGKILL as a successful stop", async () => {
122
+ const signals: Array<NodeJS.Signals | number | undefined> = [];
123
+ const originalKill = process.kill.bind(process);
124
+ process.kill = ((_pid: number, signal?: NodeJS.Signals | number) => {
125
+ signals.push(signal);
126
+ if (signal === "SIGKILL") {
127
+ throw Object.assign(new Error("no such process"), { code: "ESRCH" });
128
+ }
129
+ return true;
130
+ }) as typeof process.kill;
131
+
132
+ try {
133
+ expect(await stopProcess(4812, "test process", 0, "darwin")).toBeTrue();
134
+ expect(signals).toEqual([0, "SIGTERM", 0, "SIGKILL"]);
135
+ } finally {
136
+ process.kill = originalKill;
137
+ }
138
+ });
139
+
140
+ test("preserves PID tracking when process termination fails", async () => {
141
+ const dir = mkdtempSync(path.join(tmpdir(), "vellum live pid "));
142
+ const pidFile = path.join(dir, "assistant.pid");
143
+ writeFileSync(pidFile, `${process.pid}\n`, "utf8");
144
+
145
+ expect(
146
+ await stopProcessByPidFile(
147
+ pidFile,
148
+ "test process",
149
+ undefined,
150
+ 0,
151
+ async () => false,
152
+ () => true,
153
+ ),
154
+ ).toBeFalse();
155
+ expect(isProcessAlive(pidFile)).toEqual({ alive: true, pid: process.pid });
156
+ rmSync(dir, { recursive: true, force: true });
157
+ });
@@ -29,6 +29,7 @@ import {
29
29
  } from "../lib/client-identity";
30
30
  import {
31
31
  getLockfileData,
32
+ renameLockfileAssistantIfPresent,
32
33
  upsertRendererLockfileAssistant,
33
34
  replacePlatformAssistants,
34
35
  isActiveAssistant,
@@ -72,6 +73,7 @@ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
72
73
  import { probePort } from "../lib/port-probe.js";
73
74
  import { openBrowser } from "../lib/open-browser";
74
75
  import { isCompiledCli } from "../lib/local.js";
76
+ import { findWebDistDir } from "../lib/web-dist.js";
75
77
  import { getLogDir, openLogFile, resetLogFile } from "../lib/xdg-log.js";
76
78
 
77
79
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
@@ -403,37 +405,6 @@ async function maybeHydratePlatformAssistantName(
403
405
 
404
406
  const SPA_BASE = "/assistant/";
405
407
 
406
- /**
407
- * Locate the pre-built @vellumai/web dist directory.
408
- *
409
- * Resolution order:
410
- * 1. npm-installed package — require.resolve('@vellumai/web/package.json')
411
- * 2. Source checkout — walk up from cli/ to find clients/web/dist/
412
- */
413
- function findWebDistDir(): string | null {
414
- try {
415
- const pkgPath = require.resolve("@vellumai/web/package.json");
416
- const distDir = path.join(path.dirname(pkgPath), "dist");
417
- if (existsSync(path.join(distDir, "index.html"))) {
418
- return distDir;
419
- }
420
- } catch {
421
- // Package not installed; try source checkout.
422
- }
423
-
424
- let dir = import.meta.dir;
425
- for (let depth = 0; depth < 8; depth++) {
426
- const candidate = path.join(dir, "clients", "web", "dist", "index.html");
427
- if (existsSync(candidate)) {
428
- return path.dirname(candidate);
429
- }
430
- const parent = path.dirname(dir);
431
- if (parent === dir) break;
432
- dir = parent;
433
- }
434
- return null;
435
- }
436
-
437
408
  /**
438
409
  * Locate the clients/web source directory for running the Vite dev server.
439
410
  * Only works from a source checkout (not npm-installed).
@@ -586,6 +557,13 @@ async function handleLocalEndpoints(
586
557
  body.platformAssistants as Array<Record<string, unknown>>,
587
558
  body.organizationId as string | undefined,
588
559
  );
560
+ } else if (body.rename && typeof body.rename === "object") {
561
+ const rename = body.rename as Record<string, unknown>;
562
+ result = renameLockfileAssistantIfPresent(
563
+ lockfilePaths,
564
+ rename.assistantId as string,
565
+ rename.name as string,
566
+ );
589
567
  } else {
590
568
  result = upsertRendererLockfileAssistant(
591
569
  lockfilePaths,
@@ -9,7 +9,6 @@ import {
9
9
  import type { AssistantEntry } from "../lib/assistant-config.js";
10
10
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
11
11
  import { GATEWAY_PORT } from "../lib/constants.js";
12
- import { WEB_REMOTE_INGRESS_FLAG } from "../lib/feature-flags.js";
13
12
  import {
14
13
  DEFAULT_NGINX_INGRESS_PORT,
15
14
  ensureTunnelEdge,
@@ -61,15 +60,6 @@ function printHelp(): void {
61
60
  console.log(" $ vellum nginx-ingress status");
62
61
  console.log(" $ vellum nginx-ingress up");
63
62
  console.log(" $ vellum nginx-ingress down my-assistant");
64
- console.log("");
65
- console.log("Feature flags:");
66
- console.log(
67
- ` ${WEB_REMOTE_INGRESS_FLAG} selects the edge mode: enabled serves the`,
68
- );
69
- console.log(
70
- " remote web app alongside webhooks; disabled starts a webhooks-only",
71
- );
72
- console.log(" proxy.");
73
63
  }
74
64
 
75
65
  interface NginxIngressTarget {
@@ -177,11 +167,6 @@ export async function up(target: NginxIngressTarget): Promise<void> {
177
167
 
178
168
  console.log("");
179
169
  console.log(`nginx ingress running: http://127.0.0.1:${edge.port} (${mode})`);
180
- if (!edge.includesWebApp) {
181
- console.log(
182
- `Enable the ${WEB_REMOTE_INGRESS_FLAG} feature flag to also serve the web app remotely.`,
183
- );
184
- }
185
170
  console.log("");
186
171
  console.log("Next steps:");
187
172
  console.log(
@@ -42,11 +42,6 @@ import {
42
42
  } from "../lib/client-identity.js";
43
43
  import { GATEWAY_PORT } from "../lib/constants.js";
44
44
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
45
- import {
46
- formatFeatureFlagGateMessage,
47
- isAssistantFeatureFlagEnabled,
48
- WEB_REMOTE_INGRESS_FLAG,
49
- } from "../lib/feature-flags.js";
50
45
  import { getLocalLanIPv4 } from "../lib/local.js";
51
46
  import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
52
47
  import { formatWebApproveFailure, parseGatewayErrorCode } from "../lib/pair.js";
@@ -265,34 +260,6 @@ async function approveRemoteWebPairing(
265
260
  return (await response.json()) as RemoteWebPairingVerificationResponse;
266
261
  }
267
262
 
268
- async function assertWebRemoteIngressEnabled(
269
- assistantId: string,
270
- runtimeUrl: string,
271
- ): Promise<void> {
272
- let enabled: boolean;
273
- try {
274
- enabled = await isAssistantFeatureFlagEnabled(
275
- assistantId,
276
- WEB_REMOTE_INGRESS_FLAG,
277
- { runtimeUrl },
278
- );
279
- } catch (err) {
280
- console.error(
281
- `Error: could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
282
- err instanceof Error ? err.message : String(err)
283
- }`,
284
- );
285
- process.exit(1);
286
- }
287
-
288
- if (!enabled) {
289
- console.error(
290
- `Error: ${formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG)}`,
291
- );
292
- process.exit(1);
293
- }
294
- }
295
-
296
263
  export async function pair(): Promise<void> {
297
264
  const rawArgs = process.argv.slice(3);
298
265
 
@@ -440,8 +407,6 @@ export async function pair(): Promise<void> {
440
407
  }
441
408
 
442
409
  if (webApproveCode) {
443
- await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
444
-
445
410
  // Rejections are diagnosed here rather than by exitOnHttpError: a
446
411
  // rejected code must name the gateway that was asked, or an
447
412
  // assistant/environment mismatch is indistinguishable from a typo.
@@ -472,8 +437,6 @@ export async function pair(): Promise<void> {
472
437
  }
473
438
 
474
439
  if (webPairing) {
475
- await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
476
-
477
440
  let publicBaseUrl: string;
478
441
  try {
479
442
  publicBaseUrl = normalizePairingBaseUrl(advertisedUrl);
@@ -551,8 +514,6 @@ export async function pair(): Promise<void> {
551
514
  }
552
515
  const qrBaseUrl = qrResult.url;
553
516
 
554
- await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
555
-
556
517
  // Mint a challenge and immediately approve it: running this CLI on the host
557
518
  // IS the local-presence proof, so the scanning device completes pairing in
558
519
  // one step. Reuses the `--web` + `--web-approve` code paths.
@@ -134,11 +134,24 @@ export async function wake(): Promise<void> {
134
134
  "readyz",
135
135
  );
136
136
  if (daemonState.status !== "needs_start") {
137
- if (watch && isAssistantWatchModeAvailable()) {
137
+ if (daemonState.status === "stuck") {
138
+ daemonRunning = true;
139
+ daemonUnready = true;
140
+ console.log(
141
+ `Assistant running (pid ${daemonState.pid}) but is not responding and could not be stopped.`,
142
+ );
143
+ } else if (watch && isAssistantWatchModeAvailable()) {
138
144
  console.log(
139
145
  `Assistant running (pid ${daemonState.pid}) — restarting in watch mode...`,
140
146
  );
141
- await stopProcessByPidFile(pidFile, "assistant");
147
+ const stopped = await stopProcessByPidFile(pidFile, "assistant");
148
+ if (!stopped && isProcessAlive(pidFile).alive) {
149
+ daemonRunning = true;
150
+ daemonUnready = true;
151
+ console.log(
152
+ `Assistant running (pid ${daemonState.pid}) but could not be stopped for watch mode.`,
153
+ );
154
+ }
142
155
  } else {
143
156
  daemonRunning = true;
144
157
  daemonUnready = daemonState.status !== "healthy";
@@ -249,8 +262,29 @@ export async function wake(): Promise<void> {
249
262
  "Gateway",
250
263
  );
251
264
  const gatewayAlive = gatewayState.status === "healthy";
265
+ const gatewayStuck = gatewayState.status === "stuck";
266
+ const restartGateway = async (
267
+ restartWithWatch: boolean,
268
+ ): Promise<boolean> => {
269
+ const stopped = await stopProcessByPidFile(gatewayPidFile, "gateway");
270
+ if (!stopped && isProcessAlive(gatewayPidFile).alive) {
271
+ console.log(
272
+ `Gateway running (pid ${gatewayState.pid}) but could not be stopped.`,
273
+ );
274
+ return false;
275
+ }
276
+ await startGateway(restartWithWatch, resources, {
277
+ signingKey,
278
+ bootstrapSecret,
279
+ });
280
+ return true;
281
+ };
252
282
  const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
253
- if (needsRestart) {
283
+ if (gatewayStuck) {
284
+ console.log(
285
+ `Gateway running (pid ${gatewayState.pid}) but is not responding and could not be stopped.`,
286
+ );
287
+ } else if (needsRestart) {
254
288
  const restartWithWatch = watch && isGatewayWatchModeAvailable();
255
289
  if (restartWithWatch) {
256
290
  console.log(
@@ -261,20 +295,13 @@ export async function wake(): Promise<void> {
261
295
  `Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
262
296
  );
263
297
  }
264
- await stopProcessByPidFile(gatewayPidFile, "gateway");
265
- await startGateway(restartWithWatch, resources, {
266
- signingKey,
267
- bootstrapSecret,
268
- });
269
- gatewayStarted = true;
298
+ gatewayStarted = await restartGateway(restartWithWatch);
270
299
  } else if (gatewayAlive) {
271
300
  if (watch && isGatewayWatchModeAvailable()) {
272
301
  console.log(
273
302
  `Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
274
303
  );
275
- await stopProcessByPidFile(gatewayPidFile, "gateway");
276
- await startGateway(watch, resources, { signingKey, bootstrapSecret });
277
- gatewayStarted = true;
304
+ gatewayStarted = await restartGateway(watch);
278
305
  } else {
279
306
  if (watch) {
280
307
  console.log(