@vellumai/cli 0.10.12 → 0.11.0-dev.202607290139.f1746d4

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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Drain phase for `vellum sleep --wait`.
3
+ *
4
+ * Arms the assistant's quiesce lease (pausing the START of new background
5
+ * work — heartbeats, schedule claims, memory-job claims), then polls the
6
+ * drain-status endpoint until nothing is in flight. The lease is TTL-bound
7
+ * and refreshed while polling, so a killed CLI can never leave background
8
+ * work paused. The wait narrates what it is waiting on — background jobs can
9
+ * legitimately run for many minutes, and a silent wait is indistinguishable
10
+ * from a hang.
11
+ */
12
+
13
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
14
+
15
+ const QUIESCE_TTL_MS = 60_000;
16
+ const QUIESCE_REFRESH_INTERVAL_MS = 20_000;
17
+ const REQUEST_TIMEOUT_MS = 2_500;
18
+ const DEFAULT_POLL_INTERVAL_MS = 1_000;
19
+ const NARRATE_HEARTBEAT_MS = 15_000;
20
+ /** Consecutive failed polls before giving up on the drain. Transient
21
+ * failures (a brief daemon event-loop stall) recover; a wall of failures
22
+ * means the assistant cannot report status and waiting is pointless. */
23
+ const MAX_CONSECUTIVE_FAILURES = 10;
24
+ /** Consecutive failed lease arms/refreshes before giving up. Without a live
25
+ * lease the claim gates are open and an idle snapshot proves nothing, so an
26
+ * unmaintainable lease makes the drain unsound. */
27
+ const MAX_LEASE_FAILURES = 3;
28
+
29
+ export type DrainOutcome =
30
+ | "drained"
31
+ | "timeout"
32
+ | "unsupported"
33
+ | "unreachable"
34
+ | "cancelled";
35
+
36
+ interface DrainStatusResponse {
37
+ quiescedUntil: number | null;
38
+ idle: boolean;
39
+ activeConversations: Array<{
40
+ conversationId: string;
41
+ title: string | null;
42
+ originChannel: string | null;
43
+ originInterface: string | null;
44
+ processingStartedAt: number;
45
+ }>;
46
+ memoryJobs: Array<{
47
+ id: string;
48
+ type: string;
49
+ startedAt: number | null;
50
+ }>;
51
+ scheduleRuns: Array<{
52
+ runId: string;
53
+ scheduleName: string | null;
54
+ startedAt: number;
55
+ }>;
56
+ workflowRuns?: Array<{
57
+ runId: string;
58
+ name: string | null;
59
+ startedAt: number | null;
60
+ }>;
61
+ heartbeatRuns?: Array<{
62
+ runId: string;
63
+ startedAt: number;
64
+ }>;
65
+ }
66
+
67
+ export interface DrainOptions {
68
+ baseUrl: string;
69
+ token: string;
70
+ /** Epoch ms to stop waiting at, or null to wait as long as it takes. */
71
+ deadlineAt: number | null;
72
+ /** Aborted on Ctrl-C — releases the lease and returns "cancelled". */
73
+ signal?: AbortSignal;
74
+ pollIntervalMs?: number;
75
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
76
+ log?: (line: string) => void;
77
+ }
78
+
79
+ /**
80
+ * Parse a `--wait` duration: bare seconds (`90`), seconds (`90s`), or
81
+ * minutes (`10m`). Returns milliseconds, or null when not a valid duration.
82
+ */
83
+ export function parseWaitDuration(raw: string): number | null {
84
+ const match = /^(\d+)(s|m)?$/.exec(raw.trim());
85
+ if (!match) {
86
+ return null;
87
+ }
88
+ const value = Number.parseInt(match[1]!, 10);
89
+ if (!Number.isFinite(value) || value < 1) {
90
+ return null;
91
+ }
92
+ return match[2] === "m" ? value * 60_000 : value * 1_000;
93
+ }
94
+
95
+ function formatElapsed(sinceMs: number | null, now: number): string {
96
+ if (sinceMs == null) {
97
+ return "just started";
98
+ }
99
+ const totalSeconds = Math.max(0, Math.floor((now - sinceMs) / 1000));
100
+ const minutes = Math.floor(totalSeconds / 60);
101
+ const seconds = totalSeconds % 60;
102
+ return minutes > 0
103
+ ? `${minutes}m${seconds.toString().padStart(2, "0")}s`
104
+ : `${seconds}s`;
105
+ }
106
+
107
+ function describeBusyWork(status: DrainStatusResponse, now: number): string[] {
108
+ const items: string[] = [];
109
+ for (const conv of status.activeConversations) {
110
+ const label =
111
+ conv.title != null && conv.title.length > 0
112
+ ? `conversation "${conv.title}"`
113
+ : `background turn (${conv.originInterface ?? conv.originChannel ?? "unknown origin"})`;
114
+ items.push(`${label} (${formatElapsed(conv.processingStartedAt, now)})`);
115
+ }
116
+ for (const job of status.memoryJobs) {
117
+ items.push(`memory ${job.type} (${formatElapsed(job.startedAt, now)})`);
118
+ }
119
+ for (const run of status.scheduleRuns) {
120
+ const label = run.scheduleName ?? run.runId.slice(0, 8);
121
+ items.push(`schedule "${label}" (${formatElapsed(run.startedAt, now)})`);
122
+ }
123
+ for (const run of status.workflowRuns ?? []) {
124
+ const label = run.name ?? run.runId.slice(0, 8);
125
+ items.push(`workflow "${label}" (${formatElapsed(run.startedAt, now)})`);
126
+ }
127
+ for (const run of status.heartbeatRuns ?? []) {
128
+ items.push(`heartbeat (${formatElapsed(run.startedAt, now)})`);
129
+ }
130
+ return items;
131
+ }
132
+
133
+ function busySignature(status: DrainStatusResponse): string {
134
+ return [
135
+ ...status.activeConversations.map((c) => c.conversationId),
136
+ ...status.memoryJobs.map((j) => j.id),
137
+ ...status.scheduleRuns.map((r) => r.runId),
138
+ ...(status.workflowRuns ?? []).map((r) => r.runId),
139
+ ...(status.heartbeatRuns ?? []).map((r) => r.runId),
140
+ ].join("|");
141
+ }
142
+
143
+ function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
144
+ return new Promise((resolve) => {
145
+ const timer = setTimeout(done, ms);
146
+ function done(): void {
147
+ clearTimeout(timer);
148
+ signal?.removeEventListener("abort", done);
149
+ resolve();
150
+ }
151
+ signal?.addEventListener("abort", done, { once: true });
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Wait for the assistant's in-flight background work to finish.
157
+ *
158
+ * Never throws: every failure mode maps onto a {@link DrainOutcome} so the
159
+ * caller can decide how to proceed with the stop.
160
+ */
161
+ export async function drainAssistant(
162
+ opts: DrainOptions,
163
+ ): Promise<DrainOutcome> {
164
+ const fetchImpl = opts.fetchImpl ?? loopbackSafeFetch;
165
+ const log = opts.log ?? console.log;
166
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
167
+ const base = opts.baseUrl.replace(/\/+$/, "");
168
+
169
+ const request = async (
170
+ path: string,
171
+ init: { method: string; body?: string },
172
+ ): Promise<Response> => {
173
+ // Per-request controller aborted by either the request timeout or the
174
+ // caller's cancel signal; listeners are removed per request so a long
175
+ // wait never accumulates them on the shared cancel signal.
176
+ const controller = new AbortController();
177
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
178
+ const onOuterAbort = (): void => controller.abort();
179
+ opts.signal?.addEventListener("abort", onOuterAbort, { once: true });
180
+ try {
181
+ return await fetchImpl(`${base}${path}`, {
182
+ method: init.method,
183
+ headers: {
184
+ Authorization: `Bearer ${opts.token}`,
185
+ "Content-Type": "application/json",
186
+ },
187
+ ...(init.body !== undefined && { body: init.body }),
188
+ signal: controller.signal,
189
+ });
190
+ } finally {
191
+ clearTimeout(timer);
192
+ opts.signal?.removeEventListener("abort", onOuterAbort);
193
+ }
194
+ };
195
+
196
+ const armLease = () =>
197
+ request("/v1/lifecycle/quiesce", {
198
+ method: "POST",
199
+ body: JSON.stringify({ ttlMs: QUIESCE_TTL_MS }),
200
+ });
201
+
202
+ const releaseLease = async (): Promise<void> => {
203
+ try {
204
+ await request("/v1/lifecycle/quiesce", { method: "DELETE" });
205
+ } catch {
206
+ // Best-effort — the lease self-expires.
207
+ }
208
+ };
209
+
210
+ // Arm the lease before anything else: without it a busy queue keeps
211
+ // claiming new work and the wait never converges.
212
+ let response: Response;
213
+ try {
214
+ response = await armLease();
215
+ } catch {
216
+ if (opts.signal?.aborted) {
217
+ // The abort may have landed after the server persisted the lease, so
218
+ // its outcome is ambiguous — release best-effort rather than leaving
219
+ // background work paused for a TTL on a cancelled sleep.
220
+ await releaseLease();
221
+ return "cancelled";
222
+ }
223
+ return "unreachable";
224
+ }
225
+ if (response.status === 404 || response.status === 405) {
226
+ return "unsupported";
227
+ }
228
+ if (!response.ok) {
229
+ return "unreachable";
230
+ }
231
+
232
+ let lastLeaseAt = Date.now();
233
+ let consecutiveFailures = 0;
234
+ let leaseFailureStreak = 0;
235
+ let lastSignature: string | null = null;
236
+ let lastNarratedAt = 0;
237
+ let idleStreak = 0;
238
+
239
+ for (;;) {
240
+ if (opts.signal?.aborted) {
241
+ await releaseLease();
242
+ return "cancelled";
243
+ }
244
+ if (opts.deadlineAt != null && Date.now() >= opts.deadlineAt) {
245
+ return "timeout";
246
+ }
247
+
248
+ try {
249
+ const statusResponse = await request("/v1/lifecycle/drain-status", {
250
+ method: "GET",
251
+ });
252
+ if (!statusResponse.ok) {
253
+ throw new Error(`drain-status returned ${statusResponse.status}`);
254
+ }
255
+ const status = (await statusResponse.json()) as DrainStatusResponse;
256
+ consecutiveFailures = 0;
257
+
258
+ if (status.idle) {
259
+ // Idle only counts under a LIVE lease: with the lease expired the
260
+ // claim gates are open again, so new work invisible to this snapshot
261
+ // could be starting. Re-arm and retry rather than trusting the read.
262
+ const leaseActive =
263
+ status.quiescedUntil != null && status.quiescedUntil > Date.now();
264
+ if (!leaseActive) {
265
+ idleStreak = 0;
266
+ try {
267
+ const rearm = await armLease();
268
+ if (rearm.ok) {
269
+ lastLeaseAt = Date.now();
270
+ leaseFailureStreak = 0;
271
+ } else {
272
+ leaseFailureStreak += 1;
273
+ }
274
+ } catch {
275
+ if (opts.signal?.aborted) {
276
+ await releaseLease();
277
+ return "cancelled";
278
+ }
279
+ leaseFailureStreak += 1;
280
+ }
281
+ if (leaseFailureStreak >= MAX_LEASE_FAILURES) {
282
+ return "unreachable";
283
+ }
284
+ await sleepMs(pollIntervalMs, opts.signal);
285
+ continue;
286
+ }
287
+ // Require two consecutive idle snapshots: work that raced past a
288
+ // pre-lease gate check lands its run row within a tick, so a second
289
+ // read one poll later rules out a stop landing on that window.
290
+ idleStreak += 1;
291
+ if (idleStreak >= 2) {
292
+ return "drained";
293
+ }
294
+ await sleepMs(pollIntervalMs, opts.signal);
295
+ continue;
296
+ }
297
+ idleStreak = 0;
298
+
299
+ const now = Date.now();
300
+ const signature = busySignature(status);
301
+ if (
302
+ signature !== lastSignature ||
303
+ now - lastNarratedAt >= NARRATE_HEARTBEAT_MS
304
+ ) {
305
+ const items = describeBusyWork(status, now);
306
+ const shown = items.slice(0, 4);
307
+ const more = items.length - shown.length;
308
+ log(
309
+ `Waiting for the assistant to finish: ${shown.join(", ")}${
310
+ more > 0 ? ` and ${more} more` : ""
311
+ }…`,
312
+ );
313
+ lastSignature = signature;
314
+ lastNarratedAt = now;
315
+ }
316
+ } catch {
317
+ if (opts.signal?.aborted) {
318
+ await releaseLease();
319
+ return "cancelled";
320
+ }
321
+ consecutiveFailures += 1;
322
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
323
+ return "unreachable";
324
+ }
325
+ }
326
+
327
+ if (Date.now() - lastLeaseAt >= QUIESCE_REFRESH_INTERVAL_MS) {
328
+ try {
329
+ const refresh = await armLease();
330
+ if (refresh.ok) {
331
+ lastLeaseAt = Date.now();
332
+ leaseFailureStreak = 0;
333
+ } else {
334
+ leaseFailureStreak += 1;
335
+ }
336
+ } catch {
337
+ leaseFailureStreak += 1;
338
+ }
339
+ if (leaseFailureStreak >= MAX_LEASE_FAILURES) {
340
+ return "unreachable";
341
+ }
342
+ }
343
+
344
+ await sleepMs(pollIntervalMs, opts.signal);
345
+ }
346
+ }
@@ -4,9 +4,9 @@ import { parseFeatureFlagArgs } from "./flag-args";
4
4
 
5
5
  describe("parseFeatureFlagArgs", () => {
6
6
  test("single flag produces env var and empty remaining", () => {
7
- const result = parseFeatureFlagArgs(["--flag", "voice-mode=true"]);
7
+ const result = parseFeatureFlagArgs(["--flag", "browser=true"]);
8
8
  expect(result).toEqual({
9
- envVars: { VELLUM_FLAG_VOICE_MODE: "true" },
9
+ envVars: { VELLUM_FLAG_BROWSER: "true" },
10
10
  remaining: [],
11
11
  });
12
12
  });
@@ -75,7 +75,7 @@ describe("parseFeatureFlagArgs", () => {
75
75
  "process.exit",
76
76
  );
77
77
  expect(errorSpy).toHaveBeenCalledWith(
78
- 'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "voice-mode")',
78
+ 'Error: invalid flag key "UPPER". Keys must be kebab-case (e.g. "browser")',
79
79
  );
80
80
 
81
81
  exitSpy.mockRestore();
@@ -1,4 +1,4 @@
1
- /** Only allow simple kebab-case keys (e.g. "voice-mode", "browser"). */
1
+ /** Only allow simple kebab-case keys (e.g. "browser", "contacts"). */
2
2
  const ALLOWED_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
3
3
 
4
4
  /**
@@ -38,7 +38,7 @@ export function parseFeatureFlagArgs(args: string[]): {
38
38
 
39
39
  if (!ALLOWED_KEY_RE.test(key)) {
40
40
  console.error(
41
- `Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "voice-mode")`,
41
+ `Error: invalid flag key "${key}". Keys must be kebab-case (e.g. "browser")`,
42
42
  );
43
43
  process.exit(1);
44
44
  }
@@ -46,6 +46,7 @@ import {
46
46
  import { logHatchNextSteps } from "./hatch-next-steps.js";
47
47
  import { checkProviderApiKey } from "./api-key-check.js";
48
48
  import { loopbackSafeFetch } from "./loopback-fetch.js";
49
+ import { withLocalHatchLock } from "./local-hatch-lock.js";
49
50
 
50
51
  /**
51
52
  * Attempts to place a symlink at the given path pointing to cliBinary.
@@ -179,89 +180,116 @@ export async function hatchLocal(
179
180
  name ?? process.env.VELLUM_ASSISTANT_NAME,
180
181
  );
181
182
 
182
- reporter.progress(1, 6, "Allocating resources...");
183
+ const { resources, runtimeUrl, loopbackUrl, bootstrapSecret } =
184
+ await withLocalHatchLock(async () => {
185
+ reporter.progress(1, 6, "Allocating resources...");
183
186
 
184
- const existing = findAssistantByName(instanceName);
185
- if (existing && (!existing.cloud || existing.cloud === "local")) {
186
- throw new Error(
187
- `An assistant named "${instanceName}" is already hatched.\n` +
188
- `Run \`vellum wake\` to restart it, or \`vellum retire ${instanceName}\` to remove it first.`,
189
- );
190
- }
187
+ const existing = findAssistantByName(instanceName);
188
+ if (existing && (!existing.cloud || existing.cloud === "local")) {
189
+ throw new Error(
190
+ `An assistant named "${instanceName}" is already hatched.\n` +
191
+ `Run \`vellum wake\` to restart it, or \`vellum retire ${instanceName}\` to remove it first.`,
192
+ );
193
+ }
191
194
 
192
- const resources = await allocateLocalResources(instanceName);
195
+ const resources = await allocateLocalResources(instanceName);
193
196
 
194
- const logsDir = join(
195
- resources.instanceDir,
196
- ".vellum",
197
- "workspace",
198
- "data",
199
- "logs",
200
- );
201
- archiveLogFile("hatch.log", logsDir);
202
- resetLogFile("hatch.log");
197
+ const logsDir = join(
198
+ resources.instanceDir,
199
+ ".vellum",
200
+ "workspace",
201
+ "data",
202
+ "logs",
203
+ );
204
+ archiveLogFile("hatch.log", logsDir);
205
+ resetLogFile("hatch.log");
203
206
 
204
- reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
205
- reporter.log(` Species: ${species}`);
206
- reporter.log("");
207
+ reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
208
+ reporter.log(` Species: ${species}`);
209
+ reporter.log("");
207
210
 
208
- const apiKeyCheck = checkProviderApiKey();
209
- if (!apiKeyCheck.hasKey) {
210
- reporter.warn(
211
- "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
212
- );
213
- reporter.warn(" To fix, export your key before running vellum hatch:");
214
- reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
215
- reporter.warn("");
216
- }
211
+ const apiKeyCheck = checkProviderApiKey();
212
+ if (!apiKeyCheck.hasKey) {
213
+ reporter.warn(
214
+ "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
215
+ );
216
+ reporter.warn(" To fix, export your key before running vellum hatch:");
217
+ reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
218
+ reporter.warn("");
219
+ }
217
220
 
218
- if (!process.env.APP_VERSION) {
219
- process.env.APP_VERSION = cliPkg.version;
220
- }
221
+ if (!process.env.APP_VERSION) {
222
+ process.env.APP_VERSION = cliPkg.version;
223
+ }
221
224
 
222
- reporter.progress(2, 6, "Writing configuration...");
223
- const hatchConfigValues = buildHatchConfigValues(configValues, provider);
224
- const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
225
-
226
- reporter.progress(3, 6, "Starting assistant...");
227
- const signingKey = generateLocalSigningKey();
228
- const bootstrapSecret = generateLocalSigningKey();
229
- // Launch the CES sibling alongside the daemon, in parallel — matching the
230
- // Docker topology. The assistant does not spawn its own CES, so a freshly
231
- // hatched instance would otherwise come up with CES unavailable.
232
- // startCes always launches the CES sibling.
233
- await Promise.all([
234
- startCes(watch, resources),
235
- startLocalDaemon(watch, resources, {
236
- defaultWorkspaceConfigPath,
237
- signingKey,
238
- }),
239
- ]);
240
-
241
- reporter.progress(4, 6, "Starting gateway...");
242
- let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
243
- try {
244
- runtimeUrl = await startGateway(watch, resources, {
245
- signingKey,
246
- bootstrapSecret,
247
- envOverrides: flagEnvVars,
225
+ reporter.progress(2, 6, "Writing configuration...");
226
+ const hatchConfigValues = buildHatchConfigValues(configValues, provider);
227
+ const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
228
+
229
+ reporter.progress(3, 6, "Starting assistant...");
230
+ const signingKey = generateLocalSigningKey();
231
+ const bootstrapSecret = generateLocalSigningKey();
232
+ let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
233
+ try {
234
+ // Launch the CES sibling alongside the daemon, in parallel — matching the
235
+ // Docker topology. The assistant does not spawn its own CES, so a freshly
236
+ // hatched instance would otherwise come up with CES unavailable.
237
+ // startCes always launches the CES sibling.
238
+ const startupResults = await Promise.allSettled([
239
+ startCes(watch, resources),
240
+ startLocalDaemon(watch, resources, {
241
+ defaultWorkspaceConfigPath,
242
+ requireReady: true,
243
+ signingKey,
244
+ }),
245
+ ]);
246
+ const startupFailure = startupResults.find(
247
+ (result): result is PromiseRejectedResult =>
248
+ result.status === "rejected",
249
+ );
250
+ if (startupFailure) {
251
+ throw startupFailure.reason;
252
+ }
253
+
254
+ reporter.progress(4, 6, "Starting gateway...");
255
+ runtimeUrl = await startGateway(watch, resources, {
256
+ signingKey,
257
+ bootstrapSecret,
258
+ envOverrides: flagEnvVars,
259
+ requireReady: true,
260
+ });
261
+ } catch (error) {
262
+ reporter.error(
263
+ `\n❌ Local assistant startup failed — stopping partial processes.`,
264
+ );
265
+ await stopLocalProcesses(resources);
266
+ throw error;
267
+ }
268
+
269
+ const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
270
+ const localEntry: AssistantEntry = {
271
+ assistantId: instanceName,
272
+ runtimeUrl,
273
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
274
+ cloud: "local",
275
+ species,
276
+ hatchedAt: new Date().toISOString(),
277
+ resources: { ...resources, signingKey },
278
+ guardianBootstrapSecret: bootstrapSecret,
279
+ };
280
+
281
+ reporter.progress(5, 6, "Saving configuration...");
282
+ saveAssistantEntry(localEntry);
283
+ setActiveAssistant(instanceName);
284
+
285
+ return { resources, runtimeUrl, loopbackUrl, bootstrapSecret };
248
286
  });
249
- } catch (error) {
250
- // Gateway failed — stop the daemon we just started so we don't leave
251
- // orphaned processes with no lock file entry.
252
- reporter.error(
253
- `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
254
- );
255
- await stopLocalProcesses(resources);
256
- throw error;
257
- }
258
287
 
259
288
  // Lease a guardian token so the desktop app can import it on first launch
260
289
  // instead of hitting /v1/guardian/init itself. Use loopback to satisfy
261
290
  // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
262
291
  // IP which the daemon rejects as non-loopback.
263
- reporter.progress(5, 6, "Securing connection...");
264
- const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
292
+ reporter.progress(6, 6, "Securing connection...");
265
293
  const maxLeaseAttempts = 3;
266
294
  let guardianAccessToken: string | undefined;
267
295
  for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
@@ -290,22 +318,6 @@ export async function hatchLocal(
290
318
  }
291
319
  }
292
320
 
293
- // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
294
- const localEntry: AssistantEntry = {
295
- assistantId: instanceName,
296
- runtimeUrl,
297
- localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
298
- cloud: "local",
299
- species,
300
- hatchedAt: new Date().toISOString(),
301
- resources: { ...resources, signingKey },
302
- guardianBootstrapSecret: bootstrapSecret,
303
- };
304
-
305
- reporter.progress(6, 6, "Saving configuration...");
306
- saveAssistantEntry(localEntry);
307
- setActiveAssistant(instanceName);
308
-
309
321
  if (process.env.VELLUM_DESKTOP_APP) {
310
322
  installCLISymlink();
311
323
  }