@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.
- package/README.md +7 -9
- package/package.json +1 -1
- package/src/adapters/install.sh +6 -6
- package/src/commands/clean.ts +11 -6
- package/src/commands/client.ts +5 -13
- package/src/commands/hatch.ts +8 -20
- package/src/commands/ps.ts +24 -4
- package/src/commands/recover.ts +1 -1
- package/src/commands/retire.ts +2 -5
- package/src/commands/setup.ts +172 -0
- package/src/commands/sleep.ts +1 -1
- package/src/commands/wake.ts +2 -2
- package/src/components/DefaultMainScreen.tsx +270 -22
- package/src/components/TextInput.tsx +159 -12
- package/src/index.ts +101 -22
- package/src/lib/assistant-config.ts +1 -6
- package/src/lib/constants.ts +7 -1
- package/src/lib/docker.ts +103 -9
- package/src/lib/doctor-client.ts +1 -1
- package/src/lib/gcp.ts +1 -1
- package/src/lib/health-check.ts +87 -0
- package/src/lib/http-client.ts +1 -2
- package/src/lib/input-history.ts +44 -0
- package/src/lib/local.ts +160 -161
- package/src/lib/orphan-detection.ts +13 -3
- package/src/lib/process.ts +3 -1
- package/src/lib/terminal-capabilities.ts +133 -0
- package/src/lib/xdg-log.ts +2 -2
package/src/lib/http-client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
480
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
925
|
-
|
|
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()
|
|
1052
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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|
|
|
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);
|
package/src/lib/process.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/lib/xdg-log.ts
CHANGED
|
@@ -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`));
|