@vellumai/cli 0.11.7-staging.2 → 0.11.8-staging.1

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 (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  4. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  5. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  6. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  7. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  8. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  10. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  11. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  12. package/node_modules/@vellumai/local-mode/src/hatch.ts +17 -2
  13. package/node_modules/@vellumai/local-mode/src/index.ts +5 -0
  14. package/node_modules/@vellumai/local-mode/src/lifecycle-budgets.ts +47 -0
  15. package/node_modules/@vellumai/local-mode/src/retire.ts +9 -3
  16. package/node_modules/@vellumai/local-mode/src/sleep.ts +5 -5
  17. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  18. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  21. package/package.json +4 -3
  22. package/src/__tests__/assistant-client-platform-auth.test.ts +219 -0
  23. package/src/__tests__/clean.test.ts +1 -0
  24. package/src/__tests__/nginx-ingress.test.ts +123 -8
  25. package/src/__tests__/retire-local.test.ts +32 -1
  26. package/src/__tests__/sleep.test.ts +5 -3
  27. package/src/__tests__/teleport.test.ts +1 -0
  28. package/src/__tests__/upgrade-local.test.ts +116 -0
  29. package/src/__tests__/windows-lifecycle.test.ts +77 -5
  30. package/src/adapters/openclaw-http-server.ts +5 -0
  31. package/src/commands/client.ts +35 -7
  32. package/src/commands/exec.ts +8 -2
  33. package/src/commands/logs.ts +11 -2
  34. package/src/commands/sleep.ts +6 -9
  35. package/src/commands/ssh.ts +6 -3
  36. package/src/commands/teleport.ts +6 -1
  37. package/src/commands/upgrade.ts +22 -12
  38. package/src/commands/voice.test.ts +81 -0
  39. package/src/commands/voice.ts +83 -6
  40. package/src/commands/wake.ts +7 -1
  41. package/src/lib/assistant-client.ts +145 -31
  42. package/src/lib/cloudflare-tunnel.ts +2 -1
  43. package/src/lib/detached-process.ts +1 -0
  44. package/src/lib/guardian-token.ts +4 -2
  45. package/src/lib/hatch-local.ts +5 -1
  46. package/src/lib/live-voice/client.ts +23 -2
  47. package/src/lib/live-voice/connection.ts +87 -10
  48. package/src/lib/live-voice/pcm-player.ts +16 -3
  49. package/src/lib/live-voice/velay.test.ts +99 -0
  50. package/src/lib/live-voice/velay.ts +199 -0
  51. package/src/lib/local-hatch-lock.ts +16 -3
  52. package/src/lib/local.ts +48 -13
  53. package/src/lib/nginx-ingress.ts +70 -10
  54. package/src/lib/ngrok.ts +2 -0
  55. package/src/lib/open-browser.ts +5 -1
  56. package/src/lib/process.ts +72 -10
  57. package/src/lib/provider-secrets.ts +5 -0
  58. package/src/lib/retire-local.ts +12 -5
  59. package/src/lib/step-runner.ts +3 -0
  60. package/src/lib/tailscale-tunnel.ts +6 -1
  61. package/src/lib/upgrade-lifecycle.ts +1 -0
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Resolving the live-voice WebSocket URL and the credential to open it with.
3
3
  *
4
+ * Two transports, chosen by how the assistant is deployed. A cloud assistant
5
+ * goes through velay with a minted WS token (see `./velay.ts`, which owns that
6
+ * path end to end). Everything below is the local and self-hosted one.
7
+ *
4
8
  * The endpoint is the **gateway's** `/v1/live-voice`, not the runtime's. That
5
9
  * distinction is the whole of the auth story: the runtime's copy demands a
6
10
  * gateway service token, which no client can mint, while the gateway's copy
@@ -26,18 +30,36 @@ import {
26
30
  formatAssistantReference,
27
31
  lookupAssistantByIdentifier,
28
32
  resolveTargetAssistant,
33
+ type AssistantEntry,
29
34
  } from "../assistant-config.js";
35
+ import { getPlatformUrl, readPlatformToken } from "../platform-client.js";
30
36
  import {
31
37
  guardianTokenDueForRenewal,
32
38
  loadGuardianToken,
33
39
  refreshGuardianToken,
34
40
  } from "../guardian-token.js";
41
+ import {
42
+ buildVelayLiveVoiceUrl,
43
+ mintVelayWsToken,
44
+ VelayWsTokenError,
45
+ } from "./velay.js";
46
+
47
+ /**
48
+ * Where the credential goes on the upgrade.
49
+ *
50
+ * A gateway takes a guardian JWT in an `Authorization` header. velay takes a
51
+ * minted WS token in the query string and reads no headers, so the two are not
52
+ * interchangeable: sending the header form to velay authenticates nothing.
53
+ */
54
+ export type LiveVoiceTokenTransport = "header" | "query";
35
55
 
36
56
  export interface LiveVoiceConnection {
37
- /** `ws(s)://…/v1/live-voice` on the assistant's gateway. */
57
+ /** `ws(s)://…/v1/live-voice`, on the assistant's gateway or on velay. */
38
58
  readonly url: string;
39
- /** Guardian access token, sent as `Authorization: Bearer`. */
59
+ /** Guardian access token, or a minted velay WS token. */
40
60
  readonly token: string;
61
+ /** How {@link token} reaches the server. */
62
+ readonly tokenTransport: LiveVoiceTokenTransport;
41
63
  readonly assistantId: string;
42
64
  /** `name (id)` when they differ, else the id, for user-facing output. */
43
65
  readonly reference: string;
@@ -65,15 +87,12 @@ export async function resolveLiveVoiceConnection(
65
87
  ? resolveNamedAssistant(assistantIdArg)
66
88
  : resolveTargetAssistant();
67
89
 
68
- // Platform-cloud assistants authenticate with a platform session token
69
- // (`X-Session-Token`), and the live-voice upgrade accepts only an actor edge
70
- // JWT or a velay attestation, neither of which the CLI has for a cloud
71
- // instance. Say so plainly rather than opening a socket that 401s.
90
+ // Platform-cloud assistants are reached the other way round. Their gateway
91
+ // is behind velay, which authenticates nobody and forwards an attestation
92
+ // the gateway trusts, so the credential is not the guardian JWT below but a
93
+ // WS token minted from the platform session the CLI already holds.
72
94
  if (entry.cloud === "vellum") {
73
- throw new LiveVoiceConnectionError(
74
- `${formatAssistantReference(entry)} is a Vellum cloud assistant. ` +
75
- "Voice sessions are supported for local and self-hosted assistants only.",
76
- );
95
+ return await resolveCloudConnection(entry);
77
96
  }
78
97
 
79
98
  const baseUrl = (entry.localUrl || entry.runtimeUrl).replace(/\/+$/, "");
@@ -99,6 +118,64 @@ export async function resolveLiveVoiceConnection(
99
118
  return {
100
119
  url: parsed.toString(),
101
120
  token,
121
+ tokenTransport: "header",
122
+ assistantId: entry.assistantId,
123
+ reference: formatAssistantReference(entry),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Resolve a live-voice connection to a platform-hosted assistant.
129
+ *
130
+ * The session token is the CLI's own platform login, not anything specific to
131
+ * this assistant, so a missing one is a `vellum login` problem rather than an
132
+ * assistant problem and says so.
133
+ *
134
+ * The minted token is deliberately resolved here, at the last moment before
135
+ * the socket opens, and never cached: it expires in 60 seconds and is consumed
136
+ * by the upgrade that uses it.
137
+ */
138
+ async function resolveCloudConnection(
139
+ entry: AssistantEntry,
140
+ ): Promise<LiveVoiceConnection> {
141
+ const sessionToken = readPlatformToken();
142
+ if (!sessionToken) {
143
+ throw new LiveVoiceConnectionError(
144
+ `${formatAssistantReference(entry)} is a Vellum cloud assistant, which ` +
145
+ "needs a platform login. Run 'vellum login' and try again.",
146
+ );
147
+ }
148
+
149
+ // `runtimeUrl` is the platform this entry was hatched against, which is the
150
+ // one that can mint for it. `getPlatformUrl()` is the fallback for an entry
151
+ // written before that field was recorded.
152
+ const platformUrl = (entry.runtimeUrl || getPlatformUrl()).replace(
153
+ /\/+$/,
154
+ "",
155
+ );
156
+
157
+ let token: string;
158
+ try {
159
+ token = await mintVelayWsToken({
160
+ platformUrl,
161
+ assistantId: entry.assistantId,
162
+ sessionToken,
163
+ });
164
+ } catch (err) {
165
+ if (err instanceof VelayWsTokenError) {
166
+ throw new LiveVoiceConnectionError(err.message);
167
+ }
168
+ throw err;
169
+ }
170
+
171
+ return {
172
+ url: buildVelayLiveVoiceUrl({
173
+ platformUrl,
174
+ assistantId: entry.assistantId,
175
+ token,
176
+ }),
177
+ token,
178
+ tokenTransport: "query",
102
179
  assistantId: entry.assistantId,
103
180
  reference: formatAssistantReference(entry),
104
181
  };
@@ -47,6 +47,15 @@ const PLAYERS: readonly PlayerCommand[] = [
47
47
  {
48
48
  mode: "streaming",
49
49
  name: "ffplay",
50
+ // No channel-count flag. ffplay 7.x rejects `-ac` outright ("Option not
51
+ // found"), and `-ch_layout` does not exist before 5.1, so either spelling
52
+ // is broken on some supported machine. The pcm demuxer's `ch_layout`
53
+ // defaults to mono on every version, which is exactly what these frames
54
+ // are, so the portable answer is to say nothing and take the default.
55
+ //
56
+ // Getting this wrong fails silently: ffplay exits immediately, `adopt()`
57
+ // settles on that exit like any finished playback, and the session prints
58
+ // "Speaking via ffplay" while making no sound at all.
50
59
  args: (sampleRate) => [
51
60
  "-hide_banner",
52
61
  "-loglevel",
@@ -57,8 +66,6 @@ const PLAYERS: readonly PlayerCommand[] = [
57
66
  "s16le",
58
67
  "-ar",
59
68
  String(sampleRate),
60
- "-ac",
61
- "1",
62
69
  "-i",
63
70
  "pipe:0",
64
71
  ],
@@ -117,7 +124,11 @@ export function isOnPath(command: string): boolean {
117
124
  ? ["where", [command]]
118
125
  : ["/bin/sh", ["-c", `command -v ${command}`]];
119
126
  try {
120
- execFileSync(probe, args, { stdio: "ignore", timeout: 2000 });
127
+ execFileSync(probe, args, {
128
+ stdio: "ignore",
129
+ timeout: 2000,
130
+ windowsHide: true,
131
+ });
121
132
  return true;
122
133
  } catch {
123
134
  return false;
@@ -261,6 +272,7 @@ export class PcmPlayer {
261
272
  }
262
273
  const child = spawn(this.player.name, this.player.args(sampleRate), {
263
274
  stdio: ["pipe", "ignore", "ignore"],
275
+ windowsHide: true,
264
276
  });
265
277
  this.adopt(child);
266
278
  this.activeSampleRate = sampleRate;
@@ -290,6 +302,7 @@ export class PcmPlayer {
290
302
  this.adopt(
291
303
  spawn(this.player.name, this.player.args(sampleRate, filePath), {
292
304
  stdio: "ignore",
305
+ windowsHide: true,
293
306
  }),
294
307
  );
295
308
  }
@@ -0,0 +1,99 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ buildVelayLiveVoiceUrl,
5
+ velayHostForPlatformUrl,
6
+ velayWsScheme,
7
+ } from "./velay.js";
8
+
9
+ const ORIGINAL_VELAY_HOST = process.env.VELLUM_VELAY_HOST;
10
+
11
+ afterEach(() => {
12
+ if (ORIGINAL_VELAY_HOST === undefined) {
13
+ delete process.env.VELLUM_VELAY_HOST;
14
+ } else {
15
+ process.env.VELLUM_VELAY_HOST = ORIGINAL_VELAY_HOST;
16
+ }
17
+ });
18
+
19
+ describe("velayHostForPlatformUrl", () => {
20
+ test("maps a platform host onto its environment's velay", () => {
21
+ delete process.env.VELLUM_VELAY_HOST;
22
+ expect(velayHostForPlatformUrl("https://platform.vellum.ai")).toBe(
23
+ "velay.vellum.ai",
24
+ );
25
+ expect(velayHostForPlatformUrl("https://dev-platform.vellum.ai")).toBe(
26
+ "velay-dev.vellum.ai",
27
+ );
28
+ });
29
+
30
+ test("falls back to production velay for a host outside the convention", () => {
31
+ delete process.env.VELLUM_VELAY_HOST;
32
+ // A local platform predicts no velay of its own; guessing one would be
33
+ // worse than dialing the host that certainly exists.
34
+ expect(velayHostForPlatformUrl("http://localhost:8000")).toBe(
35
+ "velay.vellum.ai",
36
+ );
37
+ expect(velayHostForPlatformUrl("not a url")).toBe("velay.vellum.ai");
38
+ });
39
+
40
+ test("the env override wins, which is how a local vel up velay is reached", () => {
41
+ process.env.VELLUM_VELAY_HOST = "localhost:8501";
42
+ expect(velayHostForPlatformUrl("https://platform.vellum.ai")).toBe(
43
+ "localhost:8501",
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("velayWsScheme", () => {
49
+ test("downgrades to ws only for loopback", () => {
50
+ expect(velayWsScheme("localhost:8501")).toBe("ws");
51
+ expect(velayWsScheme("127.0.0.1:8501")).toBe("ws");
52
+ expect(velayWsScheme("velay.vellum.ai")).toBe("wss");
53
+ });
54
+ });
55
+
56
+ describe("buildVelayLiveVoiceUrl", () => {
57
+ test("prefixes the assistant id, which is what selects the tunnel", () => {
58
+ delete process.env.VELLUM_VELAY_HOST;
59
+ const url = new URL(
60
+ buildVelayLiveVoiceUrl({
61
+ platformUrl: "https://platform.vellum.ai",
62
+ assistantId: "01a02514-e452-7019-88d5-30828f42cf17",
63
+ token: "tok_abc",
64
+ }),
65
+ );
66
+
67
+ expect(url.protocol).toBe("wss:");
68
+ expect(url.host).toBe("velay.vellum.ai");
69
+ expect(url.pathname).toBe(
70
+ "/01a02514-e452-7019-88d5-30828f42cf17/v1/live-voice",
71
+ );
72
+ expect(url.searchParams.get("token")).toBe("tok_abc");
73
+ });
74
+
75
+ test("encodes a token rather than letting it break the query", () => {
76
+ delete process.env.VELLUM_VELAY_HOST;
77
+ const url = new URL(
78
+ buildVelayLiveVoiceUrl({
79
+ platformUrl: "https://platform.vellum.ai",
80
+ assistantId: "a1",
81
+ token: "a+b/c=&d",
82
+ }),
83
+ );
84
+ expect(url.searchParams.get("token")).toBe("a+b/c=&d");
85
+ });
86
+
87
+ test("uses ws against a loopback velay so a local stack is reachable", () => {
88
+ process.env.VELLUM_VELAY_HOST = "localhost:8501";
89
+ const url = new URL(
90
+ buildVelayLiveVoiceUrl({
91
+ platformUrl: "http://localhost:8000",
92
+ assistantId: "a1",
93
+ token: "t",
94
+ }),
95
+ );
96
+ expect(url.protocol).toBe("ws:");
97
+ expect(url.host).toBe("localhost:8501");
98
+ });
99
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The cloud leg of a live-voice session: minting a velay WS token and building
3
+ * the URL it opens.
4
+ *
5
+ * A platform-hosted assistant is not reachable the way a local one is. Its
6
+ * gateway sits behind velay, velay performs no user authentication of its own,
7
+ * and the gateway upstream accepts only an actor edge JWT or a velay
8
+ * attestation. The CLI holds neither for a cloud instance: what it has is a
9
+ * platform session token, which is the wrong credential for the socket but the
10
+ * right one for the platform.
11
+ *
12
+ * So the connection is made in two hops, the same two the web voice room makes
13
+ * (`clients/web/src/domains/chat/voice/live-voice/connection.ts`):
14
+ *
15
+ * 1. POST the platform for a short-lived, org+assistant-scoped WS token.
16
+ * 2. Open `wss://<velay>/<assistantId>/v1/live-voice?token=<wsToken>`.
17
+ *
18
+ * velay consumes the token on the upgrade and injects the authenticated user
19
+ * and org downstream as `X-Velay-*` headers alongside its bridge proof. The
20
+ * gateway reads that attestation rather than the query param, and pins it to
21
+ * the assistant's bound guardian (`requireManagedGuardian`). The token is
22
+ * therefore an admission ticket for one upgrade, not a session credential.
23
+ *
24
+ * Two properties of that ticket shape this module's API. It lives for
25
+ * `LIVE_VOICE_WS_TOKEN_TTL` (60 seconds) and it is **single-use**: the
26
+ * validator consumes it atomically, so the same token cannot open a second
27
+ * socket. Minting is consequently tied to connecting, and a reconnect has to
28
+ * mint again rather than reuse what resolution returned.
29
+ */
30
+
31
+ import { velayHostForPlatformHost } from "@vellumai/service-contracts/ingress";
32
+
33
+ import { authHeaders } from "../platform-client.js";
34
+
35
+ /** Production velay, when the platform host implies nothing more specific. */
36
+ const DEFAULT_VELAY_HOST = "velay.vellum.ai";
37
+
38
+ /** Give up on the mint rather than hang the session behind a stalled platform. */
39
+ const MINT_TIMEOUT_MS = 10_000;
40
+
41
+ export class VelayWsTokenError extends Error {
42
+ readonly status: number;
43
+
44
+ constructor(status: number, message: string) {
45
+ super(message);
46
+ this.name = "VelayWsTokenError";
47
+ this.status = status;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The velay host serving a given platform base URL.
53
+ *
54
+ * `VELLUM_VELAY_HOST` wins when set, which is how a local `vel up` stack is
55
+ * reached: its velay is a loopback port that no naming convention predicts.
56
+ * Otherwise the deployment convention maps the platform host onto its sibling
57
+ * (`platform.vellum.ai` to `velay.vellum.ai`, `dev-platform.vellum.ai` to
58
+ * `velay-dev.vellum.ai`). A host outside that convention, including localhost,
59
+ * yields the production default, because guessing a velay for it would be
60
+ * worse than dialing the one host that certainly exists.
61
+ */
62
+ export function velayHostForPlatformUrl(platformUrl: string): string {
63
+ const override = process.env.VELLUM_VELAY_HOST?.trim();
64
+ if (override) {
65
+ return override;
66
+ }
67
+ try {
68
+ return (
69
+ velayHostForPlatformHost(new URL(platformUrl).host) ?? DEFAULT_VELAY_HOST
70
+ );
71
+ } catch {
72
+ return DEFAULT_VELAY_HOST;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * `ws` for a loopback velay, `wss` for everything else.
78
+ *
79
+ * The local `vel up` velay is plain HTTP on a loopback port, which a `wss` dial
80
+ * cannot reach. Mirrors `getVelayWsScheme` in the web client so the two ends of
81
+ * the transport cannot disagree about which scheme a host takes.
82
+ */
83
+ export function velayWsScheme(host: string): "ws" | "wss" {
84
+ let hostname: string;
85
+ try {
86
+ ({ hostname } = new URL(`http://${host}`));
87
+ } catch {
88
+ return "wss";
89
+ }
90
+ return hostname === "localhost" ||
91
+ hostname === "127.0.0.1" ||
92
+ hostname === "[::1]" ||
93
+ hostname === "::1"
94
+ ? "ws"
95
+ : "wss";
96
+ }
97
+
98
+ /**
99
+ * Build the velay live-voice URL.
100
+ *
101
+ * The `/<assistantId>` prefix is what selects the tunnel: velay strips it to
102
+ * recover the upstream path, and matches that path against its allowlist and
103
+ * against the scope recorded for the token. The token rides as `?token=`
104
+ * because a WebSocket upgrade from a non-browser client still cannot carry a
105
+ * credential anywhere velay reads except the query string.
106
+ */
107
+ export function buildVelayLiveVoiceUrl(args: {
108
+ platformUrl: string;
109
+ assistantId: string;
110
+ token: string;
111
+ }): string {
112
+ const host = velayHostForPlatformUrl(args.platformUrl);
113
+ const url = new URL(
114
+ `${velayWsScheme(host)}://${host}/${args.assistantId}/v1/live-voice`,
115
+ );
116
+ url.searchParams.set("token", args.token);
117
+ return url.toString();
118
+ }
119
+
120
+ interface LiveVoiceTokenResponse {
121
+ token?: unknown;
122
+ expiresAt?: unknown;
123
+ }
124
+
125
+ /**
126
+ * Mint a single-use velay WS token for one assistant.
127
+ *
128
+ * Authenticates with the CLI's stored platform session token. The mint
129
+ * endpoint pins its authenticators to the user-session ones
130
+ * (`SessionAuthentication` and `XSessionTokenAuthentication`) and deliberately
131
+ * excludes API-key auth, so `X-Session-Token` is the CLI's way in and a
132
+ * `vak_` key is not: a caller holding only an API key cannot mint here, by
133
+ * design on the platform's side.
134
+ *
135
+ * Throws {@link VelayWsTokenError} carrying the HTTP status, so the caller can
136
+ * tell "sign in again" (401) from "not your assistant" (403) from a platform
137
+ * that is simply down.
138
+ */
139
+ export async function mintVelayWsToken(args: {
140
+ platformUrl: string;
141
+ assistantId: string;
142
+ sessionToken: string;
143
+ }): Promise<string> {
144
+ const url = `${args.platformUrl.replace(/\/+$/, "")}/v1/auth/live-voice-token/`;
145
+ // `authHeaders` resolves `Vellum-Organization-Id`, which the endpoint needs
146
+ // to scope the token, and which is cached per token so a session does not
147
+ // pay for an extra round trip.
148
+ const headers = await authHeaders(args.sessionToken, args.platformUrl);
149
+
150
+ let response: Response;
151
+ try {
152
+ response = await fetch(url, {
153
+ method: "POST",
154
+ headers,
155
+ body: JSON.stringify({ assistantId: args.assistantId }),
156
+ signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
157
+ });
158
+ } catch (err) {
159
+ throw new VelayWsTokenError(
160
+ 0,
161
+ `Could not reach the Vellum platform to authorize the voice session: ${
162
+ err instanceof Error ? err.message : String(err)
163
+ }`,
164
+ );
165
+ }
166
+
167
+ if (!response.ok) {
168
+ throw new VelayWsTokenError(
169
+ response.status,
170
+ describeMintFailure(response.status),
171
+ );
172
+ }
173
+
174
+ const body = (await response.json().catch(() => ({}))) as
175
+ | LiveVoiceTokenResponse
176
+ | undefined;
177
+ const token = typeof body?.token === "string" ? body.token.trim() : "";
178
+ if (!token) {
179
+ throw new VelayWsTokenError(
180
+ response.status,
181
+ "The Vellum platform returned no voice session token.",
182
+ );
183
+ }
184
+ return token;
185
+ }
186
+
187
+ /** Turn a mint rejection into something the user can act on. */
188
+ function describeMintFailure(status: number): string {
189
+ if (status === 401) {
190
+ return "Not signed in to the Vellum platform. Run 'vellum login' and try again.";
191
+ }
192
+ if (status === 403) {
193
+ return "This account cannot open a voice session on that assistant. Check that it belongs to the organization you are signed in to.";
194
+ }
195
+ if (status === 404) {
196
+ return "The Vellum platform does not offer voice session tokens. It may be running an older release.";
197
+ }
198
+ return `The Vellum platform refused to authorize the voice session (HTTP ${status}).`;
199
+ }
@@ -10,7 +10,7 @@ import {
10
10
  utimesSync,
11
11
  writeFileSync,
12
12
  } from "fs";
13
- import { hostname, userInfo } from "os";
13
+ import { hostname, platform, userInfo } from "os";
14
14
  import { dirname, join } from "path";
15
15
 
16
16
  const DEFAULT_TIMEOUT_MS = 240_000;
@@ -62,11 +62,24 @@ function processIsAlive(pid: number): boolean {
62
62
  }
63
63
 
64
64
  function processStartedAt(pid: number): string | undefined {
65
+ const [file, args] =
66
+ platform() === "win32"
67
+ ? [
68
+ "powershell.exe",
69
+ [
70
+ "-NoProfile",
71
+ "-NonInteractive",
72
+ "-Command",
73
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CreationDate.ToString('o')`,
74
+ ],
75
+ ]
76
+ : ["ps", ["-o", "lstart=", "-p", String(pid)]];
65
77
  try {
66
- const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
78
+ const value = execFileSync(file, args, {
67
79
  encoding: "utf-8",
68
80
  stdio: ["ignore", "pipe", "ignore"],
69
- timeout: 1_000,
81
+ timeout: platform() === "win32" ? 5_000 : 1_000,
82
+ windowsHide: true,
70
83
  }).trim();
71
84
  return value || undefined;
72
85
  } catch {
package/src/lib/local.ts CHANGED
@@ -44,10 +44,12 @@ import {
44
44
  } from "./http-client.js";
45
45
  import { stopIngressNginx } from "./nginx-ingress.js";
46
46
  import {
47
+ DAEMON_STOP_TIMEOUT_MS,
47
48
  type ProcessState,
48
49
  executableName,
49
50
  isProcessAlive,
50
51
  pathListDelimiter,
52
+ readProcessCommandLine,
51
53
  resolveProcessState,
52
54
  stopProcess,
53
55
  stopProcessByPidFile,
@@ -140,6 +142,18 @@ function compiledSibling(name: string): string {
140
142
  return join(dirname(process.execPath), executableName(name, platform()));
141
143
  }
142
144
 
145
+ function envWithCompiledRuntimeNodePath(
146
+ env: Record<string, string>,
147
+ ): Record<string, string> {
148
+ if (!isCompiledCli()) {
149
+ return env;
150
+ }
151
+ return {
152
+ ...env,
153
+ NODE_PATH: join(dirname(process.execPath), "node_modules"),
154
+ };
155
+ }
156
+
143
157
  function resolveBunExecutable(): string {
144
158
  if (!isCompiledCli()) {
145
159
  return process.execPath;
@@ -266,6 +280,7 @@ export function ensureLocalRuntime(
266
280
  cwd: installDir,
267
281
  stdio: "inherit",
268
282
  env: envWithBunPath(process.env),
283
+ windowsHide: true,
269
284
  });
270
285
 
271
286
  if (result.error || result.status !== 0) {
@@ -517,6 +532,7 @@ function ensureBunInstalled(): void {
517
532
  execFileSync(resolveBunExecutable(), ["--version"], {
518
533
  stdio: "pipe",
519
534
  env: envWithBunPath(process.env),
535
+ windowsHide: true,
520
536
  });
521
537
  return;
522
538
  } catch {
@@ -555,6 +571,7 @@ function ensureBunInstalled(): void {
555
571
  stdio: "pipe",
556
572
  timeout: 60_000,
557
573
  env: installEnv,
574
+ windowsHide: true,
558
575
  });
559
576
  console.log(" Bun installed successfully");
560
577
  } catch {
@@ -832,6 +849,7 @@ async function startDaemonFromSource(
832
849
  ? spawn(bunPath, ["run", daemonMainPath], {
833
850
  stdio: "inherit",
834
851
  env: spawnEnv,
852
+ windowsHide: true,
835
853
  })
836
854
  : (() => {
837
855
  const daemonLogFd = openLogFile("hatch.log");
@@ -1170,7 +1188,12 @@ function findPidListeningOnPort(port: number): number | undefined {
1170
1188
  const output = execFileSync(
1171
1189
  "lsof",
1172
1190
  ["-iTCP:" + port, "-sTCP:LISTEN", "-t"],
1173
- { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] },
1191
+ {
1192
+ encoding: "utf-8",
1193
+ timeout: 3000,
1194
+ stdio: ["ignore", "pipe", "ignore"],
1195
+ windowsHide: true,
1196
+ },
1174
1197
  ).trim();
1175
1198
  // lsof -t may return multiple PIDs (one per line); take the first.
1176
1199
  const pid = parseInt(output.split("\n")[0], 10);
@@ -1645,6 +1668,7 @@ export async function startLocalDaemon(
1645
1668
  }
1646
1669
  }
1647
1670
  applyDaemonEnvOverrides(daemonEnv, resources, options);
1671
+ const daemonSpawnEnv = envWithCompiledRuntimeNodePath(daemonEnv);
1648
1672
 
1649
1673
  // Write a sentinel PID file before spawning so concurrent hatch() calls
1650
1674
  // see the file and fall through to the isDaemonResponsive() port check
@@ -1655,7 +1679,8 @@ export async function startLocalDaemon(
1655
1679
  ? spawn(daemonBinary, [], {
1656
1680
  cwd: dirname(daemonBinary),
1657
1681
  stdio: "inherit",
1658
- env: daemonEnv,
1682
+ env: daemonSpawnEnv,
1683
+ windowsHide: true,
1659
1684
  })
1660
1685
  : (() => {
1661
1686
  const daemonLogFd = openLogFile("hatch.log");
@@ -1664,7 +1689,7 @@ export async function startLocalDaemon(
1664
1689
  detached: true,
1665
1690
  stdio: ["ignore", "pipe", "pipe"],
1666
1691
  windowsHide: true,
1667
- env: daemonEnv,
1692
+ env: daemonSpawnEnv,
1668
1693
  });
1669
1694
  pipeToLogFile(c, daemonLogFd, "daemon");
1670
1695
  c.unref();
@@ -1716,8 +1741,17 @@ export async function startLocalDaemon(
1716
1741
  console.log(
1717
1742
  " Bundled assistant not healthy after 60s, falling back to source assistant...",
1718
1743
  );
1719
- // Kill the bundled daemon to avoid two processes competing for the same port
1720
- await stopProcessByPidFile(pidFile, "bundled daemon");
1744
+ // Kill the bundled daemon to avoid two processes competing for the
1745
+ // same port. It gets the full stop budget like any other daemon:
1746
+ // failing the health probe does not mean it has nothing to flush, and
1747
+ // a daemon still working through a long DB migration is exactly the
1748
+ // one whose WAL checkpoint must not be interrupted.
1749
+ await stopProcessByPidFile(
1750
+ pidFile,
1751
+ "bundled daemon",
1752
+ undefined,
1753
+ DAEMON_STOP_TIMEOUT_MS,
1754
+ );
1721
1755
  daemonSpawn = watch
1722
1756
  ? await startDaemonWatchFromSource(
1723
1757
  assistantIndex,
@@ -1876,7 +1910,7 @@ export async function startGateway(
1876
1910
  detached: true,
1877
1911
  stdio: ["ignore", "pipe", "pipe"],
1878
1912
  windowsHide: true,
1879
- env: gatewayEnv,
1913
+ env: envWithCompiledRuntimeNodePath(gatewayEnv),
1880
1914
  });
1881
1915
  pipeToLogFile(gateway, gatewayLogFd, "gateway");
1882
1916
  } else {
@@ -1932,12 +1966,8 @@ export async function startGateway(
1932
1966
  /** Check whether a PID belongs to an ngrok process via its command line. */
1933
1967
  function isNgrokProcess(pid: number): boolean {
1934
1968
  try {
1935
- const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
1936
- encoding: "utf-8",
1937
- timeout: 3000,
1938
- stdio: ["ignore", "pipe", "ignore"],
1939
- }).trim();
1940
- return /ngrok/.test(output);
1969
+ const output = readProcessCommandLine(pid);
1970
+ return /ngrok/i.test(output);
1941
1971
  } catch {
1942
1972
  return false;
1943
1973
  }
@@ -1958,7 +1988,12 @@ export async function stopLocalProcesses(
1958
1988
  ? join(resources.instanceDir, ".vellum")
1959
1989
  : join(homedir(), ".vellum");
1960
1990
  const daemonPidFile = getDaemonPidPath(resources);
1961
- await stopProcessByPidFile(daemonPidFile, "daemon");
1991
+ await stopProcessByPidFile(
1992
+ daemonPidFile,
1993
+ "daemon",
1994
+ undefined,
1995
+ DAEMON_STOP_TIMEOUT_MS,
1996
+ );
1962
1997
 
1963
1998
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1964
1999
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);