@vellumai/cli 0.6.5 → 0.7.0

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 (47) hide show
  1. package/AGENTS.md +8 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/assistant-config.test.ts +1 -7
  4. package/src/__tests__/config-utils.test.ts +159 -0
  5. package/src/__tests__/env-drift.test.ts +10 -32
  6. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  7. package/src/__tests__/multi-local.test.ts +0 -5
  8. package/src/__tests__/sleep.test.ts +1 -2
  9. package/src/__tests__/teleport.test.ts +919 -1255
  10. package/src/commands/env.ts +93 -0
  11. package/src/commands/events.ts +2 -0
  12. package/src/commands/exec.ts +40 -8
  13. package/src/commands/hatch.ts +6 -2
  14. package/src/commands/login.ts +89 -6
  15. package/src/commands/ps.ts +104 -20
  16. package/src/commands/retire.ts +23 -0
  17. package/src/commands/sleep.ts +5 -2
  18. package/src/commands/ssh.ts +15 -2
  19. package/src/commands/teleport.ts +447 -583
  20. package/src/commands/terminal.ts +225 -0
  21. package/src/commands/wake.ts +2 -1
  22. package/src/components/DefaultMainScreen.tsx +304 -152
  23. package/src/index.ts +6 -0
  24. package/src/lib/__tests__/docker.test.ts +50 -74
  25. package/src/lib/__tests__/job-polling.test.ts +278 -0
  26. package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  28. package/src/lib/assistant-config.ts +12 -8
  29. package/src/lib/client-identity.ts +67 -0
  30. package/src/lib/config-utils.ts +97 -1
  31. package/src/lib/docker.ts +73 -75
  32. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  33. package/src/lib/environments/resolve.ts +89 -7
  34. package/src/lib/environments/seeds.ts +8 -5
  35. package/src/lib/environments/types.ts +10 -0
  36. package/src/lib/hatch-local.ts +15 -120
  37. package/src/lib/health-check.ts +98 -0
  38. package/src/lib/job-polling.ts +195 -0
  39. package/src/lib/local-runtime-client.ts +178 -0
  40. package/src/lib/local.ts +139 -15
  41. package/src/lib/orphan-detection.ts +2 -35
  42. package/src/lib/platform-client.ts +215 -0
  43. package/src/lib/retire-local.ts +6 -2
  44. package/src/lib/terminal-client.ts +177 -0
  45. package/src/lib/terminal-session.ts +457 -0
  46. package/src/shared/provider-env-vars.ts +2 -3
  47. package/src/__tests__/orphan-detection.test.ts +0 -214
package/src/lib/local.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, execSync, spawn } from "child_process";
2
- import { randomBytes } from "crypto";
2
+ import { createHash, randomBytes } from "crypto";
3
3
  import {
4
4
  existsSync,
5
5
  mkdirSync,
@@ -8,10 +8,13 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { createRequire } from "module";
11
- import { homedir, hostname, networkInterfaces, platform } from "os";
11
+ import { homedir, hostname, networkInterfaces, platform, tmpdir } from "os";
12
12
  import { dirname, join } from "path";
13
13
 
14
- import { type LocalInstanceResources } from "./assistant-config.js";
14
+ import {
15
+ getDaemonPidPath,
16
+ type LocalInstanceResources,
17
+ } from "./assistant-config.js";
15
18
  import { GATEWAY_PORT } from "./constants.js";
16
19
  import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
17
20
  import { stopProcessByPidFile } from "./process.js";
@@ -19,6 +22,107 @@ import { openLogFile, pipeToLogFile } from "./xdg-log.js";
19
22
 
20
23
  const _require = createRequire(import.meta.url);
21
24
 
25
+ // macOS AF_UNIX path limit (sun_path is 104 bytes, null-terminated → 103 usable).
26
+ const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
27
+
28
+ // The longest socket filename we place in the workspace directory.
29
+ // assistant-skill.sock = 20 chars, plus 1 for the "/" separator = 21 overhead.
30
+ const LONGEST_SOCKET_FILENAME = "assistant-skill.sock";
31
+
32
+ /**
33
+ * Warn when an assistant appears to have legacy data in the global workspace.
34
+ *
35
+ * Old local startup paths could launch the daemon without
36
+ * `VELLUM_WORKSPACE_DIR`, causing writes to fall back to `~/.vellum/workspace`.
37
+ * New local instance launches pin the workspace under
38
+ * `<instanceDir>/.vellum/workspace`. If we detect data only in the legacy
39
+ * global path, warn with migration instructions so users are not surprised by
40
+ * missing history/settings after the fix.
41
+ */
42
+ function warnIfLegacyWorkspaceFallbackDetected(
43
+ resources: LocalInstanceResources,
44
+ ): void {
45
+ const instanceWorkspace = join(resources.instanceDir, ".vellum", "workspace");
46
+ const instanceDbPath = join(instanceWorkspace, "data", "db", "assistant.db");
47
+
48
+ const legacyWorkspace = join(homedir(), ".vellum", "workspace");
49
+ const legacyDbPath = join(legacyWorkspace, "data", "db", "assistant.db");
50
+
51
+ // Legacy "first local" entries use ~/.vellum directly; no drift possible.
52
+ if (instanceWorkspace === legacyWorkspace) return;
53
+
54
+ if (existsSync(legacyDbPath) && !existsSync(instanceDbPath)) {
55
+ console.warn("");
56
+ console.warn(
57
+ "WARNING: Detected legacy workspace data in ~/.vellum/workspace for this local assistant.",
58
+ );
59
+ console.warn(" What this means:");
60
+ console.warn(
61
+ " - An older startup path likely wrote assistant data to the global workspace.",
62
+ );
63
+ console.warn(
64
+ " - This assistant now uses its instance workspace instead:",
65
+ );
66
+ console.warn(` ${instanceWorkspace}`);
67
+ console.warn(" What to do:");
68
+ console.warn(
69
+ " 1. Stop the assistant before migrating files (retire/sleep or quit app).",
70
+ );
71
+ console.warn(
72
+ " 2. Copy needed data from ~/.vellum/workspace into the instance workspace.",
73
+ );
74
+ console.warn(
75
+ ` Example: cp -a ~/.vellum/workspace/data/db/assistant.db* ${join(instanceWorkspace, "data", "db")}/`,
76
+ );
77
+ console.warn(
78
+ " 3. Re-launch and confirm history/settings appear as expected.",
79
+ );
80
+ console.warn("");
81
+ }
82
+ }
83
+
84
+ /**
85
+ * On macOS, if `{workspaceDir}/assistant-skill.sock` would exceed the
86
+ * 103-byte AF_UNIX path limit, compute a short tmpdir-based IPC socket
87
+ * directory and return it. Returns `undefined` when no override is needed
88
+ * (the workspace path is short enough, or we're not on macOS).
89
+ */
90
+ function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
91
+ if (platform() !== "darwin") return undefined;
92
+
93
+ const longestPath = join(workspaceDir, LONGEST_SOCKET_FILENAME);
94
+ if (
95
+ Buffer.byteLength(longestPath, "utf8") <= DARWIN_UNIX_SOCKET_MAX_PATH_BYTES
96
+ ) {
97
+ return undefined;
98
+ }
99
+
100
+ // Use a short hash of the workspace dir so multiple instances get
101
+ // distinct socket directories under /tmp.
102
+ const hash = createHash("sha256")
103
+ .update(workspaceDir)
104
+ .digest("hex")
105
+ .slice(0, 12);
106
+ return join(tmpdir(), `vellum-ipc-${hash}`);
107
+ }
108
+
109
+ /**
110
+ * If the workspace path is too long for AF_UNIX sockets on macOS, compute
111
+ * a short override directory and set all IPC socket env vars on the target
112
+ * env object. No-op on non-macOS or when paths are within limits.
113
+ */
114
+ function applyIpcSocketDirOverride(env: Record<string, string>): void {
115
+ const workspaceDir =
116
+ env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
117
+ const override = computeIpcSocketDirOverride(workspaceDir);
118
+ if (!override) return;
119
+
120
+ mkdirSync(override, { recursive: true });
121
+ env.GATEWAY_IPC_SOCKET_DIR = override;
122
+ env.ASSISTANT_IPC_SOCKET_DIR = override;
123
+ env.ASSISTANT_SKILL_IPC_SOCKET_DIR = override;
124
+ }
125
+
22
126
  function isAssistantSourceDir(dir: string): boolean {
23
127
  const pkgPath = join(dir, "package.json");
24
128
  if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts")))
@@ -222,10 +326,9 @@ async function startDaemonFromSource(
222
326
  const daemonMainPath = resolveDaemonMainPath(assistantIndex);
223
327
 
224
328
  // Ensure the directory containing PID/socket files exists. For named
225
- // instances this is instanceDir/.vellum/ (matching daemon's getRootDir()).
226
- mkdirSync(dirname(resources.pidFile), { recursive: true });
227
-
228
- const pidFile = resources.pidFile;
329
+ // instances this is instanceDir/.vellum/workspace/ (matching daemon's getWorkspaceDir()).
330
+ const pidFile = getDaemonPidPath(resources);
331
+ mkdirSync(dirname(pidFile), { recursive: true });
229
332
 
230
333
  // --- Lifecycle guard: prevent split-brain daemon state ---
231
334
  if (existsSync(pidFile)) {
@@ -290,6 +393,11 @@ async function startDaemonFromSource(
290
393
  };
291
394
  if (resources) {
292
395
  env.BASE_DATA_DIR = resources.instanceDir;
396
+ env.VELLUM_WORKSPACE_DIR = join(
397
+ resources.instanceDir,
398
+ ".vellum",
399
+ "workspace",
400
+ );
293
401
  env.GATEWAY_SECURITY_DIR = join(
294
402
  resources.instanceDir,
295
403
  ".vellum",
@@ -349,9 +457,8 @@ async function startDaemonWatchFromSource(
349
457
  throw new Error(`Daemon main.ts not found at ${mainPath}`);
350
458
  }
351
459
 
352
- mkdirSync(dirname(resources.pidFile), { recursive: true });
353
-
354
- const pidFile = resources.pidFile;
460
+ const pidFile = getDaemonPidPath(resources);
461
+ mkdirSync(dirname(pidFile), { recursive: true });
355
462
 
356
463
  // --- Lifecycle guard: prevent split-brain daemon state ---
357
464
  // If a daemon is already running, skip spawning a new one.
@@ -417,6 +524,11 @@ async function startDaemonWatchFromSource(
417
524
  };
418
525
  if (resources) {
419
526
  env.BASE_DATA_DIR = resources.instanceDir;
527
+ env.VELLUM_WORKSPACE_DIR = join(
528
+ resources.instanceDir,
529
+ ".vellum",
530
+ "workspace",
531
+ );
420
532
  env.GATEWAY_SECURITY_DIR = join(
421
533
  resources.instanceDir,
422
534
  ".vellum",
@@ -769,6 +881,8 @@ export async function startLocalDaemon(
769
881
  resources: LocalInstanceResources,
770
882
  options?: DaemonStartOptions,
771
883
  ): Promise<void> {
884
+ warnIfLegacyWorkspaceFallbackDetected(resources);
885
+
772
886
  const foreground = options?.foreground ?? false;
773
887
  // Check for a compiled daemon binary adjacent to the CLI executable.
774
888
  // This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
@@ -779,7 +893,7 @@ export async function startLocalDaemon(
779
893
  // In watch mode, skip the bundled binary and use source (bun --watch
780
894
  // only works with source files, not compiled binaries).
781
895
 
782
- const pidFile = resources.pidFile;
896
+ const pidFile = getDaemonPidPath(resources);
783
897
 
784
898
  // If a daemon is already running, skip spawning a new one.
785
899
  // This prevents cascading kill→restart cycles when multiple callers
@@ -902,6 +1016,11 @@ export async function startLocalDaemon(
902
1016
  // all paths under the instance directory and listens on its own port.
903
1017
  if (resources) {
904
1018
  daemonEnv.BASE_DATA_DIR = resources.instanceDir;
1019
+ daemonEnv.VELLUM_WORKSPACE_DIR = join(
1020
+ resources.instanceDir,
1021
+ ".vellum",
1022
+ "workspace",
1023
+ );
905
1024
  daemonEnv.GATEWAY_SECURITY_DIR = join(
906
1025
  resources.instanceDir,
907
1026
  ".vellum",
@@ -917,6 +1036,8 @@ export async function startLocalDaemon(
917
1036
  daemonEnv.ACTOR_TOKEN_SIGNING_KEY = options.signingKey;
918
1037
  }
919
1038
 
1039
+ applyIpcSocketDirOverride(daemonEnv);
1040
+
920
1041
  // Write a sentinel PID file before spawning so concurrent hatch() calls
921
1042
  // see the file and fall through to the isDaemonResponsive() port check
922
1043
  // instead of racing to spawn a duplicate daemon.
@@ -1071,9 +1192,9 @@ export async function startGateway(
1071
1192
  VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
1072
1193
  }
1073
1194
  : {}),
1074
- // Set VELLUM_WORKSPACE_DIR and GATEWAY_SECURITY_DIR so the gateway
1075
- // loads the correct credentials and workspace config for this instance
1076
- // (mirrors the daemon env setup).
1195
+ // Pin gateway workspace/security paths to the named instance so parent
1196
+ // env vars cannot leak a different workspace. The gateway opens the
1197
+ // assistant DB directly for guardian bootstrap.
1077
1198
  ...(resources
1078
1199
  ? {
1079
1200
  BASE_DATA_DIR: resources.instanceDir,
@@ -1090,6 +1211,9 @@ export async function startGateway(
1090
1211
  }
1091
1212
  : {}),
1092
1213
  };
1214
+
1215
+ applyIpcSocketDirOverride(gatewayEnv);
1216
+
1093
1217
  if (publicUrl) {
1094
1218
  console.log(` Ingress URL: ${publicUrl}`);
1095
1219
  }
@@ -1186,7 +1310,7 @@ export async function stopLocalProcesses(
1186
1310
  const vellumDir = resources
1187
1311
  ? join(resources.instanceDir, ".vellum")
1188
1312
  : join(homedir(), ".vellum");
1189
- const daemonPidFile = resources?.pidFile ?? join(vellumDir, "vellum.pid");
1313
+ const daemonPidFile = getDaemonPidPath(resources);
1190
1314
  await stopProcessByPidFile(daemonPidFile, "daemon");
1191
1315
 
1192
1316
  const gatewayPidFile = join(vellumDir, "gateway.pid");
@@ -1,8 +1,5 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
- import { join } from "path";
4
2
 
5
- import { loadAllAssistants } from "./assistant-config.js";
6
3
  import { execOutput } from "./step-runner";
7
4
 
8
5
  export interface RemoteProcess {
@@ -74,38 +71,8 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
74
71
  const results: OrphanedProcess[] = [];
75
72
  const seenPids = new Set<string>();
76
73
 
77
- // Collect every known local instance's `.vellum/` directory from the
78
- // lockfile so orphan detection scans all containers under the current
79
- // multi-instance data layout, not just the legacy `~/.vellum/` root.
80
- const dirs = new Set<string>();
81
- for (const entry of loadAllAssistants()) {
82
- if (entry.cloud !== "local" || !entry.resources) continue;
83
- dirs.add(join(entry.resources.instanceDir, ".vellum"));
84
- }
85
- // Preserve the legacy root scan for installs that predate multi-instance
86
- // tracking. This catches orphans from a pre-upgrade `~/.vellum/` that
87
- // may not have a lockfile entry at all.
88
- dirs.add(join(homedir(), ".vellum"));
89
-
90
- // Strategy 1: PID file scan — check every known data directory.
91
- for (const dir of dirs) {
92
- const pidFiles: Array<{ file: string; name: string }> = [
93
- { file: join(dir, "vellum.pid"), name: "assistant" },
94
- { file: join(dir, "gateway.pid"), name: "gateway" },
95
- { file: join(dir, "qdrant.pid"), name: "qdrant" },
96
- ];
97
-
98
- for (const { file, name } of pidFiles) {
99
- const pid = readPidFile(file);
100
- if (!pid || seenPids.has(pid)) continue;
101
- if (isProcessAlive(pid)) {
102
- results.push({ name, pid, source: "pid file" });
103
- seenPids.add(pid);
104
- }
105
- }
106
- }
107
-
108
- // Strategy 2: Process table scan
74
+ // Process table scan discover orphaned processes by scanning the OS
75
+ // process table rather than reading PID files from the workspace.
109
76
  try {
110
77
  const output = await execOutput("sh", [
111
78
  "-c",
@@ -40,6 +40,19 @@ export function getPlatformUrl(): string {
40
40
  );
41
41
  }
42
42
 
43
+ /**
44
+ * Resolve the web app (Next.js) base URL for browser-facing pages like
45
+ * `/account/login`. Mirrors `VellumEnvironment.resolvedWebURL` on the
46
+ * Swift side.
47
+ *
48
+ * Resolution order:
49
+ * 1. `VELLUM_WEB_URL` env var (explicit override)
50
+ * 2. The current environment's seed web URL
51
+ */
52
+ export function getWebUrl(): string {
53
+ return process.env.VELLUM_WEB_URL?.trim() || getCurrentEnvironment().webUrl;
54
+ }
55
+
43
56
  export function readPlatformToken(): string | null {
44
57
  try {
45
58
  return readFileSync(getPlatformTokenPath(), "utf-8").trim();
@@ -516,6 +529,30 @@ export async function checkExistingPlatformAssistant(
516
529
  return active ?? null;
517
530
  }
518
531
 
532
+ /**
533
+ * Fetch all active assistants for the authenticated user from the platform.
534
+ * Returns an empty array on failure (non-fatal).
535
+ */
536
+ export async function fetchPlatformAssistants(
537
+ token: string,
538
+ platformUrl?: string,
539
+ ): Promise<HatchedAssistant[]> {
540
+ const resolvedUrl = platformUrl || getPlatformUrl();
541
+ const url = `${resolvedUrl}/v1/assistants/`;
542
+
543
+ const response = await fetch(url, {
544
+ headers: await authHeaders(token, platformUrl),
545
+ });
546
+
547
+ if (!response.ok) return [];
548
+
549
+ const body = (await response.json()) as {
550
+ results?: HatchedAssistant[];
551
+ };
552
+
553
+ return (body.results ?? []).filter((a) => a.status === "active");
554
+ }
555
+
519
556
  export interface PlatformUser {
520
557
  id: string;
521
558
  email: string;
@@ -913,3 +950,181 @@ export async function platformPollImportStatus(
913
950
  error: body.error,
914
951
  };
915
952
  }
953
+
954
+ // ---------------------------------------------------------------------------
955
+ // Unified signed-url + job-status endpoints (teleport-gcs-unify)
956
+ // ---------------------------------------------------------------------------
957
+
958
+ /**
959
+ * Discriminated union representing the unified migration job status shape
960
+ * returned by `GET /v1/migrations/jobs/{job_id}/` on both the platform and
961
+ * the local runtime.
962
+ */
963
+ export type UnifiedJobStatus =
964
+ | {
965
+ jobId: string;
966
+ type: "export" | "import";
967
+ status: "processing";
968
+ }
969
+ | {
970
+ jobId: string;
971
+ type: "export" | "import";
972
+ status: "complete";
973
+ bundleKey?: string;
974
+ result?: unknown;
975
+ }
976
+ | {
977
+ jobId: string;
978
+ type: "export" | "import";
979
+ status: "failed";
980
+ error: string;
981
+ };
982
+
983
+ interface RawUnifiedJobStatus {
984
+ job_id: string;
985
+ type: "export" | "import";
986
+ status: "processing" | "complete" | "failed";
987
+ bundle_key?: string;
988
+ result?: unknown;
989
+ error?: string;
990
+ }
991
+
992
+ /**
993
+ * Normalise the wire-format job-status payload into the TypeScript
994
+ * discriminated union. Shared between platform and local-runtime helpers
995
+ * since both endpoints return the same shape.
996
+ */
997
+ export function parseUnifiedJobStatus(
998
+ raw: RawUnifiedJobStatus,
999
+ ): UnifiedJobStatus {
1000
+ if (raw.status === "processing") {
1001
+ return { jobId: raw.job_id, type: raw.type, status: "processing" };
1002
+ }
1003
+ if (raw.status === "complete") {
1004
+ return {
1005
+ jobId: raw.job_id,
1006
+ type: raw.type,
1007
+ status: "complete",
1008
+ bundleKey: raw.bundle_key,
1009
+ result: raw.result,
1010
+ };
1011
+ }
1012
+ return {
1013
+ jobId: raw.job_id,
1014
+ type: raw.type,
1015
+ status: "failed",
1016
+ error: raw.error ?? "Job failed without an error message",
1017
+ };
1018
+ }
1019
+
1020
+ /**
1021
+ * Request a signed URL from the platform for either uploading a new bundle
1022
+ * or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
1023
+ *
1024
+ * - `operation: "upload"` (optionally with `contentType` / `contentLength`)
1025
+ * returns a URL the CLI can PUT a bundle to.
1026
+ * - `operation: "download"` with a `bundleKey` returns a URL the local
1027
+ * runtime can GET the bundle from during an import-from-GCS flow.
1028
+ *
1029
+ * Retries once with a fresh org-ID cache on 401 to match the retry pattern
1030
+ * used by other authenticated platform helpers. 503 is bubbled up so
1031
+ * callers can decide to fall back (e.g. legacy inline upload).
1032
+ */
1033
+ export async function platformRequestSignedUrl(
1034
+ params: {
1035
+ operation: "upload" | "download";
1036
+ bundleKey?: string;
1037
+ contentType?: string;
1038
+ contentLength?: number;
1039
+ },
1040
+ token: string,
1041
+ platformUrl?: string,
1042
+ ): Promise<{
1043
+ url: string;
1044
+ bundleKey: string;
1045
+ expiresAt: string;
1046
+ maxContentLength?: number;
1047
+ }> {
1048
+ const resolvedUrl = platformUrl || getPlatformUrl();
1049
+ const body: Record<string, unknown> = { operation: params.operation };
1050
+ if (params.bundleKey !== undefined) body.bundle_key = params.bundleKey;
1051
+ if (params.contentType !== undefined) body.content_type = params.contentType;
1052
+ if (params.contentLength !== undefined) {
1053
+ body.content_length = params.contentLength;
1054
+ }
1055
+
1056
+ const doRequest = async (): Promise<Response> =>
1057
+ fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
1058
+ method: "POST",
1059
+ headers: await authHeaders(token, platformUrl),
1060
+ body: JSON.stringify(body),
1061
+ });
1062
+
1063
+ let response = await doRequest();
1064
+
1065
+ if (response.status === 401) {
1066
+ // Invalidate the cached org-ID (if any) and retry once with a fresh
1067
+ // lookup. For session-token callers, a 401 frequently means the
1068
+ // cached org ID is stale — calling doRequest() again without clearing
1069
+ // the cache would just send the same stale header and fail again.
1070
+ orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
1071
+ response = await doRequest();
1072
+ }
1073
+
1074
+ if (response.status === 201 || response.status === 200) {
1075
+ const json = (await response.json()) as {
1076
+ url: string;
1077
+ bundle_key: string;
1078
+ expires_at: string;
1079
+ max_content_length?: number;
1080
+ };
1081
+ return {
1082
+ url: json.url,
1083
+ bundleKey: json.bundle_key,
1084
+ expiresAt: json.expires_at,
1085
+ maxContentLength: json.max_content_length,
1086
+ };
1087
+ }
1088
+
1089
+ if (response.status === 503) {
1090
+ throw new Error(
1091
+ `Signed URL endpoint unavailable (503) — caller may fall back`,
1092
+ );
1093
+ }
1094
+
1095
+ const errorBody = (await response.json().catch(() => ({}))) as {
1096
+ detail?: string;
1097
+ };
1098
+ throw new Error(
1099
+ errorBody.detail ??
1100
+ `Failed to request signed URL: ${response.status} ${response.statusText}`,
1101
+ );
1102
+ }
1103
+
1104
+ /**
1105
+ * Poll the unified job-status endpoint on the platform. Calls
1106
+ * `GET /v1/migrations/jobs/{jobId}/` and parses into {@link UnifiedJobStatus}.
1107
+ */
1108
+ export async function platformPollJobStatus(
1109
+ jobId: string,
1110
+ token: string,
1111
+ platformUrl?: string,
1112
+ ): Promise<UnifiedJobStatus> {
1113
+ const resolvedUrl = platformUrl || getPlatformUrl();
1114
+ const response = await fetch(`${resolvedUrl}/v1/migrations/jobs/${jobId}/`, {
1115
+ headers: await authHeaders(token, platformUrl),
1116
+ });
1117
+
1118
+ if (response.status === 404) {
1119
+ throw new Error("Migration job not found");
1120
+ }
1121
+
1122
+ if (!response.ok) {
1123
+ throw new Error(
1124
+ `Job status check failed: ${response.status} ${response.statusText}`,
1125
+ );
1126
+ }
1127
+
1128
+ const raw = (await response.json()) as RawUnifiedJobStatus;
1129
+ return parseUnifiedJobStatus(raw);
1130
+ }
@@ -2,7 +2,11 @@ import { spawn } from "child_process";
2
2
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
3
  import { basename, dirname, join } from "path";
4
4
 
5
- import { getBaseDir, loadAllAssistants } from "./assistant-config.js";
5
+ import {
6
+ getBaseDir,
7
+ getDaemonPidPath,
8
+ loadAllAssistants,
9
+ } from "./assistant-config.js";
6
10
  import type { AssistantEntry } from "./assistant-config.js";
7
11
  import {
8
12
  stopOrphanedDaemonProcesses,
@@ -41,7 +45,7 @@ export async function retireLocal(
41
45
  return;
42
46
  }
43
47
 
44
- const daemonPidFile = resources.pidFile;
48
+ const daemonPidFile = getDaemonPidPath(resources);
45
49
  const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon");
46
50
 
47
51
  // Stop gateway via PID file — use a longer timeout because the gateway has a