@vellumai/cli 0.4.48 → 0.4.50

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.
@@ -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
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Terminal capability detection module.
3
+ *
4
+ * Detects color support, unicode availability, and terminal dimensions
5
+ * by inspecting TERM, COLORTERM, NO_COLOR, and related environment
6
+ * variables. Designed to enable graceful degradation on dumb terminals
7
+ * and constrained environments (e.g. SSH to a Raspberry Pi).
8
+ */
9
+
10
+ export type ColorLevel = "none" | "basic" | "256" | "truecolor";
11
+
12
+ export interface TerminalCapabilities {
13
+ /** Detected color support level */
14
+ colorLevel: ColorLevel;
15
+ /** Whether the terminal likely supports unicode glyphs */
16
+ unicodeSupported: boolean;
17
+ /** Current terminal width in columns (falls back to 80) */
18
+ columns: number;
19
+ /** Current terminal rows (falls back to 24) */
20
+ rows: number;
21
+ /** True when TERM=dumb — indicates a terminal with no cursor addressing */
22
+ isDumb: boolean;
23
+ }
24
+
25
+ /**
26
+ * Detect the color support level from environment variables.
27
+ *
28
+ * Precedence (highest to lowest):
29
+ * 1. NO_COLOR or TERM=dumb → "none"
30
+ * 2. COLORTERM=truecolor / 24bit → "truecolor"
31
+ * 3. TERM contains "256color" → "256"
32
+ * 4. Any other interactive terminal → "basic"
33
+ * 5. Non-TTY → "none"
34
+ */
35
+ function detectColorLevel(env: NodeJS.ProcessEnv): ColorLevel {
36
+ // NO_COLOR spec: https://no-color.org/
37
+ if (env.NO_COLOR !== undefined) return "none";
38
+
39
+ const term = (env.TERM ?? "").toLowerCase();
40
+ if (term === "dumb") return "none";
41
+
42
+ const colorterm = (env.COLORTERM ?? "").toLowerCase();
43
+
44
+ if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
45
+ if (term.includes("256color")) return "256";
46
+
47
+ // If we have a TERM value at all, assume basic color support
48
+ if (term.length > 0) return "basic";
49
+
50
+ // Fallback: if stdout is a TTY, assume basic
51
+ if (process.stdout.isTTY) return "basic";
52
+
53
+ return "none";
54
+ }
55
+
56
+ /**
57
+ * Heuristic for unicode support.
58
+ *
59
+ * Checks LANG / LC_ALL / LC_CTYPE for UTF-8. Falls back to false on
60
+ * dumb terminals since many dumb terminal emulators lack glyph support.
61
+ */
62
+ function detectUnicode(env: NodeJS.ProcessEnv, isDumb: boolean): boolean {
63
+ if (isDumb) return false;
64
+
65
+ const locale = (env.LC_ALL ?? env.LC_CTYPE ?? env.LANG ?? "").toLowerCase();
66
+
67
+ return locale.includes("utf-8") || locale.includes("utf8");
68
+ }
69
+
70
+ /**
71
+ * Detect terminal capabilities from the current process environment.
72
+ *
73
+ * The result is a plain object (no singletons) so tests can call this
74
+ * with a mocked env if needed.
75
+ */
76
+ export function detectCapabilities(
77
+ env: NodeJS.ProcessEnv = process.env,
78
+ ): TerminalCapabilities {
79
+ const term = (env.TERM ?? "").toLowerCase();
80
+ const isDumb = term === "dumb";
81
+ const colorLevel = detectColorLevel(env);
82
+ const unicodeSupported = detectUnicode(env, isDumb);
83
+
84
+ return {
85
+ colorLevel,
86
+ unicodeSupported,
87
+ columns: process.stdout.columns || 80,
88
+ rows: process.stdout.rows || 24,
89
+ isDumb,
90
+ };
91
+ }
92
+
93
+ /** Lazily-cached capabilities for the current process. */
94
+ let _cached: TerminalCapabilities | undefined;
95
+
96
+ /**
97
+ * Return (and cache) the terminal capabilities for the running process.
98
+ *
99
+ * Safe to call multiple times — subsequent calls return the cached
100
+ * result. Use `detectCapabilities()` directly if you need a fresh read
101
+ * (e.g. after a terminal resize).
102
+ */
103
+ export function getTerminalCapabilities(): TerminalCapabilities {
104
+ if (!_cached) {
105
+ _cached = detectCapabilities();
106
+ }
107
+ return _cached;
108
+ }
109
+
110
+ /**
111
+ * Clear the cached capabilities so the next `getTerminalCapabilities()`
112
+ * call re-detects from the current environment. Useful after modifying
113
+ * `process.env.NO_COLOR` at startup or in tests.
114
+ */
115
+ export function resetCapabilitiesCache(): void {
116
+ _cached = undefined;
117
+ }
118
+
119
+ // ── Convenience helpers ──────────────────────────────────────
120
+
121
+ /** True when colors should be used (any level above "none"). */
122
+ export function supportsColor(): boolean {
123
+ return getTerminalCapabilities().colorLevel !== "none";
124
+ }
125
+
126
+ /**
127
+ * Return `fancy` when unicode is supported, otherwise `fallback`.
128
+ *
129
+ * Example: `unicodeOrFallback("🟢", "[ok]")`
130
+ */
131
+ export function unicodeOrFallback(fancy: string, fallback: string): string {
132
+ return getTerminalCapabilities().unicodeSupported ? fancy : fallback;
133
+ }
@@ -49,14 +49,14 @@ export function resetLogFile(name: string): void {
49
49
  /**
50
50
  * Copy the current log file into `destDir` with a timestamped name so that
51
51
  * previous session logs are preserved for debugging. No-op when the source
52
- * file is missing or empty.
52
+ * file is missing or empty, or when `destDir` does not already exist.
53
53
  */
54
54
  export function archiveLogFile(name: string, destDir: string): void {
55
55
  try {
56
+ if (!existsSync(destDir)) return;
56
57
  const srcPath = join(getLogDir(), name);
57
58
  if (!existsSync(srcPath) || statSync(srcPath).size === 0) return;
58
59
 
59
- mkdirSync(destDir, { recursive: true });
60
60
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
61
61
  const base = name.replace(/\.log$/, "");
62
62
  copyFileSync(srcPath, join(destDir, `${base}-${ts}.log`));