@vellumai/cli 0.8.6 → 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 (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -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,6 +4,8 @@ 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";
@@ -13,6 +15,7 @@ import { hatch } from "./commands/hatch";
13
15
  import { login, logout, whoami } from "./commands/login";
14
16
  import { logs } from "./commands/logs";
15
17
  import { message } from "./commands/message";
18
+ import { pair } from "./commands/pair";
16
19
  import { ps } from "./commands/ps";
17
20
  import { recover } from "./commands/recover";
18
21
  import { restore } from "./commands/restore";
@@ -25,6 +28,7 @@ import { ssh } from "./commands/ssh";
25
28
  import { teleport } from "./commands/teleport";
26
29
  import { terminal } from "./commands/terminal";
27
30
  import { tunnel } from "./commands/tunnel";
31
+ import { unpair } from "./commands/unpair";
28
32
  import { upgrade } from "./commands/upgrade";
29
33
  import { use } from "./commands/use";
30
34
  import { wake } from "./commands/wake";
@@ -36,6 +40,8 @@ const commands = {
36
40
  backup,
37
41
  clean,
38
42
  client,
43
+ connect,
44
+ devices,
39
45
  env,
40
46
  events,
41
47
  exec,
@@ -46,6 +52,7 @@ const commands = {
46
52
  logout,
47
53
  logs,
48
54
  message,
55
+ pair,
49
56
  ps,
50
57
  recover,
51
58
  restore,
@@ -58,6 +65,7 @@ const commands = {
58
65
  teleport,
59
66
  terminal,
60
67
  tunnel,
68
+ unpair,
61
69
  upgrade,
62
70
  use,
63
71
  wake,
@@ -73,6 +81,8 @@ function printHelp(): void {
73
81
  console.log(" backup Export a backup of a running assistant");
74
82
  console.log(" clean Kill orphaned vellum processes");
75
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");
76
86
  console.log(" env Manage the default CLI environment");
77
87
  console.log(" events Stream events from a running assistant");
78
88
  console.log(" exec Execute a command inside an assistant's container");
@@ -83,6 +93,9 @@ function printHelp(): void {
83
93
  console.log(" login Log in to the Vellum platform");
84
94
  console.log(" logout Log out of the Vellum platform");
85
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
+ );
86
99
  console.log(
87
100
  " ps List assistants (or processes for a specific assistant)",
88
101
  );
@@ -99,6 +112,9 @@ function printHelp(): void {
99
112
  console.log(" teleport Transfer assistant data between environments");
100
113
  console.log(" terminal Open a terminal into a managed assistant container");
101
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
+ );
102
118
  console.log(" upgrade Upgrade an assistant to a newer version");
103
119
  console.log(" use Set the active assistant for commands");
104
120
  console.log(" wake Start the assistant and gateway");
@@ -0,0 +1,59 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+
3
+ import { consoleLifecycleReporter } from "../lifecycle-reporter.js";
4
+
5
+ describe("consoleLifecycleReporter", () => {
6
+ const originalDesktopApp = process.env.VELLUM_DESKTOP_APP;
7
+ let stdoutWriteSpy: ReturnType<typeof spyOn>;
8
+
9
+ beforeEach(() => {
10
+ stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation(
11
+ () => true,
12
+ );
13
+ });
14
+
15
+ afterEach(() => {
16
+ stdoutWriteSpy.mockRestore();
17
+ if (originalDesktopApp === undefined) {
18
+ delete process.env.VELLUM_DESKTOP_APP;
19
+ } else {
20
+ process.env.VELLUM_DESKTOP_APP = originalDesktopApp;
21
+ }
22
+ });
23
+
24
+ test("routes log/warn/error to the matching console methods", () => {
25
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
26
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
27
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
28
+
29
+ consoleLifecycleReporter.log("hello");
30
+ consoleLifecycleReporter.warn("careful");
31
+ consoleLifecycleReporter.error("boom");
32
+
33
+ expect(logSpy).toHaveBeenCalledWith("hello");
34
+ expect(warnSpy).toHaveBeenCalledWith("careful");
35
+ expect(errorSpy).toHaveBeenCalledWith("boom");
36
+
37
+ logSpy.mockRestore();
38
+ warnSpy.mockRestore();
39
+ errorSpy.mockRestore();
40
+ });
41
+
42
+ test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => {
43
+ process.env.VELLUM_DESKTOP_APP = "1";
44
+
45
+ consoleLifecycleReporter.progress(3, 6, "Starting assistant...");
46
+
47
+ expect(stdoutWriteSpy).toHaveBeenCalledWith(
48
+ `HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`,
49
+ );
50
+ });
51
+
52
+ test("suppresses progress output when not running under the desktop app", () => {
53
+ delete process.env.VELLUM_DESKTOP_APP;
54
+
55
+ consoleLifecycleReporter.progress(1, 6, "Allocating resources...");
56
+
57
+ expect(stdoutWriteSpy).not.toHaveBeenCalled();
58
+ });
59
+ });
@@ -12,11 +12,9 @@
12
12
  * ```
13
13
  */
14
14
 
15
- import {
16
- resolveAssistant,
17
- } from "./assistant-config.js";
15
+ import { resolveAssistant } from "./assistant-config.js";
18
16
  import { GATEWAY_PORT } from "./constants.js";
19
- import { loadGuardianToken } from "./guardian-token.js";
17
+ import { loadGuardianToken, refreshGuardianToken } from "./guardian-token.js";
20
18
 
21
19
  const DEFAULT_TIMEOUT_MS = 30_000;
22
20
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
@@ -45,7 +43,8 @@ export class AssistantClient {
45
43
  readonly runtimeUrl: string;
46
44
 
47
45
  private readonly _assistantId: string;
48
- private readonly token: string | undefined;
46
+ /** Mutable: a 401 on the guardian path refreshes this in place (see request). */
47
+ private token: string | undefined;
49
48
  /** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
50
49
  private readonly isSessionAuth: boolean;
51
50
  private readonly orgId: string | undefined;
@@ -176,45 +175,67 @@ export class AssistantClient {
176
175
  ? `?${new URLSearchParams(opts.query).toString()}`
177
176
  : "";
178
177
  const url = `${this.runtimeUrl}/v1/assistants/${this._assistantId}${urlPath}${qs}`;
179
-
180
- const headers: Record<string, string> = { ...opts?.headers };
181
- if (this.token) {
182
- if (this.isSessionAuth) {
183
- headers["X-Session-Token"] ??= this.token;
184
- } else {
185
- headers["Authorization"] ??= `Bearer ${this.token}`;
186
- }
187
- }
188
- if (this.orgId) {
189
- headers["Vellum-Organization-Id"] ??= this.orgId;
190
- }
191
- if (body !== undefined) {
192
- headers["Content-Type"] = "application/json";
193
- }
194
-
195
178
  const jsonBody = body !== undefined ? JSON.stringify(body) : undefined;
196
179
 
197
- if (opts?.signal) {
180
+ // Headers are built per-attempt so a refreshed token is picked up on retry.
181
+ const buildHeaders = (): Record<string, string> => {
182
+ const headers: Record<string, string> = { ...opts?.headers };
183
+ if (this.token) {
184
+ if (this.isSessionAuth) {
185
+ headers["X-Session-Token"] ??= this.token;
186
+ } else {
187
+ headers["Authorization"] ??= `Bearer ${this.token}`;
188
+ }
189
+ }
190
+ if (this.orgId) {
191
+ headers["Vellum-Organization-Id"] ??= this.orgId;
192
+ }
193
+ if (body !== undefined) {
194
+ headers["Content-Type"] = "application/json";
195
+ }
196
+ return headers;
197
+ };
198
+
199
+ const doFetch = (): Promise<Response> => {
200
+ const headers = buildHeaders();
201
+ if (opts?.signal) {
202
+ return fetch(url, {
203
+ method,
204
+ headers,
205
+ body: jsonBody,
206
+ signal: opts.signal,
207
+ });
208
+ }
209
+ const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
210
+ const controller = new AbortController();
211
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
198
212
  return fetch(url, {
199
- method,
200
- headers,
201
- body: jsonBody,
202
- signal: opts.signal,
203
- });
204
- }
205
-
206
- const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
207
- const controller = new AbortController();
208
- const timeoutId = setTimeout(() => controller.abort(), timeout);
209
- try {
210
- return await fetch(url, {
211
213
  method,
212
214
  headers,
213
215
  body: jsonBody,
214
216
  signal: controller.signal,
215
- });
216
- } finally {
217
- clearTimeout(timeoutId);
217
+ }).finally(() => clearTimeout(timeoutId));
218
+ };
219
+
220
+ const response = await doFetch();
221
+
222
+ // Reactive auto-refresh: a paired/local guardian access token that has
223
+ // expired comes back 401. Refresh it once via the stored refresh credential
224
+ // and retry. Self-gating — refreshGuardianToken returns null unless a usable
225
+ // refresh token is stored, so ephemeral (`--token`) and access-only sessions
226
+ // just see the original 401. The platform session-auth path is never
227
+ // refreshed here (its token is managed by the Vellum platform).
228
+ if (response.status === 401 && !this.isSessionAuth) {
229
+ const refreshed = await refreshGuardianToken(
230
+ this.runtimeUrl,
231
+ this._assistantId,
232
+ );
233
+ if (refreshed?.accessToken) {
234
+ this.token = refreshed.accessToken;
235
+ return doFetch();
236
+ }
218
237
  }
238
+
239
+ return response;
219
240
  }
220
241
  }
@@ -10,6 +10,8 @@ import {
10
10
  import { homedir } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
14
+
13
15
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
14
16
  import {
15
17
  getDefaultPorts,
@@ -18,8 +20,6 @@ import {
18
20
  getMultiInstanceDir,
19
21
  } from "./environments/paths.js";
20
22
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
- import { SEEDS } from "./environments/seeds.js";
22
- import type { EnvironmentDefinition } from "./environments/types.js";
23
23
  import { probePort } from "./port-probe.js";
24
24
 
25
25
  /**
@@ -76,7 +76,19 @@ export interface AssistantEntry {
76
76
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
77
77
  localUrl?: string;
78
78
  bearerToken?: string;
79
+ /** Deployment topology / how the assistant is reached. Known values:
80
+ * `"local"` (on-machine daemon), `"docker"` (local container),
81
+ * `"apple-container"` (macOS-app-managed container), `"vellum"`
82
+ * (platform-managed, uses the X-Session-Token auth path), `"gcp"` / `"aws"`
83
+ * / `"custom"` (remote, SSH-managed), and `"paired"` (a remote assistant
84
+ * paired from another machine — reached via a bearer guardian token at
85
+ * `runtimeUrl`; has no local process, container, or `resources`).
86
+ * Kept as a free `string` (not a union) for forward-compatibility. */
79
87
  cloud: string;
88
+ /** True when this entry was registered via `vellum connect import` (a remote
89
+ * pairing). Set alongside `cloud: "paired"`; also backs the re-import /
90
+ * overwrite guard in connect import. */
91
+ paired?: boolean;
80
92
  instanceId?: string;
81
93
  namespace?: string;
82
94
  project?: string;
@@ -631,7 +643,7 @@ export async function allocateLocalResources(
631
643
 
632
644
  // Env-aware bases: non-prod envs sit in their own 1000-port window so
633
645
  // running prod and staging assistants side-by-side doesn't collide. See
634
- // `environments/seeds.ts:portBlock` for the layout.
646
+ // the `@vellumai/environments` `portBlock` layout.
635
647
  const basePorts = getDefaultPorts(env);
636
648
  const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
637
649
  const gatewayPort = await findAvailablePort(basePorts.gateway, [