@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
@@ -99,6 +99,23 @@ function tokenAuthHeader(token: string): Record<string, string> {
99
99
  const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
100
100
  const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
101
101
 
102
+ /**
103
+ * Drop the cached org ID for a given (token, platformUrl) pair. Used by the
104
+ * one-shot 401-retry path: a 401 on a session-token request frequently means
105
+ * the cached `Vellum-Organization-Id` header is stale (e.g. user switched
106
+ * orgs in another tab). Clearing the entry forces the next `authHeaders`
107
+ * call to refetch the org ID from the platform.
108
+ *
109
+ * Exported so other modules (e.g. local-runtime-client) can implement the
110
+ * same retry pattern without needing direct access to the cache map.
111
+ */
112
+ export function invalidateOrgIdCache(
113
+ token: string,
114
+ platformUrl?: string,
115
+ ): void {
116
+ orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
117
+ }
118
+
102
119
  /**
103
120
  * Returns the full set of headers needed for an authenticated platform
104
121
  * API request:
@@ -468,6 +485,7 @@ export async function hatchAssistant(
468
485
  method: "POST",
469
486
  headers: await authHeaders(token, platformUrl),
470
487
  body: JSON.stringify({}),
488
+ signal: AbortSignal.timeout(300_000),
471
489
  });
472
490
 
473
491
  if (response.ok) {
@@ -665,182 +683,10 @@ export async function rollbackPlatformAssistant(
665
683
  throw new Error(`Rollback failed: ${response.status} ${response.statusText}`);
666
684
  }
667
685
 
668
- // ---------------------------------------------------------------------------
669
- // Migration export
670
- // ---------------------------------------------------------------------------
671
-
672
- export async function platformInitiateExport(
673
- token: string,
674
- description?: string,
675
- platformUrl?: string,
676
- ): Promise<{ jobId: string; status: string }> {
677
- const resolvedUrl = platformUrl || getPlatformUrl();
678
- const response = await fetch(`${resolvedUrl}/v1/migrations/export/`, {
679
- method: "POST",
680
- headers: await authHeaders(token, platformUrl),
681
- body: JSON.stringify({ description: description ?? "CLI backup" }),
682
- });
683
-
684
- if (response.status !== 201) {
685
- const body = (await response.json().catch(() => ({}))) as {
686
- detail?: string;
687
- };
688
- throw new Error(
689
- body.detail ??
690
- `Export initiation failed: ${response.status} ${response.statusText}`,
691
- );
692
- }
693
-
694
- const body = (await response.json()) as {
695
- job_id: string;
696
- status: string;
697
- };
698
- return { jobId: body.job_id, status: body.status };
699
- }
700
-
701
- export async function platformPollExportStatus(
702
- jobId: string,
703
- token: string,
704
- platformUrl?: string,
705
- ): Promise<{ status: string; downloadUrl?: string; error?: string }> {
706
- const resolvedUrl = platformUrl || getPlatformUrl();
707
- const response = await fetch(
708
- `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
709
- {
710
- headers: await authHeaders(token, platformUrl),
711
- },
712
- );
713
-
714
- if (response.status === 404) {
715
- throw new Error("Export job not found");
716
- }
717
-
718
- if (!response.ok) {
719
- throw new Error(
720
- `Export status check failed: ${response.status} ${response.statusText}`,
721
- );
722
- }
723
-
724
- const body = (await response.json()) as {
725
- status: string;
726
- download_url?: string;
727
- error?: string;
728
- };
729
- return {
730
- status: body.status,
731
- downloadUrl: body.download_url,
732
- error: body.error,
733
- };
734
- }
735
-
736
- export async function platformDownloadExport(
737
- downloadUrl: string,
738
- ): Promise<Response> {
739
- const response = await fetch(downloadUrl);
740
- if (!response.ok) {
741
- throw new Error(
742
- `Download failed: ${response.status} ${response.statusText}`,
743
- );
744
- }
745
- return response;
746
- }
747
-
748
- // ---------------------------------------------------------------------------
749
- // Migration import
750
- // ---------------------------------------------------------------------------
751
-
752
- export async function platformImportPreflight(
753
- bundleData: Uint8Array<ArrayBuffer>,
754
- token: string,
755
- platformUrl?: string,
756
- ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
757
- const resolvedUrl = platformUrl || getPlatformUrl();
758
- const response = await fetch(
759
- `${resolvedUrl}/v1/migrations/import-preflight/`,
760
- {
761
- method: "POST",
762
- headers: {
763
- ...(await authHeaders(token, platformUrl)),
764
- "Content-Type": "application/octet-stream",
765
- },
766
- body: new Blob([bundleData]),
767
- signal: AbortSignal.timeout(120_000),
768
- },
769
- );
770
-
771
- const body = (await response.json().catch(() => ({}))) as Record<
772
- string,
773
- unknown
774
- >;
775
- return { statusCode: response.status, body };
776
- }
777
-
778
- export async function platformImportBundle(
779
- bundleData: Uint8Array<ArrayBuffer>,
780
- token: string,
781
- platformUrl?: string,
782
- ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
783
- const resolvedUrl = platformUrl || getPlatformUrl();
784
- const response = await fetch(`${resolvedUrl}/v1/migrations/import/`, {
785
- method: "POST",
786
- headers: {
787
- ...(await authHeaders(token, platformUrl)),
788
- "Content-Type": "application/octet-stream",
789
- },
790
- body: new Blob([bundleData]),
791
- signal: AbortSignal.timeout(300_000),
792
- });
793
-
794
- const body = (await response.json().catch(() => ({}))) as Record<
795
- string,
796
- unknown
797
- >;
798
- return { statusCode: response.status, body };
799
- }
800
-
801
686
  // ---------------------------------------------------------------------------
802
687
  // Signed-URL upload flow
803
688
  // ---------------------------------------------------------------------------
804
689
 
805
- export async function platformRequestUploadUrl(
806
- token: string,
807
- platformUrl?: string,
808
- ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
809
- const resolvedUrl = platformUrl || getPlatformUrl();
810
- const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
811
- method: "POST",
812
- headers: await authHeaders(token, platformUrl),
813
- body: JSON.stringify({ content_type: "application/octet-stream" }),
814
- });
815
-
816
- if (response.status === 201) {
817
- const body = (await response.json()) as {
818
- upload_url: string;
819
- bundle_key: string;
820
- expires_at: string;
821
- };
822
- return {
823
- uploadUrl: body.upload_url,
824
- bundleKey: body.bundle_key,
825
- expiresAt: body.expires_at,
826
- };
827
- }
828
-
829
- if (response.status === 404 || response.status === 503) {
830
- throw new Error(
831
- "Signed uploads are not available on this platform instance",
832
- );
833
- }
834
-
835
- const errorBody = (await response.json().catch(() => ({}))) as {
836
- detail?: string;
837
- };
838
- throw new Error(
839
- errorBody.detail ??
840
- `Failed to request upload URL: ${response.status} ${response.statusText}`,
841
- );
842
- }
843
-
844
690
  export async function platformUploadToSignedUrl(
845
691
  uploadUrl: string,
846
692
  bundleData: Uint8Array<ArrayBuffer>,
@@ -911,46 +757,6 @@ export async function platformImportBundleFromGcs(
911
757
  return { statusCode: response.status, body };
912
758
  }
913
759
 
914
- export async function platformPollImportStatus(
915
- jobId: string,
916
- token: string,
917
- platformUrl?: string,
918
- ): Promise<{
919
- status: string;
920
- result?: Record<string, unknown>;
921
- error?: string;
922
- }> {
923
- const resolvedUrl = platformUrl || getPlatformUrl();
924
- const response = await fetch(
925
- `${resolvedUrl}/v1/migrations/import/${jobId}/status/`,
926
- {
927
- headers: await authHeaders(token, platformUrl),
928
- },
929
- );
930
-
931
- if (response.status === 404) {
932
- throw new Error("Import job not found");
933
- }
934
-
935
- if (!response.ok) {
936
- throw new Error(
937
- `Import status check failed: ${response.status} ${response.statusText}`,
938
- );
939
- }
940
-
941
- const body = (await response.json()) as {
942
- status: string;
943
- job_id?: string;
944
- result?: Record<string, unknown>;
945
- error?: string;
946
- };
947
- return {
948
- status: body.status,
949
- result: body.result,
950
- error: body.error,
951
- };
952
- }
953
-
954
760
  // ---------------------------------------------------------------------------
955
761
  // Unified signed-url + job-status endpoints (teleport-gcs-unify)
956
762
  // ---------------------------------------------------------------------------
@@ -1017,6 +823,45 @@ export function parseUnifiedJobStatus(
1017
823
  };
1018
824
  }
1019
825
 
826
+ export interface BundleCompatibility {
827
+ min_runtime_version: string;
828
+ max_runtime_version: string | null;
829
+ }
830
+
831
+ /**
832
+ * Thrown by platformRequestSignedUrl when the platform rejects a download
833
+ * signed-URL request because the target runtime version is outside the
834
+ * ExportJob's [min_runtime_version, max_runtime_version] band. Terminal
835
+ * — callers must NOT retry; surface to the user and abort the
836
+ * teleport/restore wizard.
837
+ */
838
+ export class VersionMismatchError extends Error {
839
+ readonly bundleCompat: BundleCompatibility;
840
+ readonly targetRuntimeVersion: string;
841
+
842
+ constructor(bundleCompat: BundleCompatibility, targetRuntimeVersion: string) {
843
+ super(
844
+ VersionMismatchError.formatMessage(bundleCompat, targetRuntimeVersion),
845
+ );
846
+ this.name = "VersionMismatchError";
847
+ this.bundleCompat = bundleCompat;
848
+ this.targetRuntimeVersion = targetRuntimeVersion;
849
+ }
850
+
851
+ static formatMessage(
852
+ compat: BundleCompatibility,
853
+ targetRuntimeVersion: string,
854
+ ): string {
855
+ const range = compat.max_runtime_version
856
+ ? `${compat.min_runtime_version}–${compat.max_runtime_version}`
857
+ : `${compat.min_runtime_version}+`;
858
+ return (
859
+ `Cannot import: bundle requires runtime ${range}, but this runtime is ${targetRuntimeVersion}. ` +
860
+ `Update your runtime before importing.`
861
+ );
862
+ }
863
+ }
864
+
1020
865
  /**
1021
866
  * Request a signed URL from the platform for either uploading a new bundle
1022
867
  * or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
@@ -1027,8 +872,10 @@ export function parseUnifiedJobStatus(
1027
872
  * runtime can GET the bundle from during an import-from-GCS flow.
1028
873
  *
1029
874
  * 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).
875
+ * used by other authenticated platform helpers.
876
+ *
877
+ * Throws {@link VersionMismatchError} on a 422 `version_mismatch` response,
878
+ * which is terminal — callers must NOT retry.
1032
879
  */
1033
880
  export async function platformRequestSignedUrl(
1034
881
  params: {
@@ -1036,6 +883,11 @@ export async function platformRequestSignedUrl(
1036
883
  bundleKey?: string;
1037
884
  contentType?: string;
1038
885
  contentLength?: number;
886
+ // Source-side, upload only: runtime version that produced the bundle.
887
+ minRuntimeVersion?: string;
888
+ maxRuntimeVersion?: string | null;
889
+ // Target-side, download only: runtime version that will import.
890
+ targetRuntimeVersion?: string;
1039
891
  },
1040
892
  token: string,
1041
893
  platformUrl?: string,
@@ -1052,6 +904,17 @@ export async function platformRequestSignedUrl(
1052
904
  if (params.contentLength !== undefined) {
1053
905
  body.content_length = params.contentLength;
1054
906
  }
907
+ if (params.minRuntimeVersion !== undefined) {
908
+ body.min_runtime_version = params.minRuntimeVersion;
909
+ }
910
+ if (params.maxRuntimeVersion !== undefined) {
911
+ // Explicit null is the documented "no upper bound" sentinel; keep it
912
+ // in the payload rather than stripping to undefined.
913
+ body.max_runtime_version = params.maxRuntimeVersion;
914
+ }
915
+ if (params.targetRuntimeVersion !== undefined) {
916
+ body.target_runtime_version = params.targetRuntimeVersion;
917
+ }
1055
918
 
1056
919
  const doRequest = async (): Promise<Response> =>
1057
920
  fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
@@ -1067,7 +930,7 @@ export async function platformRequestSignedUrl(
1067
930
  // lookup. For session-token callers, a 401 frequently means the
1068
931
  // cached org ID is stale — calling doRequest() again without clearing
1069
932
  // the cache would just send the same stale header and fail again.
1070
- orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
933
+ invalidateOrgIdCache(token, platformUrl);
1071
934
  response = await doRequest();
1072
935
  }
1073
936
 
@@ -1086,15 +949,28 @@ export async function platformRequestSignedUrl(
1086
949
  };
1087
950
  }
1088
951
 
1089
- if (response.status === 503) {
1090
- throw new Error(
1091
- `Signed URL endpoint unavailable (503) caller may fall back`,
1092
- );
1093
- }
1094
-
952
+ // Non-success body. Read once and reuse for both the 422 version-mismatch
953
+ // branch and the generic-error fallthrough — `response.json()` consumes
954
+ // the body, so a second read would always return undefined.
1095
955
  const errorBody = (await response.json().catch(() => ({}))) as {
1096
956
  detail?: string;
957
+ reason?: string;
958
+ bundle_compat?: BundleCompatibility;
959
+ target_runtime_version?: string;
1097
960
  };
961
+
962
+ if (
963
+ response.status === 422 &&
964
+ errorBody.reason === "version_mismatch" &&
965
+ errorBody.bundle_compat &&
966
+ typeof errorBody.target_runtime_version === "string"
967
+ ) {
968
+ throw new VersionMismatchError(
969
+ errorBody.bundle_compat,
970
+ errorBody.target_runtime_version,
971
+ );
972
+ }
973
+
1098
974
  throw new Error(
1099
975
  errorBody.detail ??
1100
976
  `Failed to request signed URL: ${response.status} ${response.statusText}`,
@@ -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
  *
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "child_process";
2
+ import { homedir } from "os";
2
3
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
4
  import { basename, dirname, join } from "path";
4
5
 
5
6
  import {
6
- getBaseDir,
7
7
  getDaemonPidPath,
8
8
  loadAllAssistants,
9
9
  } from "./assistant-config.js";
@@ -77,7 +77,7 @@ export async function retireLocal(
77
77
  // For named instances (instanceDir differs from the base directory),
78
78
  // archive and remove the entire instance directory. For the default
79
79
  // instance, archive only the .vellum subdirectory.
80
- const isNamedInstance = resources.instanceDir !== getBaseDir();
80
+ const isNamedInstance = resources.instanceDir !== homedir();
81
81
  const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
82
82
 
83
83
  // Move the data directory out of the way so the path is immediately available
@@ -0,0 +1,52 @@
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
+ }
31
+
32
+ /**
33
+ * Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
34
+ * taking the assistant's topology into account.
35
+ *
36
+ * - For local/docker assistants, `runtimeUrl` is the loopback gateway and
37
+ * the runtime serves `/v1/<subpath>` directly.
38
+ * - For platform-managed (cloud="vellum") assistants the path is rewritten
39
+ * to the wildcard runtime proxy:
40
+ * `{platformUrl}/v1/assistants/<assistantId>/<subpath>`.
41
+ *
42
+ * The `subpath` is appended verbatim (e.g. `"identity"`).
43
+ */
44
+ export function resolveRuntimeUrl(
45
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
46
+ subpath: string,
47
+ ): string {
48
+ if (entry.cloud === "vellum") {
49
+ return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/${subpath}`;
50
+ }
51
+ return `${entry.runtimeUrl}/v1/${subpath}`;
52
+ }
@@ -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) {