@vellumai/cli 0.4.55 → 0.4.57

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.
@@ -1,4 +1,3 @@
1
- import { createHash, randomBytes, randomUUID } from "crypto";
2
1
  import {
3
2
  appendFileSync,
4
3
  existsSync,
@@ -6,16 +5,14 @@ import {
6
5
  mkdirSync,
7
6
  readFileSync,
8
7
  readlinkSync,
8
+ rmSync,
9
9
  symlinkSync,
10
10
  unlinkSync,
11
11
  writeFileSync,
12
12
  } from "fs";
13
- import { homedir, hostname, userInfo } from "os";
13
+ import { homedir } from "os";
14
14
  import { join } from "path";
15
15
 
16
- import QRCode from "qrcode";
17
- import qrcode from "qrcode-terminal";
18
-
19
16
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
20
17
  import cliPkg from "../../package.json";
21
18
 
@@ -40,7 +37,6 @@ import {
40
37
  } from "../lib/constants";
41
38
  import type { RemoteHost, Species } from "../lib/constants";
42
39
  import { hatchDocker } from "../lib/docker";
43
- import { mintLocalBearerToken } from "../lib/jwt";
44
40
  import { hatchGcp } from "../lib/gcp";
45
41
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
46
42
  import {
@@ -49,9 +45,10 @@ import {
49
45
  stopLocalProcesses,
50
46
  } from "../lib/local";
51
47
  import { maybeStartNgrokTunnel } from "../lib/ngrok";
48
+ import { httpHealthCheck } from "../lib/http-client";
52
49
  import { detectOrphanedProcesses } from "../lib/orphan-detection";
53
50
  import { isProcessAlive, stopProcess } from "../lib/process";
54
- import { generateRandomSuffix } from "../lib/random-name";
51
+ import { generateInstanceName } from "../lib/random-name";
55
52
  import { validateAssistantName } from "../lib/retire-archive";
56
53
  import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
57
54
 
@@ -97,14 +94,12 @@ chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
97
94
 
98
95
  export async function buildStartupScript(
99
96
  species: Species,
100
- bearerToken: string,
101
97
  sshUser: string,
102
98
  anthropicApiKey: string,
103
99
  instanceName: string,
104
100
  cloud: RemoteHost,
105
101
  ): Promise<string> {
106
- const platformUrl =
107
- process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai";
102
+ const platformUrl = process.env.VELLUM_PLATFORM_URL ?? "https://vellum.ai";
108
103
  const logPath =
109
104
  cloud === "custom"
110
105
  ? "/tmp/vellum-startup.log"
@@ -117,7 +112,6 @@ export async function buildStartupScript(
117
112
 
118
113
  if (species === "openclaw") {
119
114
  return await buildOpenclawStartupScript(
120
- bearerToken,
121
115
  sshUser,
122
116
  anthropicApiKey,
123
117
  timestampRedirect,
@@ -389,7 +383,7 @@ export async function watchHatching(
389
383
  );
390
384
  console.log(` Monitor with: vel logs ${instanceName}`);
391
385
  console.log("");
392
- resolve({ success: true, errorContent: lastErrorContent });
386
+ resolve({ success: false, errorContent: lastErrorContent });
393
387
  return;
394
388
  }
395
389
 
@@ -434,7 +428,7 @@ function watchHatchingDesktop(
434
428
  `Timed out after ${formatElapsed(elapsed)}. Instance is still running.`,
435
429
  );
436
430
  desktopLog(`Monitor with: vel logs ${instanceName}`);
437
- resolve({ success: true, errorContent: lastErrorContent });
431
+ resolve({ success: false, errorContent: lastErrorContent });
438
432
  return;
439
433
  }
440
434
 
@@ -567,121 +561,6 @@ function installCLISymlink(): void {
567
561
  );
568
562
  }
569
563
 
570
- async function waitForDaemonReady(
571
- runtimeUrl: string,
572
- bearerToken: string | undefined,
573
- timeoutMs = 15000,
574
- ): Promise<boolean> {
575
- const start = Date.now();
576
- const pollInterval = 1000;
577
- while (Date.now() - start < timeoutMs) {
578
- try {
579
- const res = await fetch(`${runtimeUrl}/v1/health`, {
580
- method: "GET",
581
- headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {},
582
- signal: AbortSignal.timeout(2000),
583
- });
584
- if (res.ok) return true;
585
- } catch {
586
- // Daemon not ready yet
587
- }
588
- await new Promise((r) => setTimeout(r, pollInterval));
589
- }
590
- return false;
591
- }
592
-
593
- async function displayPairingQRCode(
594
- runtimeUrl: string,
595
- bearerToken: string | undefined,
596
- /** External gateway URL for the QR payload. When omitted, runtimeUrl is used. */
597
- externalGatewayUrl?: string,
598
- ): Promise<void> {
599
- try {
600
- const pairingRequestId = randomUUID();
601
- const pairingSecret = randomBytes(32).toString("hex");
602
-
603
- // The daemon's HTTP server may not be fully ready even though the gateway
604
- // health check passed (the gateway is up, but the upstream daemon HTTP
605
- // endpoint it proxies to may still be initializing). Poll the daemon's
606
- // health endpoint through the gateway to ensure it's reachable.
607
- const daemonReady = await waitForDaemonReady(runtimeUrl, bearerToken);
608
- if (!daemonReady) {
609
- console.warn(
610
- "⚠ Assistant health check did not pass within 15s. Run `vellum pair` to try again.\n",
611
- );
612
- return;
613
- }
614
-
615
- const registerRes = await fetch(`${runtimeUrl}/pairing/register`, {
616
- method: "POST",
617
- headers: {
618
- "Content-Type": "application/json",
619
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
620
- },
621
- body: JSON.stringify({
622
- pairingRequestId,
623
- pairingSecret,
624
- gatewayUrl: externalGatewayUrl ?? runtimeUrl,
625
- }),
626
- });
627
-
628
- if (!registerRes.ok) {
629
- const body = await registerRes.text().catch(() => "");
630
- console.warn(
631
- `⚠ Could not register pairing request: ${registerRes.status} ${registerRes.statusText}${body ? ` — ${body}` : ""}. Run \`vellum pair\` to try again.\n`,
632
- );
633
- return;
634
- }
635
-
636
- const hostId = createHash("sha256")
637
- .update(hostname() + userInfo().username)
638
- .digest("hex");
639
- const payload = JSON.stringify({
640
- type: "vellum-daemon",
641
- v: 4,
642
- id: hostId,
643
- g: externalGatewayUrl ?? runtimeUrl,
644
- pairingRequestId,
645
- pairingSecret,
646
- });
647
-
648
- const qrString = await new Promise<string>((resolve) => {
649
- qrcode.generate(payload, { small: true }, (code: string) => {
650
- resolve(code);
651
- });
652
- });
653
-
654
- // Save QR code as PNG to a well-known location so it can be retrieved
655
- // (e.g. via SCP) for pairing through the Desktop app.
656
- const qrDir = join(homedir(), ".vellum", "pairing-qr");
657
- mkdirSync(qrDir, { recursive: true });
658
- const qrPngPath = join(qrDir, "initial.png");
659
- try {
660
- const pngBuffer = await QRCode.toBuffer(payload, {
661
- type: "png",
662
- width: 512,
663
- });
664
- writeFileSync(qrPngPath, pngBuffer);
665
- console.log(`QR code PNG saved to ${qrPngPath}\n`);
666
- } catch (pngErr) {
667
- const pngReason =
668
- pngErr instanceof Error ? pngErr.message : String(pngErr);
669
- console.warn(`\u26A0 Could not save QR code PNG: ${pngReason}\n`);
670
- }
671
-
672
- console.log("Scan this QR code with the Vellum iOS app to pair:\n");
673
- console.log(qrString);
674
- console.log("This pairing request expires in 5 minutes.");
675
- console.log("Run `vellum pair` to generate a new one.\n");
676
- } catch (err) {
677
- // Non-fatal — pairing is optional
678
- const reason = err instanceof Error ? err.message : String(err);
679
- console.warn(
680
- `⚠ Could not generate pairing QR code: ${reason}. Run \`vellum pair\` to try again.\n`,
681
- );
682
- }
683
- }
684
-
685
564
  async function hatchLocal(
686
565
  species: Species,
687
566
  name: string | null,
@@ -697,13 +576,15 @@ async function hatchLocal(
697
576
  process.exit(1);
698
577
  }
699
578
 
700
- const instanceName =
701
- name ??
702
- process.env.VELLUM_ASSISTANT_NAME ??
703
- `${species}-${generateRandomSuffix()}`;
579
+ const instanceName = generateInstanceName(
580
+ species,
581
+ name ?? process.env.VELLUM_ASSISTANT_NAME,
582
+ );
704
583
 
705
584
  // Clean up stale local state: if daemon/gateway processes are running but
706
- // the lock file has no entries, stop them before starting fresh.
585
+ // the lock file has no entries AND the daemon is not healthy, stop them
586
+ // before starting fresh. A healthy daemon should be reused, not killed —
587
+ // it may have been started intentionally via `vellum wake`.
707
588
  const vellumDir = join(homedir(), ".vellum");
708
589
  const existingAssistants = loadAllAssistants();
709
590
  const localAssistants = existingAssistants.filter((a) => a.cloud === "local");
@@ -711,10 +592,16 @@ async function hatchLocal(
711
592
  const daemonPid = isProcessAlive(join(vellumDir, "vellum.pid"));
712
593
  const gatewayPid = isProcessAlive(join(vellumDir, "gateway.pid"));
713
594
  if (daemonPid.alive || gatewayPid.alive) {
714
- console.log(
715
- "🧹 Cleaning up stale local processes (no lock file entry)...\n",
716
- );
717
- await stopLocalProcesses();
595
+ // Check if the daemon is actually healthy before killing it.
596
+ // Default port 7821 is used when there's no lockfile entry.
597
+ const defaultPort = parseInt(process.env.RUNTIME_HTTP_PORT || "7821", 10);
598
+ const healthy = await httpHealthCheck(defaultPort);
599
+ if (!healthy) {
600
+ console.log(
601
+ "🧹 Cleaning up stale local processes (no lock file entry)...\n",
602
+ );
603
+ await stopLocalProcesses();
604
+ }
718
605
  }
719
606
  }
720
607
 
@@ -722,17 +609,32 @@ async function hatchLocal(
722
609
  // are not tracked by any PID file or lock file entry and kill them before
723
610
  // starting new ones. This prevents resource leaks when the desktop app
724
611
  // crashes or is force-quit without a clean shutdown.
612
+ //
613
+ // Skip orphan cleanup if the daemon is already healthy on the expected port
614
+ // — those processes are intentional (e.g. started via `vellum wake`) and
615
+ // startLocalDaemon() will reuse them.
725
616
  if (IS_DESKTOP) {
726
- const orphans = await detectOrphanedProcesses();
727
- if (orphans.length > 0) {
728
- desktopLog(
729
- `🧹 Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} — cleaning up...`,
730
- );
731
- for (const orphan of orphans) {
732
- await stopProcess(
733
- parseInt(orphan.pid, 10),
734
- `${orphan.name} (PID ${orphan.pid})`,
617
+ const existingResources = findAssistantByName(instanceName);
618
+ const expectedPort =
619
+ existingResources?.cloud === "local" && existingResources.resources
620
+ ? existingResources.resources.daemonPort
621
+ : undefined;
622
+ const daemonAlreadyHealthy = expectedPort
623
+ ? await httpHealthCheck(expectedPort)
624
+ : false;
625
+
626
+ if (!daemonAlreadyHealthy) {
627
+ const orphans = await detectOrphanedProcesses();
628
+ if (orphans.length > 0) {
629
+ desktopLog(
630
+ `🧹 Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} — cleaning up...`,
735
631
  );
632
+ for (const orphan of orphans) {
633
+ await stopProcess(
634
+ parseInt(orphan.pid, 10),
635
+ `${orphan.name} (PID ${orphan.pid})`,
636
+ );
637
+ }
736
638
  }
737
639
  }
738
640
  }
@@ -747,6 +649,38 @@ async function hatchLocal(
747
649
  resources = await allocateLocalResources(instanceName);
748
650
  }
749
651
 
652
+ // Clean up stale workspace data: if the workspace directory already exists for
653
+ // this instance but no local lockfile entry owns it, a previous retire failed
654
+ // to archive it (or a managed-only retire left local data behind). Remove the
655
+ // workspace subtree so the new assistant starts fresh — but preserve the rest
656
+ // of .vellum (e.g. protected/, credentials) which may be shared.
657
+ if (
658
+ !existingEntry ||
659
+ (existingEntry.cloud != null && existingEntry.cloud !== "local")
660
+ ) {
661
+ const instanceWorkspaceDir = join(
662
+ resources.instanceDir,
663
+ ".vellum",
664
+ "workspace",
665
+ );
666
+ if (existsSync(instanceWorkspaceDir)) {
667
+ const ownedByOther = loadAllAssistants().some((a) => {
668
+ if ((a.cloud != null && a.cloud !== "local") || !a.resources)
669
+ return false;
670
+ return (
671
+ join(a.resources.instanceDir, ".vellum", "workspace") ===
672
+ instanceWorkspaceDir
673
+ );
674
+ });
675
+ if (!ownedByOther) {
676
+ console.log(
677
+ `🧹 Removing stale workspace at ${instanceWorkspaceDir} (not owned by any assistant)...\n`,
678
+ );
679
+ rmSync(instanceWorkspaceDir, { recursive: true, force: true });
680
+ }
681
+ }
682
+ }
683
+
750
684
  const logsDir = join(
751
685
  resources.instanceDir,
752
686
  ".vellum",
@@ -795,15 +729,10 @@ async function hatchLocal(
795
729
  delete process.env.BASE_DATA_DIR;
796
730
  }
797
731
 
798
- // Mint a JWT from the signing key so the CLI can authenticate with the
799
- // daemon/gateway (which requires auth by default).
800
- const bearerToken = mintLocalBearerToken(resources.instanceDir);
801
-
802
732
  const localEntry: AssistantEntry = {
803
733
  assistantId: instanceName,
804
734
  runtimeUrl,
805
735
  localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
806
- bearerToken,
807
736
  cloud: "local",
808
737
  species,
809
738
  hatchedAt: new Date().toISOString(),
@@ -825,12 +754,6 @@ async function hatchLocal(
825
754
  console.log(` Name: ${instanceName}`);
826
755
  console.log(` Runtime: ${runtimeUrl}`);
827
756
  console.log("");
828
-
829
- // Use loopback for HTTP calls (health check + pairing register) since
830
- // mDNS hostnames may not resolve on the local machine, but keep the
831
- // external runtimeUrl in the QR payload so iOS devices can reach it.
832
- const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
833
- await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
834
757
  }
835
758
 
836
759
  if (keepAlive) {
@@ -7,7 +7,9 @@ import { PNG } from "pngjs";
7
7
  import { saveAssistantEntry } from "../lib/assistant-config";
8
8
  import type { AssistantEntry } from "../lib/assistant-config";
9
9
  import type { Species } from "../lib/constants";
10
- import { generateRandomSuffix } from "../lib/random-name";
10
+ import { saveGuardianToken } from "../lib/guardian-token";
11
+ import type { GuardianTokenData } from "../lib/guardian-token";
12
+ import { generateInstanceName } from "../lib/random-name";
11
13
 
12
14
  interface QRPairingPayload {
13
15
  type: string;
@@ -119,7 +121,7 @@ export async function pair(): Promise<void> {
119
121
  throw new Error("QR code does not contain valid Vellum pairing data.");
120
122
  }
121
123
 
122
- const instanceName = `${species}-${generateRandomSuffix()}`;
124
+ const instanceName = generateInstanceName(species);
123
125
  const runtimeUrl = payload.g;
124
126
  const deviceId = getDeviceId();
125
127
  const deviceName = hostname();
@@ -168,13 +170,27 @@ export async function pair(): Promise<void> {
168
170
  const customEntry: AssistantEntry = {
169
171
  assistantId: instanceName,
170
172
  runtimeUrl,
171
- bearerToken,
172
173
  cloud: "custom",
173
174
  species,
174
175
  hatchedAt: new Date().toISOString(),
175
176
  };
176
177
  saveAssistantEntry(customEntry);
177
178
 
179
+ if (bearerToken) {
180
+ const tokenData: GuardianTokenData = {
181
+ guardianPrincipalId: "",
182
+ accessToken: bearerToken,
183
+ accessTokenExpiresAt: "",
184
+ refreshToken: "",
185
+ refreshTokenExpiresAt: "",
186
+ refreshAfter: "",
187
+ isNew: true,
188
+ deviceId: getDeviceId(),
189
+ leasedAt: new Date().toISOString(),
190
+ };
191
+ saveGuardianToken(instanceName, tokenData);
192
+ }
193
+
178
194
  console.log("");
179
195
  console.log("Successfully paired with remote assistant!");
180
196
  console.log("Instance details:");
@@ -6,7 +6,9 @@ import {
6
6
  loadAllAssistants,
7
7
  type AssistantEntry,
8
8
  } from "../lib/assistant-config";
9
+ import { loadGuardianToken } from "../lib/guardian-token";
9
10
  import { checkHealth, checkManagedHealth } from "../lib/health-check";
11
+ import { dockerResourceNames } from "../lib/docker";
10
12
  import {
11
13
  classifyProcess,
12
14
  detectOrphanedProcesses,
@@ -248,6 +250,73 @@ async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
248
250
  }));
249
251
  }
250
252
 
253
+ async function getDockerContainerState(
254
+ containerName: string,
255
+ ): Promise<string | null> {
256
+ try {
257
+ const output = await execOutput("docker", [
258
+ "inspect",
259
+ "--format",
260
+ "{{.State.Status}}",
261
+ containerName,
262
+ ]);
263
+ return output.trim() || "unknown";
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ function isLocalProcessAlive(pid: number): boolean {
270
+ try {
271
+ process.kill(pid, 0);
272
+ return true;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ async function getDockerProcesses(entry: AssistantEntry): Promise<TableRow[]> {
279
+ const res = dockerResourceNames(entry.assistantId);
280
+
281
+ const containers: { name: string; containerName: string }[] = [
282
+ { name: "assistant", containerName: res.assistantContainer },
283
+ { name: "gateway", containerName: res.gatewayContainer },
284
+ { name: "credential-executor", containerName: res.cesContainer },
285
+ ];
286
+
287
+ const results = await Promise.all(
288
+ containers.map(async ({ name, containerName }) => {
289
+ const state = await getDockerContainerState(containerName);
290
+ if (!state) {
291
+ return {
292
+ name,
293
+ status: withStatusEmoji("not found"),
294
+ info: `container ${containerName}`,
295
+ };
296
+ }
297
+ return {
298
+ name,
299
+ status: withStatusEmoji(state === "running" ? "running" : state),
300
+ info: `container ${containerName}`,
301
+ };
302
+ }),
303
+ );
304
+
305
+ // Show the file watcher process if the instance was hatched with --watch.
306
+ const watcherPid =
307
+ typeof entry.watcherPid === "number" ? entry.watcherPid : null;
308
+ if (watcherPid !== null) {
309
+ const alive = isLocalProcessAlive(watcherPid);
310
+ results.push({
311
+ name: "file-watcher",
312
+ status: withStatusEmoji(alive ? "running" : "not running"),
313
+ info: alive ? `PID ${watcherPid}` : "not detected",
314
+ });
315
+ }
316
+
317
+ return results;
318
+ }
319
+
251
320
  async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
252
321
  const cloud = resolveCloud(entry);
253
322
 
@@ -259,6 +328,12 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
259
328
  return;
260
329
  }
261
330
 
331
+ if (cloud === "docker") {
332
+ const rows = await getDockerProcesses(entry);
333
+ printTable(rows);
334
+ return;
335
+ }
336
+
262
337
  let output: string;
263
338
  try {
264
339
  if (cloud === "gcp") {
@@ -357,12 +432,23 @@ async function listAllAssistants(): Promise<void> {
357
432
  if (!alive) {
358
433
  health = { status: "sleeping", detail: null };
359
434
  } else {
360
- health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
435
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
436
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
437
+ }
438
+ } else if (a.cloud === "docker") {
439
+ const res = dockerResourceNames(a.assistantId);
440
+ const state = await getDockerContainerState(res.assistantContainer);
441
+ if (!state || state !== "running") {
442
+ health = { status: "sleeping", detail: null };
443
+ } else {
444
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
445
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
361
446
  }
362
447
  } else if (a.cloud === "vellum") {
363
448
  health = await checkManagedHealth(a.runtimeUrl, a.assistantId);
364
449
  } else {
365
- health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
450
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
451
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
366
452
  }
367
453
 
368
454
  const infoParts = [a.runtimeUrl];
@@ -9,7 +9,11 @@ import {
9
9
  removeAssistantEntry,
10
10
  } from "../lib/assistant-config";
11
11
  import type { AssistantEntry } from "../lib/assistant-config";
12
- import { getPlatformUrl, readPlatformToken } from "../lib/platform-client";
12
+ import {
13
+ fetchOrganizationId,
14
+ getPlatformUrl,
15
+ readPlatformToken,
16
+ } from "../lib/platform-client";
13
17
  import { retireInstance as retireAwsInstance } from "../lib/aws";
14
18
  import { retireDocker } from "../lib/docker";
15
19
  import { retireInstance as retireGcpInstance } from "../lib/gcp";
@@ -84,6 +88,21 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
84
88
  const gatewayPidFile = join(vellumDir, "gateway.pid");
85
89
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
86
90
 
91
+ // Stop Qdrant — the daemon's graceful shutdown tries to stop it via
92
+ // qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
93
+ // Qdrant may still be running as an orphan. Check both the current PID file
94
+ // location and the legacy location.
95
+ const qdrantPidFile = join(
96
+ vellumDir,
97
+ "workspace",
98
+ "data",
99
+ "qdrant",
100
+ "qdrant.pid",
101
+ );
102
+ const qdrantLegacyPidFile = join(vellumDir, "qdrant.pid");
103
+ await stopProcessByPidFile(qdrantPidFile, "qdrant", undefined, 5000);
104
+ await stopProcessByPidFile(qdrantLegacyPidFile, "qdrant", undefined, 5000);
105
+
87
106
  // If the PID file didn't track a running daemon, scan for orphaned
88
107
  // daemon processes that may have been started without writing a PID.
89
108
  if (!daemonStopped) {
@@ -116,12 +135,12 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
116
135
  try {
117
136
  renameSync(dirToArchive, stagingDir);
118
137
  } catch (err) {
119
- console.warn(
120
- `⚠️ Failed to move ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
138
+ // Re-throw so the caller (and the desktop app) knows the archive failed.
139
+ // If the rename fails, old workspace data stays in place and a subsequent
140
+ // hatch would inherit stale SOUL.md, IDENTITY.md, and memories.
141
+ throw new Error(
142
+ `Failed to archive ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
121
143
  );
122
- console.warn("Skipping archive.");
123
- console.log("\u2705 Local instance retired.");
124
- return;
125
144
  }
126
145
 
127
146
  writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
@@ -189,10 +208,15 @@ async function retireVellum(assistantId: string): Promise<void> {
189
208
  process.exit(1);
190
209
  }
191
210
 
211
+ const orgId = await fetchOrganizationId(token);
212
+
192
213
  const url = `${getPlatformUrl()}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
193
214
  const response = await fetch(url, {
194
215
  method: "DELETE",
195
- headers: { "X-Session-Token": token },
216
+ headers: {
217
+ "X-Session-Token": token,
218
+ "Vellum-Organization-Id": orgId,
219
+ },
196
220
  });
197
221
 
198
222
  if (!response.ok) {