@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.
Files changed (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -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("Create a tunnel for a locally hosted assistant.");
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 latest)",
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
+ }
@@ -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(res, `${instanceName}-upgrade-failure`);
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 ?? `exited with code ${installResult.status}`;
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);
@@ -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 { isProcessAlive, stopProcessByPidFile } from "../lib/process";
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
- if (existsSync(pidFile)) {
91
- const pidStr = readFileSync(pidFile, "utf-8").trim();
92
- const pid = parseInt(pidStr, 10);
93
- if (!isNaN(pid)) {
94
- try {
95
- process.kill(pid, 0);
96
- daemonRunning = true;
97
- if (watch) {
98
- // Restart in watch mode but only if source files are available.
99
- // Watch mode requires bun --watch with .ts sources; packaged desktop
100
- // builds only have a compiled binary. Stopping the daemon without a
101
- // viable watch-mode path would leave the user with no running assistant.
102
- if (!isAssistantWatchModeAvailable()) {
103
- console.log(
104
- `Assistant running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
105
- );
106
- } else {
107
- console.log(
108
- `Assistant running (pid ${pid}) — restarting in watch mode...`,
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 { alive, pid } = isProcessAlive(gatewayPidFile);
165
- if (alive) {
166
- if (watch) {
167
- // Guard gateway restart separately: check gateway source availability.
168
- if (!isGatewayWatchModeAvailable()) {
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(resources.gatewayPort, workspaceDir);
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 response = await fetch(url, {
188
- ...init,
189
- headers: {
190
- "Content-Type": "application/json",
191
- ...auth,
192
- ...(init?.headers as Record<string, string> | undefined),
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
- return runtimeRequest<ListMessagesResponse>(
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 res = await fetch(
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");