@vellumai/cli 0.7.0 → 0.7.2

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 (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
@@ -1,14 +1,20 @@
1
1
  import { mkdirSync, writeFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
3
 
4
- import { findAssistantByName } from "../lib/assistant-config";
4
+ import type { AssistantEntry } from "../lib/assistant-config.js";
5
+ import { findAssistantByName } from "../lib/assistant-config.js";
5
6
  import { getBackupsDir, formatSize } from "../lib/backup-ops.js";
6
7
  import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
8
+ import { pollJobUntilDone } from "../lib/job-polling.js";
7
9
  import {
10
+ MigrationInProgressError,
11
+ localRuntimeExportToGcs,
12
+ localRuntimeIdentity,
13
+ localRuntimePollJobStatus,
14
+ } from "../lib/local-runtime-client.js";
15
+ import {
16
+ platformRequestSignedUrl,
8
17
  readPlatformToken,
9
- platformInitiateExport,
10
- platformPollExportStatus,
11
- platformDownloadExport,
12
18
  } from "../lib/platform-client.js";
13
19
 
14
20
  export async function backup(): Promise<void> {
@@ -73,7 +79,7 @@ export async function backup(): Promise<void> {
73
79
  }
74
80
 
75
81
  if (cloud === "vellum") {
76
- await backupPlatform(name, outputArg, entry.runtimeUrl);
82
+ await backupPlatform(entry, name, outputArg);
77
83
  return;
78
84
  }
79
85
 
@@ -188,39 +194,87 @@ export async function backup(): Promise<void> {
188
194
  }
189
195
 
190
196
  // ---------------------------------------------------------------------------
191
- // Platform (Vellum-hosted) backup via Django async migration export
197
+ // Platform-managed (cloud="vellum") backup over GCS.
198
+ //
199
+ // The runtime exports the bundle straight to a platform-issued signed GCS
200
+ // URL; the CLI then downloads from GCS to local disk. Bytes never flow
201
+ // through Django. Same architectural shape as the platform-source half of
202
+ // `vellum teleport`. Output format and success log lines match mode 1
203
+ // (runtime-direct local backup) so users see one consistent UX.
204
+ //
205
+ // Lifecycle: the GCS bucket has a 1-day TTL on `uploads/<org>/*` objects
206
+ // (see `vellum-assistant-platform/django/app/assistant/migration/views.py`
207
+ // and `migration/services.py`). Backup is single-shot with no import to
208
+ // trigger best-effort cleanup, so the bundle sits in GCS up to 24h before
209
+ // TTL deletion. No explicit cleanup endpoint exists; relying on TTL is
210
+ // intentional.
192
211
  // ---------------------------------------------------------------------------
193
-
194
212
  async function backupPlatform(
213
+ entry: AssistantEntry,
195
214
  name: string,
196
215
  outputArg?: string,
197
- runtimeUrl?: string,
198
216
  ): Promise<void> {
199
- // Step 1 — Authenticate
200
- const token = readPlatformToken();
201
- if (!token) {
202
- console.error("Not logged in. Run 'vellum login' first.");
217
+ const platformToken = readPlatformToken();
218
+ if (!platformToken) {
219
+ console.error(
220
+ "Not logged in. Run 'vellum login' first (required for platform-managed backup).",
221
+ );
222
+ process.exit(1);
223
+ }
224
+ // Pin upload, download, and runtime requests to the same platform instance
225
+ // the assistant lives on. Using `getPlatformUrl()` instead would target
226
+ // whatever the lockfile / env-var resolves to, which may differ from
227
+ // `entry.runtimeUrl` for staging/dev assistants and end up signing URLs
228
+ // for the wrong GCS bucket. Mirrors the teleport bundlePlatformUrl
229
+ // threading at `cli/src/commands/teleport.ts:1311-1312`.
230
+ const platformUrl = entry.runtimeUrl;
231
+ // Track the working platform token across kickoff/poll/download so a
232
+ // 401-driven refresh during polling stays consistent through the final
233
+ // signed-download request.
234
+ let exportPlatformToken = platformToken;
235
+
236
+ // Step 0 — Ask the source runtime which version it's running. The bundle
237
+ // is produced by the daemon (not the CLI), and the CLI version can drift
238
+ // from the daemon version, so the daemon's version is the authoritative
239
+ // value to record as the bundle's `min_runtime_version`. Stamping with
240
+ // `cliPkg.version` here would record an inaccurate compatibility band on
241
+ // the signed-URL request.
242
+ let runtimeIdentity: { version: string };
243
+ try {
244
+ runtimeIdentity = await localRuntimeIdentity(entry, exportPlatformToken);
245
+ } catch (err) {
246
+ const msg = err instanceof Error ? err.message : String(err);
247
+ console.error(
248
+ `Error: Could not fetch runtime identity from '${name}': ${msg}`,
249
+ );
203
250
  process.exit(1);
204
251
  }
205
252
 
206
- // Step 2Initiate export job
253
+ // Step 1Request a signed upload URL.
254
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
255
+ {
256
+ operation: "upload",
257
+ minRuntimeVersion: runtimeIdentity.version,
258
+ maxRuntimeVersion: null,
259
+ },
260
+ exportPlatformToken,
261
+ platformUrl,
262
+ );
263
+
264
+ // Step 2 — Kick off runtime export-to-GCS through the platform's
265
+ // wildcard runtime proxy. `localRuntimeExportToGcs` builds the
266
+ // `/v1/assistants/<id>/migrations/export-to-gcs` URL for cloud="vellum"
267
+ // and uses platform-token auth (no guardian-token bootstrap).
207
268
  let jobId: string;
208
269
  try {
209
- const result = await platformInitiateExport(
210
- token,
211
- "CLI backup",
212
- runtimeUrl,
213
- );
214
- jobId = result.jobId;
270
+ ({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
271
+ uploadUrl,
272
+ description: "CLI backup",
273
+ }));
215
274
  } catch (err) {
216
- const msg = err instanceof Error ? err.message : String(err);
217
- if (msg.includes("401") || msg.includes("403")) {
218
- console.error("Authentication failed. Run 'vellum login' to refresh.");
219
- process.exit(1);
220
- }
221
- if (msg.includes("429")) {
275
+ if (err instanceof MigrationInProgressError) {
222
276
  console.error(
223
- "Too many export requests. Please wait before trying again.",
277
+ `Error: Another backup or teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish, then re-run.`,
224
278
  );
225
279
  process.exit(1);
226
280
  }
@@ -229,65 +283,90 @@ async function backupPlatform(
229
283
 
230
284
  console.log(`Export started (job ${jobId})...`);
231
285
 
232
- // Step 3 — Poll for completion
233
- const POLL_INTERVAL_MS = 2_000;
234
- const TIMEOUT_MS = 5 * 60 * 1_000; // 5 minutes
235
- const deadline = Date.now() + TIMEOUT_MS;
236
- let downloadUrl: string | undefined;
237
-
238
- while (Date.now() < deadline) {
239
- let status: { status: string; downloadUrl?: string; error?: string };
240
- try {
241
- status = await platformPollExportStatus(jobId, token, runtimeUrl);
242
- } catch (err) {
243
- const msg = err instanceof Error ? err.message : String(err);
244
- // Let non-transient errors (e.g. 404 "job not found") propagate immediately
245
- if (msg.includes("not found")) {
246
- throw err;
286
+ // Step 3 — Poll the job through the wildcard proxy. The dedicated
287
+ // `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
288
+ // records and would 404 on runtime-created job IDs.
289
+ const terminal = await pollJobUntilDone({
290
+ label: "platform export",
291
+ poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
292
+ refreshOn401: async () => {
293
+ const refreshed = readPlatformToken();
294
+ if (!refreshed) {
295
+ throw new Error(
296
+ "Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
297
+ );
247
298
  }
248
- console.warn(`Polling failed, retrying... (${msg})`);
249
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
250
- continue;
251
- }
299
+ exportPlatformToken = refreshed;
300
+ },
301
+ });
252
302
 
253
- if (status.status === "complete") {
254
- downloadUrl = status.downloadUrl;
255
- break;
256
- }
257
-
258
- if (status.status === "failed") {
259
- console.error(`Export failed: ${status.error ?? "unknown error"}`);
260
- process.exit(1);
261
- }
262
-
263
- // Still in progress — wait and retry
264
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
303
+ if (terminal.status === "failed") {
304
+ console.error(`Error: Export failed: ${terminal.error}`);
305
+ process.exit(1);
265
306
  }
266
307
 
267
- if (!downloadUrl) {
268
- console.error("Export timed out after 5 minutes.");
308
+ // Step 4 — Request a signed download URL for the same bundle and fetch
309
+ // it from GCS directly. No auth on signed URLs.
310
+ // Use `exportPlatformToken` (not the original `platformToken`) so a
311
+ // poll-loop 401 refresh doesn't get clobbered here — otherwise a long
312
+ // export that recovered mid-poll via re-auth would still 401 on the
313
+ // download-URL request and abort an otherwise successful run.
314
+ //
315
+ // We deliberately do NOT send `targetRuntimeVersion` here. This flow
316
+ // saves the bundle to disk for offline storage; there is no target
317
+ // runtime to gate against, and the user can later restore the file
318
+ // into any compatible runtime. Sending the CLI's version would
319
+ // incorrectly block older CLIs from backing up newer assistants.
320
+ // The platform treats `target_runtime_version` as optional and skips
321
+ // the version check when it's omitted.
322
+ const { url: bundleUrl } = await platformRequestSignedUrl(
323
+ {
324
+ operation: "download",
325
+ bundleKey,
326
+ },
327
+ exportPlatformToken,
328
+ platformUrl,
329
+ );
330
+
331
+ let downloadResponse: Response;
332
+ try {
333
+ downloadResponse = await fetch(bundleUrl);
334
+ } catch (err) {
335
+ const msg = err instanceof Error ? err.message : String(err);
336
+ console.error(`Error: Failed to fetch bundle from GCS: ${msg}`);
337
+ process.exit(1);
338
+ }
339
+ if (!downloadResponse.ok) {
340
+ const body = await downloadResponse.text().catch(() => "");
341
+ console.error(
342
+ `Error: Failed to fetch bundle from GCS (${downloadResponse.status}): ${body}`,
343
+ );
269
344
  process.exit(1);
270
345
  }
271
346
 
272
- // Step 4 Download bundle
347
+ const arrayBuffer = await downloadResponse.arrayBuffer();
348
+ const data = new Uint8Array(arrayBuffer);
349
+
350
+ // Step 5 — Write to disk using the same path resolution mode 1 uses.
273
351
  const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
274
352
  const outputPath =
275
353
  outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
276
-
277
354
  mkdirSync(dirname(outputPath), { recursive: true });
278
-
279
- const response = await platformDownloadExport(downloadUrl);
280
- const arrayBuffer = await response.arrayBuffer();
281
- const data = new Uint8Array(arrayBuffer);
282
-
283
355
  writeFileSync(outputPath, data);
284
356
 
285
- // Step 5 — Print success
357
+ // Step 6 — Print success. Manifest SHA is included only if the runtime
358
+ // surfaced it via the unified job result; the export-to-gcs runtime
359
+ // route does not set the legacy `X-Vbundle-Manifest-Sha256` response
360
+ // header.
286
361
  console.log(`Backup saved to ${outputPath}`);
287
362
  console.log(`Size: ${formatSize(data.byteLength)}`);
288
-
289
- const manifestSha = response.headers.get("X-Vbundle-Manifest-Sha256");
290
- if (manifestSha) {
363
+ const manifestSha =
364
+ terminal.status === "complete" &&
365
+ terminal.result &&
366
+ typeof terminal.result === "object"
367
+ ? (terminal.result as Record<string, unknown>).manifest_sha256
368
+ : undefined;
369
+ if (typeof manifestSha === "string") {
291
370
  console.log(`Manifest SHA-256: ${manifestSha}`);
292
371
  }
293
372
  }
@@ -3,7 +3,7 @@ import { hostname } from "os";
3
3
  import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
- loadLatestAssistant,
6
+ resolveAssistant,
7
7
  } from "../lib/assistant-config";
8
8
  import {
9
9
  DAEMON_INTERNAL_ASSISTANT_ID,
@@ -11,7 +11,12 @@ import {
11
11
  type Species,
12
12
  } from "../lib/constants";
13
13
  import { loadGuardianToken } from "../lib/guardian-token";
14
- import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
14
+ import { getLocalLanIPv4 } from "../lib/local";
15
+ import {
16
+ fetchOrganizationId,
17
+ readPlatformToken,
18
+ } from "../lib/platform-client";
19
+ import { tuiLog } from "../lib/tui-log";
15
20
 
16
21
  const ANSI = {
17
22
  reset: "\x1b[0m",
@@ -25,6 +30,11 @@ interface ParsedArgs {
25
30
  runtimeUrl: string;
26
31
  assistantId: string;
27
32
  species: Species;
33
+ /** "vellum" for platform-hosted assistants, undefined for local. */
34
+ cloud?: string;
35
+ /** Platform session token (X-Session-Token), set when cloud === "vellum". */
36
+ platformToken?: string;
37
+ /** Guardian JWT (Authorization: Bearer), set for local assistants. */
28
38
  bearerToken?: string;
29
39
  project?: string;
30
40
  zone?: string;
@@ -76,8 +86,8 @@ function parseArgs(): ParsedArgs {
76
86
  }
77
87
  }
78
88
  if (!entry && hasExplicitUrl) {
79
- // URL provided but active assistant missing or unset — use latest for remaining defaults
80
- entry = loadLatestAssistant();
89
+ // URL provided but active assistant missing or unset — resolve for remaining defaults
90
+ entry = resolveAssistant();
81
91
  } else if (!entry) {
82
92
  console.error(
83
93
  "No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
@@ -88,10 +98,17 @@ function parseArgs(): ParsedArgs {
88
98
 
89
99
  let runtimeUrl = entry?.localUrl || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
90
100
  let assistantId = entry?.assistantId || DAEMON_INTERNAL_ASSISTANT_ID;
91
- const bearerToken =
92
- loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined;
101
+ const cloud = entry?.cloud;
93
102
  const species: Species = (entry?.species as Species) ?? "vellum";
94
103
 
104
+ // Platform-hosted assistants use a session token; local assistants use a guardian JWT.
105
+ const platformToken =
106
+ cloud === "vellum" ? (readPlatformToken() ?? undefined) : undefined;
107
+ const bearerToken =
108
+ cloud === "vellum"
109
+ ? undefined
110
+ : (loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined);
111
+
95
112
  for (let i = 0; i < flagArgs.length; i++) {
96
113
  const flag = flagArgs[i];
97
114
  if ((flag === "--url" || flag === "-u") && flagArgs[i + 1]) {
@@ -108,6 +125,8 @@ function parseArgs(): ParsedArgs {
108
125
  runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
109
126
  assistantId,
110
127
  species,
128
+ cloud,
129
+ platformToken,
111
130
  bearerToken,
112
131
  project: entry?.project,
113
132
  zone: entry?.zone,
@@ -140,11 +159,6 @@ function maybeSwapToLocalhost(url: string): string {
140
159
  }
141
160
  }
142
161
 
143
- const macHost = getMacLocalHostname();
144
- if (macHost) {
145
- localNames.push(macHost.toLowerCase());
146
- }
147
-
148
162
  const lanIp = getLocalLanIPv4();
149
163
  if (lanIp) {
150
164
  localNames.push(lanIp);
@@ -185,8 +199,34 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
185
199
  }
186
200
 
187
201
  export async function client(): Promise<void> {
188
- const { runtimeUrl, assistantId, species, bearerToken, project, zone } =
189
- parseArgs();
202
+ const {
203
+ runtimeUrl,
204
+ assistantId,
205
+ species,
206
+ cloud,
207
+ platformToken,
208
+ bearerToken,
209
+ project,
210
+ zone,
211
+ } = parseArgs();
212
+
213
+ tuiLog.init();
214
+ tuiLog.info("session start", { runtimeUrl, assistantId, species, cloud });
215
+
216
+ // Build pre-constructed auth headers so all fetch sites share a single object.
217
+ let auth: Record<string, string> | undefined;
218
+ if (cloud === "vellum" && platformToken) {
219
+ const orgId = await fetchOrganizationId(platformToken).catch((err) => {
220
+ tuiLog.warn("failed to fetch organization id", { err: String(err) });
221
+ return undefined;
222
+ });
223
+ auth = {
224
+ "X-Session-Token": platformToken,
225
+ ...(orgId ? { "Vellum-Organization-Id": orgId } : {}),
226
+ };
227
+ } else if (bearerToken) {
228
+ auth = { Authorization: `Bearer ${bearerToken}` };
229
+ }
190
230
 
191
231
  const { renderChatApp } = await import("../components/DefaultMainScreen");
192
232
 
@@ -197,11 +237,13 @@ export async function client(): Promise<void> {
197
237
  assistantId,
198
238
  species,
199
239
  () => {
240
+ tuiLog.info("session end (user disconnect)");
241
+ tuiLog.close();
200
242
  app.unmount();
201
243
  process.stdout.write("\x1b[2J\x1b[H");
202
244
  console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
203
245
  process.exit(0);
204
246
  },
205
- { bearerToken, project, zone },
247
+ { auth, project, zone },
206
248
  );
207
249
  }
@@ -139,6 +139,9 @@ export async function events(): Promise<void> {
139
139
  query,
140
140
  headers: getClientRegistrationHeaders(),
141
141
  })) {
142
+ if (!event.message) {
143
+ continue;
144
+ }
142
145
  if (jsonOutput) {
143
146
  console.log(JSON.stringify(event));
144
147
  } else {
@@ -1,10 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- import {
4
- findAssistantByName,
5
- loadLatestAssistant,
6
- resolveCloud,
7
- } from "../lib/assistant-config";
3
+ import { resolveAssistant, resolveCloud } from "../lib/assistant-config";
8
4
  import { dockerResourceNames } from "../lib/docker";
9
5
  import type { ServiceName } from "../lib/docker";
10
6
  import { execAppleContainer } from "../lib/exec-apple-container";
@@ -84,6 +80,9 @@ export async function exec(): Promise<void> {
84
80
  console.log(
85
81
  " -it Interactive mode with TTY (like docker exec -it)",
86
82
  );
83
+ console.log(
84
+ " --timeout <secs> Timeout in seconds (default: 30, 0 = no timeout)",
85
+ );
87
86
  console.log(
88
87
  " --verbose Show debug output (SSE events, sentinel parsing)",
89
88
  );
@@ -120,12 +119,20 @@ export async function exec(): Promise<void> {
120
119
  let serviceRaw = "assistant";
121
120
  let interactive = false;
122
121
  let verbose = false;
122
+ let timeoutMs = 30_000;
123
123
 
124
124
  for (let i = 0; i < preArgs.length; i++) {
125
125
  if (preArgs[i] === "--service" && preArgs[i + 1]) {
126
126
  serviceRaw = preArgs[++i];
127
127
  } else if (preArgs[i] === "-it" || preArgs[i] === "-ti") {
128
128
  interactive = true;
129
+ } else if (preArgs[i] === "--timeout" && preArgs[i + 1]) {
130
+ const secs = Number(preArgs[++i]);
131
+ if (!Number.isFinite(secs) || secs < 0) {
132
+ console.error("Error: --timeout must be a non-negative number.");
133
+ process.exit(1);
134
+ }
135
+ timeoutMs = secs === 0 ? 0 : secs * 1000;
129
136
  } else if (preArgs[i] === "--verbose") {
130
137
  verbose = true;
131
138
  } else if (!preArgs[i].startsWith("-")) {
@@ -135,7 +142,7 @@ export async function exec(): Promise<void> {
135
142
 
136
143
  const service = normalizeService(serviceRaw);
137
144
 
138
- const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
145
+ const entry = resolveAssistant(nameArg);
139
146
 
140
147
  if (!entry) {
141
148
  if (nameArg) {
@@ -149,10 +156,19 @@ export async function exec(): Promise<void> {
149
156
  const cloud = resolveCloud(entry);
150
157
 
151
158
  if (cloud === "local") {
152
- console.error(
153
- "Cannot exec into a local assistant — it runs directly on this machine.",
154
- );
155
- process.exit(1);
159
+ const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
160
+ await new Promise<void>((resolve) => {
161
+ child.on("close", (code) => {
162
+ process.exitCode = code ?? 0;
163
+ resolve();
164
+ });
165
+ child.on("error", (err) => {
166
+ console.error(`Error: ${err.message}`);
167
+ process.exitCode = 1;
168
+ resolve();
169
+ });
170
+ });
171
+ return;
156
172
  }
157
173
 
158
174
  if (cloud === "apple-container") {
@@ -200,14 +216,20 @@ export async function exec(): Promise<void> {
200
216
  platformUrl: getPlatformUrl(),
201
217
  };
202
218
 
219
+ const serviceParam = service === "assistant" ? undefined : service;
220
+
203
221
  if (interactive) {
204
222
  // Interactive mode: shell-escape argv and delegate to full terminal
205
- await interactiveSession(assistant, shellEscapeArgs(command));
223
+ await interactiveSession(assistant, shellEscapeArgs(command), serviceParam);
206
224
  return;
207
225
  }
208
226
 
209
227
  // Non-interactive: sentinel-based output capture with exit code
210
- await nonInteractiveExec(assistant, command, { verbose });
228
+ await nonInteractiveExec(assistant, command, {
229
+ verbose,
230
+ timeoutMs,
231
+ service: serviceParam,
232
+ });
211
233
  return;
212
234
  }
213
235
 
@@ -15,7 +15,7 @@ import {
15
15
  VALID_SPECIES,
16
16
  } from "../lib/constants";
17
17
  import type { RemoteHost, Species } from "../lib/constants";
18
- import { buildInitialConfig } from "../lib/config-utils";
18
+ import { buildNestedConfig } from "../lib/config-utils";
19
19
  import { hatchDocker } from "../lib/docker";
20
20
  import { hatchGcp } from "../lib/gcp";
21
21
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
@@ -32,7 +32,7 @@ export type { PollResult, WatchHatchingResult } from "../lib/gcp";
32
32
  const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
33
33
 
34
34
  const HATCH_TIMEOUT_MS: Record<Species, number> = {
35
- vellum: 2 * 60 * 1000,
35
+ vellum: 5 * 60 * 1000,
36
36
  openclaw: 10 * 60 * 1000,
37
37
  };
38
38
  const DEFAULT_SPECIES: Species = "vellum";
@@ -123,11 +123,7 @@ export async function buildStartupScript(
123
123
  // and export the env var so the daemon reads it on first boot.
124
124
  let configWriteBlock = "";
125
125
  if (Object.keys(configValues).length > 0) {
126
- const configJson = JSON.stringify(
127
- buildInitialConfig(configValues),
128
- null,
129
- 2,
130
- );
126
+ const configJson = JSON.stringify(buildNestedConfig(configValues), null, 2);
131
127
  configWriteBlock = `
132
128
  echo "Writing default workspace config..."
133
129
  VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH="/tmp/vellum-initial-config-$$.json"
@@ -3,12 +3,10 @@ import { spawn } from "child_process";
3
3
  import { randomBytes } from "crypto";
4
4
 
5
5
  import {
6
- findAssistantByName,
7
6
  getActiveAssistant,
8
- loadLatestAssistant,
7
+ resolveAssistant,
9
8
  loadAllAssistants,
10
9
  removeAssistantEntry,
11
- saveAssistantEntry,
12
10
  setActiveAssistant,
13
11
  } from "../lib/assistant-config";
14
12
  import { computeDeviceId } from "../lib/guardian-token";
@@ -26,6 +24,7 @@ import {
26
24
  reprovisionAssistantApiKey,
27
25
  savePlatformToken,
28
26
  } from "../lib/platform-client";
27
+ import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
29
28
 
30
29
  const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
31
30
 
@@ -205,10 +204,7 @@ export async function login(): Promise<void> {
205
204
  // Register the local assistant with the platform (non-fatal).
206
205
  // Mirrors the desktop app's LocalAssistantBootstrapService flow.
207
206
  try {
208
- const activeName = getActiveAssistant();
209
- const entry = activeName
210
- ? findAssistantByName(activeName)
211
- : loadLatestAssistant();
207
+ const entry = resolveAssistant();
212
208
 
213
209
  // Skip managed ("vellum") assistants — they are handled by the platform.
214
210
  if (entry && entry.cloud !== "vellum") {
@@ -282,36 +278,22 @@ export async function login(): Promise<void> {
282
278
  // This ensures `vellum ps` shows managed assistants immediately
283
279
  // after login (e.g. after a retire-and-rehatch cycle).
284
280
  try {
285
- const platformAssistants = await fetchPlatformAssistants(token);
286
- const existingIds = new Set(
287
- loadAllAssistants()
288
- .filter((a) => a.cloud === "vellum")
289
- .map((a) => a.assistantId),
290
- );
291
-
292
- let synced = 0;
293
- for (const pa of platformAssistants) {
294
- if (!existingIds.has(pa.id)) {
295
- saveAssistantEntry({
296
- assistantId: pa.id,
297
- runtimeUrl: getPlatformUrl(),
298
- cloud: "vellum",
299
- species: "vellum",
300
- hatchedAt: new Date().toISOString(),
301
- });
302
- synced++;
281
+ const result = await syncCloudAssistants();
282
+ if (result) {
283
+ const total = result.added + result.removed;
284
+ if (total > 0) {
285
+ console.log(
286
+ `Synced cloud assistants (${result.added} added, ${result.removed} removed).`,
287
+ );
303
288
  }
304
289
  }
305
290
 
306
- if (synced > 0) {
307
- console.log(
308
- `Synced ${synced} cloud assistant${synced > 1 ? "s" : ""} to local lockfile.`,
309
- );
310
- }
311
-
312
291
  // If no active assistant is set, activate the first cloud one.
313
- if (!getActiveAssistant() && platformAssistants.length > 0) {
314
- setActiveAssistant(platformAssistants[0].id);
292
+ if (!getActiveAssistant()) {
293
+ const platformAssistants = await fetchPlatformAssistants(token);
294
+ if (platformAssistants.length > 0) {
295
+ setActiveAssistant(platformAssistants[0].id);
296
+ }
315
297
  }
316
298
  } catch {
317
299
  // Non-fatal — login succeeded even if sync fails
@@ -4,10 +4,7 @@ import { createInterface } from "readline";
4
4
  import { watch } from "fs";
5
5
  import { join } from "path";
6
6
 
7
- import {
8
- findAssistantByName,
9
- loadLatestAssistant,
10
- } from "../lib/assistant-config";
7
+ import { resolveAssistant } from "../lib/assistant-config";
11
8
  import type { AssistantEntry } from "../lib/assistant-config";
12
9
  import { dockerResourceNames } from "../lib/docker";
13
10
  import { getLogDir } from "../lib/xdg-log";
@@ -593,9 +590,7 @@ async function showAwsLogs(
593
590
  export async function logs(): Promise<void> {
594
591
  const opts = parseArgs();
595
592
 
596
- const entry = opts.name
597
- ? findAssistantByName(opts.name)
598
- : loadLatestAssistant();
593
+ const entry = resolveAssistant(opts.name);
599
594
 
600
595
  if (!entry) {
601
596
  if (opts.name) {