@vellumai/cli 0.8.5 → 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 (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -521,6 +521,8 @@ export async function hatchAssistant(
521
521
  );
522
522
  }
523
523
 
524
+ const PLATFORM_FETCH_TIMEOUT_MS = 10_000;
525
+
524
526
  /**
525
527
  * Lightweight pre-check: returns the first active managed assistant for the
526
528
  * authenticated user, or `null` if none exists. Calls `GET /v1/assistants/`
@@ -536,20 +538,31 @@ export async function checkExistingPlatformAssistant(
536
538
  const resolvedUrl = platformUrl || getPlatformUrl();
537
539
  const url = `${resolvedUrl}/v1/assistants/`;
538
540
 
539
- const response = await fetch(url, {
540
- headers: await authHeaders(token, platformUrl),
541
- });
541
+ const controller = new AbortController();
542
+ const timeoutId = setTimeout(
543
+ () => controller.abort(),
544
+ PLATFORM_FETCH_TIMEOUT_MS,
545
+ );
542
546
 
543
- if (!response.ok) {
544
- // Non-fatal: if the list call fails, fall through and let hatch handle it.
545
- return null;
546
- }
547
+ try {
548
+ const response = await fetch(url, {
549
+ signal: controller.signal,
550
+ headers: await authHeaders(token, platformUrl),
551
+ });
547
552
 
548
- const body = (await response.json()) as {
549
- results?: HatchedAssistant[];
550
- };
551
- const active = body.results?.find((a) => a.status === "active");
552
- return active ?? null;
553
+ if (!response.ok) {
554
+ // Non-fatal: if the list call fails, fall through and let hatch handle it.
555
+ return null;
556
+ }
557
+
558
+ const body = (await response.json()) as {
559
+ results?: HatchedAssistant[];
560
+ };
561
+ const active = body.results?.find((a) => a.status === "active");
562
+ return active ?? null;
563
+ } finally {
564
+ clearTimeout(timeoutId);
565
+ }
553
566
  }
554
567
 
555
568
  /**
@@ -563,17 +576,28 @@ export async function fetchPlatformAssistants(
563
576
  const resolvedUrl = platformUrl || getPlatformUrl();
564
577
  const url = `${resolvedUrl}/v1/assistants/`;
565
578
 
566
- const response = await fetch(url, {
567
- headers: await authHeaders(token, platformUrl),
568
- });
579
+ const controller = new AbortController();
580
+ const timeoutId = setTimeout(
581
+ () => controller.abort(),
582
+ PLATFORM_FETCH_TIMEOUT_MS,
583
+ );
569
584
 
570
- if (!response.ok) return [];
585
+ try {
586
+ const response = await fetch(url, {
587
+ signal: controller.signal,
588
+ headers: await authHeaders(token, platformUrl),
589
+ });
571
590
 
572
- const body = (await response.json()) as {
573
- results?: HatchedAssistant[];
574
- };
591
+ if (!response.ok) return [];
575
592
 
576
- return (body.results ?? []).filter((a) => a.status === "active");
593
+ const body = (await response.json()) as {
594
+ results?: HatchedAssistant[];
595
+ };
596
+
597
+ return (body.results ?? []).filter((a) => a.status === "active");
598
+ } finally {
599
+ clearTimeout(timeoutId);
600
+ }
577
601
  }
578
602
 
579
603
  export interface PlatformUser {
@@ -592,22 +616,34 @@ export async function fetchOrganizationId(
592
616
  ): Promise<string> {
593
617
  const resolvedUrl = platformUrl || getPlatformUrl();
594
618
  const url = `${resolvedUrl}/v1/organizations/`;
595
- const response = await fetch(url, {
596
- headers: { ...tokenAuthHeader(token) },
597
- });
598
619
 
599
- if (!response.ok) {
600
- throw new Error(
601
- `Failed to fetch organizations from ${resolvedUrl} (${response.status}). Try logging in again.`,
602
- );
603
- }
620
+ const controller = new AbortController();
621
+ const timeoutId = setTimeout(
622
+ () => controller.abort(),
623
+ PLATFORM_FETCH_TIMEOUT_MS,
624
+ );
604
625
 
605
- const body = (await response.json()) as OrganizationListResponse;
606
- const orgId = body.results?.[0]?.id;
607
- if (!orgId) {
608
- throw new Error("No organization found for this account.");
626
+ try {
627
+ const response = await fetch(url, {
628
+ signal: controller.signal,
629
+ headers: { ...tokenAuthHeader(token) },
630
+ });
631
+
632
+ if (!response.ok) {
633
+ throw new Error(
634
+ `Failed to fetch organizations from ${resolvedUrl} (${response.status}). Try logging in again.`,
635
+ );
636
+ }
637
+
638
+ const body = (await response.json()) as OrganizationListResponse;
639
+ const orgId = body.results?.[0]?.id;
640
+ if (!orgId) {
641
+ throw new Error("No organization found for this account.");
642
+ }
643
+ return orgId;
644
+ } finally {
645
+ clearTimeout(timeoutId);
609
646
  }
610
- return orgId;
611
647
  }
612
648
 
613
649
  interface AllauthSessionResponse {
@@ -627,25 +663,37 @@ export async function fetchCurrentUser(
627
663
  ): Promise<PlatformUser> {
628
664
  const resolvedUrl = platformUrl || getPlatformUrl();
629
665
  const url = `${resolvedUrl}/_allauth/app/v1/auth/session`;
630
- const response = await fetch(url, {
631
- headers: { "X-Session-Token": token },
632
- });
633
666
 
634
- if (!response.ok) {
635
- if (
636
- response.status === 401 ||
637
- response.status === 403 ||
638
- response.status === 410
639
- ) {
640
- throw new Error("Invalid or expired token. Please login again.");
667
+ const controller = new AbortController();
668
+ const timeoutId = setTimeout(
669
+ () => controller.abort(),
670
+ PLATFORM_FETCH_TIMEOUT_MS,
671
+ );
672
+
673
+ try {
674
+ const response = await fetch(url, {
675
+ signal: controller.signal,
676
+ headers: { "X-Session-Token": token },
677
+ });
678
+
679
+ if (!response.ok) {
680
+ if (
681
+ response.status === 401 ||
682
+ response.status === 403 ||
683
+ response.status === 410
684
+ ) {
685
+ throw new Error("Invalid or expired token. Please login again.");
686
+ }
687
+ throw new Error(
688
+ `Platform API error: ${response.status} ${response.statusText}`,
689
+ );
641
690
  }
642
- throw new Error(
643
- `Platform API error: ${response.status} ${response.statusText}`,
644
- );
645
- }
646
691
 
647
- const body = (await response.json()) as AllauthSessionResponse;
648
- return body.data.user;
692
+ const body = (await response.json()) as AllauthSessionResponse;
693
+ return body.data.user;
694
+ } finally {
695
+ clearTimeout(timeoutId);
696
+ }
649
697
  }
650
698
 
651
699
  // ---------------------------------------------------------------------------
@@ -1,6 +1,8 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { existsSync, readFileSync, unlinkSync } from "fs";
3
3
 
4
+ import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
5
+
4
6
  /**
5
7
  * Verify that a PID belongs to a vellum-related process by inspecting its
6
8
  * command line via `ps`. Prevents killing unrelated processes when a PID file
@@ -21,13 +23,15 @@ export function isVellumProcess(pid: number): boolean {
21
23
  }
22
24
  }
23
25
 
26
+ /** Discriminated union: when `alive` is true, `pid` is guaranteed non-null. */
27
+ export type ProcessAliveResult =
28
+ | { alive: true; pid: number }
29
+ | { alive: false; pid: null };
30
+
24
31
  /**
25
32
  * Check if a PID file's process is alive.
26
33
  */
27
- export function isProcessAlive(pidFile: string): {
28
- alive: boolean;
29
- pid: number | null;
30
- } {
34
+ export function isProcessAlive(pidFile: string): ProcessAliveResult {
31
35
  if (!existsSync(pidFile)) {
32
36
  return { alive: false, pid: null };
33
37
  }
@@ -46,6 +50,91 @@ export function isProcessAlive(pidFile: string): {
46
50
  }
47
51
  }
48
52
 
53
+ /** Discriminated union: when `alive` is true, `pid` is guaranteed non-null. */
54
+ export type ProcessHealthResult =
55
+ | { alive: true; healthy: boolean; pid: number }
56
+ | { alive: false; healthy: false; pid: null };
57
+
58
+ /**
59
+ * Check if a PID file's process is alive AND responding to HTTP health checks.
60
+ *
61
+ * Combines PID existence check with an HTTP `/healthz` probe. A process that
62
+ * exists but does not respond (hung, deadlocked, at 100% CPU) returns
63
+ * `alive: true, healthy: false` — callers should kill and restart it.
64
+ */
65
+ export async function isProcessHealthy(
66
+ pidFile: string,
67
+ healthPort: number,
68
+ timeoutMs: number = 3000,
69
+ ): Promise<ProcessHealthResult> {
70
+ const { alive, pid } = isProcessAlive(pidFile);
71
+ if (!alive || pid === null) {
72
+ return { alive: false, healthy: false, pid: null };
73
+ }
74
+
75
+ const healthy = await httpHealthCheck(healthPort, timeoutMs);
76
+ return { alive: true, healthy, pid };
77
+ }
78
+
79
+ /**
80
+ * Outcome of {@link resolveProcessState}. Callers switch on `status`:
81
+ * - `"healthy"` — process is alive and responding; `pid` is the live PID.
82
+ * - `"needs_start"` — process was dead, hung (and killed), or a stale PID
83
+ * was cleaned up. Caller should start a fresh process.
84
+ */
85
+ export type ProcessState =
86
+ | { status: "healthy"; pid: number }
87
+ | { status: "needs_start"; pid: number | null };
88
+
89
+ /**
90
+ * Determine whether a PID-tracked process is alive and healthy. If the
91
+ * process exists but is unresponsive, waits up to `readinessWaitMs`
92
+ * (default 60s — matches the spawner's own `waitForDaemonReady` timeout
93
+ * so a concurrent caller never kills a daemon the spawner is still
94
+ * waiting on) for it to finish initializing. If it remains unresponsive,
95
+ * verifies it belongs to Vellum before killing it, then cleans up the
96
+ * PID file.
97
+ *
98
+ * Encapsulates the full health → readiness-wait → guard → kill → cleanup
99
+ * flow so callers don't need to reimplement it.
100
+ */
101
+ export async function resolveProcessState(
102
+ pidFile: string,
103
+ healthPort: number,
104
+ label: string,
105
+ readinessWaitMs: number = 60_000,
106
+ ): Promise<ProcessState> {
107
+ const result = await isProcessHealthy(pidFile, healthPort);
108
+
109
+ if (!result.alive) {
110
+ return { status: "needs_start", pid: null };
111
+ }
112
+
113
+ if (result.healthy) {
114
+ return { status: "healthy", pid: result.pid };
115
+ }
116
+
117
+ // Alive but not healthy — may still be starting up.
118
+ const becameHealthy = await waitForDaemonReady(healthPort, readinessWaitMs);
119
+ if (becameHealthy) {
120
+ return { status: "healthy", pid: result.pid };
121
+ }
122
+
123
+ // Genuinely hung — kill if it belongs to Vellum, otherwise just clean up.
124
+ if (isVellumProcess(result.pid)) {
125
+ console.log(
126
+ `${label} process alive (pid ${result.pid}) but not responding — killing and restarting...`,
127
+ );
128
+ await stopProcess(result.pid, label);
129
+ } else {
130
+ console.log(
131
+ `Stale PID file (pid ${result.pid} is not a Vellum process) — cleaning up...`,
132
+ );
133
+ }
134
+ removeFiles(pidFile);
135
+ return { status: "needs_start", pid: result.pid };
136
+ }
137
+
49
138
  /**
50
139
  * Stop a process by PID: SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
51
140
  * Returns true if the process was stopped, false if it wasn't alive.
@@ -85,6 +174,18 @@ export async function stopProcess(
85
174
  return true;
86
175
  }
87
176
 
177
+ /** Remove one or more files, ignoring missing-file errors. */
178
+ function removeFiles(...files: (string | string[] | undefined)[]): void {
179
+ for (const entry of files) {
180
+ if (!entry) continue;
181
+ for (const f of Array.isArray(entry) ? entry : [entry]) {
182
+ try {
183
+ unlinkSync(f);
184
+ } catch {}
185
+ }
186
+ }
187
+ }
188
+
88
189
  /**
89
190
  * Stop a process tracked by a PID file, then clean up the file.
90
191
  * Returns true if the process was stopped, false if it wasn't alive.
@@ -92,24 +193,13 @@ export async function stopProcess(
92
193
  export async function stopProcessByPidFile(
93
194
  pidFile: string,
94
195
  label: string,
95
- cleanupFiles?: string[],
196
+ extraCleanupFiles?: string[],
96
197
  timeoutMs?: number,
97
198
  ): Promise<boolean> {
98
199
  const { alive, pid } = isProcessAlive(pidFile);
99
200
 
100
201
  if (!alive || pid === null) {
101
- if (existsSync(pidFile)) {
102
- try {
103
- unlinkSync(pidFile);
104
- } catch {}
105
- }
106
- if (cleanupFiles) {
107
- for (const f of cleanupFiles) {
108
- try {
109
- unlinkSync(f);
110
- } catch {}
111
- }
112
- }
202
+ removeFiles(pidFile, extraCleanupFiles);
113
203
  return false;
114
204
  }
115
205
 
@@ -120,32 +210,12 @@ export async function stopProcessByPidFile(
120
210
  console.log(
121
211
  `PID ${pid} is not a vellum process — cleaning up stale ${label} PID file.`,
122
212
  );
123
- try {
124
- unlinkSync(pidFile);
125
- } catch {}
126
- if (cleanupFiles) {
127
- for (const f of cleanupFiles) {
128
- try {
129
- unlinkSync(f);
130
- } catch {}
131
- }
132
- }
213
+ removeFiles(pidFile, extraCleanupFiles);
133
214
  return false;
134
215
  }
135
216
 
136
217
  const stopped = await stopProcess(pid, label, timeoutMs);
137
-
138
- try {
139
- unlinkSync(pidFile);
140
- } catch {}
141
- if (cleanupFiles) {
142
- for (const f of cleanupFiles) {
143
- try {
144
- unlinkSync(f);
145
- } catch {}
146
- }
147
- }
148
-
218
+ removeFiles(pidFile, extraCleanupFiles);
149
219
  return stopped;
150
220
  }
151
221
 
@@ -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
+ }
@@ -65,10 +65,52 @@ export function exec(
65
65
  });
66
66
  }
67
67
 
68
- export function execOutput(
68
+ /**
69
+ * Run `command` with `args` and pipe `input` to its stdin. Mirrors `exec` —
70
+ * same no-args-in-error-message contract from `buildExecErrorMessage` — but
71
+ * lets callers stream content (e.g. a small JSON blob) into a child process
72
+ * without having to put the content on the command line where `ps` could
73
+ * read it and where Docker bind-mounts would be involved.
74
+ */
75
+ export function execWithStdin(
69
76
  command: string,
70
77
  args: string[],
78
+ input: string,
71
79
  options: { cwd?: string } = {},
80
+ ): Promise<void> {
81
+ return new Promise((resolve, reject) => {
82
+ const child = spawn(command, args, {
83
+ cwd: options.cwd,
84
+ stdio: ["pipe", "pipe", "pipe"],
85
+ });
86
+
87
+ let stdout = "";
88
+ child.stdout.on("data", (data: Buffer) => {
89
+ stdout += data.toString();
90
+ });
91
+
92
+ let stderr = "";
93
+ child.stderr.on("data", (data: Buffer) => {
94
+ stderr += data.toString();
95
+ });
96
+
97
+ child.on("close", (code) => {
98
+ if (code === 0) {
99
+ resolve();
100
+ } else {
101
+ reject(new Error(buildExecErrorMessage(command, code, stderr, stdout)));
102
+ }
103
+ });
104
+ child.on("error", reject);
105
+
106
+ child.stdin.end(input);
107
+ });
108
+ }
109
+
110
+ export function execOutput(
111
+ command: string,
112
+ args: string[],
113
+ options: { cwd?: string; timeoutMs?: number } = {},
72
114
  ): Promise<string> {
73
115
  return new Promise((resolve, reject) => {
74
116
  const child = spawn(command, args, {
@@ -76,6 +118,21 @@ export function execOutput(
76
118
  stdio: ["pipe", "pipe", "pipe"],
77
119
  });
78
120
 
121
+ let settled = false;
122
+ let timer: ReturnType<typeof setTimeout> | undefined;
123
+
124
+ if (options.timeoutMs !== undefined) {
125
+ timer = setTimeout(() => {
126
+ if (!settled) {
127
+ settled = true;
128
+ child.kill("SIGTERM");
129
+ reject(
130
+ new Error(`${command} timed out after ${options.timeoutMs}ms`),
131
+ );
132
+ }
133
+ }, options.timeoutMs);
134
+ }
135
+
79
136
  let stdout = "";
80
137
  child.stdout.on("data", (data: Buffer) => {
81
138
  stdout += data.toString();
@@ -87,17 +144,20 @@ export function execOutput(
87
144
  });
88
145
 
89
146
  child.on("close", (code) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ if (timer) clearTimeout(timer);
90
150
  if (code === 0) {
91
151
  resolve(stdout.trim());
92
152
  } else {
93
- // execOutput intentionally drops stdout from the error message
94
- // (callers that read stdout via the success path don't expect
95
- // partial stdout to land in error.message). Stderr is enough
96
- // for diagnostics, and the no-args-in-message guarantee from
97
- // exec() still holds.
98
153
  reject(new Error(buildExecErrorMessage(command, code, stderr, "")));
99
154
  }
100
155
  });
101
- child.on("error", reject);
156
+ child.on("error", (err) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ if (timer) clearTimeout(timer);
160
+ reject(err);
161
+ });
102
162
  });
103
163
  }
@@ -26,6 +26,13 @@ import {
26
26
 
27
27
  export type SyncLogger = (message: string) => void;
28
28
 
29
+ function isTimeoutError(err: unknown): boolean {
30
+ return (
31
+ err instanceof Error &&
32
+ (err.name === "AbortError" || err.message.includes("timed out"))
33
+ );
34
+ }
35
+
29
36
  export interface SyncResult {
30
37
  added: number;
31
38
  removed: number;
@@ -68,6 +75,11 @@ export async function syncCloudAssistants(
68
75
  } catch (err) {
69
76
  const msg = err instanceof Error ? err.message : String(err);
70
77
  log?.(`Failed to fetch current user: ${msg}`);
78
+ if (isTimeoutError(err)) {
79
+ console.warn(
80
+ "Warning: platform user lookup timed out — skipping cloud sync",
81
+ );
82
+ }
71
83
  }
72
84
 
73
85
  let platformAssistants: { id: string; name: string; status: string }[];
@@ -80,6 +92,11 @@ export async function syncCloudAssistants(
80
92
  } catch (err) {
81
93
  const msg = err instanceof Error ? err.message : String(err);
82
94
  log?.(`fetchPlatformAssistants failed: ${msg}`);
95
+ if (isTimeoutError(err)) {
96
+ console.warn(
97
+ "Warning: platform assistant fetch timed out — using cached data",
98
+ );
99
+ }
83
100
  return null;
84
101
  }
85
102