@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356
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/AGENTS.md +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +569 -39
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +57 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +23 -70
- package/src/commands/rollback.ts +2 -14
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +75 -45
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +22 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +28 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +82 -8
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +193 -298
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
package/src/commands/tunnel.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
|
|
1
3
|
import { resolveAssistant } from "../lib/assistant-config";
|
|
4
|
+
import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
|
|
2
5
|
import { runNgrokTunnel } from "../lib/ngrok";
|
|
3
6
|
|
|
4
7
|
const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
|
|
@@ -21,17 +24,45 @@ function parseArgs(): TunnelArgs {
|
|
|
21
24
|
if (arg === "--help" || arg === "-h") {
|
|
22
25
|
console.log("Usage: vellum tunnel [<name>] [options]");
|
|
23
26
|
console.log("");
|
|
24
|
-
console.log(
|
|
27
|
+
console.log(
|
|
28
|
+
"Expose a locally running assistant to the internet via a tunnel.",
|
|
29
|
+
);
|
|
30
|
+
console.log(
|
|
31
|
+
"The public URL is saved to the workspace config as the ingress base URL,",
|
|
32
|
+
);
|
|
33
|
+
console.log(
|
|
34
|
+
"enabling webhook integrations (Telegram, Twilio, etc.) to reach the assistant.",
|
|
35
|
+
);
|
|
25
36
|
console.log("");
|
|
26
37
|
console.log("Arguments:");
|
|
27
38
|
console.log(
|
|
28
|
-
" <name> Name of the assistant (defaults to
|
|
39
|
+
" <name> Name of the assistant (defaults to active or only local)",
|
|
29
40
|
);
|
|
30
41
|
console.log("");
|
|
31
42
|
console.log("Options:");
|
|
32
43
|
console.log(
|
|
33
44
|
` --provider <provider> Tunnel provider: ${VALID_PROVIDERS.join(", ")} (default: ${DEFAULT_PROVIDER})`,
|
|
34
45
|
);
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log("Providers:");
|
|
48
|
+
console.log(
|
|
49
|
+
" vellum Managed tunnel via Vellum Cloud (default; requires account)",
|
|
50
|
+
);
|
|
51
|
+
console.log(
|
|
52
|
+
" ngrok ngrok tunnel — install: brew install ngrok/ngrok/ngrok",
|
|
53
|
+
);
|
|
54
|
+
console.log(
|
|
55
|
+
" cloudflare Cloudflare quick tunnel — install: brew install cloudflare/cloudflare/cloudflared",
|
|
56
|
+
);
|
|
57
|
+
console.log(
|
|
58
|
+
" No Cloudflare account required for quick tunnels.",
|
|
59
|
+
);
|
|
60
|
+
console.log("");
|
|
61
|
+
console.log("Examples:");
|
|
62
|
+
console.log(" $ vellum tunnel");
|
|
63
|
+
console.log(" $ vellum tunnel --provider ngrok");
|
|
64
|
+
console.log(" $ vellum tunnel --provider cloudflare");
|
|
65
|
+
console.log(" $ vellum tunnel my-assistant --provider cloudflare");
|
|
35
66
|
process.exit(0);
|
|
36
67
|
} else if (arg === "--provider") {
|
|
37
68
|
const next = args[i + 1];
|
|
@@ -78,5 +109,18 @@ export async function tunnel(): Promise<void> {
|
|
|
78
109
|
return;
|
|
79
110
|
}
|
|
80
111
|
|
|
112
|
+
if (provider === "cloudflare") {
|
|
113
|
+
const resources = entry.resources;
|
|
114
|
+
await runCloudflareTunnel(
|
|
115
|
+
resources
|
|
116
|
+
? {
|
|
117
|
+
port: resources.gatewayPort,
|
|
118
|
+
workspaceDir: join(resources.instanceDir, ".vellum", "workspace"),
|
|
119
|
+
}
|
|
120
|
+
: {},
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
81
125
|
throw new Error(`Tunnel provider '${provider}' is not yet implemented.`);
|
|
82
126
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum unpair <name> [--yes]`
|
|
3
|
+
*
|
|
4
|
+
* Forget a pairing imported from another machine via `vellum connect import`:
|
|
5
|
+
* remove its lockfile entry and stored guardian token from THIS machine. Only
|
|
6
|
+
* paired assistants (`cloud: "paired"`) can be unpaired — `vellum retire` owns
|
|
7
|
+
* local and managed assistants.
|
|
8
|
+
*
|
|
9
|
+
* This is client-side only: it forgets the connection here but does not revoke
|
|
10
|
+
* the device on the host. (Host-side revocation is `vellum devices`.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
formatAssistantLookupError,
|
|
15
|
+
getAssistantDisplayName,
|
|
16
|
+
lookupAssistantByIdentifier,
|
|
17
|
+
removeAssistantEntry,
|
|
18
|
+
} from "../lib/assistant-config";
|
|
19
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
20
|
+
import {
|
|
21
|
+
canPromptForConfirmation,
|
|
22
|
+
confirmAction,
|
|
23
|
+
} from "../lib/confirm-action.js";
|
|
24
|
+
import { deleteGuardianToken } from "../lib/guardian-token";
|
|
25
|
+
|
|
26
|
+
function printUsage(): void {
|
|
27
|
+
console.log(`vellum unpair - Forget a paired assistant imported from another machine
|
|
28
|
+
|
|
29
|
+
USAGE:
|
|
30
|
+
vellum unpair <name> [--yes]
|
|
31
|
+
|
|
32
|
+
ARGUMENTS:
|
|
33
|
+
<name> Name or id of the paired assistant to forget
|
|
34
|
+
|
|
35
|
+
OPTIONS:
|
|
36
|
+
--yes Skip the interactive confirmation prompt (for automation)
|
|
37
|
+
|
|
38
|
+
Removes the local connection (lockfile entry + stored token). Only paired
|
|
39
|
+
assistants (imported via 'vellum connect import') can be unpaired; use
|
|
40
|
+
'vellum retire' for local or managed assistants.
|
|
41
|
+
|
|
42
|
+
EXAMPLES:
|
|
43
|
+
vellum unpair paired-desk
|
|
44
|
+
vellum unpair "Desk Box"
|
|
45
|
+
vellum unpair paired-desk --yes
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function unpair(): Promise<void> {
|
|
50
|
+
const args = process.argv.slice(3);
|
|
51
|
+
|
|
52
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
53
|
+
printUsage();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const yes = args.includes("--yes");
|
|
58
|
+
const name = parseAssistantTargetArg(args, []);
|
|
59
|
+
if (!name) {
|
|
60
|
+
console.error("Error: assistant name or id is required.");
|
|
61
|
+
printUsage();
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lookup = lookupAssistantByIdentifier(name);
|
|
66
|
+
if (lookup.status !== "found") {
|
|
67
|
+
console.error(formatAssistantLookupError(name, lookup));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const entry = lookup.entry;
|
|
71
|
+
|
|
72
|
+
if (entry.cloud !== "paired") {
|
|
73
|
+
console.error(
|
|
74
|
+
`Error: '${name}' is not a paired assistant. Use \`vellum retire\` to remove a local or managed assistant.`,
|
|
75
|
+
);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Print the resolved identity before acting (cli/AGENTS.md).
|
|
80
|
+
const displayName = getAssistantDisplayName(entry);
|
|
81
|
+
console.log("Pairing to unpair:");
|
|
82
|
+
if (displayName !== entry.assistantId) {
|
|
83
|
+
console.log(` Name: ${displayName}`);
|
|
84
|
+
}
|
|
85
|
+
console.log(` ID: ${entry.assistantId}`);
|
|
86
|
+
if (entry.runtimeUrl) {
|
|
87
|
+
console.log(` Host: ${entry.runtimeUrl}`);
|
|
88
|
+
}
|
|
89
|
+
console.log("");
|
|
90
|
+
|
|
91
|
+
if (!yes) {
|
|
92
|
+
if (!canPromptForConfirmation()) {
|
|
93
|
+
console.error(
|
|
94
|
+
"Error: Refusing to unpair without confirmation in a non-interactive terminal.",
|
|
95
|
+
);
|
|
96
|
+
console.error("Re-run with --yes to confirm from automation.");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const confirmed = await confirmAction(
|
|
100
|
+
"Press Enter to unpair, or Esc/q to cancel: ",
|
|
101
|
+
);
|
|
102
|
+
if (!confirmed) {
|
|
103
|
+
console.log("Unpair cancelled.");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
removeAssistantEntry(entry.assistantId);
|
|
109
|
+
deleteGuardianToken(entry.assistantId);
|
|
110
|
+
|
|
111
|
+
console.log(
|
|
112
|
+
`Unpaired '${name}' — removed the local connection (lockfile entry + token).`,
|
|
113
|
+
);
|
|
114
|
+
console.log("");
|
|
115
|
+
console.log(
|
|
116
|
+
"Note: this only forgets the connection on this machine. The assistant's host can fully revoke this device from its side.",
|
|
117
|
+
);
|
|
118
|
+
}
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
findAssistantByName,
|
|
8
8
|
getActiveAssistant,
|
|
9
9
|
loadAllAssistants,
|
|
10
|
+
resolveCloud,
|
|
10
11
|
saveAssistantEntry,
|
|
12
|
+
type AssistantEntry,
|
|
11
13
|
} from "../lib/assistant-config";
|
|
12
|
-
import type { AssistantEntry } from "../lib/assistant-config";
|
|
13
14
|
import {
|
|
14
15
|
captureImageRefs,
|
|
15
16
|
GATEWAY_INTERNAL_PORT,
|
|
@@ -145,19 +146,6 @@ function parseArgs(): UpgradeArgs {
|
|
|
145
146
|
return { name, version, latest, prepare, finalize };
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
function resolveCloud(entry: AssistantEntry): string {
|
|
149
|
-
if (entry.cloud) {
|
|
150
|
-
return entry.cloud;
|
|
151
|
-
}
|
|
152
|
-
if (entry.project) {
|
|
153
|
-
return "gcp";
|
|
154
|
-
}
|
|
155
|
-
if (entry.sshUser) {
|
|
156
|
-
return "custom";
|
|
157
|
-
}
|
|
158
|
-
return "local";
|
|
159
|
-
}
|
|
160
|
-
|
|
161
149
|
/**
|
|
162
150
|
* Resolve which assistant to target for the upgrade command. Priority:
|
|
163
151
|
* 1. Explicit name argument
|
|
@@ -512,7 +500,10 @@ async function upgradeDocker(
|
|
|
512
500
|
} else {
|
|
513
501
|
console.error(`\n❌ Containers failed to become ready within the timeout.`);
|
|
514
502
|
|
|
515
|
-
const logDir = await captureUpgradeFailureLogs(
|
|
503
|
+
const logDir = await captureUpgradeFailureLogs(
|
|
504
|
+
res,
|
|
505
|
+
`${instanceName}-upgrade-failure`,
|
|
506
|
+
);
|
|
516
507
|
if (logDir) {
|
|
517
508
|
console.log(`📋 Container logs saved to: ${logDir}`);
|
|
518
509
|
}
|
|
@@ -938,7 +929,8 @@ async function resolveLatestAndMaybeSelfUpdate(
|
|
|
938
929
|
);
|
|
939
930
|
if (installResult.error || installResult.status !== 0) {
|
|
940
931
|
const detail =
|
|
941
|
-
installResult.error?.message ??
|
|
932
|
+
installResult.error?.message ??
|
|
933
|
+
`exited with code ${installResult.status}`;
|
|
942
934
|
console.error(`\n❌ CLI self-update failed: ${detail}`);
|
|
943
935
|
emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
|
|
944
936
|
process.exit(1);
|
package/src/commands/wake.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../lib/assistant-config.js";
|
|
9
9
|
import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
|
|
10
10
|
import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
|
|
11
|
-
import {
|
|
11
|
+
import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
|
|
12
12
|
import {
|
|
13
13
|
generateLocalSigningKey,
|
|
14
14
|
isAssistantWatchModeAvailable,
|
|
@@ -68,6 +68,13 @@ export async function wake(): Promise<void> {
|
|
|
68
68
|
process.exit(1);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
if (entry.cloud === "paired") {
|
|
72
|
+
console.error(
|
|
73
|
+
`Error: '${entry.assistantId}' is a remote assistant paired from another machine — its lifecycle is managed on its host machine, not here. Use \`vellum client ${entry.assistantId}\` to chat with it.`,
|
|
74
|
+
);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
if (entry.cloud && entry.cloud !== "local") {
|
|
72
79
|
console.error(
|
|
73
80
|
`Error: 'vellum wake' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
|
|
@@ -85,36 +92,26 @@ export async function wake(): Promise<void> {
|
|
|
85
92
|
|
|
86
93
|
const pidFile = getDaemonPidPath(resources);
|
|
87
94
|
|
|
88
|
-
// Check if daemon is already running
|
|
89
95
|
let daemonRunning = false;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
await stopProcessByPidFile(pidFile, "assistant");
|
|
111
|
-
daemonRunning = false;
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
console.log(`Assistant already running (pid ${pid}).`);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Process not alive, will start below
|
|
96
|
+
const daemonState = await resolveProcessState(
|
|
97
|
+
pidFile,
|
|
98
|
+
resources.daemonPort,
|
|
99
|
+
"Assistant",
|
|
100
|
+
);
|
|
101
|
+
if (daemonState.status === "healthy") {
|
|
102
|
+
if (watch && isAssistantWatchModeAvailable()) {
|
|
103
|
+
console.log(
|
|
104
|
+
`Assistant running (pid ${daemonState.pid}) — restarting in watch mode...`,
|
|
105
|
+
);
|
|
106
|
+
await stopProcessByPidFile(pidFile, "assistant");
|
|
107
|
+
} else {
|
|
108
|
+
daemonRunning = true;
|
|
109
|
+
if (watch) {
|
|
110
|
+
console.log(
|
|
111
|
+
`Assistant running (pid ${daemonState.pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`Assistant already running (pid ${daemonState.pid}).`);
|
|
118
115
|
}
|
|
119
116
|
}
|
|
120
117
|
}
|
|
@@ -153,6 +150,15 @@ export async function wake(): Promise<void> {
|
|
|
153
150
|
saveAssistantEntry(entry);
|
|
154
151
|
}
|
|
155
152
|
|
|
153
|
+
let bootstrapSecret = entry.guardianBootstrapSecret;
|
|
154
|
+
let bootstrapSecretBackfilled = false;
|
|
155
|
+
if (!bootstrapSecret) {
|
|
156
|
+
bootstrapSecret = generateLocalSigningKey();
|
|
157
|
+
entry.guardianBootstrapSecret = bootstrapSecret;
|
|
158
|
+
saveAssistantEntry(entry);
|
|
159
|
+
bootstrapSecretBackfilled = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
156
162
|
if (!daemonRunning) {
|
|
157
163
|
await startLocalDaemon(watch, resources, { foreground, signingKey });
|
|
158
164
|
}
|
|
@@ -161,26 +167,47 @@ export async function wake(): Promise<void> {
|
|
|
161
167
|
{
|
|
162
168
|
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
163
169
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
const gatewayState = await resolveProcessState(
|
|
171
|
+
gatewayPidFile,
|
|
172
|
+
resources.gatewayPort,
|
|
173
|
+
"Gateway",
|
|
174
|
+
);
|
|
175
|
+
const gatewayAlive = gatewayState.status === "healthy";
|
|
176
|
+
const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
|
|
177
|
+
if (needsRestart) {
|
|
178
|
+
const restartWithWatch = watch && isGatewayWatchModeAvailable();
|
|
179
|
+
if (restartWithWatch) {
|
|
180
|
+
console.log(
|
|
181
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting to apply bootstrap secret...`,
|
|
182
|
+
);
|
|
183
|
+
} else {
|
|
184
|
+
console.log(
|
|
185
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
189
|
+
await startGateway(restartWithWatch, resources, {
|
|
190
|
+
signingKey,
|
|
191
|
+
bootstrapSecret,
|
|
192
|
+
});
|
|
193
|
+
} else if (gatewayAlive) {
|
|
194
|
+
if (watch && isGatewayWatchModeAvailable()) {
|
|
195
|
+
console.log(
|
|
196
|
+
`Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
|
|
197
|
+
);
|
|
198
|
+
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
199
|
+
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
200
|
+
} else {
|
|
201
|
+
if (watch) {
|
|
169
202
|
console.log(
|
|
170
|
-
`Gateway running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
203
|
+
`Gateway running (pid ${gatewayState.pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
171
204
|
);
|
|
172
205
|
} else {
|
|
173
|
-
console.log(
|
|
174
|
-
`Gateway running (pid ${pid}) — restarting in watch mode...`,
|
|
175
|
-
);
|
|
176
|
-
await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
177
|
-
await startGateway(watch, resources, { signingKey });
|
|
206
|
+
console.log(`Gateway already running (pid ${gatewayState.pid}).`);
|
|
178
207
|
}
|
|
179
|
-
} else {
|
|
180
|
-
console.log(`Gateway already running (pid ${pid}).`);
|
|
181
208
|
}
|
|
182
209
|
} else {
|
|
183
|
-
await startGateway(watch, resources, { signingKey });
|
|
210
|
+
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
184
211
|
}
|
|
185
212
|
}
|
|
186
213
|
|
|
@@ -196,7 +223,10 @@ export async function wake(): Promise<void> {
|
|
|
196
223
|
|
|
197
224
|
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
198
225
|
const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
|
|
199
|
-
const ngrokChild = await maybeStartNgrokTunnel(
|
|
226
|
+
const ngrokChild = await maybeStartNgrokTunnel(
|
|
227
|
+
resources.gatewayPort,
|
|
228
|
+
workspaceDir,
|
|
229
|
+
);
|
|
200
230
|
if (ngrokChild?.pid) {
|
|
201
231
|
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
202
232
|
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
|
|
11
11
|
|
|
12
12
|
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
13
|
+
import { lookupAssistantByIdentifier } from "../lib/assistant-config";
|
|
13
14
|
import { checkHealth } from "../lib/health-check";
|
|
15
|
+
import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
|
|
14
16
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
15
17
|
import { tuiLog } from "../lib/tui-log";
|
|
18
|
+
import { segmentsToPlainText } from "../lib/segments-to-plain-text";
|
|
16
19
|
import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
|
|
17
20
|
import {
|
|
18
21
|
getTerminalCapabilities,
|
|
@@ -62,6 +65,9 @@ const HELP_COMMANDS = [
|
|
|
62
65
|
] as const;
|
|
63
66
|
|
|
64
67
|
const SEND_TIMEOUT_MS = 5000;
|
|
68
|
+
/** Fresh deadline for a request retried after a mid-session token refresh —
|
|
69
|
+
* the original caller signal may have already timed out during the refresh. */
|
|
70
|
+
const RETRY_TIMEOUT_MS = 30_000;
|
|
65
71
|
|
|
66
72
|
// ── Layout constants ──────────────────────────────────────
|
|
67
73
|
const MAX_TOTAL_WIDTH = 72;
|
|
@@ -176,6 +182,44 @@ function friendlyErrorMessage(status: number, body: string): string {
|
|
|
176
182
|
return `HTTP ${status}: ${body || "Unknown error"}`;
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
/**
|
|
186
|
+
* On a 401, refresh a stale PAIRED-assistant guardian token and update the
|
|
187
|
+
* shared `auth` headers IN PLACE, returning true if the caller should retry.
|
|
188
|
+
*
|
|
189
|
+
* Scoped to paired assistants only (a remote assistant on another machine,
|
|
190
|
+
* `cloud: "paired"`) — the local/docker TUI flow and platform sessions are left
|
|
191
|
+
* untouched. Also self-gating: skips platform session auth (no `Authorization`
|
|
192
|
+
* header), ephemeral `--token` overrides (whose bearer won't match the store),
|
|
193
|
+
* and access-only tokens. Because the TUI threads one shared `auth` object by
|
|
194
|
+
* reference, mutating it here propagates to every later request and the SSE
|
|
195
|
+
* reconnect — no callback threading needed.
|
|
196
|
+
*/
|
|
197
|
+
export async function maybeRefreshAuthHeaders(
|
|
198
|
+
baseUrl: string,
|
|
199
|
+
assistantId: string,
|
|
200
|
+
auth?: Record<string, string>,
|
|
201
|
+
): Promise<boolean> {
|
|
202
|
+
if (!auth) return false;
|
|
203
|
+
const bearer = auth["Authorization"]?.replace(/^Bearer /, "");
|
|
204
|
+
if (!bearer) return false;
|
|
205
|
+
|
|
206
|
+
// Only paired (remote-on-another-machine) assistants use refreshable pair
|
|
207
|
+
// tokens; don't perturb the local/docker session flow.
|
|
208
|
+
const lookup = lookupAssistantByIdentifier(assistantId);
|
|
209
|
+
if (lookup.status !== "found" || lookup.entry.cloud !== "paired") {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stored = loadGuardianToken(assistantId);
|
|
214
|
+
if (!stored || stored.accessToken !== bearer || !stored.refreshToken) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const refreshed = await refreshGuardianToken(baseUrl, assistantId);
|
|
218
|
+
if (!refreshed?.accessToken) return false;
|
|
219
|
+
auth["Authorization"] = `Bearer ${refreshed.accessToken}`;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
179
223
|
async function runtimeRequest<T>(
|
|
180
224
|
baseUrl: string,
|
|
181
225
|
assistantId: string,
|
|
@@ -184,14 +228,30 @@ async function runtimeRequest<T>(
|
|
|
184
228
|
auth?: Record<string, string>,
|
|
185
229
|
): Promise<T> {
|
|
186
230
|
const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
231
|
+
const doFetch = (signalOverride?: AbortSignal) =>
|
|
232
|
+
fetch(url, {
|
|
233
|
+
...init,
|
|
234
|
+
// The retry overrides the caller's signal (see below); otherwise use it.
|
|
235
|
+
signal: signalOverride ?? init?.signal,
|
|
236
|
+
headers: {
|
|
237
|
+
"Content-Type": "application/json",
|
|
238
|
+
...auth,
|
|
239
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
let response = await doFetch();
|
|
244
|
+
// Mid-session token expiry → 401: refresh once and retry (auth headers are
|
|
245
|
+
// mutated in place by the helper, so the retry carries the new token). The
|
|
246
|
+
// refresh can take longer than the caller's timeout (lock wait + refresh
|
|
247
|
+
// fetch), which would already have aborted the original signal — so give the
|
|
248
|
+
// retry a fresh deadline instead of reusing the (likely-expired) one.
|
|
249
|
+
if (
|
|
250
|
+
response.status === 401 &&
|
|
251
|
+
(await maybeRefreshAuthHeaders(baseUrl, assistantId, auth))
|
|
252
|
+
) {
|
|
253
|
+
response = await doFetch(AbortSignal.timeout(RETRY_TIMEOUT_MS));
|
|
254
|
+
}
|
|
195
255
|
|
|
196
256
|
if (!response.ok) {
|
|
197
257
|
const body = await response.text().catch(() => "");
|
|
@@ -230,13 +290,20 @@ async function pollMessages(
|
|
|
230
290
|
auth?: Record<string, string>,
|
|
231
291
|
): Promise<ListMessagesResponse> {
|
|
232
292
|
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
233
|
-
|
|
293
|
+
const response = await runtimeRequest<ListMessagesResponse>(
|
|
234
294
|
baseUrl,
|
|
235
295
|
assistantId,
|
|
236
296
|
`/messages?${params.toString()}`,
|
|
237
297
|
undefined,
|
|
238
298
|
auth,
|
|
239
299
|
);
|
|
300
|
+
return {
|
|
301
|
+
...response,
|
|
302
|
+
messages: response.messages.map((msg) => ({
|
|
303
|
+
...msg,
|
|
304
|
+
content: segmentsToPlainText(msg.textSegments),
|
|
305
|
+
})),
|
|
306
|
+
};
|
|
240
307
|
}
|
|
241
308
|
|
|
242
309
|
async function sendMessage(
|
|
@@ -366,6 +433,11 @@ async function* streamEvents(
|
|
|
366
433
|
const params = new URLSearchParams({ conversationKey });
|
|
367
434
|
const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
|
|
368
435
|
tuiLog.info("sse connect", { url, authHeaders: Object.keys(auth ?? {}) });
|
|
436
|
+
// NOTE: the SSE connect deliberately does NOT refresh-on-401 — keeping the
|
|
437
|
+
// stream path simple. After a mid-session token expiry, the REST path
|
|
438
|
+
// (runtimeRequest) refreshes the shared `auth` on the next request, and the
|
|
439
|
+
// existing reconnect (ensureConnected on the next message) re-opens the
|
|
440
|
+
// stream with the refreshed token.
|
|
369
441
|
const response = await fetch(url, {
|
|
370
442
|
headers: {
|
|
371
443
|
Accept: "text/event-stream",
|
|
@@ -626,6 +698,13 @@ export interface RuntimeMessage {
|
|
|
626
698
|
id: string;
|
|
627
699
|
role: "user" | "assistant";
|
|
628
700
|
content: string;
|
|
701
|
+
/**
|
|
702
|
+
* Ordered text segments from the daemon's history payload, split at
|
|
703
|
+
* tool_use/surface boundaries. The flat `content` body is derived from
|
|
704
|
+
* these (see `segmentsToPlainText`); the daemon no longer sends a
|
|
705
|
+
* redundant flattened `content` field on the wire.
|
|
706
|
+
*/
|
|
707
|
+
textSegments?: string[];
|
|
629
708
|
timestamp: string;
|
|
630
709
|
toolCalls?: ToolCallInfo[];
|
|
631
710
|
label?: string;
|
|
@@ -1969,9 +2048,8 @@ function ChatApp({
|
|
|
1969
2048
|
if (!isConnected) return;
|
|
1970
2049
|
|
|
1971
2050
|
try {
|
|
1972
|
-
const
|
|
1973
|
-
`${runtimeUrl}/v1/assistants/${assistantId}/btw`,
|
|
1974
|
-
{
|
|
2051
|
+
const btwFetch = () =>
|
|
2052
|
+
fetch(`${runtimeUrl}/v1/assistants/${assistantId}/btw`, {
|
|
1975
2053
|
method: "POST",
|
|
1976
2054
|
headers: {
|
|
1977
2055
|
"Content-Type": "application/json",
|
|
@@ -1982,8 +2060,16 @@ function ChatApp({
|
|
|
1982
2060
|
content: question,
|
|
1983
2061
|
}),
|
|
1984
2062
|
signal: AbortSignal.timeout(30_000),
|
|
1985
|
-
}
|
|
1986
|
-
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
let res = await btwFetch();
|
|
2066
|
+
// Mid-session token expiry → 401: refresh once and retry.
|
|
2067
|
+
if (
|
|
2068
|
+
res.status === 401 &&
|
|
2069
|
+
(await maybeRefreshAuthHeaders(runtimeUrl, assistantId, auth))
|
|
2070
|
+
) {
|
|
2071
|
+
res = await btwFetch();
|
|
2072
|
+
}
|
|
1987
2073
|
|
|
1988
2074
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1989
2075
|
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,18 @@ import cliPkg from "../package.json";
|
|
|
4
4
|
import { backup } from "./commands/backup";
|
|
5
5
|
import { clean } from "./commands/clean";
|
|
6
6
|
import { client } from "./commands/client";
|
|
7
|
+
import { connect } from "./commands/connect";
|
|
8
|
+
import { devices } from "./commands/devices";
|
|
7
9
|
import { env } from "./commands/env";
|
|
8
10
|
import { events } from "./commands/events";
|
|
9
11
|
import { exec } from "./commands/exec";
|
|
12
|
+
import { flags } from "./commands/flags";
|
|
13
|
+
import { gateway } from "./commands/gateway";
|
|
10
14
|
import { hatch } from "./commands/hatch";
|
|
11
15
|
import { login, logout, whoami } from "./commands/login";
|
|
12
16
|
import { logs } from "./commands/logs";
|
|
13
17
|
import { message } from "./commands/message";
|
|
18
|
+
import { pair } from "./commands/pair";
|
|
14
19
|
import { ps } from "./commands/ps";
|
|
15
20
|
import { recover } from "./commands/recover";
|
|
16
21
|
import { restore } from "./commands/restore";
|
|
@@ -23,6 +28,7 @@ import { ssh } from "./commands/ssh";
|
|
|
23
28
|
import { teleport } from "./commands/teleport";
|
|
24
29
|
import { terminal } from "./commands/terminal";
|
|
25
30
|
import { tunnel } from "./commands/tunnel";
|
|
31
|
+
import { unpair } from "./commands/unpair";
|
|
26
32
|
import { upgrade } from "./commands/upgrade";
|
|
27
33
|
import { use } from "./commands/use";
|
|
28
34
|
import { wake } from "./commands/wake";
|
|
@@ -34,14 +40,19 @@ const commands = {
|
|
|
34
40
|
backup,
|
|
35
41
|
clean,
|
|
36
42
|
client,
|
|
43
|
+
connect,
|
|
44
|
+
devices,
|
|
37
45
|
env,
|
|
38
46
|
events,
|
|
39
47
|
exec,
|
|
48
|
+
flags,
|
|
49
|
+
gateway,
|
|
40
50
|
hatch,
|
|
41
51
|
login,
|
|
42
52
|
logout,
|
|
43
53
|
logs,
|
|
44
54
|
message,
|
|
55
|
+
pair,
|
|
45
56
|
ps,
|
|
46
57
|
recover,
|
|
47
58
|
restore,
|
|
@@ -54,6 +65,7 @@ const commands = {
|
|
|
54
65
|
teleport,
|
|
55
66
|
terminal,
|
|
56
67
|
tunnel,
|
|
68
|
+
unpair,
|
|
57
69
|
upgrade,
|
|
58
70
|
use,
|
|
59
71
|
wake,
|
|
@@ -69,14 +81,21 @@ function printHelp(): void {
|
|
|
69
81
|
console.log(" backup Export a backup of a running assistant");
|
|
70
82
|
console.log(" clean Kill orphaned vellum processes");
|
|
71
83
|
console.log(" client Connect to a hatched assistant");
|
|
84
|
+
console.log(" connect Import an assistant paired from another machine");
|
|
85
|
+
console.log(" devices List or revoke devices paired to a local assistant");
|
|
72
86
|
console.log(" env Manage the default CLI environment");
|
|
73
87
|
console.log(" events Stream events from a running assistant");
|
|
74
88
|
console.log(" exec Execute a command inside an assistant's container");
|
|
89
|
+
console.log(" flags Show and toggle feature flags");
|
|
90
|
+
console.log(" gateway Gateway management commands");
|
|
75
91
|
console.log(" hatch Create a new assistant instance");
|
|
76
92
|
console.log(" logs View logs from an assistant instance");
|
|
77
93
|
console.log(" login Log in to the Vellum platform");
|
|
78
94
|
console.log(" logout Log out of the Vellum platform");
|
|
79
95
|
console.log(" message Send a message to a running assistant");
|
|
96
|
+
console.log(
|
|
97
|
+
" pair Mint a device-scoped token to connect another machine",
|
|
98
|
+
);
|
|
80
99
|
console.log(
|
|
81
100
|
" ps List assistants (or processes for a specific assistant)",
|
|
82
101
|
);
|
|
@@ -93,6 +112,9 @@ function printHelp(): void {
|
|
|
93
112
|
console.log(" teleport Transfer assistant data between environments");
|
|
94
113
|
console.log(" terminal Open a terminal into a managed assistant container");
|
|
95
114
|
console.log(" tunnel Create a tunnel for a locally hosted assistant");
|
|
115
|
+
console.log(
|
|
116
|
+
" unpair Forget a paired assistant imported from another machine",
|
|
117
|
+
);
|
|
96
118
|
console.log(" upgrade Upgrade an assistant to a newer version");
|
|
97
119
|
console.log(" use Set the active assistant for commands");
|
|
98
120
|
console.log(" wake Start the assistant and gateway");
|