@vellumai/cli 0.8.6 → 0.8.7

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 (43) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +21 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  13. package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
  14. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
  15. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  16. package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
  17. package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
  18. package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
  19. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  20. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  21. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  22. package/package.json +12 -1
  23. package/src/__tests__/env-drift.test.ts +32 -44
  24. package/src/__tests__/flags.test.ts +248 -0
  25. package/src/__tests__/multi-local.test.ts +1 -1
  26. package/src/__tests__/orphan-detection.test.ts +8 -6
  27. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  28. package/src/commands/client.ts +413 -2
  29. package/src/commands/env.ts +1 -1
  30. package/src/commands/flags.ts +89 -17
  31. package/src/components/DefaultMainScreen.tsx +16 -1
  32. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  33. package/src/lib/assistant-config.ts +3 -3
  34. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  35. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  36. package/src/lib/environments/paths.ts +1 -1
  37. package/src/lib/environments/resolve.ts +2 -5
  38. package/src/lib/guardian-token.ts +12 -5
  39. package/src/lib/hatch-local.ts +73 -33
  40. package/src/lib/lifecycle-reporter.ts +31 -0
  41. package/src/lib/retire-local.ts +28 -14
  42. package/src/lib/segments-to-plain-text.ts +35 -0
  43. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,59 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+
3
+ import { consoleLifecycleReporter } from "../lifecycle-reporter.js";
4
+
5
+ describe("consoleLifecycleReporter", () => {
6
+ const originalDesktopApp = process.env.VELLUM_DESKTOP_APP;
7
+ let stdoutWriteSpy: ReturnType<typeof spyOn>;
8
+
9
+ beforeEach(() => {
10
+ stdoutWriteSpy = spyOn(process.stdout, "write").mockImplementation(
11
+ () => true,
12
+ );
13
+ });
14
+
15
+ afterEach(() => {
16
+ stdoutWriteSpy.mockRestore();
17
+ if (originalDesktopApp === undefined) {
18
+ delete process.env.VELLUM_DESKTOP_APP;
19
+ } else {
20
+ process.env.VELLUM_DESKTOP_APP = originalDesktopApp;
21
+ }
22
+ });
23
+
24
+ test("routes log/warn/error to the matching console methods", () => {
25
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
26
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
27
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
28
+
29
+ consoleLifecycleReporter.log("hello");
30
+ consoleLifecycleReporter.warn("careful");
31
+ consoleLifecycleReporter.error("boom");
32
+
33
+ expect(logSpy).toHaveBeenCalledWith("hello");
34
+ expect(warnSpy).toHaveBeenCalledWith("careful");
35
+ expect(errorSpy).toHaveBeenCalledWith("boom");
36
+
37
+ logSpy.mockRestore();
38
+ warnSpy.mockRestore();
39
+ errorSpy.mockRestore();
40
+ });
41
+
42
+ test("emits the HATCH_PROGRESS stdout contract under VELLUM_DESKTOP_APP", () => {
43
+ process.env.VELLUM_DESKTOP_APP = "1";
44
+
45
+ consoleLifecycleReporter.progress(3, 6, "Starting assistant...");
46
+
47
+ expect(stdoutWriteSpy).toHaveBeenCalledWith(
48
+ `HATCH_PROGRESS:${JSON.stringify({ step: 3, total: 6, label: "Starting assistant..." })}\n`,
49
+ );
50
+ });
51
+
52
+ test("suppresses progress output when not running under the desktop app", () => {
53
+ delete process.env.VELLUM_DESKTOP_APP;
54
+
55
+ consoleLifecycleReporter.progress(1, 6, "Allocating resources...");
56
+
57
+ expect(stdoutWriteSpy).not.toHaveBeenCalled();
58
+ });
59
+ });
@@ -10,6 +10,8 @@ import {
10
10
  import { homedir } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
14
+
13
15
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
14
16
  import {
15
17
  getDefaultPorts,
@@ -18,8 +20,6 @@ import {
18
20
  getMultiInstanceDir,
19
21
  } from "./environments/paths.js";
20
22
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
- import { SEEDS } from "./environments/seeds.js";
22
- import type { EnvironmentDefinition } from "./environments/types.js";
23
23
  import { probePort } from "./port-probe.js";
24
24
 
25
25
  /**
@@ -631,7 +631,7 @@ export async function allocateLocalResources(
631
631
 
632
632
  // Env-aware bases: non-prod envs sit in their own 1000-port window so
633
633
  // running prod and staging assistants side-by-side doesn't collide. See
634
- // `environments/seeds.ts:portBlock` for the layout.
634
+ // the `@vellumai/environments` `portBlock` layout.
635
635
  const basePorts = getDefaultPorts(env);
636
636
  const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
637
637
  const gatewayPort = await findAvailablePort(basePorts.gateway, [
@@ -27,7 +27,8 @@ const {
27
27
  getLockfilePaths,
28
28
  getMultiInstanceDir,
29
29
  } = await import("../paths.js");
30
- type EnvironmentDefinition = import("../types.js").EnvironmentDefinition;
30
+ type EnvironmentDefinition =
31
+ import("@vellumai/environments").EnvironmentDefinition;
31
32
 
32
33
  const prod: EnvironmentDefinition = {
33
34
  name: "production",
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { SEEDS } from "@vellumai/environments";
4
+
3
5
  import { getDefaultPorts } from "../paths.js";
4
- import { SEEDS } from "../seeds.js";
5
6
 
6
7
  describe("SEEDS port blocks", () => {
7
8
  test("production uses the legacy (pre-MVP) port layout", () => {
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
 
4
- import type { EnvironmentDefinition, PortMap } from "./types.js";
4
+ import type { EnvironmentDefinition, PortMap } from "@vellumai/environments";
5
5
 
6
6
  const PRODUCTION_ENVIRONMENT_NAME = "production";
7
7
 
@@ -8,8 +8,7 @@ import {
8
8
  import { homedir } from "os";
9
9
  import { dirname, join } from "path";
10
10
 
11
- import { SEEDS } from "./seeds.js";
12
- import type { EnvironmentDefinition } from "./types.js";
11
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
13
12
 
14
13
  const DEFAULT_ENVIRONMENT_NAME = "production";
15
14
 
@@ -115,7 +114,7 @@ export function getCurrentEnvironment(
115
114
  // writers don't end up in disjoint states on a typo.
116
115
  process.stderr.write(
117
116
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
118
- `Add it to cli/src/lib/environments/seeds.ts and rebuild if this was intentional.\n`,
117
+ `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
119
118
  );
120
119
  }
121
120
  const fallback = SEEDS[DEFAULT_ENVIRONMENT_NAME];
@@ -174,5 +173,3 @@ export function resolveEnvironmentSource(override?: string): {
174
173
  }
175
174
  return { name: DEFAULT_ENVIRONMENT_NAME, source: "default" };
176
175
  }
177
-
178
-
@@ -11,9 +11,10 @@ import {
11
11
  import { platform } from "os";
12
12
  import { dirname, join } from "path";
13
13
 
14
+ import { SEEDS } from "@vellumai/environments";
15
+
14
16
  import { getConfigDir } from "./environments/paths.js";
15
17
  import { getCurrentEnvironment } from "./environments/resolve.js";
16
- import { SEEDS } from "./environments/seeds.js";
17
18
 
18
19
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
19
20
 
@@ -176,7 +177,8 @@ export async function refreshGuardianToken(
176
177
  // Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
177
178
  // returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
178
179
  const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
179
- if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now()) return null;
180
+ if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now())
181
+ return null;
180
182
 
181
183
  try {
182
184
  const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
@@ -191,11 +193,16 @@ export async function refreshGuardianToken(
191
193
 
192
194
  const json = (await response.json()) as Record<string, unknown>;
193
195
  const refreshed: GuardianTokenData = {
194
- guardianPrincipalId: (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
196
+ guardianPrincipalId:
197
+ (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
195
198
  accessToken: json.accessToken as string,
196
- accessTokenExpiresAt: (json.accessTokenExpiresAt as string | number) ?? tokenData.accessTokenExpiresAt,
199
+ accessTokenExpiresAt:
200
+ (json.accessTokenExpiresAt as string | number) ??
201
+ tokenData.accessTokenExpiresAt,
197
202
  refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
198
- refreshTokenExpiresAt: (json.refreshTokenExpiresAt as string | number) ?? tokenData.refreshTokenExpiresAt,
203
+ refreshTokenExpiresAt:
204
+ (json.refreshTokenExpiresAt as string | number) ??
205
+ tokenData.refreshTokenExpiresAt,
199
206
  refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
200
207
  isNew: false,
201
208
  deviceId: tokenData.deviceId,
@@ -33,7 +33,10 @@ import {
33
33
  import { generateInstanceName } from "./random-name.js";
34
34
  import { leaseGuardianToken } from "./guardian-token.js";
35
35
  import { archiveLogFile, resetLogFile } from "./xdg-log.js";
36
- import { emitProgress } from "./desktop-progress.js";
36
+ import {
37
+ consoleLifecycleReporter,
38
+ type LifecycleReporter,
39
+ } from "./lifecycle-reporter.js";
37
40
  import {
38
41
  configureHatchProviderApiKey,
39
42
  formatProviderName,
@@ -134,6 +137,25 @@ function installCLISymlink(): void {
134
137
 
135
138
  export interface HatchLocalOptions {
136
139
  setupProviderCredentials?: boolean;
140
+ /**
141
+ * Sink for progress and log output. Defaults to the console reporter so CLI
142
+ * callers keep their existing terminal output; in-process callers can inject
143
+ * their own reporter to consume progress without writing to stdout.
144
+ */
145
+ reporter?: LifecycleReporter;
146
+ }
147
+
148
+ export interface HatchLocalResult {
149
+ assistantId: string;
150
+ runtimeUrl: string;
151
+ localUrl: string;
152
+ species: Species;
153
+ /**
154
+ * Guardian access token leased during hatch, when the lease succeeded. The
155
+ * full token pair is persisted to disk regardless; this is surfaced so an
156
+ * in-process caller can prime a connection without re-reading the file.
157
+ */
158
+ guardianAccessToken?: string;
137
159
  }
138
160
 
139
161
  export async function hatchLocal(
@@ -143,7 +165,8 @@ export async function hatchLocal(
143
165
  keepAlive: boolean = false,
144
166
  configValues: Record<string, string> = {},
145
167
  options: HatchLocalOptions = {},
146
- ): Promise<void> {
168
+ ): Promise<HatchLocalResult> {
169
+ const reporter = options.reporter ?? consoleLifecycleReporter;
147
170
  const provider =
148
171
  options.setupProviderCredentials === false
149
172
  ? undefined
@@ -153,7 +176,7 @@ export async function hatchLocal(
153
176
  name ?? process.env.VELLUM_ASSISTANT_NAME,
154
177
  );
155
178
 
156
- emitProgress(1, 6, "Allocating resources...");
179
+ reporter.progress(1, 6, "Allocating resources...");
157
180
 
158
181
  const existing = findAssistantByName(instanceName);
159
182
  if (existing && (!existing.cloud || existing.cloud === "local")) {
@@ -175,29 +198,29 @@ export async function hatchLocal(
175
198
  archiveLogFile("hatch.log", logsDir);
176
199
  resetLogFile("hatch.log");
177
200
 
178
- console.log(`🥚 Hatching local assistant: ${instanceName}`);
179
- console.log(` Species: ${species}`);
180
- console.log("");
201
+ reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
202
+ reporter.log(` Species: ${species}`);
203
+ reporter.log("");
181
204
 
182
205
  const apiKeyCheck = checkProviderApiKey();
183
206
  if (!apiKeyCheck.hasKey) {
184
- console.warn(
207
+ reporter.warn(
185
208
  "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
186
209
  );
187
- console.warn(" To fix, export your key before running vellum hatch:");
188
- console.warn(" export ANTHROPIC_API_KEY=<your-key>");
189
- console.warn("");
210
+ reporter.warn(" To fix, export your key before running vellum hatch:");
211
+ reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
212
+ reporter.warn("");
190
213
  }
191
214
 
192
215
  if (!process.env.APP_VERSION) {
193
216
  process.env.APP_VERSION = cliPkg.version;
194
217
  }
195
218
 
196
- emitProgress(2, 6, "Writing configuration...");
219
+ reporter.progress(2, 6, "Writing configuration...");
197
220
  const hatchConfigValues = buildHatchConfigValues(configValues, provider);
198
221
  const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
199
222
 
200
- emitProgress(3, 6, "Starting assistant...");
223
+ reporter.progress(3, 6, "Starting assistant...");
201
224
  const signingKey = generateLocalSigningKey();
202
225
  const bootstrapSecret = generateLocalSigningKey();
203
226
  await startLocalDaemon(watch, resources, {
@@ -205,14 +228,17 @@ export async function hatchLocal(
205
228
  signingKey,
206
229
  });
207
230
 
208
- emitProgress(4, 6, "Starting gateway...");
231
+ reporter.progress(4, 6, "Starting gateway...");
209
232
  let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
210
233
  try {
211
- runtimeUrl = await startGateway(watch, resources, { signingKey, bootstrapSecret });
234
+ runtimeUrl = await startGateway(watch, resources, {
235
+ signingKey,
236
+ bootstrapSecret,
237
+ });
212
238
  } catch (error) {
213
239
  // Gateway failed — stop the daemon we just started so we don't leave
214
240
  // orphaned processes with no lock file entry.
215
- console.error(
241
+ reporter.error(
216
242
  `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
217
243
  );
218
244
  await stopLocalProcesses(resources);
@@ -223,24 +249,28 @@ export async function hatchLocal(
223
249
  // instead of hitting /v1/guardian/init itself. Use loopback to satisfy
224
250
  // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
225
251
  // IP which the daemon rejects as non-loopback.
226
- emitProgress(5, 6, "Securing connection...");
252
+ reporter.progress(5, 6, "Securing connection...");
227
253
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
228
254
  const maxLeaseAttempts = 3;
229
255
  let guardianAccessToken: string | undefined;
230
256
  for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
231
257
  try {
232
- const tokenData = await leaseGuardianToken(loopbackUrl, instanceName, bootstrapSecret);
258
+ const tokenData = await leaseGuardianToken(
259
+ loopbackUrl,
260
+ instanceName,
261
+ bootstrapSecret,
262
+ );
233
263
  guardianAccessToken = tokenData.accessToken;
234
264
  break;
235
265
  } catch (err) {
236
266
  if (attempt < maxLeaseAttempts) {
237
267
  const delayMs = 2000 * 2 ** (attempt - 1);
238
- console.error(
268
+ reporter.error(
239
269
  `⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
240
270
  );
241
271
  await new Promise((r) => setTimeout(r, delayMs));
242
272
  } else {
243
- console.error(
273
+ reporter.error(
244
274
  `⚠️ Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` +
245
275
  ` The assistant is running but guardian-token.json was not written.\n` +
246
276
  ` If the desktop app loses its stored credentials, re-hatch to recover.`,
@@ -261,7 +291,7 @@ export async function hatchLocal(
261
291
  guardianBootstrapSecret: bootstrapSecret,
262
292
  };
263
293
 
264
- emitProgress(6, 6, "Saving configuration...");
294
+ reporter.progress(6, 6, "Saving configuration...");
265
295
  saveAssistantEntry(localEntry);
266
296
  setActiveAssistant(instanceName);
267
297
 
@@ -270,13 +300,13 @@ export async function hatchLocal(
270
300
  }
271
301
 
272
302
  if (provider !== undefined && provider !== null && !guardianAccessToken) {
273
- console.error(
303
+ reporter.error(
274
304
  `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
275
305
  ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`,
276
306
  );
277
307
  } else if (provider !== undefined) {
278
- console.log("");
279
- console.log(
308
+ reporter.log("");
309
+ reporter.log(
280
310
  provider === null
281
311
  ? "Checking provider credentials..."
282
312
  : `Checking ${formatProviderName(provider)} credentials...`,
@@ -289,14 +319,22 @@ export async function hatchLocal(
289
319
  });
290
320
  }
291
321
 
292
- console.log("");
293
- console.log(`✅ Local assistant hatched!`);
294
- console.log("");
295
- console.log("Instance details:");
296
- console.log(` Name: ${instanceName}`);
297
- console.log(` Runtime: ${runtimeUrl}`);
298
- console.log("");
299
- logHatchNextSteps(console.log, instanceName);
322
+ reporter.log("");
323
+ reporter.log(`✅ Local assistant hatched!`);
324
+ reporter.log("");
325
+ reporter.log("Instance details:");
326
+ reporter.log(` Name: ${instanceName}`);
327
+ reporter.log(` Runtime: ${runtimeUrl}`);
328
+ reporter.log("");
329
+ logHatchNextSteps((message) => reporter.log(message), instanceName);
330
+
331
+ const result: HatchLocalResult = {
332
+ assistantId: instanceName,
333
+ runtimeUrl,
334
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
335
+ species,
336
+ guardianAccessToken,
337
+ };
300
338
 
301
339
  if (keepAlive) {
302
340
  const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
@@ -306,7 +344,7 @@ export async function hatchLocal(
306
344
  let consecutiveFailures = 0;
307
345
 
308
346
  const shutdown = async (): Promise<void> => {
309
- console.log("\nShutting down local processes...");
347
+ reporter.log("\nShutting down local processes...");
310
348
  await stopLocalProcesses(resources);
311
349
  process.exit(0);
312
350
  };
@@ -330,7 +368,7 @@ export async function hatchLocal(
330
368
  consecutiveFailures++;
331
369
  }
332
370
  if (consecutiveFailures >= MAX_FAILURES) {
333
- console.log(
371
+ reporter.log(
334
372
  `\n⚠️ ${healthTarget} stopped responding — shutting down.`,
335
373
  );
336
374
  await stopLocalProcesses(resources);
@@ -338,4 +376,6 @@ export async function hatchLocal(
338
376
  }
339
377
  }
340
378
  }
379
+
380
+ return result;
341
381
  }
@@ -0,0 +1,31 @@
1
+ import { emitProgress } from "./desktop-progress.js";
2
+
3
+ /**
4
+ * Sink for the human-facing and structured output of long-running lifecycle
5
+ * operations (hatch, retire). Injecting it lets an in-process caller (e.g. a
6
+ * desktop main process embedding these functions) observe progress without the
7
+ * operation writing to the terminal, while the CLI keeps its existing stdout.
8
+ */
9
+ export interface LifecycleReporter {
10
+ /**
11
+ * Coarse step progress. The CLI reporter mirrors this to the desktop
12
+ * `HATCH_PROGRESS:` stdout channel.
13
+ */
14
+ progress(step: number, total: number, label: string): void;
15
+ log(message: string): void;
16
+ warn(message: string): void;
17
+ error(message: string): void;
18
+ }
19
+
20
+ /**
21
+ * Reporter used by the CLI commands: human-readable lines to the console plus
22
+ * structured step events on the desktop progress channel. Reproduces the exact
23
+ * terminal output — and the `HATCH_PROGRESS:` lines under `VELLUM_DESKTOP_APP` —
24
+ * that existing subprocess consumers parse.
25
+ */
26
+ export const consoleLifecycleReporter: LifecycleReporter = {
27
+ progress: (step, total, label) => emitProgress(step, total, label),
28
+ log: (message) => console.log(message),
29
+ warn: (message) => console.warn(message),
30
+ error: (message) => console.error(message),
31
+ };
@@ -3,22 +3,34 @@ import { homedir } from "os";
3
3
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
4
4
  import { basename, dirname, join } from "path";
5
5
 
6
- import {
7
- getDaemonPidPath,
8
- loadAllAssistants,
9
- } from "./assistant-config.js";
6
+ import { getDaemonPidPath, loadAllAssistants } from "./assistant-config.js";
10
7
  import type { AssistantEntry } from "./assistant-config.js";
11
8
  import {
12
9
  stopOrphanedDaemonProcesses,
13
10
  stopProcessByPidFile,
14
11
  } from "./process.js";
15
12
  import { getArchivePath, getMetadataPath } from "./retire-archive.js";
13
+ import {
14
+ consoleLifecycleReporter,
15
+ type LifecycleReporter,
16
+ } from "./lifecycle-reporter.js";
17
+
18
+ export interface RetireLocalResult {
19
+ assistantId: string;
20
+ /** Whether the instance data directory was archived (false when skipped). */
21
+ archived: boolean;
22
+ /** Path to the background tar archive, when archiving was started. */
23
+ archivePath?: string;
24
+ /** True when another local assistant shared the data dir, so it was kept. */
25
+ sharedDataDir?: boolean;
26
+ }
16
27
 
17
28
  export async function retireLocal(
18
29
  name: string,
19
30
  entry: AssistantEntry,
20
- ): Promise<void> {
21
- console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
31
+ reporter: LifecycleReporter = consoleLifecycleReporter,
32
+ ): Promise<RetireLocalResult> {
33
+ reporter.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
22
34
 
23
35
  if (!entry.resources) {
24
36
  throw new Error(
@@ -38,11 +50,11 @@ export async function retireLocal(
38
50
  });
39
51
 
40
52
  if (otherSharesDir) {
41
- console.log(
53
+ reporter.log(
42
54
  ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`,
43
55
  );
44
- console.log("\u2705 Local instance retired (config entry removed only).");
45
- return;
56
+ reporter.log("\u2705 Local instance retired (config entry removed only).");
57
+ return { assistantId: name, archived: false, sharedDataDir: true };
46
58
  }
47
59
 
48
60
  const daemonPidFile = getDaemonPidPath(resources);
@@ -87,11 +99,11 @@ export async function retireLocal(
87
99
  const stagingDir = `${archivePath}.staging`;
88
100
 
89
101
  if (!existsSync(dirToArchive)) {
90
- console.log(
102
+ reporter.log(
91
103
  ` No data directory at ${dirToArchive} — nothing to archive.`,
92
104
  );
93
- console.log("\u2705 Local instance retired.");
94
- return;
105
+ reporter.log("\u2705 Local instance retired.");
106
+ return { assistantId: name, archived: false };
95
107
  }
96
108
 
97
109
  // Ensure the retired archive directory exists before attempting the rename
@@ -123,6 +135,8 @@ export async function retireLocal(
123
135
  });
124
136
  child.unref();
125
137
 
126
- console.log(`📦 Archiving to ${archivePath} in the background.`);
127
- console.log("\u2705 Local instance retired.");
138
+ reporter.log(`📦 Archiving to ${archivePath} in the background.`);
139
+ reporter.log("\u2705 Local instance retired.");
140
+
141
+ return { assistantId: name, archived: true, archivePath };
128
142
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Derive a message's flat plain-text body from its ordered text segments.
3
+ *
4
+ * Mirrors the daemon's `joinWithSpacing` (assistant `daemon/handlers/shared.ts`):
5
+ * adjacent segments are concatenated, inserting a single space between two
6
+ * segments only when neither the end of the left nor the start of the right is
7
+ * already whitespace. Keeping these byte-identical means CLI-rendered text
8
+ * matches what the daemon would have produced for the now-removed flat
9
+ * `content` field.
10
+ */
11
+ export function segmentsToPlainText(segments: string[] | undefined): string {
12
+ if (!segments || segments.length === 0) {
13
+ return "";
14
+ }
15
+ let result = segments[0] ?? "";
16
+ for (let i = 1; i < segments.length; i++) {
17
+ const prev = result[result.length - 1];
18
+ const next = segments[i]![0];
19
+ // Only insert a space when neither side already has whitespace.
20
+ if (
21
+ prev &&
22
+ next &&
23
+ prev !== " " &&
24
+ prev !== "\n" &&
25
+ prev !== "\t" &&
26
+ next !== " " &&
27
+ next !== "\n" &&
28
+ next !== "\t"
29
+ ) {
30
+ result += " ";
31
+ }
32
+ result += segments[i];
33
+ }
34
+ return result;
35
+ }