@vellumai/cli 0.6.6 → 0.7.1

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 (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -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;
@@ -628,182 +665,10 @@ export async function rollbackPlatformAssistant(
628
665
  throw new Error(`Rollback failed: ${response.status} ${response.statusText}`);
629
666
  }
630
667
 
631
- // ---------------------------------------------------------------------------
632
- // Migration export
633
- // ---------------------------------------------------------------------------
634
-
635
- export async function platformInitiateExport(
636
- token: string,
637
- description?: string,
638
- platformUrl?: string,
639
- ): Promise<{ jobId: string; status: string }> {
640
- const resolvedUrl = platformUrl || getPlatformUrl();
641
- const response = await fetch(`${resolvedUrl}/v1/migrations/export/`, {
642
- method: "POST",
643
- headers: await authHeaders(token, platformUrl),
644
- body: JSON.stringify({ description: description ?? "CLI backup" }),
645
- });
646
-
647
- if (response.status !== 201) {
648
- const body = (await response.json().catch(() => ({}))) as {
649
- detail?: string;
650
- };
651
- throw new Error(
652
- body.detail ??
653
- `Export initiation failed: ${response.status} ${response.statusText}`,
654
- );
655
- }
656
-
657
- const body = (await response.json()) as {
658
- job_id: string;
659
- status: string;
660
- };
661
- return { jobId: body.job_id, status: body.status };
662
- }
663
-
664
- export async function platformPollExportStatus(
665
- jobId: string,
666
- token: string,
667
- platformUrl?: string,
668
- ): Promise<{ status: string; downloadUrl?: string; error?: string }> {
669
- const resolvedUrl = platformUrl || getPlatformUrl();
670
- const response = await fetch(
671
- `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
672
- {
673
- headers: await authHeaders(token, platformUrl),
674
- },
675
- );
676
-
677
- if (response.status === 404) {
678
- throw new Error("Export job not found");
679
- }
680
-
681
- if (!response.ok) {
682
- throw new Error(
683
- `Export status check failed: ${response.status} ${response.statusText}`,
684
- );
685
- }
686
-
687
- const body = (await response.json()) as {
688
- status: string;
689
- download_url?: string;
690
- error?: string;
691
- };
692
- return {
693
- status: body.status,
694
- downloadUrl: body.download_url,
695
- error: body.error,
696
- };
697
- }
698
-
699
- export async function platformDownloadExport(
700
- downloadUrl: string,
701
- ): Promise<Response> {
702
- const response = await fetch(downloadUrl);
703
- if (!response.ok) {
704
- throw new Error(
705
- `Download failed: ${response.status} ${response.statusText}`,
706
- );
707
- }
708
- return response;
709
- }
710
-
711
- // ---------------------------------------------------------------------------
712
- // Migration import
713
- // ---------------------------------------------------------------------------
714
-
715
- export async function platformImportPreflight(
716
- bundleData: Uint8Array<ArrayBuffer>,
717
- token: string,
718
- platformUrl?: string,
719
- ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
720
- const resolvedUrl = platformUrl || getPlatformUrl();
721
- const response = await fetch(
722
- `${resolvedUrl}/v1/migrations/import-preflight/`,
723
- {
724
- method: "POST",
725
- headers: {
726
- ...(await authHeaders(token, platformUrl)),
727
- "Content-Type": "application/octet-stream",
728
- },
729
- body: new Blob([bundleData]),
730
- signal: AbortSignal.timeout(120_000),
731
- },
732
- );
733
-
734
- const body = (await response.json().catch(() => ({}))) as Record<
735
- string,
736
- unknown
737
- >;
738
- return { statusCode: response.status, body };
739
- }
740
-
741
- export async function platformImportBundle(
742
- bundleData: Uint8Array<ArrayBuffer>,
743
- token: string,
744
- platformUrl?: string,
745
- ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
746
- const resolvedUrl = platformUrl || getPlatformUrl();
747
- const response = await fetch(`${resolvedUrl}/v1/migrations/import/`, {
748
- method: "POST",
749
- headers: {
750
- ...(await authHeaders(token, platformUrl)),
751
- "Content-Type": "application/octet-stream",
752
- },
753
- body: new Blob([bundleData]),
754
- signal: AbortSignal.timeout(300_000),
755
- });
756
-
757
- const body = (await response.json().catch(() => ({}))) as Record<
758
- string,
759
- unknown
760
- >;
761
- return { statusCode: response.status, body };
762
- }
763
-
764
668
  // ---------------------------------------------------------------------------
765
669
  // Signed-URL upload flow
766
670
  // ---------------------------------------------------------------------------
767
671
 
768
- export async function platformRequestUploadUrl(
769
- token: string,
770
- platformUrl?: string,
771
- ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
772
- const resolvedUrl = platformUrl || getPlatformUrl();
773
- const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
774
- method: "POST",
775
- headers: await authHeaders(token, platformUrl),
776
- body: JSON.stringify({ content_type: "application/octet-stream" }),
777
- });
778
-
779
- if (response.status === 201) {
780
- const body = (await response.json()) as {
781
- upload_url: string;
782
- bundle_key: string;
783
- expires_at: string;
784
- };
785
- return {
786
- uploadUrl: body.upload_url,
787
- bundleKey: body.bundle_key,
788
- expiresAt: body.expires_at,
789
- };
790
- }
791
-
792
- if (response.status === 404 || response.status === 503) {
793
- throw new Error(
794
- "Signed uploads are not available on this platform instance",
795
- );
796
- }
797
-
798
- const errorBody = (await response.json().catch(() => ({}))) as {
799
- detail?: string;
800
- };
801
- throw new Error(
802
- errorBody.detail ??
803
- `Failed to request upload URL: ${response.status} ${response.statusText}`,
804
- );
805
- }
806
-
807
672
  export async function platformUploadToSignedUrl(
808
673
  uploadUrl: string,
809
674
  bundleData: Uint8Array<ArrayBuffer>,
@@ -874,42 +739,173 @@ export async function platformImportBundleFromGcs(
874
739
  return { statusCode: response.status, body };
875
740
  }
876
741
 
877
- export async function platformPollImportStatus(
878
- jobId: string,
742
+ // ---------------------------------------------------------------------------
743
+ // Unified signed-url + job-status endpoints (teleport-gcs-unify)
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /**
747
+ * Discriminated union representing the unified migration job status shape
748
+ * returned by `GET /v1/migrations/jobs/{job_id}/` on both the platform and
749
+ * the local runtime.
750
+ */
751
+ export type UnifiedJobStatus =
752
+ | {
753
+ jobId: string;
754
+ type: "export" | "import";
755
+ status: "processing";
756
+ }
757
+ | {
758
+ jobId: string;
759
+ type: "export" | "import";
760
+ status: "complete";
761
+ bundleKey?: string;
762
+ result?: unknown;
763
+ }
764
+ | {
765
+ jobId: string;
766
+ type: "export" | "import";
767
+ status: "failed";
768
+ error: string;
769
+ };
770
+
771
+ interface RawUnifiedJobStatus {
772
+ job_id: string;
773
+ type: "export" | "import";
774
+ status: "processing" | "complete" | "failed";
775
+ bundle_key?: string;
776
+ result?: unknown;
777
+ error?: string;
778
+ }
779
+
780
+ /**
781
+ * Normalise the wire-format job-status payload into the TypeScript
782
+ * discriminated union. Shared between platform and local-runtime helpers
783
+ * since both endpoints return the same shape.
784
+ */
785
+ export function parseUnifiedJobStatus(
786
+ raw: RawUnifiedJobStatus,
787
+ ): UnifiedJobStatus {
788
+ if (raw.status === "processing") {
789
+ return { jobId: raw.job_id, type: raw.type, status: "processing" };
790
+ }
791
+ if (raw.status === "complete") {
792
+ return {
793
+ jobId: raw.job_id,
794
+ type: raw.type,
795
+ status: "complete",
796
+ bundleKey: raw.bundle_key,
797
+ result: raw.result,
798
+ };
799
+ }
800
+ return {
801
+ jobId: raw.job_id,
802
+ type: raw.type,
803
+ status: "failed",
804
+ error: raw.error ?? "Job failed without an error message",
805
+ };
806
+ }
807
+
808
+ /**
809
+ * Request a signed URL from the platform for either uploading a new bundle
810
+ * or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
811
+ *
812
+ * - `operation: "upload"` (optionally with `contentType` / `contentLength`)
813
+ * returns a URL the CLI can PUT a bundle to.
814
+ * - `operation: "download"` with a `bundleKey` returns a URL the local
815
+ * runtime can GET the bundle from during an import-from-GCS flow.
816
+ *
817
+ * Retries once with a fresh org-ID cache on 401 to match the retry pattern
818
+ * used by other authenticated platform helpers.
819
+ */
820
+ export async function platformRequestSignedUrl(
821
+ params: {
822
+ operation: "upload" | "download";
823
+ bundleKey?: string;
824
+ contentType?: string;
825
+ contentLength?: number;
826
+ },
879
827
  token: string,
880
828
  platformUrl?: string,
881
829
  ): Promise<{
882
- status: string;
883
- result?: Record<string, unknown>;
884
- error?: string;
830
+ url: string;
831
+ bundleKey: string;
832
+ expiresAt: string;
833
+ maxContentLength?: number;
885
834
  }> {
886
835
  const resolvedUrl = platformUrl || getPlatformUrl();
887
- const response = await fetch(
888
- `${resolvedUrl}/v1/migrations/import/${jobId}/status/`,
889
- {
836
+ const body: Record<string, unknown> = { operation: params.operation };
837
+ if (params.bundleKey !== undefined) body.bundle_key = params.bundleKey;
838
+ if (params.contentType !== undefined) body.content_type = params.contentType;
839
+ if (params.contentLength !== undefined) {
840
+ body.content_length = params.contentLength;
841
+ }
842
+
843
+ const doRequest = async (): Promise<Response> =>
844
+ fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
845
+ method: "POST",
890
846
  headers: await authHeaders(token, platformUrl),
891
- },
847
+ body: JSON.stringify(body),
848
+ });
849
+
850
+ let response = await doRequest();
851
+
852
+ if (response.status === 401) {
853
+ // Invalidate the cached org-ID (if any) and retry once with a fresh
854
+ // lookup. For session-token callers, a 401 frequently means the
855
+ // cached org ID is stale — calling doRequest() again without clearing
856
+ // the cache would just send the same stale header and fail again.
857
+ orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
858
+ response = await doRequest();
859
+ }
860
+
861
+ if (response.status === 201 || response.status === 200) {
862
+ const json = (await response.json()) as {
863
+ url: string;
864
+ bundle_key: string;
865
+ expires_at: string;
866
+ max_content_length?: number;
867
+ };
868
+ return {
869
+ url: json.url,
870
+ bundleKey: json.bundle_key,
871
+ expiresAt: json.expires_at,
872
+ maxContentLength: json.max_content_length,
873
+ };
874
+ }
875
+
876
+ const errorBody = (await response.json().catch(() => ({}))) as {
877
+ detail?: string;
878
+ };
879
+ throw new Error(
880
+ errorBody.detail ??
881
+ `Failed to request signed URL: ${response.status} ${response.statusText}`,
892
882
  );
883
+ }
884
+
885
+ /**
886
+ * Poll the unified job-status endpoint on the platform. Calls
887
+ * `GET /v1/migrations/jobs/{jobId}/` and parses into {@link UnifiedJobStatus}.
888
+ */
889
+ export async function platformPollJobStatus(
890
+ jobId: string,
891
+ token: string,
892
+ platformUrl?: string,
893
+ ): Promise<UnifiedJobStatus> {
894
+ const resolvedUrl = platformUrl || getPlatformUrl();
895
+ const response = await fetch(`${resolvedUrl}/v1/migrations/jobs/${jobId}/`, {
896
+ headers: await authHeaders(token, platformUrl),
897
+ });
893
898
 
894
899
  if (response.status === 404) {
895
- throw new Error("Import job not found");
900
+ throw new Error("Migration job not found");
896
901
  }
897
902
 
898
903
  if (!response.ok) {
899
904
  throw new Error(
900
- `Import status check failed: ${response.status} ${response.statusText}`,
905
+ `Job status check failed: ${response.status} ${response.statusText}`,
901
906
  );
902
907
  }
903
908
 
904
- const body = (await response.json()) as {
905
- status: string;
906
- job_id?: string;
907
- result?: Record<string, unknown>;
908
- error?: string;
909
- };
910
- return {
911
- status: body.status,
912
- result: body.result,
913
- error: body.error,
914
- };
909
+ const raw = (await response.json()) as RawUnifiedJobStatus;
910
+ return parseUnifiedJobStatus(raw);
915
911
  }
@@ -7,6 +7,29 @@ export interface ResolvedImageRefs {
7
7
  source: "platform" | "dockerhub";
8
8
  }
9
9
 
10
+ /**
11
+ * Fetch the latest stable release version from the platform API.
12
+ * Returns the version string (e.g. "0.7.0") or null if unavailable.
13
+ * The releases endpoint returns entries ordered newest-first.
14
+ */
15
+ export async function fetchLatestStableVersion(): Promise<string | null> {
16
+ try {
17
+ const platformUrl = getPlatformUrl();
18
+ const response = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
19
+ signal: AbortSignal.timeout(10_000),
20
+ });
21
+ if (!response.ok) return null;
22
+
23
+ const releases = (await response.json()) as Array<{
24
+ version?: string;
25
+ }>;
26
+ const first = releases[0];
27
+ return first?.version ?? null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
10
33
  /**
11
34
  * Resolve image references for a given version.
12
35
  *
@@ -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
@@ -0,0 +1,30 @@
1
+ import type { AssistantEntry } from "./assistant-config.js";
2
+
3
+ /**
4
+ * Resolve the URL for a runtime migration endpoint, taking the assistant's
5
+ * topology into account.
6
+ *
7
+ * - For local/docker assistants, `runtimeUrl` is the loopback gateway and
8
+ * the runtime serves `/v1/migrations/<subpath>` directly. The CLI hits
9
+ * that path with guardian-token bearer auth.
10
+ * - For platform-managed (cloud="vellum") assistants, `runtimeUrl` is the
11
+ * platform host (e.g. `https://platform.vellum.ai`). The platform's
12
+ * `MigrationViewSet` does NOT expose `export-to-gcs` or arbitrary runtime
13
+ * migration paths under `/v1/migrations/...`. The wildcard runtime proxy
14
+ * at `/v1/assistants/<id>/<path:rest>` is what forwards arbitrary runtime
15
+ * paths to the managed runtime — vembda's unified proxy bootstraps the
16
+ * guardian token internally for the runtime call. From the CLI side it's
17
+ * user-session auth.
18
+ *
19
+ * The `subpath` is appended to the migrations namespace verbatim
20
+ * (e.g. `"export-to-gcs"`, `"import-from-gcs"`, `\`jobs/${jobId}\``).
21
+ */
22
+ export function resolveRuntimeMigrationUrl(
23
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
24
+ subpath: string,
25
+ ): string {
26
+ if (entry.cloud === "vellum") {
27
+ return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/migrations/${subpath}`;
28
+ }
29
+ return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
30
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Sync cloud-managed (platform) assistants into the local lockfile.
3
+ *
4
+ * - Adds new platform assistants that aren't in the lockfile yet.
5
+ * - Removes lockfile entries whose IDs are no longer returned by the platform
6
+ * (e.g. retired assistants).
7
+ *
8
+ * Used by both `vellum login` and `vellum ps` to keep the lockfile fresh.
9
+ */
10
+
11
+ import {
12
+ loadAllAssistants,
13
+ removeAssistantEntry,
14
+ saveAssistantEntry,
15
+ } from "./assistant-config.js";
16
+ import {
17
+ fetchCurrentUser,
18
+ fetchPlatformAssistants,
19
+ getPlatformUrl,
20
+ readPlatformToken,
21
+ } from "./platform-client.js";
22
+
23
+ export type SyncLogger = (message: string) => void;
24
+
25
+ export interface SyncResult {
26
+ added: number;
27
+ removed: number;
28
+ email?: string;
29
+ }
30
+
31
+ export interface SyncOptions {
32
+ log?: SyncLogger;
33
+ }
34
+
35
+ /**
36
+ * Fetch platform assistants and reconcile against the lockfile.
37
+ * Returns the number of entries added/removed, or `null` if the user
38
+ * is not logged in or the fetch fails.
39
+ */
40
+ export async function syncCloudAssistants(
41
+ options?: SyncOptions,
42
+ ): Promise<SyncResult | null> {
43
+ const log = options?.log;
44
+ const platformUrl = getPlatformUrl();
45
+ log?.(`Platform URL: ${platformUrl}`);
46
+
47
+ const token = readPlatformToken();
48
+ if (!token) {
49
+ log?.("No platform token found — skipping cloud sync");
50
+ return null;
51
+ }
52
+ log?.(
53
+ `Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`,
54
+ );
55
+
56
+ // Fetch user info for the login status line
57
+ let email: string | undefined;
58
+ try {
59
+ log?.("Fetching current user…");
60
+ const user = await fetchCurrentUser(token);
61
+ email = user.email;
62
+ log?.(`Authenticated as ${user.email} (${user.id})`);
63
+ } catch (err) {
64
+ const msg = err instanceof Error ? err.message : String(err);
65
+ log?.(`Failed to fetch current user: ${msg}`);
66
+ }
67
+
68
+ let platformAssistants: { id: string; name: string; status: string }[];
69
+ try {
70
+ log?.("Fetching platform assistants…");
71
+ platformAssistants = await fetchPlatformAssistants(token);
72
+ log?.(
73
+ `Platform returned ${platformAssistants.length} assistant(s): ${platformAssistants.map((a) => a.name || a.id).join(", ") || "(none)"}`,
74
+ );
75
+ } catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ log?.(`fetchPlatformAssistants failed: ${msg}`);
78
+ return null;
79
+ }
80
+
81
+ if (platformAssistants.length === 0) {
82
+ log?.(
83
+ "Platform returned 0 assistants — this may mean the API returned a non-ok status (check token validity)",
84
+ );
85
+ }
86
+
87
+ const platformIds = new Set(platformAssistants.map((a) => a.id));
88
+
89
+ // Add new platform assistants not yet in the lockfile
90
+ const existingCloudIds = new Set(
91
+ loadAllAssistants()
92
+ .filter((a) => a.cloud === "vellum")
93
+ .map((a) => a.assistantId),
94
+ );
95
+ log?.(
96
+ `Lockfile has ${existingCloudIds.size} cloud assistant(s): ${[...existingCloudIds].join(", ") || "(none)"}`,
97
+ );
98
+
99
+ let added = 0;
100
+ for (const pa of platformAssistants) {
101
+ if (!existingCloudIds.has(pa.id)) {
102
+ log?.(`Adding ${pa.name || pa.id} to lockfile`);
103
+ saveAssistantEntry({
104
+ assistantId: pa.id,
105
+ runtimeUrl: getPlatformUrl(),
106
+ cloud: "vellum",
107
+ species: "vellum",
108
+ hatchedAt: new Date().toISOString(),
109
+ });
110
+ added++;
111
+ }
112
+ }
113
+
114
+ // Remove stale lockfile entries that the platform no longer knows about
115
+ let removed = 0;
116
+ for (const id of existingCloudIds) {
117
+ if (!platformIds.has(id)) {
118
+ log?.(`Removing stale entry ${id} from lockfile`);
119
+ removeAssistantEntry(id);
120
+ removed++;
121
+ }
122
+ }
123
+
124
+ log?.(`Sync complete: ${added} added, ${removed} removed`);
125
+ return { added, removed, email };
126
+ }
@@ -18,14 +18,19 @@ export async function createTerminalSession(
18
18
  cols: number,
19
19
  rows: number,
20
20
  platformUrl?: string,
21
+ service?: string,
21
22
  ): Promise<{ session_id: string }> {
22
23
  const baseUrl = platformUrl || getPlatformUrl();
24
+ const body: Record<string, unknown> = { cols, rows };
25
+ if (service) {
26
+ body.service = service;
27
+ }
23
28
  const response = await fetch(
24
29
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
25
30
  {
26
31
  method: "POST",
27
32
  headers: await authHeaders(token, platformUrl),
28
- body: JSON.stringify({ cols, rows }),
33
+ body: JSON.stringify(body),
29
34
  },
30
35
  );
31
36
  if (!response.ok) {