@vellumai/cli 0.8.5 → 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 (72) 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 +21 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  14. package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
  15. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
  16. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  17. package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
  18. package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
  19. package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
  20. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  21. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  22. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  23. package/package.json +12 -1
  24. package/src/__tests__/backup.test.ts +38 -0
  25. package/src/__tests__/env-drift.test.ts +32 -44
  26. package/src/__tests__/flags.test.ts +248 -0
  27. package/src/__tests__/multi-local.test.ts +1 -1
  28. package/src/__tests__/orphan-detection.test.ts +8 -6
  29. package/src/__tests__/recover.test.ts +307 -0
  30. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  31. package/src/__tests__/wake.test.ts +215 -0
  32. package/src/commands/backup.ts +2 -0
  33. package/src/commands/client.ts +471 -30
  34. package/src/commands/env.ts +1 -1
  35. package/src/commands/flags.ts +269 -0
  36. package/src/commands/gateway/token.ts +73 -0
  37. package/src/commands/gateway.ts +29 -0
  38. package/src/commands/logs.ts +6 -18
  39. package/src/commands/ps.ts +41 -41
  40. package/src/commands/recover.ts +47 -9
  41. package/src/commands/restore.ts +8 -1
  42. package/src/commands/retire.ts +3 -23
  43. package/src/commands/rollback.ts +2 -14
  44. package/src/commands/ssh.ts +5 -24
  45. package/src/commands/teleport.ts +34 -26
  46. package/src/commands/upgrade.ts +8 -16
  47. package/src/commands/wake.ts +68 -45
  48. package/src/components/DefaultMainScreen.tsx +16 -1
  49. package/src/index.ts +6 -0
  50. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  51. package/src/lib/__tests__/step-runner.test.ts +49 -1
  52. package/src/lib/assistant-config.ts +16 -3
  53. package/src/lib/config-utils.ts +24 -3
  54. package/src/lib/docker.ts +57 -7
  55. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  56. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  57. package/src/lib/environments/paths.ts +1 -1
  58. package/src/lib/environments/resolve.ts +2 -5
  59. package/src/lib/guardian-token.ts +12 -5
  60. package/src/lib/hatch-local.ts +75 -33
  61. package/src/lib/http-client.ts +1 -3
  62. package/src/lib/lifecycle-reporter.ts +31 -0
  63. package/src/lib/local.ts +173 -292
  64. package/src/lib/orphan-detection.ts +9 -5
  65. package/src/lib/pgrep.ts +5 -1
  66. package/src/lib/platform-client.ts +97 -49
  67. package/src/lib/process.ts +109 -39
  68. package/src/lib/retire-local.ts +28 -14
  69. package/src/lib/segments-to-plain-text.ts +35 -0
  70. package/src/lib/step-runner.ts +67 -7
  71. package/src/lib/sync-cloud-assistants.ts +17 -0
  72. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -2,14 +2,16 @@ import { existsSync, unlinkSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
4
  import {
5
+ extractHostFromUrl,
5
6
  formatAssistantLookupError,
6
7
  formatAssistantReference,
7
8
  getAssistantDisplayName,
8
9
  loadAllAssistants,
9
10
  lookupAssistantByIdentifier,
10
11
  removeAssistantEntry,
12
+ resolveCloud,
13
+ type AssistantEntry,
11
14
  } from "../lib/assistant-config.js";
12
- import type { AssistantEntry } from "../lib/assistant-config.js";
13
15
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
14
16
  import { getConfigDir } from "../lib/environments/paths.js";
15
17
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
@@ -31,28 +33,6 @@ import {
31
33
  writeToLogFile,
32
34
  } from "../lib/xdg-log.js";
33
35
 
34
- function resolveCloud(entry: AssistantEntry): string {
35
- if (entry.cloud) {
36
- return entry.cloud;
37
- }
38
- if (entry.project) {
39
- return "gcp";
40
- }
41
- if (entry.sshUser) {
42
- return "custom";
43
- }
44
- return "local";
45
- }
46
-
47
- function extractHostFromUrl(url: string): string {
48
- try {
49
- const parsed = new URL(url);
50
- return parsed.hostname;
51
- } catch {
52
- return url.replace(/^https?:\/\//, "").split(":")[0];
53
- }
54
- }
55
-
56
36
  export { retireLocal };
57
37
 
58
38
  interface RetireArgs {
@@ -4,9 +4,10 @@ import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
6
  loadAllAssistants,
7
+ resolveCloud,
7
8
  saveAssistantEntry,
9
+ type AssistantEntry,
8
10
  } from "../lib/assistant-config";
9
- import type { AssistantEntry } from "../lib/assistant-config";
10
11
  import {
11
12
  captureImageRefs,
12
13
  GATEWAY_INTERNAL_PORT,
@@ -90,19 +91,6 @@ function parseArgs(): { name: string | null; version: string | null } {
90
91
  return { name, version };
91
92
  }
92
93
 
93
- function resolveCloud(entry: AssistantEntry): string {
94
- if (entry.cloud) {
95
- return entry.cloud;
96
- }
97
- if (entry.project) {
98
- return "gcp";
99
- }
100
- if (entry.sshUser) {
101
- return "custom";
102
- }
103
- return "local";
104
- }
105
-
106
94
  /**
107
95
  * Resolve which assistant to target for the rollback command. Priority:
108
96
  * 1. Explicit name argument
@@ -1,7 +1,10 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- import { resolveAssistant } from "../lib/assistant-config";
4
- import type { AssistantEntry } from "../lib/assistant-config";
3
+ import {
4
+ extractHostFromUrl,
5
+ resolveAssistant,
6
+ resolveCloud,
7
+ } from "../lib/assistant-config";
5
8
  import { dockerResourceNames } from "../lib/docker";
6
9
  import { getPlatformUrl, readPlatformToken } from "../lib/platform-client";
7
10
  import { sshAppleContainer } from "../lib/ssh-apple-container";
@@ -18,28 +21,6 @@ const SSH_OPTS = [
18
21
  "LogLevel=ERROR",
19
22
  ];
20
23
 
21
- function resolveCloud(entry: AssistantEntry): string {
22
- if (entry.cloud) {
23
- return entry.cloud;
24
- }
25
- if (entry.project) {
26
- return "gcp";
27
- }
28
- if (entry.sshUser) {
29
- return "custom";
30
- }
31
- return "local";
32
- }
33
-
34
- function extractHostFromUrl(url: string): string {
35
- try {
36
- const parsed = new URL(url);
37
- return parsed.hostname;
38
- } catch {
39
- return url.replace(/^https?:\/\//, "").split(":")[0];
40
- }
41
- }
42
-
43
24
  export async function ssh(): Promise<void> {
44
25
  const args = process.argv.slice(3);
45
26
  if (args.includes("--help") || args.includes("-h")) {
@@ -3,10 +3,11 @@ import {
3
3
  loadAllAssistants,
4
4
  getDaemonPidPath,
5
5
  removeAssistantEntry,
6
+ resolveCloud,
6
7
  saveAssistantEntry,
7
8
  setActiveAssistant,
9
+ type AssistantEntry,
8
10
  } from "../lib/assistant-config.js";
9
- import type { AssistantEntry } from "../lib/assistant-config.js";
10
11
  import {
11
12
  loadGuardianToken,
12
13
  leaseGuardianToken,
@@ -214,12 +215,6 @@ export function parseArgs(argv: string[]): {
214
215
  return { from, to, targetEnv, targetName, keepSource, dryRun, help };
215
216
  }
216
217
 
217
- function resolveCloud(entry: AssistantEntry): string {
218
- return (
219
- entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local")
220
- );
221
- }
222
-
223
218
  // ---------------------------------------------------------------------------
224
219
  // Auth helper — same pattern as restore.ts
225
220
  // ---------------------------------------------------------------------------
@@ -228,7 +223,7 @@ async function getAccessToken(
228
223
  runtimeUrl: string,
229
224
  assistantId: string,
230
225
  displayName: string,
231
- options?: { forceRefresh?: boolean },
226
+ options?: { forceRefresh?: boolean; bootstrapSecret?: string },
232
227
  ): Promise<string> {
233
228
  // When forceRefresh is set (e.g. after a runtime 401 on the cached token)
234
229
  // we skip the cache and lease a brand-new token from the gateway, so a
@@ -242,7 +237,11 @@ async function getAccessToken(
242
237
  }
243
238
 
244
239
  try {
245
- const freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
240
+ const freshToken = await leaseGuardianToken(
241
+ runtimeUrl,
242
+ assistantId,
243
+ options?.bootstrapSecret,
244
+ );
246
245
  return freshToken.accessToken;
247
246
  } catch (err) {
248
247
  const msg = err instanceof Error ? err.message : String(err);
@@ -281,11 +280,15 @@ function isRuntime401(err: unknown): boolean {
281
280
  * — propagates to the caller.
282
281
  */
283
282
  async function callRuntimeWithAuthRetry<T>(
284
- runtimeUrl: string,
285
- assistantId: string,
283
+ entry: AssistantEntry,
286
284
  fn: (token: string) => Promise<T>,
287
285
  ): Promise<T> {
288
- const firstToken = await getAccessToken(runtimeUrl, assistantId, assistantId);
286
+ const firstToken = await getAccessToken(
287
+ entry.runtimeUrl,
288
+ entry.assistantId,
289
+ entry.assistantId,
290
+ { bootstrapSecret: entry.guardianBootstrapSecret },
291
+ );
289
292
  try {
290
293
  return await fn(firstToken);
291
294
  } catch (err) {
@@ -293,10 +296,13 @@ async function callRuntimeWithAuthRetry<T>(
293
296
  throw err;
294
297
  }
295
298
  const refreshedToken = await getAccessToken(
296
- runtimeUrl,
297
- assistantId,
298
- assistantId,
299
- { forceRefresh: true },
299
+ entry.runtimeUrl,
300
+ entry.assistantId,
301
+ entry.assistantId,
302
+ {
303
+ forceRefresh: true,
304
+ bootstrapSecret: entry.guardianBootstrapSecret,
305
+ },
300
306
  );
301
307
  return await fn(refreshedToken);
302
308
  }
@@ -386,8 +392,7 @@ async function exportFromAssistant(
386
392
  let sourceRuntimeVersion: string;
387
393
  try {
388
394
  const identity = await callRuntimeWithAuthRetry(
389
- entry.runtimeUrl,
390
- entry.assistantId,
395
+ entry,
391
396
  async (token) => localRuntimeIdentity(entry, token),
392
397
  );
393
398
  sourceRuntimeVersion = identity.version;
@@ -423,8 +428,7 @@ async function exportFromAssistant(
423
428
  let accessToken: string;
424
429
  try {
425
430
  const result = await callRuntimeWithAuthRetry(
426
- entry.runtimeUrl,
427
- entry.assistantId,
431
+ entry,
428
432
  async (token) => {
429
433
  const r = await localRuntimeExportToGcs(entry, token, {
430
434
  uploadUrl,
@@ -462,7 +466,10 @@ async function exportFromAssistant(
462
466
  entry.runtimeUrl,
463
467
  entry.assistantId,
464
468
  entry.assistantId,
465
- { forceRefresh: true },
469
+ {
470
+ forceRefresh: true,
471
+ bootstrapSecret: entry.guardianBootstrapSecret,
472
+ },
466
473
  );
467
474
  },
468
475
  });
@@ -728,8 +735,7 @@ async function importToAssistant(
728
735
  let targetRuntimeVersion: string;
729
736
  try {
730
737
  const identity = await callRuntimeWithAuthRetry(
731
- entry.runtimeUrl,
732
- entry.assistantId,
738
+ entry,
733
739
  (token) => localRuntimeIdentity(entry, token),
734
740
  );
735
741
  targetRuntimeVersion = identity.version;
@@ -774,8 +780,7 @@ async function importToAssistant(
774
780
  let accessToken: string;
775
781
  try {
776
782
  const result = await callRuntimeWithAuthRetry(
777
- entry.runtimeUrl,
778
- entry.assistantId,
783
+ entry,
779
784
  async (token) => {
780
785
  const r = await localRuntimeImportFromGcs(entry, token, {
781
786
  bundleUrl,
@@ -806,7 +811,10 @@ async function importToAssistant(
806
811
  entry.runtimeUrl,
807
812
  entry.assistantId,
808
813
  entry.assistantId,
809
- { forceRefresh: true },
814
+ {
815
+ forceRefresh: true,
816
+ bootstrapSecret: entry.guardianBootstrapSecret,
817
+ },
810
818
  );
811
819
  },
812
820
  });
@@ -7,9 +7,10 @@ import {
7
7
  findAssistantByName,
8
8
  getActiveAssistant,
9
9
  loadAllAssistants,
10
+ resolveCloud,
10
11
  saveAssistantEntry,
12
+ type AssistantEntry,
11
13
  } from "../lib/assistant-config";
12
- import type { AssistantEntry } from "../lib/assistant-config";
13
14
  import {
14
15
  captureImageRefs,
15
16
  GATEWAY_INTERNAL_PORT,
@@ -145,19 +146,6 @@ function parseArgs(): UpgradeArgs {
145
146
  return { name, version, latest, prepare, finalize };
146
147
  }
147
148
 
148
- function resolveCloud(entry: AssistantEntry): string {
149
- if (entry.cloud) {
150
- return entry.cloud;
151
- }
152
- if (entry.project) {
153
- return "gcp";
154
- }
155
- if (entry.sshUser) {
156
- return "custom";
157
- }
158
- return "local";
159
- }
160
-
161
149
  /**
162
150
  * Resolve which assistant to target for the upgrade command. Priority:
163
151
  * 1. Explicit name argument
@@ -512,7 +500,10 @@ async function upgradeDocker(
512
500
  } else {
513
501
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
514
502
 
515
- const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-upgrade-failure`);
503
+ const logDir = await captureUpgradeFailureLogs(
504
+ res,
505
+ `${instanceName}-upgrade-failure`,
506
+ );
516
507
  if (logDir) {
517
508
  console.log(`📋 Container logs saved to: ${logDir}`);
518
509
  }
@@ -938,7 +929,8 @@ async function resolveLatestAndMaybeSelfUpdate(
938
929
  );
939
930
  if (installResult.error || installResult.status !== 0) {
940
931
  const detail =
941
- installResult.error?.message ?? `exited with code ${installResult.status}`;
932
+ installResult.error?.message ??
933
+ `exited with code ${installResult.status}`;
942
934
  console.error(`\n❌ CLI self-update failed: ${detail}`);
943
935
  emitCliError("CLI_UPDATE_FAILED", "CLI self-update failed", detail);
944
936
  process.exit(1);
@@ -8,7 +8,7 @@ import {
8
8
  } from "../lib/assistant-config.js";
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
10
  import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
11
- import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
11
+ import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
12
12
  import {
13
13
  generateLocalSigningKey,
14
14
  isAssistantWatchModeAvailable,
@@ -85,36 +85,26 @@ export async function wake(): Promise<void> {
85
85
 
86
86
  const pidFile = getDaemonPidPath(resources);
87
87
 
88
- // Check if daemon is already running
89
88
  let daemonRunning = false;
90
- if (existsSync(pidFile)) {
91
- const pidStr = readFileSync(pidFile, "utf-8").trim();
92
- const pid = parseInt(pidStr, 10);
93
- if (!isNaN(pid)) {
94
- try {
95
- process.kill(pid, 0);
96
- daemonRunning = true;
97
- if (watch) {
98
- // Restart in watch mode but only if source files are available.
99
- // Watch mode requires bun --watch with .ts sources; packaged desktop
100
- // builds only have a compiled binary. Stopping the daemon without a
101
- // viable watch-mode path would leave the user with no running assistant.
102
- if (!isAssistantWatchModeAvailable()) {
103
- console.log(
104
- `Assistant running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
105
- );
106
- } else {
107
- console.log(
108
- `Assistant running (pid ${pid}) — restarting in watch mode...`,
109
- );
110
- await stopProcessByPidFile(pidFile, "assistant");
111
- daemonRunning = false;
112
- }
113
- } else {
114
- console.log(`Assistant already running (pid ${pid}).`);
115
- }
116
- } catch {
117
- // Process not alive, will start below
89
+ const daemonState = await resolveProcessState(
90
+ pidFile,
91
+ resources.daemonPort,
92
+ "Assistant",
93
+ );
94
+ if (daemonState.status === "healthy") {
95
+ if (watch && isAssistantWatchModeAvailable()) {
96
+ console.log(
97
+ `Assistant running (pid ${daemonState.pid})restarting in watch mode...`,
98
+ );
99
+ await stopProcessByPidFile(pidFile, "assistant");
100
+ } else {
101
+ daemonRunning = true;
102
+ if (watch) {
103
+ console.log(
104
+ `Assistant running (pid ${daemonState.pid}) — watch mode not available (no source files). Keeping existing process.`,
105
+ );
106
+ } else {
107
+ console.log(`Assistant already running (pid ${daemonState.pid}).`);
118
108
  }
119
109
  }
120
110
  }
@@ -153,6 +143,15 @@ export async function wake(): Promise<void> {
153
143
  saveAssistantEntry(entry);
154
144
  }
155
145
 
146
+ let bootstrapSecret = entry.guardianBootstrapSecret;
147
+ let bootstrapSecretBackfilled = false;
148
+ if (!bootstrapSecret) {
149
+ bootstrapSecret = generateLocalSigningKey();
150
+ entry.guardianBootstrapSecret = bootstrapSecret;
151
+ saveAssistantEntry(entry);
152
+ bootstrapSecretBackfilled = true;
153
+ }
154
+
156
155
  if (!daemonRunning) {
157
156
  await startLocalDaemon(watch, resources, { foreground, signingKey });
158
157
  }
@@ -161,26 +160,47 @@ export async function wake(): Promise<void> {
161
160
  {
162
161
  const vellumDir = join(resources.instanceDir, ".vellum");
163
162
  const gatewayPidFile = join(vellumDir, "gateway.pid");
164
- const { alive, pid } = isProcessAlive(gatewayPidFile);
165
- if (alive) {
166
- if (watch) {
167
- // Guard gateway restart separately: check gateway source availability.
168
- if (!isGatewayWatchModeAvailable()) {
163
+ const gatewayState = await resolveProcessState(
164
+ gatewayPidFile,
165
+ resources.gatewayPort,
166
+ "Gateway",
167
+ );
168
+ const gatewayAlive = gatewayState.status === "healthy";
169
+ const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
170
+ if (needsRestart) {
171
+ const restartWithWatch = watch && isGatewayWatchModeAvailable();
172
+ if (restartWithWatch) {
173
+ console.log(
174
+ `Gateway running (pid ${gatewayState.pid}) — restarting to apply bootstrap secret...`,
175
+ );
176
+ } else {
177
+ console.log(
178
+ `Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
179
+ );
180
+ }
181
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
182
+ await startGateway(restartWithWatch, resources, {
183
+ signingKey,
184
+ bootstrapSecret,
185
+ });
186
+ } else if (gatewayAlive) {
187
+ if (watch && isGatewayWatchModeAvailable()) {
188
+ console.log(
189
+ `Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
190
+ );
191
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
192
+ await startGateway(watch, resources, { signingKey, bootstrapSecret });
193
+ } else {
194
+ if (watch) {
169
195
  console.log(
170
- `Gateway running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
196
+ `Gateway running (pid ${gatewayState.pid}) — watch mode not available (no source files). Keeping existing process.`,
171
197
  );
172
198
  } else {
173
- console.log(
174
- `Gateway running (pid ${pid}) — restarting in watch mode...`,
175
- );
176
- await stopProcessByPidFile(gatewayPidFile, "gateway");
177
- await startGateway(watch, resources, { signingKey });
199
+ console.log(`Gateway already running (pid ${gatewayState.pid}).`);
178
200
  }
179
- } else {
180
- console.log(`Gateway already running (pid ${pid}).`);
181
201
  }
182
202
  } else {
183
- await startGateway(watch, resources, { signingKey });
203
+ await startGateway(watch, resources, { signingKey, bootstrapSecret });
184
204
  }
185
205
  }
186
206
 
@@ -196,7 +216,10 @@ export async function wake(): Promise<void> {
196
216
 
197
217
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
198
218
  const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
199
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort, workspaceDir);
219
+ const ngrokChild = await maybeStartNgrokTunnel(
220
+ resources.gatewayPort,
221
+ workspaceDir,
222
+ );
200
223
  if (ngrokChild?.pid) {
201
224
  const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
202
225
  writeFileSync(ngrokPidFile, String(ngrokChild.pid));
@@ -13,6 +13,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
13
13
  import { checkHealth } from "../lib/health-check";
14
14
  import { appendHistory, loadHistory } from "../lib/input-history";
15
15
  import { tuiLog } from "../lib/tui-log";
16
+ import { segmentsToPlainText } from "../lib/segments-to-plain-text";
16
17
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
17
18
  import {
18
19
  getTerminalCapabilities,
@@ -230,13 +231,20 @@ async function pollMessages(
230
231
  auth?: Record<string, string>,
231
232
  ): Promise<ListMessagesResponse> {
232
233
  const params = new URLSearchParams({ conversationKey: assistantId });
233
- return runtimeRequest<ListMessagesResponse>(
234
+ const response = await runtimeRequest<ListMessagesResponse>(
234
235
  baseUrl,
235
236
  assistantId,
236
237
  `/messages?${params.toString()}`,
237
238
  undefined,
238
239
  auth,
239
240
  );
241
+ return {
242
+ ...response,
243
+ messages: response.messages.map((msg) => ({
244
+ ...msg,
245
+ content: segmentsToPlainText(msg.textSegments),
246
+ })),
247
+ };
240
248
  }
241
249
 
242
250
  async function sendMessage(
@@ -626,6 +634,13 @@ export interface RuntimeMessage {
626
634
  id: string;
627
635
  role: "user" | "assistant";
628
636
  content: string;
637
+ /**
638
+ * Ordered text segments from the daemon's history payload, split at
639
+ * tool_use/surface boundaries. The flat `content` body is derived from
640
+ * these (see `segmentsToPlainText`); the daemon no longer sends a
641
+ * redundant flattened `content` field on the wire.
642
+ */
643
+ textSegments?: string[];
629
644
  timestamp: string;
630
645
  toolCalls?: ToolCallInfo[];
631
646
  label?: string;
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import { client } from "./commands/client";
7
7
  import { env } from "./commands/env";
8
8
  import { events } from "./commands/events";
9
9
  import { exec } from "./commands/exec";
10
+ import { flags } from "./commands/flags";
11
+ import { gateway } from "./commands/gateway";
10
12
  import { hatch } from "./commands/hatch";
11
13
  import { login, logout, whoami } from "./commands/login";
12
14
  import { logs } from "./commands/logs";
@@ -37,6 +39,8 @@ const commands = {
37
39
  env,
38
40
  events,
39
41
  exec,
42
+ flags,
43
+ gateway,
40
44
  hatch,
41
45
  login,
42
46
  logout,
@@ -72,6 +76,8 @@ function printHelp(): void {
72
76
  console.log(" env Manage the default CLI environment");
73
77
  console.log(" events Stream events from a running assistant");
74
78
  console.log(" exec Execute a command inside an assistant's container");
79
+ console.log(" flags Show and toggle feature flags");
80
+ console.log(" gateway Gateway management commands");
75
81
  console.log(" hatch Create a new assistant instance");
76
82
  console.log(" logs View logs from an assistant instance");
77
83
  console.log(" login Log in to the Vellum platform");
@@ -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
+ });
@@ -1,6 +1,15 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+
1
5
  import { describe, expect, it } from "bun:test";
2
6
 
3
- import { buildExecErrorMessage, exec, execOutput } from "../step-runner";
7
+ import {
8
+ buildExecErrorMessage,
9
+ exec,
10
+ execOutput,
11
+ execWithStdin,
12
+ } from "../step-runner";
4
13
 
5
14
  describe("buildExecErrorMessage", () => {
6
15
  it("omits the argv from the header so secrets in args can't leak", () => {
@@ -63,6 +72,45 @@ describe("exec — secret leak regression", () => {
63
72
  });
64
73
  });
65
74
 
75
+ describe("execWithStdin — pipes input + no secret leak in errors", () => {
76
+ it("writes the supplied input to the child's stdin", async () => {
77
+ // Use sh `cat > path` to capture stdin to a real file we can inspect.
78
+ // Mirrors the Docker-hatch overlay-staging call site shape.
79
+ const workDir = mkdtempSync(join(tmpdir(), "step-runner-stdin-"));
80
+ const dest = join(workDir, "captured.txt");
81
+ try {
82
+ const payload = '{"hello":"world"}\n';
83
+ await execWithStdin("sh", ["-c", `cat > ${dest}`], payload);
84
+ expect(readFileSync(dest, "utf-8")).toBe(payload);
85
+ } finally {
86
+ rmSync(workDir, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
91
+ const fakeSecret = "sk-anthropic-stdin-canary";
92
+ try {
93
+ await execWithStdin(
94
+ "sh",
95
+ [
96
+ "-c",
97
+ 'echo "permission denied while trying to connect to docker daemon" 1>&2 && exit 1',
98
+ "-e",
99
+ `ANTHROPIC_API_KEY=${fakeSecret}`,
100
+ ],
101
+ "",
102
+ );
103
+ throw new Error("execWithStdin should have rejected");
104
+ } catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ expect(message).not.toContain(fakeSecret);
107
+ expect(message).not.toContain("ANTHROPIC_API_KEY");
108
+ expect(message).toContain("sh exited with code 1");
109
+ expect(message).toContain("permission denied");
110
+ }
111
+ });
112
+ });
113
+
66
114
  describe("execOutput — secret leak regression", () => {
67
115
  it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
68
116
  const fakeSecret = "sk-openai-leak-canary";