@vellumai/cli 0.4.54 → 0.4.56

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.
package/src/lib/gcp.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { randomBytes } from "crypto";
2
1
  import { unlinkSync, writeFileSync } from "fs";
3
2
  import { tmpdir, userInfo } from "os";
4
3
  import { join } from "path";
@@ -7,7 +6,8 @@ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
7
6
  import type { AssistantEntry } from "./assistant-config";
8
7
  import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
9
8
  import type { Species } from "./constants";
10
- import { generateRandomSuffix } from "./random-name";
9
+ import { leaseGuardianToken } from "./guardian-token";
10
+ import { generateInstanceName } from "./random-name";
11
11
  import { exec, execOutput } from "./step-runner";
12
12
 
13
13
  export async function getActiveProject(): Promise<string> {
@@ -447,7 +447,6 @@ export async function hatchGcp(
447
447
  name: string | null,
448
448
  buildStartupScript: (
449
449
  species: Species,
450
- bearerToken: string,
451
450
  sshUser: string,
452
451
  anthropicApiKey: string,
453
452
  instanceName: string,
@@ -467,12 +466,7 @@ export async function hatchGcp(
467
466
  const project = process.env.GCP_PROJECT ?? (await getActiveProject());
468
467
  let instanceName: string;
469
468
 
470
- if (name) {
471
- instanceName = name;
472
- } else {
473
- const suffix = generateRandomSuffix();
474
- instanceName = `${species}-${suffix}`;
475
- }
469
+ instanceName = generateInstanceName(species, name);
476
470
 
477
471
  console.log(`\ud83e\udd5a Creating new assistant: ${instanceName}`);
478
472
  console.log(` Species: ${species}`);
@@ -500,13 +494,11 @@ export async function hatchGcp(
500
494
  console.log(
501
495
  `\u26a0\ufe0f Instance name ${instanceName} already exists, generating a new name...`,
502
496
  );
503
- const suffix = generateRandomSuffix();
504
- instanceName = `${species}-${suffix}`;
497
+ instanceName = generateInstanceName(species);
505
498
  }
506
499
  }
507
500
 
508
501
  const sshUser = userInfo().username;
509
- const bearerToken = randomBytes(32).toString("hex");
510
502
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
511
503
  const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
512
504
  if (!anthropicApiKey) {
@@ -517,7 +509,6 @@ export async function hatchGcp(
517
509
  }
518
510
  const startupScript = await buildStartupScript(
519
511
  species,
520
- bearerToken,
521
512
  sshUser,
522
513
  anthropicApiKey,
523
514
  instanceName,
@@ -637,7 +628,7 @@ export async function hatchGcp(
637
628
  species === "vellum" &&
638
629
  (await checkCurlFailure(instanceName, project, zone, account))
639
630
  ) {
640
- const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
631
+ const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://vellum.ai"}/install.sh`;
641
632
  console.log(
642
633
  `\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
643
634
  );
@@ -654,6 +645,16 @@ export async function hatchGcp(
654
645
  }
655
646
  }
656
647
 
648
+ try {
649
+ const tokenData = await leaseGuardianToken(runtimeUrl, instanceName);
650
+ gcpEntry.bearerToken = tokenData.accessToken;
651
+ saveAssistantEntry(gcpEntry);
652
+ } catch (err) {
653
+ console.warn(
654
+ `\u26a0\ufe0f Could not lease guardian token: ${err instanceof Error ? err.message : err}`,
655
+ );
656
+ }
657
+
657
658
  console.log("Instance details:");
658
659
  console.log(` Name: ${instanceName}`);
659
660
  console.log(` Project: ${project}`);
@@ -0,0 +1,174 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execSync } from "node:child_process";
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { homedir, platform } from "os";
5
+ import { dirname, join } from "path";
6
+
7
+ const DEVICE_ID_SALT = "vellum-assistant-host-id";
8
+
9
+ export interface GuardianTokenData {
10
+ guardianPrincipalId: string;
11
+ accessToken: string;
12
+ accessTokenExpiresAt: string;
13
+ refreshToken: string;
14
+ refreshTokenExpiresAt: string;
15
+ refreshAfter: string;
16
+ isNew: boolean;
17
+ deviceId: string;
18
+ leasedAt: string;
19
+ }
20
+
21
+ function getXdgConfigHome(): string {
22
+ return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
23
+ }
24
+
25
+ function getGuardianTokenPath(assistantId: string): string {
26
+ return join(
27
+ getXdgConfigHome(),
28
+ "vellum",
29
+ "assistants",
30
+ assistantId,
31
+ "guardian-token.json",
32
+ );
33
+ }
34
+
35
+ function getPersistedDeviceIdPath(): string {
36
+ return join(getXdgConfigHome(), "vellum", "device-id");
37
+ }
38
+
39
+ function hashWithSalt(input: string): string {
40
+ return createHash("sha256")
41
+ .update(input + DEVICE_ID_SALT)
42
+ .digest("hex");
43
+ }
44
+
45
+ function getMacOSPlatformUUID(): string | null {
46
+ try {
47
+ const output = execSync(
48
+ "ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/{print $3}'",
49
+ { encoding: "utf-8", timeout: 5000 },
50
+ ).trim();
51
+ const uuid = output.replace(/"/g, "");
52
+ return uuid.length > 0 ? uuid : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function getLinuxMachineId(): string | null {
59
+ try {
60
+ return readFileSync("/etc/machine-id", "utf-8").trim() || null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function getWindowsMachineGuid(): string | null {
67
+ try {
68
+ const output = execSync(
69
+ 'reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
70
+ { encoding: "utf-8", timeout: 5000 },
71
+ ).trim();
72
+ const match = output.match(/MachineGuid\s+REG_SZ\s+(.+)/);
73
+ return match?.[1]?.trim() ?? null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function getOrCreatePersistedDeviceId(): string {
80
+ const path = getPersistedDeviceIdPath();
81
+ try {
82
+ const existing = readFileSync(path, "utf-8").trim();
83
+ if (existing.length > 0) {
84
+ return existing;
85
+ }
86
+ } catch {
87
+ // File doesn't exist yet
88
+ }
89
+ const newId = randomUUID();
90
+ const dir = dirname(path);
91
+ if (!existsSync(dir)) {
92
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
93
+ }
94
+ writeFileSync(path, newId + "\n", { mode: 0o600 });
95
+ return newId;
96
+ }
97
+
98
+ /**
99
+ * Compute a stable device identifier matching the native client conventions.
100
+ *
101
+ * - macOS: SHA-256 of IOPlatformUUID + salt (matches PairingQRCodeSheet.computeHostId)
102
+ * - Linux: SHA-256 of /etc/machine-id + salt
103
+ * - Windows: SHA-256 of HKLM MachineGuid + salt
104
+ * - Fallback: persisted random UUID in XDG config
105
+ */
106
+ export function computeDeviceId(): string {
107
+ const os = platform();
108
+
109
+ if (os === "darwin") {
110
+ const uuid = getMacOSPlatformUUID();
111
+ if (uuid) return hashWithSalt(uuid);
112
+ } else if (os === "linux") {
113
+ const machineId = getLinuxMachineId();
114
+ if (machineId) return hashWithSalt(machineId);
115
+ } else if (os === "win32") {
116
+ const guid = getWindowsMachineGuid();
117
+ if (guid) return hashWithSalt(guid);
118
+ }
119
+
120
+ return getOrCreatePersistedDeviceId();
121
+ }
122
+
123
+ export function saveGuardianToken(
124
+ assistantId: string,
125
+ data: GuardianTokenData,
126
+ ): void {
127
+ const tokenPath = getGuardianTokenPath(assistantId);
128
+ const dir = dirname(tokenPath);
129
+ if (!existsSync(dir)) {
130
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ }
132
+ writeFileSync(tokenPath, JSON.stringify(data, null, 2) + "\n", {
133
+ mode: 0o600,
134
+ });
135
+ chmodSync(tokenPath, 0o600);
136
+ }
137
+
138
+ /**
139
+ * Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
140
+ * credential pair. The returned tokens are persisted locally under
141
+ * `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
142
+ */
143
+ export async function leaseGuardianToken(
144
+ gatewayUrl: string,
145
+ assistantId: string,
146
+ ): Promise<GuardianTokenData> {
147
+ const deviceId = computeDeviceId();
148
+ const response = await fetch(`${gatewayUrl}/v1/guardian/init`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ platform: "cli", deviceId }),
152
+ });
153
+
154
+ if (!response.ok) {
155
+ const body = await response.text();
156
+ throw new Error(`guardian/init failed (${response.status}): ${body}`);
157
+ }
158
+
159
+ const json = (await response.json()) as Record<string, unknown>;
160
+ const tokenData: GuardianTokenData = {
161
+ guardianPrincipalId: json.guardianPrincipalId as string,
162
+ accessToken: json.accessToken as string,
163
+ accessTokenExpiresAt: json.accessTokenExpiresAt as string,
164
+ refreshToken: json.refreshToken as string,
165
+ refreshTokenExpiresAt: json.refreshTokenExpiresAt as string,
166
+ refreshAfter: json.refreshAfter as string,
167
+ isNew: json.isNew as boolean,
168
+ deviceId,
169
+ leasedAt: new Date().toISOString(),
170
+ };
171
+
172
+ saveGuardianToken(assistantId, tokenData);
173
+ return tokenData;
174
+ }
@@ -10,32 +10,6 @@ export interface HealthCheckResult {
10
10
  detail: string | null;
11
11
  }
12
12
 
13
- interface OrgListResponse {
14
- results: { id: string }[];
15
- }
16
-
17
- async function fetchOrganizationId(
18
- platformUrl: string,
19
- token: string,
20
- ): Promise<{ orgId: string } | { error: string }> {
21
- try {
22
- const response = await fetch(`${platformUrl}/v1/organizations/`, {
23
- headers: { "X-Session-Token": token },
24
- });
25
- if (!response.ok) {
26
- return { error: `org lookup failed (${response.status})` };
27
- }
28
- const body = (await response.json()) as OrgListResponse;
29
- const orgId = body.results?.[0]?.id;
30
- if (!orgId) {
31
- return { error: "no organization found" };
32
- }
33
- return { orgId };
34
- } catch {
35
- return { error: "org lookup unreachable" };
36
- }
37
- }
38
-
39
13
  export async function checkManagedHealth(
40
14
  runtimeUrl: string,
41
15
  assistantId: string,
@@ -49,14 +23,16 @@ export async function checkManagedHealth(
49
23
  };
50
24
  }
51
25
 
52
- const orgResult = await fetchOrganizationId(runtimeUrl, token);
53
- if ("error" in orgResult) {
26
+ let orgId: string;
27
+ try {
28
+ const { fetchOrganizationId } = await import("./platform-client.js");
29
+ orgId = await fetchOrganizationId(token);
30
+ } catch (err) {
54
31
  return {
55
32
  status: "error (auth)",
56
- detail: orgResult.error,
33
+ detail: err instanceof Error ? err.message : "org lookup failed",
57
34
  };
58
35
  }
59
- const { orgId } = orgResult;
60
36
 
61
37
  try {
62
38
  const url = `${runtimeUrl}/v1/assistants/${encodeURIComponent(assistantId)}/healthz/`;
package/src/lib/local.ts CHANGED
@@ -195,7 +195,9 @@ function resolveDaemonMainPath(assistantIndex: string): string {
195
195
  async function startDaemonFromSource(
196
196
  assistantIndex: string,
197
197
  resources: LocalInstanceResources,
198
+ options?: { foreground?: boolean },
198
199
  ): Promise<void> {
200
+ const foreground = options?.foreground ?? false;
199
201
  const daemonMainPath = resolveDaemonMainPath(assistantIndex);
200
202
 
201
203
  // Ensure the directory containing PID/socket files exists. For named
@@ -207,7 +209,25 @@ async function startDaemonFromSource(
207
209
  // --- Lifecycle guard: prevent split-brain daemon state ---
208
210
  if (existsSync(pidFile)) {
209
211
  try {
210
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
212
+ const content = readFileSync(pidFile, "utf-8").trim();
213
+
214
+ // Another caller is already spawning the daemon — wait for it
215
+ // instead of racing to spawn a duplicate.
216
+ if (content === "starting") {
217
+ console.log(
218
+ " Assistant is starting — waiting for it to become ready...",
219
+ );
220
+ if (await waitForDaemonReady(resources.daemonPort, 60000)) {
221
+ console.log(" Assistant is ready\n");
222
+ return;
223
+ }
224
+ // The other spawn may have failed; clean up and proceed to spawn.
225
+ try {
226
+ unlinkSync(pidFile);
227
+ } catch {}
228
+ }
229
+
230
+ const pid = parseInt(content, 10);
211
231
  if (!isNaN(pid)) {
212
232
  try {
213
233
  process.kill(pid, 0);
@@ -249,17 +269,33 @@ async function startDaemonFromSource(
249
269
  delete env.QDRANT_URL;
250
270
  }
251
271
 
252
- const daemonLogFd = openLogFile("hatch.log");
253
- const child = spawn("bun", ["run", daemonMainPath], {
254
- detached: true,
255
- stdio: ["ignore", "pipe", "pipe"],
256
- env,
257
- });
258
- pipeToLogFile(child, daemonLogFd, "daemon");
259
- child.unref();
272
+ // Write a sentinel PID file before spawning so concurrent hatch() calls
273
+ // detect the in-progress spawn and wait instead of racing.
274
+ writeFileSync(pidFile, "starting", "utf-8");
275
+
276
+ const child = foreground
277
+ ? spawn("bun", ["run", daemonMainPath], {
278
+ stdio: "inherit",
279
+ env,
280
+ })
281
+ : (() => {
282
+ const daemonLogFd = openLogFile("hatch.log");
283
+ const c = spawn("bun", ["run", daemonMainPath], {
284
+ detached: true,
285
+ stdio: ["ignore", "pipe", "pipe"],
286
+ env,
287
+ });
288
+ pipeToLogFile(c, daemonLogFd, "daemon");
289
+ c.unref();
290
+ return c;
291
+ })();
260
292
 
261
293
  if (child.pid) {
262
294
  writeFileSync(pidFile, String(child.pid), "utf-8");
295
+ } else {
296
+ try {
297
+ unlinkSync(pidFile);
298
+ } catch {}
263
299
  }
264
300
  }
265
301
 
@@ -284,7 +320,25 @@ async function startDaemonWatchFromSource(
284
320
  // If a daemon is already running, skip spawning a new one.
285
321
  if (existsSync(pidFile)) {
286
322
  try {
287
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
323
+ const content = readFileSync(pidFile, "utf-8").trim();
324
+
325
+ // Another caller is already spawning the daemon — wait for it
326
+ // instead of racing to spawn a duplicate.
327
+ if (content === "starting") {
328
+ console.log(
329
+ " Assistant is starting — waiting for it to become ready...",
330
+ );
331
+ if (await waitForDaemonReady(resources.daemonPort, 60000)) {
332
+ console.log(" Assistant is ready\n");
333
+ return;
334
+ }
335
+ // The other spawn may have failed; clean up and proceed to spawn.
336
+ try {
337
+ unlinkSync(pidFile);
338
+ } catch {}
339
+ }
340
+
341
+ const pid = parseInt(content, 10);
288
342
  if (!isNaN(pid)) {
289
343
  try {
290
344
  process.kill(pid, 0); // Check if alive
@@ -328,6 +382,10 @@ async function startDaemonWatchFromSource(
328
382
  delete env.QDRANT_URL;
329
383
  }
330
384
 
385
+ // Write a sentinel PID file before spawning so concurrent hatch() calls
386
+ // detect the in-progress spawn and wait instead of racing.
387
+ writeFileSync(pidFile, "starting", "utf-8");
388
+
331
389
  const daemonLogFd = openLogFile("hatch.log");
332
390
  const child = spawn("bun", ["--watch", "run", mainPath], {
333
391
  detached: true,
@@ -338,8 +396,13 @@ async function startDaemonWatchFromSource(
338
396
  child.unref();
339
397
  const daemonPid = child.pid;
340
398
 
399
+ // Overwrite sentinel with real PID, or clean up on spawn failure.
341
400
  if (daemonPid) {
342
401
  writeFileSync(pidFile, String(daemonPid), "utf-8");
402
+ } else {
403
+ try {
404
+ unlinkSync(pidFile);
405
+ } catch {}
343
406
  }
344
407
 
345
408
  console.log(" Assistant started in watch mode (bun --watch)");
@@ -716,19 +779,39 @@ export function getLocalLanIPv4(): string | undefined {
716
779
  }
717
780
 
718
781
  /**
719
- * Check whether watch-mode startup is possible. Watch mode requires source
720
- * files (bun --watch only works with .ts sources, not compiled binaries).
721
- * Returns true when assistant source can be resolved, false otherwise.
782
+ * Check whether watch-mode startup is possible for the assistant daemon.
783
+ * Watch mode requires source files (bun --watch only works with .ts sources,
784
+ * not compiled binaries). Returns true when assistant source can be resolved,
785
+ * false otherwise.
722
786
  *
723
787
  * Use this before stopping a running assistant for a watch-mode restart — if
724
788
  * watch mode isn't available (e.g. packaged desktop app without source), the
725
789
  * caller should keep the existing process alive rather than killing it and
726
790
  * failing.
727
791
  */
728
- export function isWatchModeAvailable(): boolean {
792
+ export function isAssistantWatchModeAvailable(): boolean {
729
793
  return resolveAssistantIndexPath() !== undefined;
730
794
  }
731
795
 
796
+ /**
797
+ * Check whether watch-mode startup is possible for the gateway. Watch mode
798
+ * requires gateway source files (bun --watch only works with .ts sources).
799
+ * Returns true when the gateway source directory can be resolved, false
800
+ * otherwise.
801
+ *
802
+ * Use this before stopping a running gateway for a watch-mode restart — if
803
+ * watch mode isn't available, the caller should keep the existing process
804
+ * alive rather than killing it and failing.
805
+ */
806
+ export function isGatewayWatchModeAvailable(): boolean {
807
+ try {
808
+ resolveGatewayDir();
809
+ return true;
810
+ } catch {
811
+ return false;
812
+ }
813
+ }
814
+
732
815
  // NOTE: startLocalDaemon() is the CLI-side daemon lifecycle manager.
733
816
  // It should eventually converge with
734
817
  // assistant/src/daemon/daemon-control.ts::startDaemon which is the
@@ -736,7 +819,9 @@ export function isWatchModeAvailable(): boolean {
736
819
  export async function startLocalDaemon(
737
820
  watch: boolean = false,
738
821
  resources: LocalInstanceResources,
822
+ options?: { foreground?: boolean },
739
823
  ): Promise<void> {
824
+ const foreground = options?.foreground ?? false;
740
825
  // Check for a compiled daemon binary adjacent to the CLI executable.
741
826
  // This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
742
827
  // the user runs the compiled CLI directly from the terminal (e.g. via a
@@ -754,7 +839,26 @@ export async function startLocalDaemon(
754
839
  let daemonAlive = false;
755
840
  if (existsSync(pidFile)) {
756
841
  try {
757
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
842
+ const content = readFileSync(pidFile, "utf-8").trim();
843
+
844
+ // Another caller is already spawning the daemon — wait for it
845
+ // instead of racing to spawn a duplicate.
846
+ if (content === "starting") {
847
+ console.log(
848
+ " Assistant is starting — waiting for it to become ready...",
849
+ );
850
+ if (await waitForDaemonReady(resources.daemonPort, 60000)) {
851
+ console.log(" Assistant is ready\n");
852
+ ensureBunInstalled();
853
+ return;
854
+ }
855
+ // The other spawn may have failed; clean up and proceed to spawn.
856
+ try {
857
+ unlinkSync(pidFile);
858
+ } catch {}
859
+ }
860
+
861
+ const pid = parseInt(content, 10);
758
862
  if (!isNaN(pid)) {
759
863
  try {
760
864
  process.kill(pid, 0); // Check if alive
@@ -820,6 +924,7 @@ export async function startLocalDaemon(
820
924
  "ANTHROPIC_API_KEY",
821
925
  "APP_VERSION",
822
926
  "BASE_DATA_DIR",
927
+ "PLATFORM_BASE_URL",
823
928
  "QDRANT_HTTP_PORT",
824
929
  "QDRANT_URL",
825
930
  "RUNTIME_HTTP_PORT",
@@ -827,6 +932,7 @@ export async function startLocalDaemon(
827
932
  "TMPDIR",
828
933
  "USER",
829
934
  "LANG",
935
+ "VELLUM_DEBUG",
830
936
  ]) {
831
937
  if (process.env[key]) {
832
938
  daemonEnv[key] = process.env[key]!;
@@ -842,21 +948,38 @@ export async function startLocalDaemon(
842
948
  delete daemonEnv.QDRANT_URL;
843
949
  }
844
950
 
845
- const daemonLogFd = openLogFile("hatch.log");
846
- const child = spawn(daemonBinary, [], {
847
- cwd: dirname(daemonBinary),
848
- detached: true,
849
- stdio: ["ignore", "pipe", "pipe"],
850
- env: daemonEnv,
851
- });
852
- pipeToLogFile(child, daemonLogFd, "daemon");
853
- child.unref();
951
+ // Write a sentinel PID file before spawning so concurrent hatch() calls
952
+ // see the file and fall through to the isDaemonResponsive() port check
953
+ // instead of racing to spawn a duplicate daemon.
954
+ writeFileSync(pidFile, "starting", "utf-8");
955
+
956
+ const child = foreground
957
+ ? spawn(daemonBinary, [], {
958
+ cwd: dirname(daemonBinary),
959
+ stdio: "inherit",
960
+ env: daemonEnv,
961
+ })
962
+ : (() => {
963
+ const daemonLogFd = openLogFile("hatch.log");
964
+ const c = spawn(daemonBinary, [], {
965
+ cwd: dirname(daemonBinary),
966
+ detached: true,
967
+ stdio: ["ignore", "pipe", "pipe"],
968
+ env: daemonEnv,
969
+ });
970
+ pipeToLogFile(c, daemonLogFd, "daemon");
971
+ c.unref();
972
+ return c;
973
+ })();
854
974
  const daemonPid = child.pid;
855
975
 
856
- // Write PID file immediately so the health monitor can find the process
857
- // and concurrent hatch() calls see it as alive.
976
+ // Overwrite sentinel with real PID, or clean up on spawn failure.
858
977
  if (daemonPid) {
859
978
  writeFileSync(pidFile, String(daemonPid), "utf-8");
979
+ } else {
980
+ try {
981
+ unlinkSync(pidFile);
982
+ } catch {}
860
983
  }
861
984
  }
862
985
 
@@ -919,7 +1042,7 @@ export async function startLocalDaemon(
919
1042
  );
920
1043
  }
921
1044
  } else {
922
- await startDaemonFromSource(assistantIndex, resources);
1045
+ await startDaemonFromSource(assistantIndex, resources, { foreground });
923
1046
 
924
1047
  const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
925
1048
  if (daemonReady) {
@@ -55,6 +55,30 @@ export interface PlatformUser {
55
55
  display: string;
56
56
  }
57
57
 
58
+ interface OrganizationListResponse {
59
+ results: { id: string; name: string }[];
60
+ }
61
+
62
+ export async function fetchOrganizationId(token: string): Promise<string> {
63
+ const url = `${getPlatformUrl()}/v1/organizations/`;
64
+ const response = await fetch(url, {
65
+ headers: { "X-Session-Token": token },
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error(
70
+ `Failed to fetch organizations (${response.status}). Try logging in again.`,
71
+ );
72
+ }
73
+
74
+ const body = (await response.json()) as OrganizationListResponse;
75
+ const orgId = body.results?.[0]?.id;
76
+ if (!orgId) {
77
+ throw new Error("No organization found for this account.");
78
+ }
79
+ return orgId;
80
+ }
81
+
58
82
  interface AllauthSessionResponse {
59
83
  status: number;
60
84
  data: {
@@ -13,7 +13,7 @@ function isVellumProcess(pid: number): boolean {
13
13
  timeout: 3000,
14
14
  stdio: ["ignore", "pipe", "ignore"],
15
15
  }).trim();
16
- return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/vellum\/|\/daemon\/main/.test(
16
+ return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
17
17
  output,
18
18
  );
19
19
  } catch {
@@ -1,3 +1,7 @@
1
+ import { customAlphabet } from "nanoid";
2
+
3
+ const nanoidLower = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 6);
4
+
1
5
  const ADJECTIVES = [
2
6
  "brave",
3
7
  "calm",
@@ -129,5 +133,17 @@ function randomElement<T>(arr: T[]): T {
129
133
  }
130
134
 
131
135
  export function generateRandomSuffix(): string {
132
- return `${randomElement(ADJECTIVES)}-${randomElement(NOUNS)}`;
136
+ return `${randomElement(ADJECTIVES)}-${randomElement(NOUNS)}-${nanoidLower()}`;
137
+ }
138
+
139
+ /**
140
+ * Generate an instance name for a new assistant. Uses the explicit name if
141
+ * provided, otherwise produces `<species>-<adjective>-<noun>-<nanoid>`.
142
+ */
143
+ export function generateInstanceName(
144
+ species: string,
145
+ explicitName?: string | null,
146
+ ): string {
147
+ if (explicitName) return explicitName;
148
+ return `${species}-${generateRandomSuffix()}`;
133
149
  }