@vellumai/cli 0.8.6 → 0.8.7-dev.202606052118.34cd356

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 (79) 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 +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -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
+ };
package/src/lib/local.ts CHANGED
@@ -230,8 +230,10 @@ function resolveAssistantIndexPath(): string | undefined {
230
230
  }
231
231
 
232
232
  try {
233
- const vellumPkgPath = _require.resolve("vellum/package.json");
234
- const resolved = join(dirname(vellumPkgPath), "src", "index.ts");
233
+ const assistantPkgPath = _require.resolve(
234
+ "@vellumai/assistant/package.json",
235
+ );
236
+ const resolved = join(dirname(assistantPkgPath), "src", "index.ts");
235
237
  if (existsSync(resolved)) {
236
238
  return resolved;
237
239
  }
@@ -416,13 +418,13 @@ async function startDaemonFromSource(
416
418
  writeFileSync(pidFile, "starting", "utf-8");
417
419
 
418
420
  const child = foreground
419
- ? spawn("bun", ["run", daemonMainPath], {
421
+ ? spawn(process.execPath, ["run", daemonMainPath], {
420
422
  stdio: "inherit",
421
423
  env,
422
424
  })
423
425
  : (() => {
424
426
  const daemonLogFd = openLogFile("hatch.log");
425
- const c = spawn("bun", ["run", daemonMainPath], {
427
+ const c = spawn(process.execPath, ["run", daemonMainPath], {
426
428
  detached: true,
427
429
  stdio: ["ignore", "pipe", "pipe"],
428
430
  env,
@@ -486,7 +488,7 @@ async function startDaemonWatchFromSource(
486
488
  writeFileSync(pidFile, "starting", "utf-8");
487
489
 
488
490
  const daemonLogFd = openLogFile("hatch.log");
489
- const child = spawn("bun", ["--watch", "run", mainPath], {
491
+ const child = spawn(process.execPath, ["--watch", "run", mainPath], {
490
492
  detached: true,
491
493
  stdio: ["ignore", "pipe", "pipe"],
492
494
  env,
@@ -514,6 +516,18 @@ function resolveGatewayDir(): string {
514
516
  return sourceDir;
515
517
  }
516
518
 
519
+ // npm-installed: @vellumai/cli and @vellumai/vellum-gateway are siblings
520
+ const npmGatewayDir = join(
521
+ import.meta.dir,
522
+ "..",
523
+ "..",
524
+ "..",
525
+ "vellum-gateway",
526
+ );
527
+ if (isGatewaySourceDir(npmGatewayDir)) {
528
+ return npmGatewayDir;
529
+ }
530
+
517
531
  // Compiled binary: gateway/ bundled adjacent to the CLI executable.
518
532
  const binGateway = join(dirname(process.execPath), "gateway");
519
533
  if (isGatewaySourceDir(binGateway)) {
@@ -1135,7 +1149,7 @@ export async function startGateway(
1135
1149
  ? ["--watch", "run", "src/index.ts", "--vellum-gateway"]
1136
1150
  : ["run", "src/index.ts", "--vellum-gateway"];
1137
1151
  const gwLogFd = openLogFile("hatch.log");
1138
- gateway = spawn("bun", bunArgs, {
1152
+ gateway = spawn(process.execPath, bunArgs, {
1139
1153
  cwd: gatewayDir,
1140
1154
  detached: true,
1141
1155
  stdio: ["ignore", "pipe", "pipe"],
@@ -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
+ }