@vellumai/cli 0.4.48 → 0.4.49

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.
package/src/lib/docker.ts CHANGED
@@ -19,6 +19,57 @@ import {
19
19
 
20
20
  const _require = createRequire(import.meta.url);
21
21
 
22
+ /**
23
+ * Checks whether the `docker` CLI and daemon are available on the system.
24
+ * Installs Colima and Docker via Homebrew if the CLI is missing, and starts
25
+ * Colima if the Docker daemon is not reachable.
26
+ */
27
+ async function ensureDockerInstalled(): Promise<void> {
28
+ let installed = false;
29
+ try {
30
+ await execOutput("docker", ["--version"]);
31
+ installed = true;
32
+ } catch {
33
+ // docker CLI not found — install it
34
+ }
35
+
36
+ if (!installed) {
37
+ console.log("🐳 Docker not found. Installing via Homebrew...");
38
+ try {
39
+ await exec("brew", ["install", "colima", "docker"]);
40
+ } catch (err) {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ throw new Error(
43
+ `Failed to install Docker via Homebrew. Please install Docker manually.\n${message}`,
44
+ );
45
+ }
46
+
47
+ try {
48
+ await execOutput("docker", ["--version"]);
49
+ } catch {
50
+ throw new Error(
51
+ "Docker was installed but is still not available on PATH. " +
52
+ "You may need to restart your terminal.",
53
+ );
54
+ }
55
+ }
56
+
57
+ // Verify the Docker daemon is reachable; start Colima if it isn't
58
+ try {
59
+ await exec("docker", ["info"]);
60
+ } catch {
61
+ console.log("🚀 Docker daemon not running. Starting Colima...");
62
+ try {
63
+ await exec("colima", ["start"]);
64
+ } catch (err) {
65
+ const message = err instanceof Error ? err.message : String(err);
66
+ throw new Error(
67
+ `Failed to start Colima. Please run 'colima start' manually.\n${message}`,
68
+ );
69
+ }
70
+ }
71
+ }
72
+
22
73
  interface DockerRoot {
23
74
  /** Directory to use as the Docker build context */
24
75
  root: string;
@@ -180,6 +231,8 @@ export async function hatchDocker(
180
231
  ): Promise<void> {
181
232
  resetLogFile("hatch.log");
182
233
 
234
+ await ensureDockerInstalled();
235
+
183
236
  let repoRoot: string;
184
237
  let dockerfileDir: string;
185
238
  try {
@@ -251,12 +304,7 @@ export async function hatchDocker(
251
304
  ];
252
305
 
253
306
  // Pass through environment variables the assistant needs
254
- for (const envVar of [
255
- "ANTHROPIC_API_KEY",
256
- "GATEWAY_RUNTIME_PROXY_ENABLED",
257
- "RUNTIME_PROXY_BEARER_TOKEN",
258
- "VELLUM_ASSISTANT_PLATFORM_URL",
259
- ]) {
307
+ for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
260
308
  if (process.env[envVar]) {
261
309
  runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
262
310
  }
@@ -1,4 +1,4 @@
1
- const DOCTOR_URL = process.env.DOCTOR_URL || "https://doctor.vellum.ai";
1
+ const DOCTOR_URL = "https://doctor.vellum.ai";
2
2
 
3
3
  export type ProgressPhase =
4
4
  | "invoking_prompt"
package/src/lib/gcp.ts CHANGED
@@ -637,7 +637,7 @@ export async function hatchGcp(
637
637
  species === "vellum" &&
638
638
  (await checkCurlFailure(instanceName, project, zone, account))
639
639
  ) {
640
- const installScriptUrl = `${process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
640
+ const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
641
641
  console.log(
642
642
  `\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
643
643
  );
@@ -32,8 +32,7 @@ export function buildDaemonUrl(port: number): string {
32
32
  */
33
33
  export function readHttpToken(instanceDir?: string): string | undefined {
34
34
  const baseDataDir =
35
- instanceDir ??
36
- (process.env.BASE_DATA_DIR?.trim() || homedir());
35
+ instanceDir ?? (process.env.BASE_DATA_DIR?.trim() || homedir());
37
36
  const tokenPath = join(baseDataDir, ".vellum", "http-token");
38
37
  try {
39
38
  const token = readFileSync(tokenPath, "utf-8").trim();
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+
5
+ const MAX_ENTRIES = 1000;
6
+
7
+ function historyPath(): string {
8
+ return join(homedir(), ".vellum", "input-history");
9
+ }
10
+
11
+ export function loadHistory(): string[] {
12
+ try {
13
+ const path = historyPath();
14
+ if (!existsSync(path)) return [];
15
+ const content = readFileSync(path, "utf-8");
16
+ return content
17
+ .split("\n")
18
+ .filter((line) => line.length > 0)
19
+ .slice(-MAX_ENTRIES);
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ export function appendHistory(entry: string): void {
26
+ const trimmed = entry.trim();
27
+ if (!trimmed || trimmed.startsWith("/")) return;
28
+ try {
29
+ const path = historyPath();
30
+ const dir = dirname(path);
31
+ if (!existsSync(dir)) {
32
+ mkdirSync(dir, { recursive: true });
33
+ }
34
+ const existing = loadHistory();
35
+ // Deduplicate: remove previous occurrence of the same entry
36
+ const deduped = existing.filter((e) => e !== trimmed);
37
+ deduped.push(trimmed);
38
+ // Keep only the last MAX_ENTRIES
39
+ const trimmedList = deduped.slice(-MAX_ENTRIES);
40
+ writeFileSync(path, trimmedList.join("\n") + "\n", { mode: 0o600 });
41
+ } catch {
42
+ // Best-effort persistence — don't crash on write failure
43
+ }
44
+ }
package/src/lib/local.ts CHANGED
@@ -10,10 +10,7 @@ import { createRequire } from "module";
10
10
  import { homedir, hostname, networkInterfaces, platform } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
- import {
14
- loadLatestAssistant,
15
- type LocalInstanceResources,
16
- } from "./assistant-config.js";
13
+ import { type LocalInstanceResources } from "./assistant-config.js";
17
14
  import { GATEWAY_PORT } from "./constants.js";
18
15
  import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
19
16
  import { stopProcessByPidFile } from "./process.js";
@@ -244,11 +241,6 @@ async function startDaemonFromSource(
244
241
  ...process.env,
245
242
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
246
243
  };
247
- // Preserve TCP listener flag when falling back from bundled desktop daemon
248
- if (process.env.VELLUM_DESKTOP_APP) {
249
- env.VELLUM_DAEMON_TCP_ENABLED =
250
- process.env.VELLUM_DAEMON_TCP_ENABLED || "1";
251
- }
252
244
  if (resources) {
253
245
  env.BASE_DATA_DIR = resources.instanceDir;
254
246
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
@@ -354,16 +346,6 @@ async function startDaemonWatchFromSource(
354
346
  }
355
347
 
356
348
  function resolveGatewayDir(): string {
357
- const override = process.env.VELLUM_GATEWAY_DIR?.trim();
358
- if (override) {
359
- if (!isGatewaySourceDir(override)) {
360
- throw new Error(
361
- `VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
362
- );
363
- }
364
- return override;
365
- }
366
-
367
349
  // Source tree: cli/src/lib/ → ../../.. → repo root → gateway/
368
350
  const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
369
351
  if (isGatewaySourceDir(sourceDir)) {
@@ -386,7 +368,7 @@ function resolveGatewayDir(): string {
386
368
  return dirname(pkgPath);
387
369
  } catch {
388
370
  throw new Error(
389
- "Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
371
+ "Gateway not found. Ensure @vellumai/vellum-gateway is installed or run from the source tree.",
390
372
  );
391
373
  }
392
374
  }
@@ -397,6 +379,79 @@ function normalizeIngressUrl(value: unknown): string | undefined {
397
379
  return normalized || undefined;
398
380
  }
399
381
 
382
+ // ── Workspace config helpers ──
383
+
384
+ function getWorkspaceConfigPath(instanceDir?: string): string {
385
+ const baseDataDir =
386
+ instanceDir ??
387
+ (process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir()));
388
+ return join(baseDataDir, ".vellum", "workspace", "config.json");
389
+ }
390
+
391
+ function loadWorkspaceConfig(instanceDir?: string): Record<string, unknown> {
392
+ const configPath = getWorkspaceConfigPath(instanceDir);
393
+ try {
394
+ if (!existsSync(configPath)) return {};
395
+ return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
396
+ string,
397
+ unknown
398
+ >;
399
+ } catch {
400
+ return {};
401
+ }
402
+ }
403
+
404
+ function saveWorkspaceConfig(
405
+ config: Record<string, unknown>,
406
+ instanceDir?: string,
407
+ ): void {
408
+ const configPath = getWorkspaceConfigPath(instanceDir);
409
+ const dir = dirname(configPath);
410
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
411
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
412
+ }
413
+
414
+ /**
415
+ * Write gateway operational settings to the workspace config file so the
416
+ * gateway reads them at startup via its config.ts readWorkspaceConfig().
417
+ */
418
+ function writeGatewayConfig(
419
+ instanceDir?: string,
420
+ opts?: {
421
+ runtimeProxyEnabled?: boolean;
422
+ runtimeProxyRequireAuth?: boolean;
423
+ unmappedPolicy?: "reject" | "default";
424
+ defaultAssistantId?: string;
425
+ routingEntries?: Array<{
426
+ type: "conversation_id" | "actor_id";
427
+ key: string;
428
+ assistantId: string;
429
+ }>;
430
+ },
431
+ ): void {
432
+ const config = loadWorkspaceConfig(instanceDir);
433
+ const gateway = (config.gateway ?? {}) as Record<string, unknown>;
434
+
435
+ if (opts?.runtimeProxyEnabled !== undefined) {
436
+ gateway.runtimeProxyEnabled = opts.runtimeProxyEnabled;
437
+ }
438
+ if (opts?.runtimeProxyRequireAuth !== undefined) {
439
+ gateway.runtimeProxyRequireAuth = opts.runtimeProxyRequireAuth;
440
+ }
441
+ if (opts?.unmappedPolicy !== undefined) {
442
+ gateway.unmappedPolicy = opts.unmappedPolicy;
443
+ }
444
+ if (opts?.defaultAssistantId !== undefined) {
445
+ gateway.defaultAssistantId = opts.defaultAssistantId;
446
+ }
447
+ if (opts?.routingEntries !== undefined) {
448
+ gateway.routingEntries = opts.routingEntries;
449
+ }
450
+
451
+ config.gateway = gateway;
452
+ saveWorkspaceConfig(config, instanceDir);
453
+ }
454
+
400
455
  function readWorkspaceIngressPublicBaseUrl(
401
456
  instanceDir?: string,
402
457
  ): string | undefined {
@@ -472,48 +527,29 @@ export async function discoverPublicUrl(
472
527
  port?: number,
473
528
  ): Promise<string | undefined> {
474
529
  const effectivePort = port ?? GATEWAY_PORT;
475
- const cloud = process.env.VELLUM_CLOUD;
476
530
 
477
- let externalIp: string | undefined;
531
+ // Discover local and cloud addresses in parallel so the cloud metadata
532
+ // timeout (1s) doesn't block startup when a local address is immediately
533
+ // available.
534
+ const cloudIpPromise = discoverCloudExternalIp();
478
535
 
479
- // Try cloud-specific metadata services for GCP and AWS.
480
- if (cloud === "gcp" || cloud === "aws") {
481
- try {
482
- if (cloud === "gcp") {
483
- const resp = await fetch(
484
- "http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
485
- { headers: { "Metadata-Flavor": "Google" } },
486
- );
487
- if (resp.ok) externalIp = (await resp.text()).trim();
488
- } else if (cloud === "aws") {
489
- // Use IMDSv2 (token-based) for compatibility with HttpTokens=required
490
- const tokenResp = await fetch(
491
- "http://169.254.169.254/latest/api/token",
492
- {
493
- method: "PUT",
494
- headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" },
495
- },
496
- );
497
- if (tokenResp.ok) {
498
- const token = await tokenResp.text();
499
- const ipResp = await fetch(
500
- "http://169.254.169.254/latest/meta-data/public-ipv4",
501
- { headers: { "X-aws-ec2-metadata-token": token } },
502
- );
503
- if (ipResp.ok) externalIp = (await ipResp.text()).trim();
504
- }
505
- }
506
- } catch {
507
- // metadata service not reachable
508
- }
536
+ // Resolve local address synchronously (no I/O).
537
+ const localUrl = discoverLocalUrl(effectivePort);
509
538
 
510
- if (externalIp) {
511
- console.log(` Discovered external IP: ${externalIp}`);
512
- return `http://${externalIp}:${effectivePort}`;
513
- }
539
+ const cloudIp = await cloudIpPromise;
540
+ if (cloudIp) {
541
+ console.log(` Discovered external IP: ${cloudIp}`);
542
+ return `http://${cloudIp}:${effectivePort}`;
514
543
  }
515
544
 
516
- // For local and custom environments, use the local LAN address.
545
+ return localUrl;
546
+ }
547
+
548
+ /**
549
+ * Resolve a LAN-reachable URL without any async I/O. Returns the best local
550
+ * address or falls back to localhost.
551
+ */
552
+ function discoverLocalUrl(effectivePort: number): string {
517
553
  // On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
518
554
  // the same network can reach the gateway by name.
519
555
  if (platform() === "darwin") {
@@ -534,6 +570,58 @@ export async function discoverPublicUrl(
534
570
  return `http://localhost:${effectivePort}`;
535
571
  }
536
572
 
573
+ /**
574
+ * Attempt to discover the VM's external/public IP via cloud metadata services.
575
+ * Tries GCP and AWS IMDSv2 in parallel with a short timeout. Returns undefined
576
+ * on non-cloud machines (the metadata endpoint is unreachable).
577
+ */
578
+ async function discoverCloudExternalIp(): Promise<string | undefined> {
579
+ const timeoutMs = 1000;
580
+
581
+ const gcpPromise = (async (): Promise<string | undefined> => {
582
+ try {
583
+ const resp = await fetch(
584
+ "http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
585
+ {
586
+ headers: { "Metadata-Flavor": "Google" },
587
+ signal: AbortSignal.timeout(timeoutMs),
588
+ },
589
+ );
590
+ if (resp.ok) return (await resp.text()).trim() || undefined;
591
+ } catch {
592
+ // metadata service not reachable
593
+ }
594
+ return undefined;
595
+ })();
596
+
597
+ const awsPromise = (async (): Promise<string | undefined> => {
598
+ try {
599
+ const tokenResp = await fetch("http://169.254.169.254/latest/api/token", {
600
+ method: "PUT",
601
+ headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" },
602
+ signal: AbortSignal.timeout(timeoutMs),
603
+ });
604
+ if (tokenResp.ok) {
605
+ const token = await tokenResp.text();
606
+ const ipResp = await fetch(
607
+ "http://169.254.169.254/latest/meta-data/public-ipv4",
608
+ {
609
+ headers: { "X-aws-ec2-metadata-token": token },
610
+ signal: AbortSignal.timeout(timeoutMs),
611
+ },
612
+ );
613
+ if (ipResp.ok) return (await ipResp.text()).trim() || undefined;
614
+ }
615
+ } catch {
616
+ // metadata service not reachable
617
+ }
618
+ return undefined;
619
+ })();
620
+
621
+ const [gcpIp, awsIp] = await Promise.all([gcpPromise, awsPromise]);
622
+ return gcpIp ?? awsIp;
623
+ }
624
+
537
625
  /**
538
626
  * Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
539
627
  * or undefined if not running on macOS or the hostname cannot be determined.
@@ -680,7 +768,6 @@ export async function startLocalDaemon(
680
768
  const daemonEnv: Record<string, string> = {
681
769
  HOME: process.env.HOME || homedir(),
682
770
  PATH: `${bunBinDir}:${basePath}`,
683
- VELLUM_DAEMON_TCP_ENABLED: "1",
684
771
  };
685
772
  // Forward optional config env vars the daemon may need
686
773
  for (const key of [
@@ -689,10 +776,6 @@ export async function startLocalDaemon(
689
776
  "QDRANT_HTTP_PORT",
690
777
  "QDRANT_URL",
691
778
  "RUNTIME_HTTP_PORT",
692
- "VELLUM_DAEMON_TCP_PORT",
693
- "VELLUM_DAEMON_TCP_HOST",
694
- "VELLUM_KEYCHAIN_BROKER_SOCKET",
695
- "VELLUM_DEBUG",
696
779
  "SENTRY_DSN",
697
780
  "TMPDIR",
698
781
  "USER",
@@ -804,7 +887,6 @@ export async function startLocalDaemon(
804
887
  }
805
888
 
806
889
  export async function startGateway(
807
- assistantId?: string,
808
890
  watch: boolean = false,
809
891
  resources?: LocalInstanceResources,
810
892
  ): Promise<string> {
@@ -827,118 +909,35 @@ export async function startGateway(
827
909
 
828
910
  console.log("🌐 Starting gateway...");
829
911
 
830
- // Resolve the default assistant ID for the gateway. Prefer the explicitly
831
- // provided assistantId (from hatch), then env override, then lockfile.
832
- const resolvedAssistantId =
833
- assistantId ||
834
- process.env.GATEWAY_DEFAULT_ASSISTANT_ID ||
835
- loadLatestAssistant()?.assistantId;
836
-
837
- // Read the bearer token so the gateway can authenticate proxied requests
838
- // (e.g. from paired iOS devices). Respect VELLUM_HTTP_TOKEN_PATH and
839
- // BASE_DATA_DIR for consistency with gateway/config.ts and the daemon.
840
- // When resources are provided, the token lives under the instance directory.
841
- const httpTokenPath =
842
- process.env.VELLUM_HTTP_TOKEN_PATH ??
843
- (resources
844
- ? join(resources.instanceDir, ".vellum", "http-token")
845
- : join(
846
- process.env.BASE_DATA_DIR?.trim() || homedir(),
847
- ".vellum",
848
- "http-token",
849
- ));
850
- let runtimeProxyBearerToken: string | undefined;
851
- try {
852
- const tok = readFileSync(httpTokenPath, "utf-8").trim();
853
- if (tok) runtimeProxyBearerToken = tok;
854
- } catch {
855
- // Token file doesn't exist yet — daemon hasn't written it.
856
- }
857
-
858
- // If no token is available (first startup — daemon hasn't written it yet),
859
- // poll for the file to appear. On fresh installs the daemon may take 60s+
860
- // for Qdrant download, migrations, and first-time init. Starting the
861
- // gateway without auth is a security risk since the config is loaded once
862
- // at startup and never reloads, so we fail rather than silently disabling auth.
863
- if (!runtimeProxyBearerToken) {
864
- console.log(" Waiting for bearer token file...");
865
- const maxWait = 60000;
866
- const pollInterval = 500;
867
- const start = Date.now();
868
- const pidFile =
869
- resources?.pidFile ??
870
- join(
871
- process.env.BASE_DATA_DIR?.trim() || homedir(),
872
- ".vellum",
873
- "vellum.pid",
874
- );
875
- while (Date.now() - start < maxWait) {
876
- await new Promise((r) => setTimeout(r, pollInterval));
877
- try {
878
- const tok = readFileSync(httpTokenPath, "utf-8").trim();
879
- if (tok) {
880
- runtimeProxyBearerToken = tok;
881
- break;
882
- }
883
- } catch {
884
- // File still doesn't exist, keep polling.
885
- }
886
- // Check if the daemon process is still alive — no point waiting if it crashed
887
- try {
888
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
889
- if (pid) process.kill(pid, 0); // throws if process doesn't exist
890
- } catch {
891
- break; // daemon process is gone
892
- }
893
- }
894
- }
895
-
896
- if (!runtimeProxyBearerToken) {
897
- throw new Error(
898
- `Bearer token file not found at ${httpTokenPath} after 60s.\n` +
899
- " The gateway cannot start without authentication — this would leave the proxy permanently unauthenticated.\n" +
900
- " Ensure the daemon is running and has written the token file, or set VELLUM_HTTP_TOKEN_PATH to the correct path.",
901
- );
902
- }
903
912
  const effectiveDaemonPort =
904
913
  resources?.daemonPort ?? Number(process.env.RUNTIME_HTTP_PORT || "7821");
905
914
 
915
+ // Write gateway operational settings to workspace config before starting
916
+ // the gateway process. The gateway reads these at startup from config.json.
917
+ writeGatewayConfig(resources?.instanceDir, {
918
+ runtimeProxyEnabled: true,
919
+ runtimeProxyRequireAuth: true,
920
+ unmappedPolicy: "default",
921
+ defaultAssistantId: "self",
922
+ });
923
+
906
924
  const gatewayEnv: Record<string, string> = {
907
925
  ...(process.env as Record<string, string>),
908
- GATEWAY_RUNTIME_PROXY_ENABLED: "true",
909
- GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
910
- RUNTIME_PROXY_BEARER_TOKEN: runtimeProxyBearerToken,
911
926
  RUNTIME_HTTP_PORT: String(effectiveDaemonPort),
912
927
  GATEWAY_PORT: String(effectiveGatewayPort),
913
- // Skip the drain window for locally-launched gateways — there is no load
914
- // balancer draining connections, so waiting serves no purpose and causes
915
- // `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
916
- // than the drain window. Respect an explicit env override.
917
- GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
918
928
  ...(watch ? { VELLUM_DEV: "1" } : {}),
919
929
  // Set BASE_DATA_DIR so the gateway loads the correct signing key and
920
930
  // credentials for this instance (mirrors the daemon env setup).
921
931
  ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
922
932
  };
923
-
924
- if (process.env.GATEWAY_UNMAPPED_POLICY) {
925
- gatewayEnv.GATEWAY_UNMAPPED_POLICY = process.env.GATEWAY_UNMAPPED_POLICY;
926
- } else {
927
- gatewayEnv.GATEWAY_UNMAPPED_POLICY = "default";
928
- }
929
-
930
- if (resolvedAssistantId) {
931
- gatewayEnv.GATEWAY_DEFAULT_ASSISTANT_ID = resolvedAssistantId;
932
- }
933
+ // The gateway reads the ingress URL from the workspace config file via
934
+ // ConfigFileCache — no env var passthrough needed. Log the resolved value
935
+ // for diagnostic visibility during startup.
933
936
  const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl(
934
937
  resources?.instanceDir,
935
938
  );
936
- const ingressPublicBaseUrl =
937
- workspaceIngressPublicBaseUrl ??
938
- normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL) ??
939
- publicUrl;
939
+ const ingressPublicBaseUrl = workspaceIngressPublicBaseUrl ?? publicUrl;
940
940
  if (ingressPublicBaseUrl) {
941
- gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
942
941
  console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
943
942
  }
944
943
 
@@ -1048,8 +1047,8 @@ export async function stopLocalProcesses(
1048
1047
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1049
1048
 
1050
1049
  // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1051
- // isVellumProcess() checks for /vellum|@vellumai|--vellum-gateway/ which
1052
- // won't match the ngrok binary — resulting in a no-op that leaves ngrok running.
1050
+ // isVellumProcess() won't match the ngrok binary — resulting in a no-op that
1051
+ // leaves ngrok running.
1053
1052
  const ngrokPidFile = join(vellumDir, "ngrok.pid");
1054
1053
  if (existsSync(ngrokPidFile)) {
1055
1054
  try {
@@ -13,13 +13,23 @@ export interface RemoteProcess {
13
13
  export function classifyProcess(command: string): string {
14
14
  if (/qdrant/.test(command)) return "qdrant";
15
15
  if (/vellum-gateway/.test(command)) return "gateway";
16
- if (/openclaw/.test(command)) return "openclaw-adapter";
16
+ if (
17
+ /vellum-openclaw-adapter|openclaw-runtime-server|openclaw-http-server/.test(
18
+ command,
19
+ )
20
+ )
21
+ return "openclaw-adapter";
17
22
  if (/vellum-daemon/.test(command)) return "assistant";
18
23
  if (/daemon\s+(start|restart)/.test(command)) return "assistant";
24
+ if (/vellum-cli/.test(command)) return "vellum";
19
25
  // Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
20
26
  // but they are not background service processes.
21
27
  if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
22
- if (/vellum/.test(command)) return "vellum";
28
+ // Match vellum CLI commands (e.g. "vellum hatch", "vellum sleep") but NOT
29
+ // unrelated processes whose working directory or repo path happens to contain
30
+ // "vellum" (e.g. /Users/runner/work/vellum-assistant/vellum-assistant/...).
31
+ // We require a word boundary before "vellum" to avoid matching repo paths.
32
+ if (/(?:^|\/)vellum(?:\s|$)/.test(command)) return "vellum";
23
33
  return "unknown";
24
34
  }
25
35
 
@@ -83,7 +93,7 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
83
93
  try {
84
94
  const output = await execOutput("sh", [
85
95
  "-c",
86
- "ps ax -o pid=,ppid=,args= | grep -E 'vellum|vellum-gateway|qdrant|openclaw' | grep -v grep",
96
+ "ps ax -o pid=,ppid=,args= | grep -E 'vellum|qdrant|openclaw' | grep -v grep",
87
97
  ]);
88
98
  const procs = parseRemotePs(output);
89
99
  const ownPid = String(process.pid);
@@ -13,7 +13,9 @@ function isVellumProcess(pid: number): boolean {
13
13
  timeout: 3000,
14
14
  stdio: ["ignore", "pipe", "ignore"],
15
15
  }).trim();
16
- return /vellum|@vellumai|--vellum-gateway/.test(output);
16
+ return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/vellum\/|\/daemon\/main/.test(
17
+ output,
18
+ );
17
19
  } catch {
18
20
  return false;
19
21
  }