vent-hq 0.13.2 → 0.15.0

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.
package/dist/index.mjs CHANGED
@@ -13,156 +13,103 @@ import * as fs3 from "node:fs/promises";
13
13
  import * as fs from "node:fs/promises";
14
14
  import * as path from "node:path";
15
15
  import { homedir } from "node:os";
16
- var CONFIG_DIR = path.join(homedir(), ".vent");
17
- var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials");
18
- var API_BASE = "https://api.venthq.dev";
19
- var DASHBOARD_URL = process.env.VENT_DASHBOARD_URL ?? "https://venthq.dev";
20
- async function loadAccessToken() {
21
- if (process.env.VENT_ACCESS_TOKEN) return process.env.VENT_ACCESS_TOKEN;
22
- try {
23
- const raw = await fs.readFile(CREDENTIALS_FILE, "utf-8");
24
- const key = raw.trim();
25
- if (key) return key;
26
- } catch {
27
- }
28
- return null;
29
- }
30
- async function saveAccessToken(token) {
31
- await fs.mkdir(CONFIG_DIR, { recursive: true });
32
- await fs.writeFile(CREDENTIALS_FILE, token + "\n", { mode: 384 });
33
- }
34
- async function deleteCredentials() {
35
- try {
36
- await fs.rm(CREDENTIALS_FILE);
37
- } catch {
38
- }
16
+ import { randomUUID } from "node:crypto";
17
+
18
+ // ../../node_modules/.pnpm/jose@6.2.15/node_modules/jose/dist/webapi/lib/buffer_utils.js
19
+ var encoder = new TextEncoder();
20
+ var decoder = new TextDecoder();
21
+ var strictDecoder = new TextDecoder("utf-8", { fatal: true });
22
+ var UINT32_RANGE = 2 ** 32;
23
+ function decodeBase64(encoded, url = false) {
24
+ if (Uint8Array.fromBase64)
25
+ return Uint8Array.fromBase64(encoded, { alphabet: url ? "base64url" : "base64" });
26
+ if (url) {
27
+ if (encoded.includes("+") || encoded.includes("/"))
28
+ throw new TypeError("Invalid base64url");
29
+ encoded = encoded.replace(/-/g, "+").replace(/_/g, "/");
30
+ }
31
+ const binary = atob(encoded), bytes = new Uint8Array(binary.length);
32
+ for (let i = 0; i < binary.length; i++)
33
+ bytes[i] = binary.charCodeAt(i);
34
+ return bytes;
39
35
  }
40
36
 
41
- // src/lib/api.ts
42
- var ApiError = class extends Error {
43
- constructor(status, body) {
44
- super(`API error ${status}: ${typeof body === "string" ? body : JSON.stringify(body)}`);
45
- this.status = status;
46
- this.body = body;
37
+ // ../../node_modules/.pnpm/jose@6.2.15/node_modules/jose/dist/webapi/util/errors.js
38
+ var JOSEError = class extends Error {
39
+ static code = "ERR_JOSE_GENERIC";
40
+ code = "ERR_JOSE_GENERIC";
41
+ constructor(message, options) {
42
+ super(message, options), this.name = this.constructor.name, Error.captureStackTrace?.(this, this.constructor);
47
43
  }
48
44
  };
49
- async function apiFetch(path4, apiKey, options = {}) {
50
- const url = `${API_BASE}${path4}`;
51
- const res = await fetch(url, {
52
- ...options,
53
- headers: {
54
- Authorization: `Bearer ${apiKey}`,
55
- "Content-Type": "application/json",
56
- ...options.headers
57
- }
58
- });
59
- if (!res.ok) {
60
- let body;
61
- try {
62
- body = await res.json();
63
- } catch {
64
- body = await res.text();
65
- }
66
- throw new ApiError(res.status, body);
45
+ var JWTInvalid = class extends JOSEError {
46
+ static code = "ERR_JWT_INVALID";
47
+ code = "ERR_JWT_INVALID";
48
+ };
49
+
50
+ // ../../node_modules/.pnpm/jose@6.2.15/node_modules/jose/dist/webapi/util/base64url.js
51
+ var invalid = "The input to be decoded is not correctly encoded.";
52
+ function decode(input) {
53
+ try {
54
+ return decodeBase64(typeof input == "string" ? input : decoder.decode(input), true);
55
+ } catch (cause) {
56
+ throw new TypeError(invalid, { cause });
67
57
  }
68
- return res;
69
58
  }
70
- async function ensurePlatformConnection(apiKey, platform) {
71
- const res = await apiFetch("/platform-connections/ensure", apiKey, {
72
- method: "POST",
73
- body: JSON.stringify({
74
- platform,
75
- client_context: {
76
- source: "cli"
77
- }
78
- })
79
- });
80
- return res.json();
59
+
60
+ // ../../node_modules/.pnpm/jose@6.2.15/node_modules/jose/dist/webapi/lib/validate.js
61
+ function isObject(input) {
62
+ if (typeof input != "object" || input === null || Object.prototype.toString.call(input) !== "[object Object]")
63
+ return false;
64
+ const prototype = Object.getPrototypeOf(input);
65
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
81
66
  }
82
67
 
83
- // src/lib/sse.ts
84
- var MAX_RETRIES = 5;
85
- var RETRY_DELAY_MS = 2e3;
86
- async function* streamRunEvents(runId, apiKey, signal) {
87
- const url = `${API_BASE}/runs/${runId}/stream`;
88
- const seenIds = /* @__PURE__ */ new Set();
89
- let retries = 0;
90
- while (retries <= MAX_RETRIES) {
91
- if (retries > 0) {
92
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
93
- }
94
- let res;
95
- try {
96
- res = await fetch(url, {
97
- headers: { Authorization: `Bearer ${apiKey}` },
98
- signal
99
- });
100
- } catch (err) {
101
- if (err.name === "AbortError") throw err;
102
- retries++;
103
- continue;
104
- }
105
- if (!res.ok) {
106
- const body = await res.text();
107
- throw new Error(`SSE stream failed (${res.status}): ${body}`);
108
- }
109
- if (!res.body) {
110
- throw new Error("SSE stream returned no body");
111
- }
112
- const reader = res.body.getReader();
113
- const decoder = new TextDecoder();
114
- let buffer = "";
115
- try {
116
- while (true) {
117
- let readResult;
118
- try {
119
- readResult = await reader.read();
120
- } catch (err) {
121
- if (err.name === "AbortError") throw err;
122
- break;
123
- }
124
- const { done, value } = readResult;
125
- if (done) {
126
- break;
127
- }
128
- const chunk = decoder.decode(value, { stream: true });
129
- buffer += chunk;
130
- const lines = buffer.split("\n");
131
- buffer = lines.pop();
132
- for (const line of lines) {
133
- if (line.startsWith("data: ")) {
134
- const raw = line.slice(6);
135
- try {
136
- const event = JSON.parse(raw);
137
- if (event.id && seenIds.has(event.id)) {
138
- continue;
139
- }
140
- if (event.id) seenIds.add(event.id);
141
- yield event;
142
- if (event.event_type === "run_complete") {
143
- return;
144
- }
145
- } catch {
146
- }
147
- }
148
- }
149
- }
150
- } finally {
151
- reader.releaseLock();
152
- }
153
- retries++;
68
+ // ../../node_modules/.pnpm/jose@6.2.15/node_modules/jose/dist/webapi/util/decode_jwt.js
69
+ function decodeJwt(jwt) {
70
+ if (typeof jwt != "string")
71
+ throw new JWTInvalid("JWTs must use Compact JWS serialization, JWT must be a string");
72
+ const { 1: encodedPayload, length } = jwt.split(".");
73
+ if (length === 5)
74
+ throw new JWTInvalid("Only JWTs using Compact JWS serialization can be decoded");
75
+ if (length !== 3)
76
+ throw new JWTInvalid("Invalid JWT");
77
+ if (!encodedPayload)
78
+ throw new JWTInvalid("JWTs must contain a payload");
79
+ let claimsSetBytes;
80
+ try {
81
+ claimsSetBytes = decode(encodedPayload);
82
+ } catch (cause) {
83
+ throw new JWTInvalid("Failed to base64url decode the payload", { cause });
154
84
  }
155
- yield {
156
- event_type: "error",
157
- message: `Stream lost after ${MAX_RETRIES} reconnect attempts without receiving run_complete`
158
- };
85
+ let claimsSet;
86
+ try {
87
+ claimsSet = JSON.parse(strictDecoder.decode(claimsSetBytes));
88
+ } catch (cause) {
89
+ throw new JWTInvalid("Failed to parse the decoded payload as JSON", { cause });
90
+ }
91
+ if (!isObject(claimsSet))
92
+ throw new JWTInvalid("Invalid JWT Claims Set");
93
+ return claimsSet;
159
94
  }
160
95
 
161
- // src/lib/output.ts
162
- import { writeFileSync } from "node:fs";
163
-
164
96
  // ../shared/src/constants.ts
165
- var RUNNER_CALLBACK_MAX_SKEW_MS = 5 * 6e4;
97
+ var DEFAULT_AGENT_PORT = 3001;
98
+ var CALLER_ACCENTS = [
99
+ "american",
100
+ "british",
101
+ "australian",
102
+ "filipino",
103
+ "spanish_mexican",
104
+ "spanish_peninsular",
105
+ "spanish_colombian",
106
+ "spanish_argentine",
107
+ "german",
108
+ "french",
109
+ "italian",
110
+ "dutch",
111
+ "japanese"
112
+ ];
166
113
 
167
114
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
168
115
  var external_exports = {};
@@ -4205,116 +4152,92 @@ var coerce = {
4205
4152
  };
4206
4153
  var NEVER = INVALID;
4207
4154
 
4208
- // ../shared/src/schemas.ts
4209
- var CallerPersonaSchema = external_exports.object({
4210
- pace: external_exports.enum(["slow", "normal", "fast"]).optional(),
4211
- clarity: external_exports.enum(["clear", "vague", "rambling"]).optional(),
4212
- disfluencies: external_exports.boolean().optional(),
4213
- cooperation: external_exports.enum(["cooperative", "reluctant", "hostile"]).optional(),
4214
- emotion: external_exports.enum(["neutral", "cheerful", "confused", "frustrated", "skeptical", "rushed"]).optional(),
4215
- memory: external_exports.enum(["reliable", "unreliable"]).optional(),
4216
- intent_clarity: external_exports.enum(["clear", "indirect", "vague"]).optional(),
4217
- confirmation_style: external_exports.enum(["explicit", "vague"]).optional()
4218
- }).optional();
4219
- var CallerAudioEffectsSchema = external_exports.object({
4220
- noise: external_exports.object({
4221
- type: external_exports.enum(["babble", "white", "pink"]),
4222
- snr_db: external_exports.number().min(0).max(40).default(10)
4223
- }).optional(),
4224
- speed: external_exports.number().min(0.5).max(2).optional(),
4225
- speakerphone: external_exports.boolean().optional(),
4226
- mic_distance: external_exports.enum(["close", "normal", "far"]).optional(),
4227
- clarity: external_exports.number().min(0).max(1).optional(),
4228
- accent: external_exports.string().optional(),
4229
- packet_loss: external_exports.number().min(0).max(0.3).optional(),
4230
- jitter_ms: external_exports.number().min(0).max(100).optional()
4231
- });
4232
- var ConversationCallSpecSchema = external_exports.object({
4233
- name: external_exports.string().optional(),
4234
- caller_prompt: external_exports.string().min(1),
4235
- max_turns: external_exports.number().int().min(1).max(50).default(6),
4236
- silence_threshold_ms: external_exports.number().int().min(200).max(1e4).optional(),
4237
- persona: CallerPersonaSchema,
4238
- caller_audio: CallerAudioEffectsSchema.optional(),
4239
- /** ISO 639-1 language code for multilingual calls (e.g., "es", "fr", "de"). Caller speaks this language, STT transcribes it, judge evaluates in it. */
4240
- language: external_exports.string().min(2).max(5).optional(),
4241
- /** Caller voice gender (English only; default female). Ignored if caller_audio.accent is set or language is non-English. */
4242
- voice: external_exports.enum(["male", "female"]).optional()
4243
- });
4244
- var CallSpecSchema = external_exports.object({
4245
- call: ConversationCallSpecSchema
4246
- });
4247
- var AdapterTypeSchema = external_exports.enum(["websocket", "livekit", "vapi", "retell", "elevenlabs", "bland"]);
4248
- var PlatformProviderSchema = external_exports.enum(["bland", "livekit", "vapi", "retell", "elevenlabs"]);
4249
- var ObservedToolCallSchema = external_exports.object({
4250
- name: external_exports.string(),
4251
- arguments: external_exports.record(external_exports.unknown()),
4252
- result: external_exports.unknown().optional(),
4253
- successful: external_exports.boolean().optional(),
4254
- provider_tool_type: external_exports.string().optional(),
4255
- timestamp_ms: external_exports.number().optional(),
4256
- latency_ms: external_exports.number().optional(),
4257
- turn_index: external_exports.number().int().min(0).optional()
4258
- });
4259
- var ToolCallMetricsSchema = external_exports.object({
4260
- total: external_exports.number().int().min(0),
4261
- successful: external_exports.number().int().min(0),
4262
- failed: external_exports.number().int().min(0),
4263
- mean_latency_ms: external_exports.number().optional(),
4264
- names: external_exports.array(external_exports.string())
4265
- });
4155
+ // ../shared/src/schemas/platforms.ts
4156
+ var PLATFORM_PROVIDERS = ["vapi", "retell", "elevenlabs", "livekit", "bland"];
4266
4157
  var BasePlatformSchema = external_exports.object({
4158
+ /** Max concurrent calls (platform-dependent) */
4267
4159
  max_concurrency: external_exports.number().int().min(1).optional()
4268
4160
  });
4269
4161
  var BlandPlatformSchema = BasePlatformSchema.extend({
4270
4162
  provider: external_exports.literal("bland"),
4163
+ /** Bland API key (the CLI reads BLAND_API_KEY from the user's env) */
4271
4164
  bland_api_key: external_exports.string().optional(),
4165
+ /** Bland pathway ID (the CLI reads BLAND_PATHWAY_ID from the user's env) */
4272
4166
  bland_pathway_id: external_exports.string().optional(),
4167
+ /** Bland persona ID — reusable agent preset (the CLI reads BLAND_PERSONA_ID from the user's env) */
4273
4168
  persona_id: external_exports.string().optional(),
4169
+ /** Task prompt — used instead of pathway_id for simple agents */
4274
4170
  task: external_exports.string().optional(),
4171
+ /** Tool definitions (inline objects) or tool IDs (TL-xxx strings) */
4275
4172
  tools: external_exports.array(external_exports.unknown()).optional(),
4173
+ /** Voice name ("maya", "josh") or custom voice clone UUID */
4276
4174
  voice: external_exports.string().optional(),
4175
+ /** Model: "base" (full features), "turbo" (fastest, limited features) */
4277
4176
  model: external_exports.string().optional(),
4177
+ /** Opening sentence — overrides any greeting in the task/pathway */
4278
4178
  first_sentence: external_exports.string().optional(),
4179
+ /** If true, agent waits for callee to speak first (default: false) */
4279
4180
  wait_for_greeting: external_exports.boolean().optional(),
4181
+ /** Max call duration in minutes (default: 30) */
4280
4182
  max_duration: external_exports.number().optional(),
4183
+ /** Temperature 0-1 (default: 0.7) */
4281
4184
  temperature: external_exports.number().min(0).max(1).optional(),
4185
+ /** Language code e.g. "babel-en", "babel-es" */
4282
4186
  language: external_exports.string().optional(),
4187
+ /** How quickly agent stops speaking when interrupted, in ms (default: 500) */
4283
4188
  interruption_threshold: external_exports.number().optional(),
4189
+ /** When true, agent ignores user interruptions entirely */
4284
4190
  block_interruptions: external_exports.boolean().optional(),
4191
+ /** When true, enable Bland's noise filtering on caller audio */
4285
4192
  noise_cancellation: external_exports.boolean().optional(),
4193
+ /** Background audio: "office", "cafe", "restaurant", "none", or null (default phone static) */
4286
4194
  background_track: external_exports.string().nullable().optional(),
4195
+ /** Boost transcription accuracy for specific words. Supports "word:boost_factor" */
4287
4196
  keywords: external_exports.array(external_exports.string()).optional(),
4197
+ /** Key-value pairs accessible as {{variable}} in agent prompts/pathways */
4288
4198
  request_data: external_exports.record(external_exports.unknown()).optional(),
4199
+ /** Pronunciation overrides */
4289
4200
  pronunciation_guide: external_exports.array(external_exports.object({
4290
4201
  word: external_exports.string(),
4291
4202
  pronunciation: external_exports.string(),
4292
4203
  case_sensitive: external_exports.boolean().optional(),
4293
4204
  spaced: external_exports.boolean().optional()
4294
4205
  })).optional(),
4206
+ /** Start pathway from a specific node instead of the default */
4295
4207
  start_node_id: external_exports.string().optional(),
4208
+ /** Specific pathway version to test (default: production) */
4296
4209
  pathway_version: external_exports.number().optional()
4297
4210
  });
4298
4211
  var LiveKitPlatformSchema = BasePlatformSchema.extend({
4299
4212
  provider: external_exports.literal("livekit"),
4213
+ /** LiveKit API key (the CLI reads LIVEKIT_API_KEY from the user's env) */
4300
4214
  livekit_api_key: external_exports.string().optional(),
4215
+ /** LiveKit API secret (the CLI reads LIVEKIT_API_SECRET from the user's env) */
4301
4216
  livekit_api_secret: external_exports.string().optional(),
4217
+ /** LiveKit server URL, e.g. wss://your-app.livekit.cloud (the CLI reads LIVEKIT_URL from the user's env) */
4302
4218
  livekit_url: external_exports.string().optional(),
4219
+ /** Explicit agent dispatch — agent_name from WorkerOptions. Omit for automatic dispatch. */
4303
4220
  livekit_agent_name: external_exports.string().optional()
4304
4221
  }).strict();
4305
4222
  var VapiPlatformSchema = BasePlatformSchema.extend({
4306
4223
  provider: external_exports.literal("vapi"),
4224
+ /** Vapi API key (the CLI reads VAPI_API_KEY from the user's env) */
4307
4225
  vapi_api_key: external_exports.string().optional(),
4226
+ /** Vapi assistant ID (the CLI reads VAPI_ASSISTANT_ID or VAPI_AGENT_ID from the user's env) */
4308
4227
  vapi_assistant_id: external_exports.string().optional()
4309
4228
  });
4310
4229
  var RetellPlatformSchema = BasePlatformSchema.extend({
4311
4230
  provider: external_exports.literal("retell"),
4231
+ /** Retell API key (the CLI reads RETELL_API_KEY from the user's env) */
4312
4232
  retell_api_key: external_exports.string().optional(),
4233
+ /** Retell agent ID (the CLI reads RETELL_AGENT_ID from the user's env) */
4313
4234
  retell_agent_id: external_exports.string().optional()
4314
4235
  });
4315
4236
  var ElevenLabsPlatformSchema = BasePlatformSchema.extend({
4316
4237
  provider: external_exports.literal("elevenlabs"),
4238
+ /** ElevenLabs API key (the CLI reads ELEVENLABS_API_KEY from the user's env) */
4317
4239
  elevenlabs_api_key: external_exports.string().optional(),
4240
+ /** ElevenLabs agent ID (the CLI reads ELEVENLABS_AGENT_ID from the user's env) */
4318
4241
  elevenlabs_agent_id: external_exports.string().optional()
4319
4242
  });
4320
4243
  var PlatformConfigSchema = external_exports.discriminatedUnion("provider", [
@@ -4324,103 +4247,118 @@ var PlatformConfigSchema = external_exports.discriminatedUnion("provider", [
4324
4247
  RetellPlatformSchema,
4325
4248
  ElevenLabsPlatformSchema
4326
4249
  ]);
4327
- var PlatformSummarySchema = external_exports.object({
4328
- provider: PlatformProviderSchema
4329
- }).catchall(external_exports.unknown());
4330
- var PlatformConnectionSummarySchema = external_exports.object({
4331
- id: external_exports.string().uuid(),
4332
- provider: PlatformProviderSchema,
4333
- version: external_exports.number().int().min(1),
4334
- resource_label: external_exports.string().min(1)
4250
+ var REQUIRED_PLATFORM_FIELDS = {
4251
+ vapi: [["vapi_api_key"], ["vapi_assistant_id"]],
4252
+ retell: [["retell_api_key"], ["retell_agent_id"]],
4253
+ elevenlabs: [["elevenlabs_api_key"], ["elevenlabs_agent_id"]],
4254
+ livekit: [["livekit_api_key"], ["livekit_api_secret"], ["livekit_url"]],
4255
+ // Each test call creates a Bland web agent, which has no persona field: the
4256
+ // instructions must come from a pathway or a task prompt, so a persona_id
4257
+ // alone gives the agent no instructions.
4258
+ bland: [["bland_api_key"], ["bland_pathway_id", "task"]]
4259
+ };
4260
+ function missingPlatformFields(platform) {
4261
+ const values = platform;
4262
+ const requirements = REQUIRED_PLATFORM_FIELDS[platform.provider];
4263
+ return requirements.filter((anyOf) => anyOf.every((field) => !values[field]));
4264
+ }
4265
+
4266
+ // ../shared/src/schemas/suite.ts
4267
+ var AdapterTypeSchema = external_exports.enum(["websocket", ...PLATFORM_PROVIDERS]);
4268
+ var CallerPersonaSchema = external_exports.object({
4269
+ pace: external_exports.enum(["slow", "normal", "fast"]).optional(),
4270
+ clarity: external_exports.enum(["clear", "vague", "rambling"]).optional(),
4271
+ disfluencies: external_exports.boolean().optional(),
4272
+ cooperation: external_exports.enum(["cooperative", "reluctant", "hostile"]).optional(),
4273
+ emotion: external_exports.enum(["neutral", "cheerful", "confused", "frustrated", "skeptical", "rushed"]).optional(),
4274
+ memory: external_exports.enum(["reliable", "unreliable"]).optional(),
4275
+ intent_clarity: external_exports.enum(["clear", "indirect", "vague"]).optional(),
4276
+ confirmation_style: external_exports.enum(["explicit", "vague"]).optional()
4335
4277
  });
4336
- var RunPlatformSummarySchema = external_exports.object({
4337
- platform_connection_id: external_exports.string().uuid().nullable(),
4338
- platform_connection: PlatformConnectionSummarySchema.nullable(),
4339
- platform: PlatformSummarySchema.nullable()
4278
+ var CallerAudioEffectsSchema = external_exports.object({
4279
+ noise: external_exports.object({
4280
+ type: external_exports.enum(["babble", "white", "pink"]),
4281
+ snr_db: external_exports.number().min(0).max(40).default(10)
4282
+ }).optional(),
4283
+ speed: external_exports.number().min(0.5).max(2).optional(),
4284
+ speakerphone: external_exports.boolean().optional(),
4285
+ mic_distance: external_exports.enum(["close", "normal", "far"]).optional(),
4286
+ clarity: external_exports.number().min(0).max(1).optional(),
4287
+ accent: external_exports.enum(CALLER_ACCENTS).optional(),
4288
+ packet_loss: external_exports.number().min(0).max(0.3).optional(),
4289
+ jitter_ms: external_exports.number().min(0).max(100).optional()
4290
+ });
4291
+ var ConversationCallSpecSchema = external_exports.object({
4292
+ name: external_exports.string().optional(),
4293
+ caller_prompt: external_exports.string().min(1),
4294
+ max_turns: external_exports.number().int().min(1).max(50).default(6),
4295
+ persona: CallerPersonaSchema.optional(),
4296
+ /** Audio effects applied to all caller audio (speakerphone, speed, noise, accent, etc.) */
4297
+ caller_audio: CallerAudioEffectsSchema.optional(),
4298
+ /** ISO 639-1 language code for multilingual calls (e.g., "es", "fr", "de"). The caller speaks it and Vent transcribes in it. */
4299
+ language: external_exports.string().min(2).max(5).optional(),
4300
+ /** Caller voice gender (English only). "male" → aura-2-apollo-en, "female" → aura-2-thalia-en (default). Ignored if caller_audio.accent is set or language is non-English. */
4301
+ voice: external_exports.enum(["male", "female"]).optional()
4302
+ });
4303
+
4304
+ // ../shared/src/schemas/call-result.ts
4305
+ var ComponentLatencySchema = external_exports.object({
4306
+ stt_ms: external_exports.number().optional(),
4307
+ llm_ms: external_exports.number().optional(),
4308
+ tts_ms: external_exports.number().optional(),
4309
+ speech_duration_ms: external_exports.number().optional()
4340
4310
  });
4341
4311
  var ConversationTurnSchema = external_exports.object({
4342
4312
  role: external_exports.enum(["caller", "agent"]),
4343
4313
  text: external_exports.string(),
4344
4314
  timestamp_ms: external_exports.number(),
4315
+ /** Caller decision mode that produced this turn, or the silent wait mode that led into an agent turn. */
4345
4316
  caller_decision_mode: external_exports.enum(["continue", "wait", "closing", "end_now"]).optional(),
4346
4317
  audio_duration_ms: external_exports.number().optional(),
4318
+ /** Time to first byte: from the caller's audio being sent to the first agent audio arriving (ms). */
4347
4319
  ttfb_ms: external_exports.number().optional(),
4320
+ /** Time to first word: from the caller's audio being sent to the agent starting to speak, by voice detection (ms). */
4348
4321
  ttfw_ms: external_exports.number().optional(),
4322
+ /** Dead audio before speech starts: ttfw_ms - ttfb_ms */
4349
4323
  silence_pad_ms: external_exports.number().optional(),
4350
4324
  stt_confidence: external_exports.number().optional(),
4325
+ /** Harness TTS synthesis time for this turn's caller audio (ms) */
4351
4326
  tts_ms: external_exports.number().optional(),
4352
- stt_ms: external_exports.number().optional(),
4353
- component_latency: external_exports.object({
4354
- stt_ms: external_exports.number().optional(),
4355
- llm_ms: external_exports.number().optional(),
4356
- tts_ms: external_exports.number().optional(),
4357
- speech_duration_ms: external_exports.number().optional()
4358
- }).optional(),
4359
- platform_transcript: external_exports.string().optional()
4327
+ /** Harness STT transcription time for this turn's agent audio (ms) */
4328
+ stt_ms: external_exports.number().optional()
4360
4329
  });
4361
- var LatencyMetricsSchema = external_exports.object({
4362
- ttfb_per_turn_ms: external_exports.array(external_exports.number()),
4363
- p50_ttfb_ms: external_exports.number(),
4364
- p90_ttfb_ms: external_exports.number(),
4365
- p95_ttfb_ms: external_exports.number(),
4366
- p99_ttfb_ms: external_exports.number(),
4367
- first_turn_ttfb_ms: external_exports.number(),
4368
- ttfw_per_turn_ms: external_exports.array(external_exports.number()).optional(),
4369
- p50_ttfw_ms: external_exports.number().optional(),
4370
- p90_ttfw_ms: external_exports.number().optional(),
4371
- p95_ttfw_ms: external_exports.number().optional(),
4372
- p99_ttfw_ms: external_exports.number().optional(),
4373
- first_turn_ttfw_ms: external_exports.number().optional(),
4374
- mean_silence_pad_ms: external_exports.number().optional(),
4375
- mouth_to_ear_est_ms: external_exports.number().optional(),
4376
- drift_slope_ms_per_turn: external_exports.number().optional()
4330
+ var PlatformTurnSchema = external_exports.object({
4331
+ role: external_exports.enum(["caller", "agent"]),
4332
+ /** Agent turns: the text the agent's LLM wrote. Caller turns: what the agent's STT heard the caller say. */
4333
+ text: external_exports.string()
4377
4334
  });
4378
- var HarnessOverheadSchema = external_exports.object({
4379
- tts_per_turn_ms: external_exports.array(external_exports.number()),
4380
- stt_per_turn_ms: external_exports.array(external_exports.number()),
4381
- mean_tts_ms: external_exports.number(),
4382
- mean_stt_ms: external_exports.number()
4335
+ var ObservedToolCallSchema = external_exports.object({
4336
+ name: external_exports.string(),
4337
+ arguments: external_exports.record(external_exports.unknown()),
4338
+ result: external_exports.unknown().optional(),
4339
+ successful: external_exports.boolean().optional(),
4340
+ provider_tool_type: external_exports.string().optional(),
4341
+ timestamp_ms: external_exports.number().optional(),
4342
+ latency_ms: external_exports.number().optional(),
4343
+ turn_index: external_exports.number().int().min(0).optional()
4383
4344
  });
4384
4345
  var SignalQualityMetricsSchema = external_exports.object({
4346
+ /** Mean signal-to-noise ratio across turns (dB). >20 good, <10 bad */
4385
4347
  mean_snr_db: external_exports.number(),
4348
+ /** Max clipping ratio across turns (0-1). >0.01 = distortion */
4386
4349
  max_clipping_ratio: external_exports.number(),
4350
+ /** Mean energy consistency across turns (0-1). <0.5 = unstable volume */
4387
4351
  energy_consistency: external_exports.number(),
4352
+ /** Total sudden volume drops across all turns */
4388
4353
  sudden_drops: external_exports.number().int().min(0),
4354
+ /** Total sudden volume spikes across all turns */
4389
4355
  sudden_spikes: external_exports.number().int().min(0),
4356
+ /** All turns have clean start/end (no clicks) */
4390
4357
  clean_edges: external_exports.boolean()
4391
4358
  });
4392
- var ComponentLatencySchema = external_exports.object({
4393
- stt_ms: external_exports.number().optional(),
4394
- llm_ms: external_exports.number().optional(),
4395
- tts_ms: external_exports.number().optional(),
4396
- speech_duration_ms: external_exports.number().optional()
4397
- });
4398
- var ComponentLatencyMetricsSchema = external_exports.object({
4399
- per_turn: external_exports.array(ComponentLatencySchema),
4400
- mean_stt_ms: external_exports.number().optional(),
4401
- mean_llm_ms: external_exports.number().optional(),
4402
- mean_tts_ms: external_exports.number().optional(),
4403
- p95_stt_ms: external_exports.number().optional(),
4404
- p95_llm_ms: external_exports.number().optional(),
4405
- p95_tts_ms: external_exports.number().optional(),
4406
- bottleneck: external_exports.enum(["stt", "llm", "tts"]).optional()
4407
- });
4408
- var UsageEntrySchema = external_exports.object({
4409
- type: external_exports.string(),
4410
- provider: external_exports.string(),
4411
- model: external_exports.string(),
4412
- input_tokens: external_exports.number().int().optional(),
4413
- output_tokens: external_exports.number().int().optional()
4414
- });
4415
- var CostBreakdownSchema = external_exports.object({
4416
- stt_usd: external_exports.number().optional(),
4417
- llm_usd: external_exports.number().optional(),
4418
- tts_usd: external_exports.number().optional(),
4419
- transport_usd: external_exports.number().optional(),
4420
- platform_usd: external_exports.number().optional(),
4421
- total_usd: external_exports.number().optional(),
4422
- llm_prompt_tokens: external_exports.number().int().optional(),
4423
- llm_completion_tokens: external_exports.number().int().optional()
4359
+ var CallCostSchema = external_exports.object({
4360
+ total_usd: external_exports.number(),
4361
+ breakdown: external_exports.record(external_exports.number()).optional()
4424
4362
  });
4425
4363
  var ProviderWarningSchema = external_exports.object({
4426
4364
  message: external_exports.string().optional(),
@@ -4436,303 +4374,333 @@ var CallTransferSchema = external_exports.object({
4436
4374
  });
4437
4375
  var CallMetadataSchema = external_exports.object({
4438
4376
  platform: external_exports.string(),
4377
+ /** Provider-visible call/conversation identifier for dashboard/API lookup. */
4439
4378
  provider_call_id: external_exports.string().optional(),
4379
+ /** Secondary runtime/session identifier when distinct from the call ID. */
4440
4380
  provider_session_id: external_exports.string().optional(),
4441
4381
  ended_reason: external_exports.string().optional(),
4442
- cost_usd: external_exports.number().nullable().optional(),
4443
- cost_breakdown: CostBreakdownSchema.optional(),
4382
+ cost: CallCostSchema.optional(),
4444
4383
  recording_url: external_exports.string().optional(),
4384
+ /** Provider-specific alternate recording artifacts keyed by variant name. */
4445
4385
  recording_variants: external_exports.record(external_exports.string()).optional(),
4386
+ /** Provider-specific debug and deep-link URLs keyed by purpose. */
4446
4387
  provider_debug_urls: external_exports.record(external_exports.string()).optional(),
4447
- variables: external_exports.record(external_exports.unknown()).optional(),
4448
- usage: external_exports.array(UsageEntrySchema).optional(),
4388
+ /** Provider/runtime warnings retained in a normalized structure. */
4449
4389
  provider_warnings: external_exports.array(ProviderWarningSchema).optional(),
4450
- provider_metadata: external_exports.record(external_exports.unknown()).optional(),
4390
+ /** Call transfers that occurred during the call */
4451
4391
  transfers: external_exports.array(CallTransferSchema).optional()
4452
4392
  });
4453
4393
  var ConversationMetricsSchema = external_exports.object({
4454
- mean_ttfb_ms: external_exports.number(),
4455
- mean_ttfw_ms: external_exports.number().optional(),
4456
- latency: LatencyMetricsSchema.optional(),
4457
- tool_calls: ToolCallMetricsSchema.optional(),
4394
+ /** How long Vent's connection to the platform took to open; feeds the mouth-to-ear estimate. */
4395
+ connect_latency_ms: external_exports.number().optional(),
4396
+ /** Raw audio signal quality (SNR, clipping, energy), aggregated across agent turns. */
4458
4397
  signal_quality: SignalQualityMetricsSchema.optional(),
4459
- harness_overhead: HarnessOverheadSchema.optional(),
4460
- component_latency: ComponentLatencyMetricsSchema.optional()
4398
+ /** Every component timing the platform reported (STT/LLM/TTS). It can report
4399
+ * more than Vent has agent turns, so this is the full list. */
4400
+ /** The platform's STT/LLM/TTS latencies for the call; the result shows their means. */
4401
+ component_latency: external_exports.object({ per_turn: external_exports.array(ComponentLatencySchema) }).optional()
4461
4402
  });
4462
4403
  var ConversationCallResultSchema = external_exports.object({
4463
4404
  name: external_exports.string().optional(),
4464
4405
  caller_prompt: external_exports.string(),
4465
4406
  status: external_exports.enum(["completed", "error"]),
4407
+ /** How a completed call ended, when Vent ended it or the connection closed. */
4408
+ end_note: external_exports.string().optional(),
4409
+ /** Vent's record of the call: what Vent's caller said, and what Vent heard the agent say. */
4466
4410
  transcript: external_exports.array(ConversationTurnSchema),
4411
+ /** The agent platform's own record of the call, when the platform provides one. */
4412
+ platform_transcript: external_exports.array(PlatformTurnSchema).optional(),
4467
4413
  observed_tool_calls: external_exports.array(ObservedToolCallSchema).optional(),
4468
4414
  duration_ms: external_exports.number(),
4469
4415
  metrics: ConversationMetricsSchema,
4470
4416
  error: external_exports.string().optional(),
4417
+ /** Platform call metadata (cost, ended reason, recording, analysis) */
4471
4418
  call_metadata: CallMetadataSchema.optional()
4472
4419
  });
4473
- var RunAggregateV2Schema = external_exports.object({
4474
- conversation_calls: external_exports.object({
4475
- total: external_exports.number(),
4476
- passed: external_exports.number(),
4477
- failed: external_exports.number()
4478
- }).default({ total: 0, passed: 0, failed: 0 }),
4479
- total_duration_ms: external_exports.number(),
4480
- total_cost_usd: external_exports.number().optional()
4420
+
4421
+ // ../shared/src/schemas/internal.ts
4422
+ var RefreshTokenRequestSchema = external_exports.object({
4423
+ refresh_token: external_exports.string().regex(/^vent_[0-9a-f]{48}$/)
4424
+ });
4425
+ var AccessCredentialsSchema = external_exports.object({
4426
+ access_token: external_exports.string().min(1),
4427
+ refresh_token: RefreshTokenRequestSchema.shape.refresh_token
4481
4428
  });
4482
- var RUNNER_TERMINAL_STATUSES = ["pass", "fail"];
4483
- var RunnerCallbackV2Schema = external_exports.object({
4429
+ var RUN_TERMINAL_STATUSES = ["pass", "fail", "cancelled"];
4430
+ var WorkerResultCallbackSchema = external_exports.object({
4484
4431
  run_id: external_exports.string().uuid(),
4485
- status: external_exports.enum(RUNNER_TERMINAL_STATUSES),
4432
+ status: external_exports.enum(RUN_TERMINAL_STATUSES),
4486
4433
  conversation_result: ConversationCallResultSchema,
4487
- aggregate: RunAggregateV2Schema,
4488
4434
  error_text: external_exports.string().optional()
4489
4435
  });
4436
+ var RunFailedCallbackSchema = external_exports.object({
4437
+ run_id: external_exports.string().uuid(),
4438
+ error_text: external_exports.string().max(2e3)
4439
+ });
4490
4440
  var RunEventCallbackSchema = external_exports.object({
4491
4441
  run_id: external_exports.string().uuid(),
4492
4442
  event_type: external_exports.string().min(1).max(64),
4493
4443
  message: external_exports.string().max(2e3),
4494
4444
  metadata_json: external_exports.record(external_exports.unknown()).optional()
4495
4445
  });
4496
- var CallProgressCallbackSchema = external_exports.object({
4446
+ var RunJobSchema = external_exports.object({
4497
4447
  run_id: external_exports.string().uuid(),
4498
- completed: external_exports.number().int().nonnegative(),
4499
- total: external_exports.number().int().positive(),
4500
- call_type: external_exports.literal("conversation"),
4501
- call_name: external_exports.string().min(1).max(256),
4502
- status: external_exports.enum(["pass", "fail", "completed", "error"]),
4503
- duration_ms: external_exports.number().nonnegative(),
4504
- result: external_exports.record(external_exports.unknown()).optional()
4448
+ user_id: external_exports.string().min(1),
4449
+ adapter: AdapterTypeSchema,
4450
+ call_spec: external_exports.object({ call: ConversationCallSpecSchema }),
4451
+ agent_url: external_exports.string().optional(),
4452
+ agent_session_id: external_exports.string().uuid().optional()
4453
+ });
4454
+ var AgentSessionResponseSchema = external_exports.object({
4455
+ session_id: external_exports.string().uuid(),
4456
+ relay_token: external_exports.string(),
4457
+ api_url: external_exports.string(),
4458
+ agent_port: external_exports.number().int().min(1).max(65535),
4459
+ start_command: external_exports.string().nullable(),
4460
+ health_endpoint: external_exports.string()
4505
4461
  });
4506
4462
 
4507
- // ../shared/src/format-result.ts
4508
- function formatConversationResult(raw, options = {}) {
4509
- if (!raw || typeof raw !== "object") return null;
4510
- const r = raw;
4511
- if (typeof r.caller_prompt !== "string") return null;
4512
- const verbose = options.verbose ?? false;
4513
- const debug = verbose ? formatDebug(r) : void 0;
4514
- const warnings = dedupeStrings([
4515
- ...formatProviderWarningMessages(r.call_metadata?.provider_warnings)
4516
- ]);
4517
- const result = {
4518
- name: r.name ?? null,
4519
- status: r.status,
4520
- duration_ms: r.duration_ms,
4521
- error: r.error ?? null,
4522
- transcript: formatTranscript(r.transcript, options),
4523
- latency: r.metrics?.latency ? formatLatency(r.metrics.latency, r.metrics, verbose) : null,
4524
- tool_calls: formatToolCalls(r.metrics?.tool_calls, r.observed_tool_calls, verbose),
4525
- component_latency: formatComponentLatency(r.metrics?.component_latency, verbose),
4526
- call_metadata: formatCallMetadata(r.call_metadata, verbose)
4527
- };
4528
- if (warnings.length > 0) result.warnings = warnings;
4529
- if (verbose) result.caller_prompt = r.caller_prompt;
4530
- if (debug) result.debug = debug;
4531
- return result;
4532
- }
4533
- function formatTranscript(turns, options) {
4534
- if (!turns) return [];
4535
- return turns.map((t) => {
4536
- const turn = {
4537
- role: t.role,
4538
- text: t.text
4463
+ // ../shared/src/callback-auth.ts
4464
+ import { createHmac, timingSafeEqual } from "node:crypto";
4465
+ var RUNNER_CALLBACK_MAX_SKEW_MS = 5 * 6e4;
4466
+
4467
+ // src/lib/config.ts
4468
+ var CONFIG_DIR = path.join(homedir(), ".vent");
4469
+ var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials");
4470
+ var API_BASE = process.env.VENT_API_URL ?? "https://api.venthq.dev";
4471
+ var refreshes = /* @__PURE__ */ new Map();
4472
+ async function readCredentials() {
4473
+ const accessToken = process.env.VENT_ACCESS_TOKEN;
4474
+ const refreshToken = process.env.VENT_REFRESH_TOKEN;
4475
+ if (accessToken || refreshToken) return { accessToken, refreshToken, environment: true };
4476
+ try {
4477
+ const parsed = AccessCredentialsSchema.safeParse(JSON.parse(await fs.readFile(CREDENTIALS_FILE, "utf-8")));
4478
+ if (parsed.success) return {
4479
+ accessToken: parsed.data.access_token,
4480
+ refreshToken: parsed.data.refresh_token,
4481
+ environment: false
4539
4482
  };
4540
- if (t.ttfb_ms != null) turn.ttfb_ms = t.ttfb_ms;
4541
- if (t.ttfw_ms != null) turn.ttfw_ms = t.ttfw_ms;
4542
- if (t.audio_duration_ms != null) turn.audio_duration_ms = t.audio_duration_ms;
4543
- if (options.verbose) {
4544
- const debug = compactUnknownRecord({
4545
- timestamp_ms: t.timestamp_ms,
4546
- caller_decision_mode: t.caller_decision_mode,
4547
- silence_pad_ms: t.silence_pad_ms,
4548
- stt_confidence: t.stt_confidence,
4549
- harness_tts_ms: t.tts_ms,
4550
- harness_stt_ms: t.stt_ms,
4551
- component_latency: t.component_latency,
4552
- platform_transcript: t.platform_transcript
4553
- });
4554
- if (debug && Object.keys(debug).length > 0) {
4555
- turn.debug = debug;
4556
- }
4557
- }
4558
- return turn;
4559
- });
4560
- }
4561
- function formatLatency(latency, metrics, verbose) {
4562
- const hasTtfw = metrics.mean_ttfw_ms != null && latency.p50_ttfw_ms != null && latency.p95_ttfw_ms != null;
4563
- const responseTimeSource = hasTtfw ? "ttfw" : "ttfb";
4564
- const result = {
4565
- response_time_ms: hasTtfw ? metrics.mean_ttfw_ms : metrics.mean_ttfb_ms,
4566
- response_time_source: responseTimeSource,
4567
- p95_response_time_ms: hasTtfw ? latency.p95_ttfw_ms : latency.p95_ttfb_ms,
4568
- first_response_time_ms: hasTtfw ? latency.first_turn_ttfw_ms ?? latency.first_turn_ttfb_ms : latency.first_turn_ttfb_ms
4569
- };
4570
- if (!verbose) return result;
4571
- result.p50_response_time_ms = hasTtfw ? latency.p50_ttfw_ms : latency.p50_ttfb_ms;
4572
- result.p90_response_time_ms = hasTtfw ? latency.p90_ttfw_ms ?? latency.p90_ttfb_ms : latency.p90_ttfb_ms;
4573
- result.p99_response_time_ms = hasTtfw ? latency.p99_ttfw_ms ?? latency.p99_ttfb_ms : latency.p99_ttfb_ms;
4574
- if (hasTtfw) {
4575
- result.mean_ttfw_ms = metrics.mean_ttfw_ms;
4576
- result.p50_ttfw_ms = latency.p50_ttfw_ms;
4577
- result.p90_ttfw_ms = latency.p90_ttfw_ms ?? latency.p90_ttfb_ms;
4578
- result.p95_ttfw_ms = latency.p95_ttfw_ms;
4579
- result.p99_ttfw_ms = latency.p99_ttfw_ms ?? latency.p99_ttfb_ms;
4580
- result.first_turn_ttfw_ms = latency.first_turn_ttfw_ms ?? latency.first_turn_ttfb_ms;
4581
- }
4582
- if (latency.drift_slope_ms_per_turn != null) result.drift_slope_ms_per_turn = latency.drift_slope_ms_per_turn;
4583
- if (latency.mean_silence_pad_ms != null) result.mean_silence_pad_ms = latency.mean_silence_pad_ms;
4584
- if (latency.mouth_to_ear_est_ms != null) result.mouth_to_ear_est_ms = latency.mouth_to_ear_est_ms;
4585
- return result;
4483
+ } catch {
4484
+ }
4485
+ return null;
4586
4486
  }
4587
- function formatToolCalls(summary, observed, verbose) {
4588
- return {
4589
- total: summary?.total ?? observed?.length ?? 0,
4590
- successful: summary?.successful ?? observed?.filter((c) => c.successful).length ?? 0,
4591
- failed: summary?.failed ?? observed?.filter((c) => c.successful === false).length ?? 0,
4592
- mean_latency_ms: summary?.mean_latency_ms,
4593
- names: summary?.names ?? [...new Set((observed ?? []).map((c) => c.name))],
4594
- observed: (observed ?? []).map((c) => {
4595
- const entry = {
4596
- name: c.name,
4597
- arguments: stripExecutionMessage(c.arguments),
4598
- result: verbose ? c.result : truncateLargeArrays(c.result),
4599
- successful: c.successful,
4600
- latency_ms: c.latency_ms,
4601
- turn_index: c.turn_index
4602
- };
4603
- if (c.provider_tool_type && c.provider_tool_type !== "custom") {
4604
- entry.provider_tool_type = c.provider_tool_type;
4605
- }
4606
- return entry;
4607
- })
4608
- };
4487
+ function usableAccessToken(token, marginMs = 0) {
4488
+ if (!token) return false;
4489
+ try {
4490
+ return (decodeJwt(token).exp ?? 0) * 1e3 > Date.now() + marginMs;
4491
+ } catch {
4492
+ return false;
4493
+ }
4609
4494
  }
4610
- var TOOL_RESULT_ARRAY_LIMIT = 5;
4611
- function truncateLargeArrays(value) {
4612
- if (Array.isArray(value)) {
4613
- if (value.length <= TOOL_RESULT_ARRAY_LIMIT) {
4614
- return value.map(truncateLargeArrays);
4495
+ async function refresh(source) {
4496
+ const refreshToken = source.refreshToken;
4497
+ const existing = refreshes.get(refreshToken);
4498
+ if (existing) return existing;
4499
+ const work = (async () => {
4500
+ let response;
4501
+ try {
4502
+ response = await fetch(`${API_BASE}/auth/refresh`, {
4503
+ method: "POST",
4504
+ headers: { "Content-Type": "application/json" },
4505
+ body: JSON.stringify({ refresh_token: refreshToken }),
4506
+ signal: AbortSignal.timeout(1e4)
4507
+ });
4508
+ } catch {
4509
+ throw new Error("Could not refresh Vent sign-in. Check your connection and try again.");
4615
4510
  }
4616
- return [
4617
- ...value.slice(0, TOOL_RESULT_ARRAY_LIMIT).map(truncateLargeArrays),
4618
- { _truncated: `${value.length - TOOL_RESULT_ARRAY_LIMIT} more entries omitted; use --verbose for full result` }
4619
- ];
4620
- }
4621
- if (value && typeof value === "object") {
4622
- const out = {};
4623
- for (const [k, v] of Object.entries(value)) {
4624
- out[k] = truncateLargeArrays(v);
4511
+ if (!response.ok) {
4512
+ throw new Error(response.status === 401 ? "Vent credentials were rejected. Run `npx vent-hq login` again or replace VENT_REFRESH_TOKEN." : `Vent sign-in refresh failed (${response.status}). Try again.`);
4513
+ }
4514
+ const parsed = AccessCredentialsSchema.safeParse(await response.json());
4515
+ if (!parsed.success) throw new Error("Vent returned invalid sign-in credentials.");
4516
+ const credentials = parsed.data;
4517
+ if (source.environment) {
4518
+ process.env.VENT_ACCESS_TOKEN = credentials.access_token;
4519
+ process.env.VENT_REFRESH_TOKEN = credentials.refresh_token;
4520
+ } else {
4521
+ await saveCredentials(credentials);
4625
4522
  }
4626
- return out;
4523
+ return credentials.access_token;
4524
+ })();
4525
+ refreshes.set(refreshToken, work);
4526
+ try {
4527
+ return await work;
4528
+ } finally {
4529
+ refreshes.delete(refreshToken);
4627
4530
  }
4628
- return value;
4629
- }
4630
- function stripExecutionMessage(args) {
4631
- if (!args || typeof args !== "object") return args;
4632
- const { execution_message: _drop, ...rest } = args;
4633
- return rest;
4634
4531
  }
4635
- function formatComponentLatency(cl, verbose) {
4636
- if (!cl) return null;
4637
- const result = {
4638
- mean_stt_ms: cl.mean_stt_ms,
4639
- mean_llm_ms: cl.mean_llm_ms,
4640
- mean_tts_ms: cl.mean_tts_ms,
4641
- bottleneck: cl.bottleneck
4642
- };
4643
- if (!verbose) return result;
4644
- const speechDurations = cl.per_turn.map((t) => t.speech_duration_ms).filter((v) => v != null);
4645
- const meanSpeech = speechDurations.length > 0 ? Math.round(speechDurations.reduce((a, b) => a + b, 0) / speechDurations.length) : void 0;
4646
- result.p95_stt_ms = cl.p95_stt_ms;
4647
- result.p95_llm_ms = cl.p95_llm_ms;
4648
- result.p95_tts_ms = cl.p95_tts_ms;
4649
- result.mean_speech_duration_ms = meanSpeech;
4650
- return result;
4532
+ async function loadAccessToken() {
4533
+ const source = await readCredentials();
4534
+ if (!source) return null;
4535
+ if (source.refreshToken && !usableAccessToken(source.accessToken, 6e4)) return refresh(source);
4536
+ return usableAccessToken(source.accessToken) ? source.accessToken : null;
4651
4537
  }
4652
- function formatCallMetadata(meta, verbose) {
4653
- if (!meta) return null;
4654
- const transfers = meta.transfers?.map((transfer) => {
4655
- const formattedTransfer = {
4656
- type: transfer.type,
4657
- destination: transfer.destination,
4658
- status: transfer.status,
4659
- sources: transfer.sources
4660
- };
4661
- if (transfer.timestamp_ms != null) {
4662
- formattedTransfer.timestamp_ms = transfer.timestamp_ms;
4663
- }
4664
- return formattedTransfer;
4665
- });
4666
- const result = {
4667
- platform: meta.platform,
4668
- provider_call_id: meta.provider_call_id,
4669
- provider_session_id: meta.provider_session_id,
4670
- ended_reason: meta.ended_reason,
4671
- cost_usd: meta.cost_usd,
4672
- recording_url: meta.recording_url
4673
- };
4674
- if (verbose) {
4675
- result.cost_breakdown = meta.cost_breakdown;
4676
- result.usage = meta.usage && meta.usage.length > 0 ? meta.usage : void 0;
4677
- result.recording_variants = meta.recording_variants;
4678
- result.provider_debug_urls = meta.provider_debug_urls;
4679
- result.variables = meta.variables;
4680
- }
4681
- if (transfers && transfers.length > 0) {
4682
- const completedTransferCount = transfers.filter((transfer) => transfer.status === "completed").length;
4683
- const transferCompleted = completedTransferCount > 0;
4684
- result.transfer_attempted = true;
4685
- result.transfer_completed = transferCompleted;
4686
- result.escalated = transferCompleted;
4687
- result.transfer_count = transfers.length;
4688
- result.completed_transfer_count = completedTransferCount;
4689
- result.transfers = transfers;
4690
- }
4691
- return result;
4538
+ async function requireAccessToken() {
4539
+ const token = await loadAccessToken();
4540
+ if (!token) throw new Error("No Vent credentials found. Set VENT_REFRESH_TOKEN or run `npx vent-hq login`.");
4541
+ return token;
4692
4542
  }
4693
- function formatDebug(result) {
4694
- const debug = compactUnknownRecord({
4695
- signal_quality: result.metrics?.signal_quality,
4696
- harness_overhead: result.metrics?.harness_overhead,
4697
- provider_warnings: nonEmptyArray(result.call_metadata?.provider_warnings),
4698
- component_latency_per_turn: nonEmptyArray(result.metrics?.component_latency?.per_turn),
4699
- observed_tool_calls: formatDebugToolCalls(result.observed_tool_calls),
4700
- provider_metadata: result.call_metadata?.provider_metadata
4701
- });
4702
- return debug && Object.keys(debug).length > 0 ? debug : void 0;
4543
+ async function refreshAccessToken(rejectedToken) {
4544
+ const source = await readCredentials();
4545
+ if (!source?.refreshToken) return null;
4546
+ if (source.accessToken !== rejectedToken && usableAccessToken(source.accessToken, 6e4)) {
4547
+ return source.accessToken;
4548
+ }
4549
+ return refresh(source);
4703
4550
  }
4704
- function formatDebugToolCalls(observed) {
4705
- if (!observed || observed.length === 0) return void 0;
4706
- return observed.map((call) => ({
4707
- name: call.name,
4708
- arguments: call.arguments,
4709
- result: call.result,
4710
- successful: call.successful,
4711
- provider_tool_type: call.provider_tool_type,
4712
- timestamp_ms: call.timestamp_ms,
4713
- latency_ms: call.latency_ms,
4714
- turn_index: call.turn_index
4715
- }));
4551
+ async function saveCredentials(credentials) {
4552
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
4553
+ const temporary = `${CREDENTIALS_FILE}.${randomUUID()}.tmp`;
4554
+ try {
4555
+ await fs.writeFile(temporary, `${JSON.stringify(credentials)}
4556
+ `, { mode: 384, flag: "wx" });
4557
+ await fs.rename(temporary, CREDENTIALS_FILE);
4558
+ } finally {
4559
+ await fs.rm(temporary, { force: true });
4560
+ }
4716
4561
  }
4717
- function nonEmptyArray(value) {
4718
- return value && value.length > 0 ? value : void 0;
4562
+ async function deleteCredentials() {
4563
+ try {
4564
+ await fs.rm(CREDENTIALS_FILE);
4565
+ } catch {
4566
+ }
4719
4567
  }
4720
- function formatProviderWarningMessages(warnings) {
4721
- if (!warnings || warnings.length === 0) return [];
4722
- return warnings.map((warning) => warning.message ?? warning.code).filter((message) => typeof message === "string" && message.length > 0);
4568
+
4569
+ // src/lib/api.ts
4570
+ var ApiError = class extends Error {
4571
+ constructor(status, body) {
4572
+ super(`API error ${status}: ${typeof body === "string" ? body : JSON.stringify(body)}`);
4573
+ this.status = status;
4574
+ this.body = body;
4575
+ }
4576
+ };
4577
+ async function authenticatedFetch(path4, options = {}) {
4578
+ const url = `${API_BASE}${path4}`;
4579
+ const token = await requireAccessToken();
4580
+ const send = (accessToken) => {
4581
+ const headers = new Headers(options.headers);
4582
+ headers.set("Authorization", `Bearer ${accessToken}`);
4583
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
4584
+ return fetch(url, { ...options, headers });
4585
+ };
4586
+ const response = await send(token);
4587
+ if (response.status !== 401) return response;
4588
+ const refreshed = await refreshAccessToken(token);
4589
+ if (!refreshed) return response;
4590
+ await response.body?.cancel();
4591
+ return send(refreshed);
4723
4592
  }
4724
- function dedupeStrings(values) {
4725
- return [...new Set(values)];
4593
+ async function apiFetch(path4, options = {}) {
4594
+ const res = await authenticatedFetch(path4, options);
4595
+ if (!res.ok) {
4596
+ const text = await res.text();
4597
+ let body = text;
4598
+ try {
4599
+ body = JSON.parse(text);
4600
+ } catch {
4601
+ }
4602
+ throw new ApiError(res.status, body);
4603
+ }
4604
+ return res;
4726
4605
  }
4727
- function compactUnknownRecord(record) {
4728
- const entries = Object.entries(record).filter(([, value]) => value != null);
4729
- return entries.length > 0 ? Object.fromEntries(entries) : void 0;
4606
+
4607
+ // src/lib/submit-run.ts
4608
+ import { randomUUID as randomUUID2 } from "node:crypto";
4609
+ var RETRY_DELAY_MS = 2e3;
4610
+ async function submitRun(payload) {
4611
+ const body = JSON.stringify({ ...payload, idempotency_key: randomUUID2() });
4612
+ const send = () => apiFetch("/runs/submit", { method: "POST", body });
4613
+ try {
4614
+ return await send();
4615
+ } catch (err) {
4616
+ const answerMayBeLost = !(err instanceof ApiError) || [502, 503, 504].includes(err.status);
4617
+ if (!answerMayBeLost) throw err;
4618
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
4619
+ return send();
4620
+ }
4730
4621
  }
4731
4622
 
4732
- // ../shared/src/callback-auth.ts
4733
- import { createHmac, timingSafeEqual } from "node:crypto";
4623
+ // src/lib/sse.ts
4624
+ var MAX_RETRIES = 5;
4625
+ var RETRY_DELAY_MS2 = 2e3;
4626
+ async function* streamRunEvents(runId, signal) {
4627
+ const path4 = `/runs/${runId}/stream`;
4628
+ const seenIds = /* @__PURE__ */ new Set();
4629
+ let retries = 0;
4630
+ while (retries <= MAX_RETRIES) {
4631
+ if (retries > 0) {
4632
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS2));
4633
+ }
4634
+ let res;
4635
+ try {
4636
+ res = await authenticatedFetch(path4, { signal });
4637
+ } catch (err) {
4638
+ if (err.name === "AbortError") throw err;
4639
+ retries++;
4640
+ continue;
4641
+ }
4642
+ if (res.status >= 502 && res.status <= 504) {
4643
+ retries++;
4644
+ continue;
4645
+ }
4646
+ if (!res.ok) {
4647
+ const body = await res.text();
4648
+ throw new Error(`SSE stream failed (${res.status}): ${body}`);
4649
+ }
4650
+ if (!res.body) {
4651
+ throw new Error("SSE stream returned no body");
4652
+ }
4653
+ const reader = res.body.getReader();
4654
+ const decoder2 = new TextDecoder();
4655
+ let buffer = "";
4656
+ try {
4657
+ while (true) {
4658
+ let readResult;
4659
+ try {
4660
+ readResult = await reader.read();
4661
+ } catch (err) {
4662
+ if (err.name === "AbortError") throw err;
4663
+ break;
4664
+ }
4665
+ const { done, value } = readResult;
4666
+ if (done) {
4667
+ break;
4668
+ }
4669
+ const chunk = decoder2.decode(value, { stream: true });
4670
+ buffer += chunk;
4671
+ const lines = buffer.split("\n");
4672
+ buffer = lines.pop();
4673
+ for (const line of lines) {
4674
+ if (line.startsWith("data: ")) {
4675
+ const raw = line.slice(6);
4676
+ try {
4677
+ const event = JSON.parse(raw);
4678
+ if (event.id && seenIds.has(event.id)) {
4679
+ continue;
4680
+ }
4681
+ if (event.id) seenIds.add(event.id);
4682
+ yield event;
4683
+ if (event.event_type === "run_complete") {
4684
+ return;
4685
+ }
4686
+ } catch {
4687
+ }
4688
+ }
4689
+ }
4690
+ }
4691
+ } finally {
4692
+ reader.releaseLock();
4693
+ }
4694
+ retries++;
4695
+ }
4696
+ yield {
4697
+ event_type: "error",
4698
+ message: `Stream lost after ${MAX_RETRIES} reconnect attempts without receiving run_complete`
4699
+ };
4700
+ }
4734
4701
 
4735
4702
  // src/lib/output.ts
4703
+ import { writeFileSync } from "node:fs";
4736
4704
  var isTTY = process.stdout.isTTY;
4737
4705
  function stdoutSync(data) {
4738
4706
  if (isTTY) {
@@ -4752,29 +4720,13 @@ var red = (s) => isTTY ? `\x1B[31m${s}\x1B[0m` : s;
4752
4720
  var yellow = (s) => isTTY ? `\x1B[33m${s}\x1B[0m` : s;
4753
4721
  var blue = (s) => isTTY ? `\x1B[34m${s}\x1B[0m` : s;
4754
4722
  function printEvent(event) {
4755
- if (!isTTY) return;
4756
- const meta = event.metadata_json ?? {};
4757
- switch (event.event_type) {
4758
- case "call_completed":
4759
- printCallResult(meta);
4760
- break;
4761
- case "run_complete":
4762
- printRunComplete(meta);
4763
- break;
4764
- case "call_started": {
4765
- const name = meta.call_name ?? "call";
4766
- stdoutSync(dim(` \u25B8 ${name}\u2026`) + "\n");
4767
- break;
4768
- }
4769
- default:
4770
- stdoutSync(dim(` [${event.event_type}]`) + "\n");
4771
- }
4723
+ if (!isTTY || event.event_type === "run_complete") return;
4724
+ stdoutSync(dim(` [${event.event_type}]`) + "\n");
4772
4725
  }
4773
- function printCallResult(meta) {
4774
- const result = meta.result;
4775
- const callName = result?.name ?? meta.call_name ?? "call";
4776
- const callStatus = result?.status ?? meta.status;
4777
- const durationMs = result?.duration_ms ?? meta.duration_ms;
4726
+ function printCallResult(result) {
4727
+ const callName = result.name ?? "call";
4728
+ const callStatus = result.status;
4729
+ const durationMs = result.duration_ms;
4778
4730
  const statusIcon = callStatus === "completed" ? green("\u25CF") : red("\u25CF");
4779
4731
  const duration = durationMs != null ? (durationMs / 1e3).toFixed(1) + "s" : "\u2014";
4780
4732
  const parts = [statusIcon, bold(callName), dim(duration)];
@@ -4804,106 +4756,21 @@ function printCallResult(meta) {
4804
4756
  }
4805
4757
  }
4806
4758
  }
4807
- function printRunComplete(meta) {
4808
- const agg = meta.aggregate;
4809
- const counts = agg?.conversation_calls;
4810
- const total = meta.total_calls ?? counts?.total;
4811
- stdoutSync("\n");
4812
- stdoutSync(bold("Run complete") + dim(" \u2014 Vent does not judge mission success; review the calls above.") + "\n");
4813
- if (total != null) {
4814
- stdoutSync(`${total} call${total === 1 ? "" : "s"} ran
4815
- `);
4816
- }
4817
- }
4818
- function printSummary(callResults, runComplete, runId, options = {}) {
4819
- const allCalls = options.rawCalls ? formatRawCalls(options.rawCalls, options.verbose ?? false) : callResults.map((e) => {
4820
- const meta = e.metadata_json ?? {};
4821
- const r = meta.result;
4822
- if (r) return r;
4823
- return {
4824
- name: meta.call_name ?? "call",
4825
- status: meta.status ?? "unknown",
4826
- duration_ms: meta.duration_ms,
4827
- error: null
4828
- };
4829
- });
4830
- const agg = runComplete.aggregate;
4831
- const counts = agg?.conversation_calls;
4832
- const summaryData = buildRunSummaryJson({
4833
- runId,
4834
- total: runComplete.total_calls ?? counts?.total,
4835
- formattedCalls: allCalls,
4836
- verbose: options.verbose,
4837
- runDetails: options.runDetails ?? { aggregate: runComplete.aggregate }
4838
- });
4759
+ function printRunOutput(output) {
4839
4760
  if (!isTTY) {
4840
- stdoutSync(JSON.stringify(summaryData, null, 2) + "\n");
4761
+ stdoutSync(JSON.stringify(output, null, 2) + "\n");
4841
4762
  return;
4842
4763
  }
4843
- const errored = allCalls.filter((t) => t.status && t.status !== "completed");
4844
- if (errored.length > 0) {
4845
- stdoutSync("\n" + bold("Pipeline errors:") + "\n");
4846
- for (const t of errored) {
4847
- const ms = typeof t.duration_ms === "number" ? t.duration_ms : null;
4848
- const duration = ms != null ? (ms / 1e3).toFixed(1) + "s" : "\u2014";
4849
- const name = typeof t.name === "string" ? t.name : "call";
4850
- const parts = [red("\u25CF"), bold(name), dim(duration)];
4851
- stdoutSync(" " + parts.join(" ") + "\n");
4852
- }
4853
- }
4854
- stdoutSync(dim(`Run ID: ${runId}`) + "\n");
4855
- }
4856
- function buildRunSummaryJson(options) {
4857
- const calls = options.rawCalls ? formatRawCalls(options.rawCalls, options.verbose ?? false) : options.formattedCalls ?? [];
4858
- const summaryData = {
4859
- run_id: options.runId,
4860
- ...options.status != null ? { status: options.status } : {},
4861
- total: options.total,
4862
- ...options.passed != null ? { passed: options.passed } : {},
4863
- ...options.failed != null ? { failed: options.failed } : {},
4864
- calls
4865
- };
4866
- const details = options.runDetails;
4867
- if (details?.created_at != null) summaryData["created_at"] = details.created_at;
4868
- if (details?.started_at != null) summaryData["started_at"] = details.started_at;
4869
- if (details?.finished_at != null) summaryData["finished_at"] = details.finished_at;
4870
- if (details?.duration_ms != null) summaryData["duration_ms"] = details.duration_ms;
4871
- if (details?.error_text != null) summaryData["error_text"] = details.error_text;
4872
- if (details?.aggregate != null) {
4873
- summaryData["aggregate"] = stripPassFailFromAggregate(details.aggregate);
4874
- }
4875
- return summaryData;
4876
- }
4877
- function stripPassFailFromAggregate(aggregate) {
4878
- if (!aggregate || typeof aggregate !== "object" || Array.isArray(aggregate)) return aggregate;
4879
- const out = {};
4880
- for (const [k, v] of Object.entries(aggregate)) {
4881
- if (k === "conversation_calls" && v && typeof v === "object") {
4882
- const cc = v;
4883
- const filtered = {};
4884
- for (const [ck, cv] of Object.entries(cc)) {
4885
- if (ck === "passed" || ck === "failed") continue;
4886
- filtered[ck] = cv;
4887
- }
4888
- out[k] = filtered;
4889
- } else {
4890
- out[k] = v;
4891
- }
4764
+ const calls = output.calls ?? [];
4765
+ for (const call of calls) printCallResult(call);
4766
+ stdoutSync("\n");
4767
+ stdoutSync(bold("Run complete") + dim(" \u2014 Vent does not judge mission success; review the calls above.") + "\n");
4768
+ stdoutSync(`${calls.length} call${calls.length === 1 ? "" : "s"} ran
4769
+ `);
4770
+ if (typeof output.error_text === "string") {
4771
+ stdoutSync(red(`Run ${String(output.status ?? "ended")}: ${output.error_text}`) + "\n");
4892
4772
  }
4893
- return out;
4894
- }
4895
- function formatRawCalls(rawCalls, verbose) {
4896
- return rawCalls.map((raw) => {
4897
- const formatted = formatConversationResult(raw, { verbose });
4898
- if (formatted) return formatted;
4899
- const fallback = raw;
4900
- return {
4901
- name: typeof fallback["name"] === "string" ? fallback["name"] : "call",
4902
- status: typeof fallback["status"] === "string" ? fallback["status"] : "unknown",
4903
- duration_ms: typeof fallback["duration_ms"] === "number" ? fallback["duration_ms"] : void 0,
4904
- error: typeof fallback["error"] === "string" ? fallback["error"] : null
4905
- };
4906
- });
4773
+ stdoutSync(dim(`Run ID: ${String(output.run_id)}`) + "\n");
4907
4774
  }
4908
4775
  function printError(message) {
4909
4776
  const line = red(bold("error")) + ` ${message}
@@ -4940,35 +4807,25 @@ function gitSha() {
4940
4807
  return null;
4941
4808
  }
4942
4809
  }
4943
- async function saveRunHistory(runId, callResults, runCompleteData) {
4810
+ async function saveRunHistory(output) {
4944
4811
  try {
4812
+ const runId = output.run_id;
4945
4813
  const dir = path2.join(process.cwd(), ".vent", "runs");
4946
4814
  await fs2.mkdir(dir, { recursive: true });
4947
4815
  const now = /* @__PURE__ */ new Date();
4948
4816
  const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
4949
4817
  const shortId = runId.slice(0, 8);
4950
- const aggregate = runCompleteData.aggregate;
4951
- const convCalls = aggregate?.conversation_calls;
4952
- const total = convCalls?.total ?? 0;
4953
- const passed = convCalls?.passed ?? 0;
4954
- const failed = convCalls?.failed ?? 0;
4818
+ const calls = output.calls;
4955
4819
  const entry = {
4956
4820
  run_id: runId,
4957
4821
  timestamp: now.toISOString(),
4958
4822
  git_sha: gitSha(),
4959
4823
  summary: {
4960
- status: runCompleteData.status ?? "unknown",
4961
- calls_total: total,
4962
- calls_passed: passed,
4963
- calls_failed: failed,
4964
- total_duration_ms: aggregate?.total_duration_ms,
4965
- total_cost_usd: aggregate?.total_cost_usd
4824
+ status: output.status,
4825
+ calls_total: calls.length,
4826
+ total_duration_ms: calls.reduce((sum, c) => sum + (c.duration_ms ?? 0), 0)
4966
4827
  },
4967
- call_results: callResults.map((e) => {
4968
- const meta = e.metadata_json ?? {};
4969
- const result = meta.result ?? {};
4970
- return { ...result, status: meta.status };
4971
- })
4828
+ call_results: calls
4972
4829
  };
4973
4830
  const filename = `${timestamp}_${shortId}.json`;
4974
4831
  const filepath = path2.join(dir, filename);
@@ -4979,7 +4836,7 @@ async function saveRunHistory(runId, callResults, runCompleteData) {
4979
4836
  }
4980
4837
  }
4981
4838
 
4982
- // src/lib/platform-connections.ts
4839
+ // src/lib/platform-config.ts
4983
4840
  var PLATFORM_ENV_MAP = {
4984
4841
  vapi: { vapi_api_key: "VAPI_API_KEY", vapi_assistant_id: "VAPI_ASSISTANT_ID" },
4985
4842
  bland: { bland_api_key: "BLAND_API_KEY", bland_pathway_id: "BLAND_PATHWAY_ID", persona_id: "BLAND_PERSONA_ID" },
@@ -4993,61 +4850,21 @@ function isRemotePlatformAdapter(adapter) {
4993
4850
  function looksLikeEnvReference(value) {
4994
4851
  return typeof value === "string" && /^[A-Z][A-Z0-9_]+$/.test(value);
4995
4852
  }
4996
- function isMissingResolvedValue(value) {
4997
- return !value || looksLikeEnvReference(value);
4853
+ function describeField(adapter, field) {
4854
+ if (adapter === "vapi" && field === "vapi_assistant_id") return "VAPI_ASSISTANT_ID or VAPI_AGENT_ID";
4855
+ const envFields = PLATFORM_ENV_MAP[adapter];
4856
+ return envFields[field] ?? `platform.${field}`;
4998
4857
  }
4999
- function requireField(platform) {
5000
- switch (platform.provider) {
5001
- case "vapi":
5002
- if (isMissingResolvedValue(platform.vapi_api_key)) {
5003
- throw new Error("Missing VAPI_API_KEY in .env");
5004
- }
5005
- if (isMissingResolvedValue(platform.vapi_assistant_id)) {
5006
- throw new Error("Missing VAPI_ASSISTANT_ID or VAPI_AGENT_ID in .env");
5007
- }
5008
- return;
5009
- case "retell":
5010
- if (isMissingResolvedValue(platform.retell_api_key)) {
5011
- throw new Error("Missing RETELL_API_KEY in .env");
5012
- }
5013
- if (isMissingResolvedValue(platform.retell_agent_id)) {
5014
- throw new Error("Missing RETELL_AGENT_ID in .env");
5015
- }
5016
- return;
5017
- case "elevenlabs":
5018
- if (isMissingResolvedValue(platform.elevenlabs_api_key)) {
5019
- throw new Error("Missing ELEVENLABS_API_KEY in .env");
5020
- }
5021
- if (isMissingResolvedValue(platform.elevenlabs_agent_id)) {
5022
- throw new Error("Missing ELEVENLABS_AGENT_ID in .env");
5023
- }
5024
- return;
5025
- case "livekit":
5026
- if (isMissingResolvedValue(platform.livekit_api_key)) {
5027
- throw new Error("Missing LIVEKIT_API_KEY in .env");
5028
- }
5029
- if (isMissingResolvedValue(platform.livekit_api_secret)) {
5030
- throw new Error("Missing LIVEKIT_API_SECRET in .env");
5031
- }
5032
- if (isMissingResolvedValue(platform.livekit_url)) {
5033
- throw new Error("Missing LIVEKIT_URL in .env");
5034
- }
5035
- return;
5036
- case "bland":
5037
- if (isMissingResolvedValue(platform.bland_api_key)) {
5038
- throw new Error("Missing BLAND_API_KEY in .env");
5039
- }
5040
- if (isMissingResolvedValue(platform.bland_pathway_id) && isMissingResolvedValue(platform.task) && isMissingResolvedValue(platform.persona_id)) {
5041
- throw new Error("Missing BLAND_PATHWAY_ID, BLAND_PERSONA_ID, or platform.task in .env");
5042
- }
5043
- return;
5044
- }
4858
+ function requireFields(adapter, platform) {
4859
+ const missing = missingPlatformFields(platform);
4860
+ if (missing.length === 0) return;
4861
+ const list = missing.map((anyOf) => anyOf.map((field) => describeField(adapter, field)).join(" or ")).join(", ");
4862
+ throw new Error(`Missing ${list} (set it in .env, .env.local or the environment)`);
5045
4863
  }
5046
4864
  function resolveRemotePlatformConfig(config) {
5047
4865
  const cfg = config;
5048
4866
  const adapter = cfg.connection?.adapter;
5049
4867
  if (!isRemotePlatformAdapter(adapter)) return null;
5050
- if (cfg.connection?.platform_connection_id) return null;
5051
4868
  const current = cfg.connection?.platform ?? {};
5052
4869
  const provider = current.provider ?? adapter;
5053
4870
  if (provider !== adapter) {
@@ -5060,12 +4877,8 @@ function resolveRemotePlatformConfig(config) {
5060
4877
  const envFields = PLATFORM_ENV_MAP[adapter];
5061
4878
  for (const [field, envVar] of Object.entries(envFields)) {
5062
4879
  const currentValue = resolved[field];
5063
- const needsResolve = !currentValue || currentValue === envVar || looksLikeEnvReference(currentValue);
5064
- if (needsResolve) {
5065
- const envValue = process.env[envVar];
5066
- if (envValue) {
5067
- resolved[field] = envValue;
5068
- }
4880
+ if (!currentValue || looksLikeEnvReference(currentValue)) {
4881
+ resolved[field] = process.env[envVar] || void 0;
5069
4882
  }
5070
4883
  }
5071
4884
  if (adapter === "vapi" && !resolved.vapi_assistant_id) {
@@ -5073,18 +4886,13 @@ function resolveRemotePlatformConfig(config) {
5073
4886
  if (alt) resolved.vapi_assistant_id = alt;
5074
4887
  }
5075
4888
  const platform = PlatformConfigSchema.parse(resolved);
5076
- requireField(platform);
4889
+ requireFields(adapter, platform);
5077
4890
  return platform;
5078
4891
  }
5079
4892
 
5080
4893
  // src/commands/run.ts
5081
4894
  var isTTY2 = process.stdout.isTTY;
5082
4895
  async function runCommand(args) {
5083
- const accessToken = await loadAccessToken();
5084
- if (!accessToken) {
5085
- printError("No Vent access token found. Set VENT_ACCESS_TOKEN, run `npx vent-hq login`, or pass --access-token.");
5086
- return 2;
5087
- }
5088
4896
  let config;
5089
4897
  try {
5090
4898
  const raw = await fs3.readFile(args.file, "utf-8");
@@ -5121,9 +4929,11 @@ async function runCommand(args) {
5121
4929
  printError(`Invalid suite JSON: ${err.message}`);
5122
4930
  return 2;
5123
4931
  }
5124
- let resolvedRemotePlatform = null;
5125
4932
  try {
5126
- resolvedRemotePlatform = resolveRemotePlatformConfig(config);
4933
+ const platform = resolveRemotePlatformConfig(config);
4934
+ if (platform) {
4935
+ config.connection.platform = platform;
4936
+ }
5127
4937
  } catch (err) {
5128
4938
  printError(err.message);
5129
4939
  return 2;
@@ -5138,31 +4948,9 @@ async function runCommand(args) {
5138
4948
  }
5139
4949
  printInfo("Submitting run\u2026");
5140
4950
  let submitResult;
5141
- let activeAccessToken = accessToken;
5142
- const configConnection = config;
5143
- async function prepareConfigForSubmit(currentAccessToken) {
5144
- if (!configConnection.connection) return;
5145
- if (resolvedRemotePlatform) {
5146
- const ensured = await ensurePlatformConnection(currentAccessToken, resolvedRemotePlatform);
5147
- configConnection.connection.platform_connection_id = ensured.platform_connection_id;
5148
- }
5149
- if ("platform" in configConnection.connection) {
5150
- delete configConnection.connection.platform;
5151
- }
5152
- }
5153
- async function submitPrepared(currentAccessToken) {
5154
- await prepareConfigForSubmit(currentAccessToken);
5155
- const res = await apiFetch("/runs/submit", currentAccessToken, {
5156
- method: "POST",
5157
- body: JSON.stringify({
5158
- config,
5159
- agent_session_id: args.session
5160
- })
5161
- });
5162
- return res.json();
5163
- }
5164
4951
  try {
5165
- submitResult = await submitPrepared(activeAccessToken);
4952
+ const res = await submitRun({ config, agent_session_id: args.session });
4953
+ submitResult = await res.json();
5166
4954
  } catch (err) {
5167
4955
  if (err instanceof ApiError && err.status === 401) {
5168
4956
  printError("Access token rejected. Run `npx vent-hq login` to re-authenticate.");
@@ -5180,14 +4968,13 @@ async function runCommand(args) {
5180
4968
  printInfo(`Streaming results for run ${run_id}\u2026`);
5181
4969
  const abortController = new AbortController();
5182
4970
  let exitCode = 0;
5183
- const callResults = [];
5184
- let runCompleteData = null;
4971
+ let runOutput = null;
5185
4972
  let stopRequested = false;
5186
4973
  let stopPromise = null;
5187
4974
  const onSignal = () => {
5188
4975
  if (!stopRequested) {
5189
4976
  stopRequested = true;
5190
- stopPromise = apiFetch(`/runs/${run_id}/stop`, activeAccessToken, {
4977
+ stopPromise = apiFetch(`/runs/${run_id}/stop`, {
5191
4978
  method: "POST",
5192
4979
  signal: AbortSignal.timeout(5e3)
5193
4980
  }).catch(() => {
@@ -5198,20 +4985,10 @@ async function runCommand(args) {
5198
4985
  process.on("SIGINT", onSignal);
5199
4986
  process.on("SIGTERM", onSignal);
5200
4987
  try {
5201
- for await (const event of streamRunEvents(run_id, activeAccessToken, abortController.signal)) {
5202
- const meta = event.metadata_json ?? {};
4988
+ for await (const event of streamRunEvents(run_id, abortController.signal)) {
5203
4989
  printEvent(event);
5204
- if (event.event_type === "call_completed") {
5205
- callResults.push(event);
5206
- }
5207
4990
  if (event.event_type === "run_complete") {
5208
- runCompleteData = meta;
5209
- const anyCallErrored = callResults.some((e) => {
5210
- const callMeta = e.metadata_json ?? {};
5211
- const callStatus = callMeta["status"];
5212
- return callStatus === "error";
5213
- });
5214
- exitCode = anyCallErrored ? 1 : 0;
4991
+ runOutput = event.result ?? null;
5215
4992
  }
5216
4993
  if (event.event_type === "error") {
5217
4994
  printError(event.message ?? "Stream connection lost");
@@ -5227,34 +5004,22 @@ async function runCommand(args) {
5227
5004
  process.off("SIGINT", onSignal);
5228
5005
  process.off("SIGTERM", onSignal);
5229
5006
  }
5230
- if (runCompleteData) {
5231
- let rawRunDetails = null;
5232
- if (args.verbose && !isTTY2) {
5233
- try {
5234
- const res = await apiFetch(`/runs/${run_id}`, activeAccessToken);
5235
- rawRunDetails = await res.json();
5236
- } catch {
5237
- printWarn("Verbose result fetch failed; falling back to streamed summary.");
5238
- }
5007
+ if (!runOutput && !stopRequested) {
5008
+ try {
5009
+ const res = await apiFetch(`/runs/${run_id}`);
5010
+ const stored = await res.json();
5011
+ if (["pass", "fail", "cancelled"].includes(String(stored.status))) runOutput = stored;
5012
+ } catch {
5013
+ printWarn("Run result fetch failed.");
5239
5014
  }
5240
- printSummary(callResults, runCompleteData, run_id, {
5241
- verbose: args.verbose,
5242
- rawCalls: Array.isArray(rawRunDetails?.["results"]) ? rawRunDetails["results"] : void 0,
5243
- runDetails: rawRunDetails ? {
5244
- created_at: rawRunDetails["created_at"],
5245
- started_at: rawRunDetails["started_at"],
5246
- finished_at: rawRunDetails["finished_at"],
5247
- duration_ms: rawRunDetails["duration_ms"],
5248
- error_text: rawRunDetails["error_text"],
5249
- aggregate: rawRunDetails["aggregate"]
5250
- } : void 0
5251
- });
5252
5015
  }
5253
- if (runCompleteData) {
5254
- const savedPath = await saveRunHistory(run_id, callResults, runCompleteData);
5255
- if (savedPath) {
5256
- printInfo(`Run saved to ${savedPath}`);
5257
- }
5016
+ if (runOutput) {
5017
+ exitCode = runOutput.status === "pass" ? 0 : 1;
5018
+ printRunOutput(runOutput);
5019
+ const savedPath = await saveRunHistory(runOutput);
5020
+ if (savedPath) printInfo(`Run saved to ${savedPath}`);
5021
+ } else if (exitCode === 0) {
5022
+ exitCode = 2;
5258
5023
  }
5259
5024
  if (stopPromise) {
5260
5025
  await stopPromise;
@@ -5269,21 +5034,20 @@ import * as net from "node:net";
5269
5034
  // src/lib/relay.ts
5270
5035
  import { spawn } from "node:child_process";
5271
5036
 
5272
- // ../relay-client/src/client.ts
5037
+ // src/lib/relay-client.ts
5038
+ var CONNECT_TIMEOUT_MS = 3e4;
5039
+ function errorEventMessage(ev) {
5040
+ return ev.message;
5041
+ }
5273
5042
  var RelayClient = class {
5274
5043
  controlWs = null;
5275
5044
  localConnections = /* @__PURE__ */ new Map();
5276
5045
  config;
5277
5046
  closed = false;
5278
5047
  handlers = /* @__PURE__ */ new Map();
5279
- _agentEnv = {};
5280
5048
  constructor(config) {
5281
5049
  this.config = config;
5282
5050
  }
5283
- /** Env vars received from Vent server to inject into the agent process. */
5284
- get agentEnv() {
5285
- return this._agentEnv;
5286
- }
5287
5051
  on(event, handler) {
5288
5052
  const list = this.handlers.get(event) ?? [];
5289
5053
  list.push(handler);
@@ -5294,17 +5058,12 @@ var RelayClient = class {
5294
5058
  handler(...args);
5295
5059
  }
5296
5060
  }
5297
- async connect(timeoutMs = 3e4) {
5298
- if (!this.config.sessionId && !this.config.runId) {
5299
- throw new Error("RelayClient.connect() requires either sessionId or runId");
5300
- }
5061
+ async connect() {
5301
5062
  const wsBase = this.config.apiUrl.replace(/^http/, "ws");
5302
- const query = this.config.sessionId ? `session_id=${this.config.sessionId}` : `run_id=${this.config.runId}`;
5303
- const controlUrl = `${wsBase}/relay/control?${query}&token=${this.config.relayToken}`;
5304
- return new Promise((resolve2, reject) => {
5063
+ const controlUrl = `${wsBase}/relay/control?session_id=${this.config.sessionId}&token=${this.config.relayToken}`;
5064
+ return new Promise((resolve, reject) => {
5305
5065
  const ws = new WebSocket(controlUrl);
5306
5066
  ws.binaryType = "arraybuffer";
5307
- let configReceived = false;
5308
5067
  let settled = false;
5309
5068
  const settle = (fn) => {
5310
5069
  if (!settled) {
@@ -5316,48 +5075,28 @@ var RelayClient = class {
5316
5075
  const hardTimeout = setTimeout(() => {
5317
5076
  settle(() => {
5318
5077
  ws.close();
5319
- reject(new Error(`Relay connection timed out after ${timeoutMs}ms \u2014 check network connectivity to ${this.config.apiUrl}`));
5078
+ reject(new Error(`Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms \u2014 check network connectivity to ${this.config.apiUrl}`));
5320
5079
  });
5321
- }, timeoutMs);
5080
+ }, CONNECT_TIMEOUT_MS);
5322
5081
  ws.addEventListener("open", () => {
5323
5082
  this.controlWs = ws;
5324
5083
  this.setupControlHandlers(ws);
5325
- this.emit("connected");
5326
5084
  });
5327
- this.on("config_received", () => {
5328
- configReceived = true;
5329
- settle(() => resolve2());
5085
+ this.on("ready", () => settle(() => resolve()));
5086
+ ws.addEventListener("close", (ev) => {
5087
+ settle(() => reject(new Error(`Relay rejected the connection (${ev.code}${ev.reason ? `: ${ev.reason}` : ""})`)));
5330
5088
  });
5331
- setTimeout(() => {
5332
- if (!configReceived && this.controlWs) settle(() => resolve2());
5333
- }, 3e3);
5334
5089
  ws.addEventListener("error", (ev) => {
5335
5090
  if (!this.controlWs) {
5336
- settle(() => reject(new Error(`Failed to connect to relay: ${ev.message ?? "connection error"}`)));
5091
+ settle(() => reject(new Error(`Failed to connect to relay: ${errorEventMessage(ev) ?? "connection error"}`)));
5337
5092
  }
5338
5093
  });
5339
5094
  });
5340
5095
  }
5341
- async activate(timeoutMs = 15e3) {
5342
- if (!this.config.runId) {
5343
- throw new Error("activate() requires runId");
5344
- }
5345
- const activateUrl = `${this.config.apiUrl}/internal/runs/${this.config.runId}/activate`;
5346
- const response = await fetch(activateUrl, {
5347
- method: "POST",
5348
- headers: { "Content-Type": "application/json" },
5349
- body: JSON.stringify({ relay_token: this.config.relayToken }),
5350
- signal: AbortSignal.timeout(timeoutMs)
5351
- });
5352
- if (!response.ok) {
5353
- const text = await response.text();
5354
- throw new Error(`Activation failed (${response.status}): ${text}`);
5355
- }
5356
- }
5357
5096
  async disconnect() {
5358
5097
  this.closed = true;
5359
- for (const [connId, conn] of this.localConnections) {
5360
- if (conn.local.readyState !== WebSocket.CLOSED) conn.local.close();
5098
+ for (const [connId, local] of this.localConnections) {
5099
+ if (local.readyState !== WebSocket.CLOSED) local.close();
5361
5100
  this.localConnections.delete(connId);
5362
5101
  }
5363
5102
  if (this.controlWs) {
@@ -5390,12 +5129,12 @@ var RelayClient = class {
5390
5129
  if (frameType !== 1 && frameType !== 2) return;
5391
5130
  const connId = new TextDecoder().decode(data.subarray(1, 37));
5392
5131
  const payload = data.subarray(37);
5393
- const conn = this.localConnections.get(connId);
5394
- if (conn?.local.readyState === WebSocket.OPEN) {
5132
+ const local = this.localConnections.get(connId);
5133
+ if (local?.readyState === WebSocket.OPEN) {
5395
5134
  if (frameType === 2) {
5396
- conn.local.send(new TextDecoder().decode(payload));
5135
+ local.send(new TextDecoder().decode(payload));
5397
5136
  } else {
5398
- conn.local.send(payload);
5137
+ local.send(payload);
5399
5138
  }
5400
5139
  }
5401
5140
  return;
@@ -5403,17 +5142,14 @@ var RelayClient = class {
5403
5142
  try {
5404
5143
  const raw = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
5405
5144
  const msg = JSON.parse(raw);
5406
- if (msg.type === "config" && msg.env) {
5407
- this._agentEnv = msg.env;
5408
- this.emit("config_received");
5145
+ if (msg.type === "config") {
5146
+ this.emit("ready");
5409
5147
  } else if (msg.type === "new_connection" && msg.conn_id) {
5410
5148
  this.handleNewConnection(msg.conn_id);
5411
5149
  } else if (msg.type === "close" && msg.conn_id) {
5412
- const conn = this.localConnections.get(msg.conn_id);
5413
- if (conn?.local.readyState !== WebSocket.CLOSED) conn?.local.close();
5150
+ const local = this.localConnections.get(msg.conn_id);
5151
+ if (local && local.readyState !== WebSocket.CLOSED) local.close();
5414
5152
  this.localConnections.delete(msg.conn_id);
5415
- } else if (msg.type === "run_complete") {
5416
- this.emit("run_complete");
5417
5153
  }
5418
5154
  } catch {
5419
5155
  }
@@ -5429,62 +5165,48 @@ var RelayClient = class {
5429
5165
  }
5430
5166
  });
5431
5167
  ws.addEventListener("error", (ev) => {
5432
- this.emit("error", new Error(ev.message ?? "WebSocket error"));
5168
+ this.emit("error", new Error(errorEventMessage(ev) ?? "WebSocket error"));
5433
5169
  });
5434
5170
  }
5435
5171
  handleNewConnection(connId) {
5436
- const agentUrl = `ws://localhost:${this.config.agentPort}`;
5437
- this.emit("log", `[relay] new_connection ${connId} \u2192 connecting to ${agentUrl}`);
5438
- try {
5439
- const localWs = new WebSocket(agentUrl);
5440
- localWs.binaryType = "arraybuffer";
5441
- localWs.addEventListener("open", () => {
5442
- this.emit("log", `[relay] local WS open for ${connId}`);
5443
- this.sendControlMessage({ type: "open_ack", conn_id: connId });
5444
- this.localConnections.set(connId, { local: localWs, connId });
5445
- });
5446
- localWs.addEventListener("message", (event) => {
5447
- if (event.data instanceof ArrayBuffer) {
5448
- this.sendDataFrame(connId, new Uint8Array(event.data), 1);
5449
- } else {
5450
- this.sendDataFrame(connId, new TextEncoder().encode(event.data), 2);
5451
- }
5452
- });
5453
- let cleaned = false;
5454
- const cleanup = (reason) => {
5455
- if (cleaned) return;
5456
- cleaned = true;
5457
- this.emit("log", `[relay] local WS cleanup for ${connId}: ${reason ?? "unknown"}`);
5458
- if (localWs.readyState !== WebSocket.CLOSED) localWs.close();
5459
- this.localConnections.delete(connId);
5460
- this.sendControlMessage({ type: "close", conn_id: connId });
5461
- };
5462
- localWs.addEventListener("close", () => cleanup("close"));
5463
- localWs.addEventListener("error", (ev) => {
5464
- const msg = ev.message ?? "unknown error";
5465
- this.emit("log", `[relay] local WS error for ${connId}: ${msg}`);
5466
- cleanup(`error: ${msg}`);
5467
- });
5468
- } catch (err) {
5469
- this.emit("log", `[relay] Failed to connect local agent for ${connId}: ${err}`);
5470
- }
5172
+ const localWs = new WebSocket(`ws://localhost:${this.config.agentPort}`);
5173
+ localWs.binaryType = "arraybuffer";
5174
+ localWs.addEventListener("open", () => {
5175
+ this.sendControlMessage({ type: "open_ack", conn_id: connId });
5176
+ this.localConnections.set(connId, localWs);
5177
+ });
5178
+ localWs.addEventListener("message", (event) => {
5179
+ if (event.data instanceof ArrayBuffer) {
5180
+ this.sendDataFrame(connId, new Uint8Array(event.data), 1);
5181
+ } else {
5182
+ this.sendDataFrame(connId, new TextEncoder().encode(event.data), 2);
5183
+ }
5184
+ });
5185
+ let cleaned = false;
5186
+ const cleanup = () => {
5187
+ if (cleaned) return;
5188
+ cleaned = true;
5189
+ if (localWs.readyState !== WebSocket.CLOSED) localWs.close();
5190
+ this.localConnections.delete(connId);
5191
+ this.sendControlMessage({ type: "close", conn_id: connId });
5192
+ };
5193
+ localWs.addEventListener("close", cleanup);
5194
+ localWs.addEventListener("error", cleanup);
5471
5195
  }
5472
5196
  };
5473
5197
 
5474
5198
  // src/lib/relay.ts
5475
5199
  async function startAgentSession(relayConfig) {
5476
- const clientConfig = {
5200
+ const client = new RelayClient({
5477
5201
  apiUrl: relayConfig.api_url,
5478
5202
  sessionId: relayConfig.session_id,
5479
5203
  relayToken: relayConfig.relay_token,
5480
- agentPort: relayConfig.agent_port,
5481
- healthEndpoint: relayConfig.health_endpoint
5482
- };
5483
- const client = new RelayClient(clientConfig);
5204
+ agentPort: relayConfig.agent_port
5205
+ });
5484
5206
  await client.connect();
5485
5207
  let agentProcess = null;
5486
5208
  if (relayConfig.start_command) {
5487
- const env = { ...process.env, ...client.agentEnv, PORT: String(relayConfig.agent_port) };
5209
+ const env = { ...process.env, PORT: String(relayConfig.agent_port) };
5488
5210
  agentProcess = spawn(relayConfig.start_command, {
5489
5211
  shell: true,
5490
5212
  stdio: "pipe",
@@ -5496,8 +5218,6 @@ async function startAgentSession(relayConfig) {
5496
5218
  process.stdout.write(`Agent process error: ${err.message}
5497
5219
  `);
5498
5220
  });
5499
- }
5500
- if (relayConfig.start_command) {
5501
5221
  await waitForHealth(relayConfig.agent_port, relayConfig.health_endpoint);
5502
5222
  }
5503
5223
  const cleanup = async () => {
@@ -5524,11 +5244,6 @@ async function waitForHealth(port, endpoint, timeoutMs = 3e4) {
5524
5244
 
5525
5245
  // src/commands/agent.ts
5526
5246
  async function agentStartCommand(args) {
5527
- const accessToken = await loadAccessToken();
5528
- if (!accessToken) {
5529
- printError("No Vent access token found. Set VENT_ACCESS_TOKEN, run `npx vent-hq login`, or pass --access-token.");
5530
- return 2;
5531
- }
5532
5247
  let parsedConfig;
5533
5248
  try {
5534
5249
  if (args.file) {
@@ -5557,7 +5272,7 @@ async function agentStartCommand(args) {
5557
5272
  const closeSession = async () => {
5558
5273
  if (!session) return;
5559
5274
  try {
5560
- await apiFetch(`/agent-sessions/${session.session_id}/close`, accessToken, {
5275
+ await apiFetch(`/agent-sessions/${session.session_id}/close`, {
5561
5276
  method: "POST"
5562
5277
  });
5563
5278
  } catch {
@@ -5565,11 +5280,11 @@ async function agentStartCommand(args) {
5565
5280
  };
5566
5281
  try {
5567
5282
  printInfo("Creating agent session\u2026");
5568
- const createRes = await apiFetch("/agent-sessions", accessToken, {
5283
+ const createRes = await apiFetch("/agent-sessions", {
5569
5284
  method: "POST",
5570
5285
  body: JSON.stringify({ config: connection })
5571
5286
  });
5572
- session = await createRes.json();
5287
+ session = AgentSessionResponseSchema.parse(await createRes.json());
5573
5288
  printInfo("Connecting relay for local agent\u2026");
5574
5289
  relay = await startAgentSession(session);
5575
5290
  const sessionData = {
@@ -5595,13 +5310,8 @@ async function agentStartCommand(args) {
5595
5310
  }
5596
5311
  }
5597
5312
  async function agentStopCommand(args) {
5598
- const accessToken = await loadAccessToken();
5599
- if (!accessToken) {
5600
- printError("No Vent access token found. Set VENT_ACCESS_TOKEN, run `npx vent-hq login`, or pass --access-token.");
5601
- return 2;
5602
- }
5603
5313
  try {
5604
- await apiFetch(`/agent-sessions/${args.sessionId}/close`, accessToken, {
5314
+ await apiFetch(`/agent-sessions/${args.sessionId}/close`, {
5605
5315
  method: "POST"
5606
5316
  });
5607
5317
  printSuccess(`Agent session closed: ${args.sessionId}`, { force: true });
@@ -5612,7 +5322,7 @@ async function agentStopCommand(args) {
5612
5322
  }
5613
5323
  }
5614
5324
  async function waitForSessionExit(relay) {
5615
- return new Promise((resolve2) => {
5325
+ return new Promise((resolve) => {
5616
5326
  let settled = false;
5617
5327
  const agentProcess = relay.agentProcess;
5618
5328
  const finish = (code) => {
@@ -5623,7 +5333,7 @@ async function waitForSessionExit(relay) {
5623
5333
  if (agentProcess) {
5624
5334
  agentProcess.off("exit", handleAgentExit);
5625
5335
  }
5626
- resolve2(code);
5336
+ resolve(code);
5627
5337
  };
5628
5338
  const handleSignal = () => finish(0);
5629
5339
  const handleAgentExit = (code, signal) => {
@@ -5652,12 +5362,12 @@ async function waitForSessionExit(relay) {
5652
5362
  });
5653
5363
  }
5654
5364
  function findFreePort() {
5655
- return new Promise((resolve2, reject) => {
5365
+ return new Promise((resolve, reject) => {
5656
5366
  const server = net.createServer();
5657
5367
  server.listen(0, () => {
5658
5368
  const addr = server.address();
5659
5369
  const port = addr.port;
5660
- server.close(() => resolve2(port));
5370
+ server.close(() => resolve(port));
5661
5371
  });
5662
5372
  server.on("error", reject);
5663
5373
  });
@@ -5706,10 +5416,11 @@ async function deviceAuthFlow() {
5706
5416
  });
5707
5417
  if (!res.ok) continue;
5708
5418
  const data = await res.json();
5709
- const accessToken = data.access_token;
5710
- if (data.status === "approved" && accessToken) {
5711
- await saveAccessToken(accessToken);
5712
- return { ok: true, accessToken };
5419
+ if (data.status === "approved") {
5420
+ const parsed = AccessCredentialsSchema.safeParse(data);
5421
+ if (!parsed.success) return { ok: false, error: "Vent returned invalid sign-in credentials." };
5422
+ await saveCredentials(parsed.data);
5423
+ return { ok: true };
5713
5424
  }
5714
5425
  if (data.status === "expired") {
5715
5426
  return { ok: false, error: "Session expired. Run `npx vent-hq login` again." };
@@ -5728,7 +5439,7 @@ async function loginCommand(args) {
5728
5439
  if (args.status) {
5729
5440
  const token = await loadAccessToken();
5730
5441
  if (token) {
5731
- printSuccess(`Logged in (${token.slice(0, 12)}...)`, { force: true });
5442
+ printSuccess("Logged in.", { force: true });
5732
5443
  return 0;
5733
5444
  }
5734
5445
  printInfo("Not logged in. Run `npx vent-hq login`.", { force: true });
@@ -5736,9 +5447,10 @@ async function loginCommand(args) {
5736
5447
  }
5737
5448
  const result = await deviceAuthFlow();
5738
5449
  if (result.ok) {
5739
- printSuccess("Logged in! Vent access token saved to ~/.vent/credentials", { force: true });
5450
+ printSuccess("Logged in! Vent credentials saved to ~/.vent/credentials", { force: true });
5740
5451
  return 0;
5741
5452
  }
5453
+ printError(result.error);
5742
5454
  return 1;
5743
5455
  }
5744
5456
 
@@ -5771,17 +5483,47 @@ function detectGitHubToken() {
5771
5483
  import * as fs5 from "node:fs/promises";
5772
5484
  import * as path3 from "node:path";
5773
5485
 
5486
+ // src/skills/body.md
5487
+ var body_default = '# Vent - Voice Agent Calls\n\n## When to Test\n\nVent is the voice CI gate. Auto-trigger a call after any non-trivial voice-affecting change \u2014 don\'t wait to be asked. Non-trivial means anything that affects what the caller hears, how the agent responds, what tools are called, how the call routes, or audio timing: prompt rewrites or sections (not one-word tweaks), tool schema or handler changes, state/memory logic, transfer logic, provider/audio config, platform integration, and any voice-behavior bug fix. One-line copy or comment edits don\'t need a call.\n\nBefore launching, scan `.vent/runs/` for unresolved issues from prior runs (failing status, regressions, warnings, latency jumps); fix in-scope ones as part of this change and verify they\'re resolved by the new run, surface out-of-scope ones rather than silently leaving them. The task is not done until you\'ve reviewed the transcript, tool calls, and observed behavior and judged that the change works \u2014 Vent\'s `status` and exit code only reflect pipeline completion, not mission success.\n\n## Autonomous Iteration\n\n**Default: iterate.** Run a fix \u2192 Vent-call \u2192 fix \u2192 Vent-call loop whenever a call surfaces a fixable issue. Don\'t report a failure (or a degraded-quality success) back to the user before attempting to fix it \u2014 they want the agent working, not a diagnosis. After each call, if the result reveals a problem you can fix with a concrete plan, apply the fix and run again. Fixable issues include: tool schema or registry gaps, missing handlers, prompt phrasing, **STT vocabulary mismatches** (e.g. domain terms misheard \u2014 Deepgram `keyterms`, custom vocabulary, prompt-level term lists), **latency tunables** (endpointing thresholds, preemptive generation, model selection), and audio quality config. If you\'d be guessing at the next attempt, stop and ask the user instead of trying.\n\n**"Mission success but with quality problems" still requires iteration.** If the call ran end-to-end but had noticeably bad latency, STT mishearing the agent\'s domain vocabulary, audio artifacts, or any other caller-experience degradation that has a concrete fix, the call is *not done* \u2014 apply the fix and re-run. Don\'t downgrade fixable problems to "observations" or "things worth flagging." The pipeline running cleanly is the floor, not the ceiling.\n\n**Never ask permission to iterate.** "Want me to apply this and re-run?" / "Should I fix it?" / "Let me know if you want me to..." \u2014 these are skill violations. If you have a concrete fix, apply it and run the next call. The user opts out by saying so explicitly; silence is not opt-out.\n\nStop iterating and report when one of:\n\n- The next call confirms the fix worked AND no further fixable issues remain (success \u2014 done).\n- The same fix fails twice (your hypothesis is wrong; ask the user).\n- The failure mode shifts between attempts (you\'re chasing a moving target).\n- You can\'t justify the next call against its provider cost \u2014 each call spends real money and provider quota.\n\nIf the user explicitly opts out ("just report", "don\'t iterate", "stop after the first call", "I\'ll fix it myself"), respect that \u2014 run once, report, stop. Otherwise iterate by default. The first time you start an autonomous loop in a session, mention it once so the user knows it\'s happening (e.g. "the call surfaced X \u2014 fixing and re-running; I\'ll report back when it converges or stops making progress").\n\n{{EXECUTION}}\n\n## Workflow\n\n1. Identify the behavior under test. Read enough of the agent codebase to understand its system prompt, tools, handlers, routes, provider config, platform wiring, and expected handoffs.\n2. Reuse an existing `.vent/suite.<adapter>.json` when possible. If `.vent/` contains multiple suites, inspect `connection.adapter` and report which suite file produced the result.\n3. Create or update a suite only when the existing calls do not cover the changed behavior. Name calls after real flows, for example `reschedule-appointment`, not `call-1`.\n4. If the suite uses `start_command`, start one shared local session first with `npx vent-hq agent start -f .vent/suite.<adapter>.json`, then pass `--session <session-id>` to each run.\n\n **For locally-run LiveKit agents: every run requires killing *all* workers, starting one fresh worker, and waiting a full 60 seconds before submitting.** Unconditional \u2014 LiveKit Cloud round-robins across registered workers, so a single survivor with a dead inference subprocess fails ~N-1 of N calls. Don\'t rely on `pkill -f <path-pattern>`; bare command lines like `node --import tsx agent.ts dev` won\'t match a path filter. Use `ps aux | grep -E "node.*agent\\.ts|@livekit/agents.*ipc"`, `kill -9` by PID, re-run `ps` to confirm zero survivors, then start the fresh worker. Skipping the 60s wait fails with `did not publish audio track`; if that error appears alongside `Error [ERR_IPC_CHANNEL_CLOSED] from InferenceProcExecutor.doInference` in the agent log right after a "running EOU detection" line, that\'s a straggler \u2014 redo the kill sweep. Hosted LiveKit Cloud agents don\'t need any of this; run normally.{{LIVEKIT_NOTE}}\n5. Pick which call(s) to run based on the change. Fixed bug: replay the failing scenario. Changed tool: include a call that triggers that tool. Prompt or routing change: include the relevant happy path and any important edge path.\n6. Compare against the previous JSON in `.vent/runs/` when validating a fix or regression. Check status flips, latency jumps, tool-call success drops, cost jumps, and transcript divergence. Correlate with `git diff` between saved `git_sha` values when available; skip if no previous run exists.\n\n## Commands\n\n```bash\nnpx vent-hq init # First-time setup (auth + skill install + starter suite)\nnpx vent-hq login # Log in to existing account\nnpx vent-hq login --status # Print whether credentials are present\nnpx vent-hq logout # Remove saved credentials from ~/.vent/credentials\nnpx vent-hq run -f .vent/suite.X.json # Run a single-call suite\nnpx vent-hq run -f .vent/suite.X.json --call NAME # Run one named call from a multi-call suite\nnpx vent-hq run ... --session <session-id> # Add to any run; routes through an existing local relay session\nnpx vent-hq stop <run-id> # Cancel a queued or running run\nnpx vent-hq agent start -f .vent/suite.X.json # Start a shared local relay session\nnpx vent-hq agent stop <session-id> # Stop a shared local relay session\n```\n\nIf `~/.vent/credentials` is missing and neither `VENT_REFRESH_TOKEN` nor `VENT_ACCESS_TOKEN` is set, run `npx vent-hq init`. For an existing account, run `npx vent-hq login` or set `VENT_REFRESH_TOKEN` to an API key. Access JWTs renew automatically; `VENT_ACCESS_TOKEN` alone is short-lived.\n\n## Suite Config\n\nSuites live in `.vent/suite.<adapter>.json`. `connection` is declared once per suite. `calls` is a named map, and each key becomes the call name used with `--call`.\n\nLocal websocket suite:\n\n```json\n{\n "connection": {\n "adapter": "websocket",\n "start_command": "npm run start",\n "health_endpoint": "/health",\n "agent_port": 3001\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8\n }\n }\n}\n```\n\nPlatform-direct suite:\n\n```json\n{\n "connection": {\n "adapter": "vapi",\n "platform": { "provider": "vapi" }\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8\n }\n }\n}\n```\n\nWrite `caller_prompt` as a realistic caller with a name, goal, mood, constraints, and conditional behavior. Set `max_turns` based on flow complexity: FAQ `4-6`, booking or tool use `8-12`, complex flows `12-20`.\n\nCall fields:\n\n- `caller_prompt` is required. `max_turns` defaults to `6`.\n- `persona` supports `pace`, `clarity`, `disfluencies`, `cooperation`, `emotion`, `memory`, `intent_clarity`, and `confirmation_style`.\n- `caller_audio` supports noise, speed, speakerphone, mic distance, clarity, accent, packet loss, and jitter.\n- `caller_audio.accent` picks the caller\'s voice. Supported: `american` (default), `british`, `australian`, `filipino`, `spanish_mexican`, `spanish_peninsular`, `spanish_colombian`, `spanish_argentine`, `german`, `french`, `italian`, `dutch`, `japanese`. Any other value is rejected. The non-English accents are voices in that language: also set `language` (e.g. `"es"`) so the caller speaks it.\n- `language` is an ISO 639-1 code such as `en`, `es`, `fr`, `de`, `it`, `nl`, or `ja`.\n- `voice` is `"male"` or `"female"` (English only; default female). Use to flip the caller\'s perceived gender. Ignored if `caller_audio.accent` is set or `language` is non-English.\n\n## Connections and Credentials\n\n### Adapter choice\n\nUse `websocket` for your own local or hosted runtime. Use `start_command` for local agents or `agent_url` for hosted custom endpoints. For `start_command` and `agent_url`, do not put Deepgram, ElevenLabs, OpenAI, or other agent runtime keys into Vent config unless the Vent adapter itself needs them \u2014 the tested agent owns its own runtime credentials.\n\nUse `vapi`, `retell`, `elevenlabs`, `bland`, or `livekit` for platform-direct testing. In this mode Vent itself talks to the provider on the user\'s behalf.\n\nVent provides `DEEPGRAM_API_KEY` and `ANTHROPIC_API_KEY` for its hosted caller/evaluation stack \u2014 those are Vent\'s, not the tested agent\'s.\n\n### Credential resolution\n\nIn platform-direct mode the CLI auto-resolves credentials from `.env.local`, `.env`, and the current shell environment. If the project uses a secrets manager like Doppler, wrap the command so the secrets are set: `doppler run -- npx vent-hq run -f <suite.json>`. Do not run `source .env && export` before Vent commands. Keep credentials out of chat and suite files; the CLI sends the resolved configuration to Vent, which stores an encrypted copy with each run.\n\nAuto-resolved env vars and JSON fields:\n\n- Vapi: `VAPI_API_KEY` -> `vapi_api_key`; `VAPI_ASSISTANT_ID` or `VAPI_AGENT_ID` -> `vapi_assistant_id`\n- Bland: `BLAND_API_KEY` -> `bland_api_key`; `BLAND_PATHWAY_ID` -> `bland_pathway_id`; `BLAND_PERSONA_ID` -> `persona_id`\n- LiveKit: `LIVEKIT_API_KEY` -> `livekit_api_key`; `LIVEKIT_API_SECRET` -> `livekit_api_secret`; `LIVEKIT_URL` -> `livekit_url`\n- Retell: `RETELL_API_KEY` -> `retell_api_key`; `RETELL_AGENT_ID` -> `retell_agent_id`\n- ElevenLabs: `ELEVENLABS_API_KEY` -> `elevenlabs_api_key`; `ELEVENLABS_AGENT_ID` -> `elevenlabs_agent_id`\n\n### Provider config\n\nUse existing provider config when possible: Vapi assistant, Retell agent, ElevenLabs agent, Bland pathway, or LiveKit agent. Bland uniquely supports inline config \u2014 `platform` needs `bland_pathway_id` or an inline `task`, optionally with `persona_id` and voice, model, and turn-handling overrides (see Bland\'s API docs for the full field list).\n\n### Concurrency\n\nWhen you fan out multiple Vent calls in parallel against the same provider (for example, running several named calls from one suite at once{{PARALLEL_EXAMPLE}}), respect the provider\'s per-account concurrency limit. Exceeding it makes calls queue or fail at the provider \u2014 Vent does not enforce these caps for you.\n\nRecord the limit as `max_concurrency` in the suite\'s `platform` block so it\'s visible on future runs. Ask the user which plan they\'re on if sizing matters; otherwise use the conservative default in bold.\n\n- **Vapi**: **10** included per account; reserved lines can be purchased self-serve; Enterprise is unlimited.\n- **Retell**: Pay-as-you-go includes **20**; Enterprise has no cap.\n- **Bland**: Start=**10**, Build=50, Scale=100, Enterprise=unlimited.\n- **ElevenLabs**: Free=**4**, Starter=6, Creator=10, Pro=20, Scale=30, Business=30. Burst pricing can temporarily allow up to 3x base.\n- **LiveKit Cloud**: Build=**5**, Ship=20, Scale=50 managed inference sessions (the usual gate for voice agents); agent-session concurrency can go higher (Scale up to 600).\n\n## WebSocket\n\nFor `adapter: "websocket"`, Vent sends binary 16-bit mono PCM audio over one websocket connection. Websocket text frames are optional JSON events. Audio-only websocket agents still work, but events improve turn detection and observability. Vent sends `{"type":"end-call"}` when the test is done.\n\nUseful websocket text frames:\n\n```jsonc\n{"type":"speech-update","status":"started"}\n{"type":"speech-update","status":"stopped"}\n{"type":"tool_call","name":"check_availability","arguments":{},"result":{},"successful":true,"duration_ms":150}\n{"type":"vent:timing","stt_ms":120,"llm_ms":450,"tts_ms":80}\n{"type":"vent:session","platform":"custom","provider_call_id":"call_123","provider_session_id":"session_abc"}\n{"type":"vent:call-metadata","call_metadata":{"recording_url":"https://...","cost":{"total_usd":0.12,"breakdown":{"llm":0.07,"tts":0.05}}}}\n{"type":"vent:transcript","role":"caller","text":"I need to reschedule"}\n{"type":"vent:transcript","role":"agent","text":"Sure, which day works for you?"}\n{"type":"vent:transfer","destination":"+15551234567","status":"attempted"}\n{"type":"vent:debug-url","label":"trace","url":"https://..."}\n{"type":"vent:warning","message":"provider warning","code":"provider_warning"}\n```\n\nPlatform adapters capture tool calls automatically. Websocket agents must emit `tool_call` frames for tool observability. Platform adapters get component latency automatically. Websocket agents should emit `vent:timing` after each agent response when STT/LLM/TTS breakdown is available.\n\n## LiveKit\n\nBefore running LiveKit tests, install and add the Vent helper to the LiveKit agent entrypoint. Node: `npm install @vent-hq/livekit`, then call `instrumentLiveKitAgent({ ctx, session })`. Python: `pip install vent-livekit`, then call `instrument_livekit_agent(ctx=ctx, session=session)`.\n\nLiveKit direct mode requires the LiveKit Agents SDK. Custom LiveKit participants should use the websocket adapter with a relay. If the LiveKit agent registered with an explicit dispatch name, set `livekit_agent_name` in `platform`.\n\nLiveKit parallel calls are supported, but capacity depends on where the user runs their agent. LiveKit Cloud only routes audio \u2014 the agent code runs on the user\'s own infrastructure (laptop, Fly.io, Railway, k8s, etc.), and that box\'s CPU/RAM is the real bottleneck. Before firing parallel calls, derive `platform.max_concurrency` from the minimum of: (1) the user\'s LiveKit plan limit (Build=5, Ship=20, Scale=50+ concurrent agent sessions), (2) their agent worker box capacity (rough rule: 1c/1GB\u21921\u20133 jobs, 2c/4GB\u21925\u201310, 4c/8GB\u219210\u201325, 8c/16GB\u219225\u201350; multiply by number of worker boxes), and (3) their STT/TTS provider concurrency (e.g. Deepgram TTS streaming = 2). If the user wants more parallel calls than that minimum allows, push back \u2014 explain which limit is the bottleneck (plan / box / provider) and either reduce the count or suggest scaling that limit. Architecture: register one `agent_name` and run N workers under it (one big worker, or several smaller ones \u2014 same name); LiveKit\'s dispatcher round-robins jobs across them with built-in failover. Don\'t use numbered names (`agent-0`, `agent-1`) \u2014 that reinvents what the dispatcher does and breaks failover.\n\nUse the LiveKit helper for observability; do not publish `vent:*` topics manually. The helper captures SDK metrics, tool events, conversation items, and close events. Native LiveKit `lk.transcription` and `lk.agent.state` provide transcript and agent-state timing.\n\n## Output\n\n### Live result\n\n`npx vent-hq run` returns a single JSON result on stdout in non-TTY mode (not an SSE JSONL stream). Exit codes: `0` = the run passed (every call ran end to end); `1` = it didn\'t (a call errored, or the run failed or was cancelled); `2` = the result stream was lost. A call `error` starting with `Vent failed, agent not tested:` is a failure of Vent itself (its caller LLM, STT/TTS or worker): rerun, it says nothing about the agent. Any other `error` comes from the agent\'s platform or your configuration. `end_note` says how a completed call ended when Vent ended it or the connection closed (e.g. `No agent speech for 30s after the caller\'s turn; Vent hung up.`): read the transcript to judge whether that\'s an agent bug.\n\nMost result fields are always present; `latency`, `component_latency`, `signal_quality`, and `call_metadata` may be `null` when the underlying analysis didn\'t run; `warnings` is absent when there are none. Branch on null before reading nested fields. Each agent turn carries `tool_calls`: the tools it ran for that reply, with arguments, result, `successful` and `latency_ms`, so you can check what the agent said against what its tools returned. A tool the agent ran but never replied after is on the call\'s last turn. A call-level `tool_calls` list appears only for a failed call whose transcript was lost. Tool results are complete, never shortened. `component_latency` is the mean time of each stage of the agent\'s pipeline across the call (`mean_stt_ms`, `mean_llm_ms`, `mean_tts_ms`), as the platform reports it, so a slow stage stands out. For anything deeper, open the links in `call_metadata.provider_debug_urls`.\n\nVent\'s transcript is ground truth. Judge on semantic intent: ignore homophones and minor mis-hears (`"check teach hat"` for `"check that"`, missing question marks on short tails) \u2014 those are streaming-STT noise on Vent\'s caller side, not agent bugs, and **don\'t surface them in the report** (they\'re Vent-side artifacts, not actionable for the user). But clear gibberish or word-soup (e.g. `"Cristoxin"` where the agent should have said `"Of course, talk soon"`) is **not** a Vent artifact \u2014 Vent\'s STT does not invent words like that. It means the platform\'s TTS produced corrupted audio or the agent\'s STT/LLM generated the wrong text, and the fix lives there (TTS voice config, agent prompt, model temperature, codec issue). Missing or cut-off agent text, unlike word-soup, can come from Vent\'s STT stream dropping: check the recording before blaming the agent.\n\nThere are two transcripts. `transcript` is Vent\'s record: caller turns are exactly what Vent\'s caller said, agent turns are what Vent heard the agent say. `platform_transcript` is the agent platform\'s own record (`null` when the platform gives none): agent turns are everything the agent was set to say (its LLM\'s text plus fixed messages like the greeting or a tool\'s "one moment"), caller turns are what the agent\'s speech-to-text heard. On Retell and LiveKit, an agent turn the caller interrupted shows only what was spoken before the cut, since those platforms keep nothing else; on the others it shows the full text. Vapi never reports the text of its tool wait messages ("Just a sec"), so those are missing from Vapi\'s agent turns. Compare them turn by turn:\n- Agent wrote it right but Vent heard it differently on a number, ID, date or name (e.g. LLM wrote `555-0102`, Vent heard "five five five one two"): the agent\'s voice read it wrong. Fix how that text reaches the TTS (normalization, spelling or pronunciation tags), not the prompt\'s logic. If the agent\'s LLM text is already wrong, the fix is in the LLM, prompt or tool data.\n- Vent\'s caller said it right but the agent\'s STT heard something else (e.g. "OSHA" heard as "ocean"): the agent misheard. Fix the agent\'s STT (keyword hints, custom vocabulary).\n\n`latency` covers agent replies: `response_time_ms` (mean), `p50_response_time_ms`, `max_response_time_ms` (the slowest reply), and `first_response_time_ms`. `signal_quality` is the agent\'s raw audio: `mean_snr_db` (>20 good, <10 bad) and `max_clipping_ratio` (>0.01 = distortion). Bad values point at the agent\'s TTS or audio pipeline. `call_metadata.cost` is what the provider charged: `total_usd`, plus `breakdown` in the provider\'s own categories when the provider splits it (Bland and some agents report only the total). `call_metadata.provider_debug_urls` links to the call in the provider\'s dashboard or logs.\n\nFor transfers: `call_metadata.transfer_attempted` (provider claimed) and `transfer_completed` (from the platform\'s call data) can disagree \u2014 report both. `transfers[]` carries destination, type, and per-attempt status.\n\n### Saved history\n\nAfter every run, the CLI saves the call result to `.vent/runs/`. Shape:\n\n```jsonc\n{\n "run_id": "...",\n "timestamp": "2026-04-21T...Z",\n "git_sha": "...",\n "summary": { "status": "pass", "calls_total": 1, "total_duration_ms": 12345 },\n "call_results": [\n { "name": "happy-path", "status": "completed", "duration_ms": 6123, "transcript": [], "latency": { "response_time_ms": 420, "max_response_time_ms": 980 }, "call_metadata": { "cost": { "total_usd": 0.004 } } }\n ]\n}\n```\n\nWhen comparing against a prior run (Workflow step 6), inspect:\n\n- Run-completion status flips: `call_results[i].status` (pipeline-only \u2014 judge mission success from the transcript)\n- Latency: `call_results[i].latency.response_time_ms` (mean) or `latency.max_response_time_ms` increased >20%\n- Tool calls: a tool that succeeded before now fails (`successful: false`), or is no longer called, in the agent turns\' `tool_calls`\n- Cost: `call_results[i].call_metadata.cost.total_usd` increased >30% (`cost.breakdown` shows which part grew)\n- Transcript: `call_results[i].transcript` diverged in semantic content (ignore STT noise)\n\n## Reporting Results\n\nBefore reporting, read the agent\'s code to locate where the observed behavior originates. If the issue is small and you can fix it, fix it and explain what you did \u2014 don\'t ask permission first.\n\nAdapt the report shape to the call \u2014 a clean pass needs little, a regression with a multi-layer cause needs more. Use a transcript excerpt when it helps the user see what happened.\n\nHard rules:\n\n- Pair raw numbers with their plain-English meaning \u2014 don\'t drop the number, but don\'t leave it unexplained. E.g. "the slowest reply took 850ms, which is snappy and well within natural conversational pacing" or "the slowest reply hit 1.6 seconds with the LLM as the bottleneck \u2014 noticeably sluggish to a caller."\n- Name the user\'s voice agent by platform on first mention (e.g. "the Vapi agent responded snappily throughout") so the user knows immediately which agent the observation is about. After that, just say "the agent" \u2014 don\'t repeat the platform name on every line.\n- Always include the recording from `call_metadata.recording_url` as an inline `[Recording](url)` link, placed in **one block at the very end of the report** \u2014 never sprinkled through the prose. Single call: one link as the last line. Multi-call: one labeled link per call (e.g. `reschedule-appointment: [Recording](url)`). Never paste a bare URL.\n- Mission success is your judgment, not Vent\'s. The per-call `status` is only `"completed"` (pipeline ran) or `"error"` (pipeline failed); decide whether the agent actually accomplished the scenario from the transcript and tool calls.\n- Similar-sounding word substitutions (e.g. "ocean" for "OSHA") are STT ambiguity, not comprehension failure. The fix lives in STT keyword hints, custom vocabulary, or a prompt-level term list \u2014 not the agent\'s reasoning.\n- Surface only what the user can act on in their own agent\'s code or config \u2014 never Vent-side artifacts (caller wait modes, harness timing, internal pipeline quirks), or cost unless asked.\n\nFor multi-call runs, lead with your own judgment of what happened across the calls (e.g. "3 of 4 did what they were supposed to; `cancel-appointment` never actually canceled"), not a parroted pass/fail count. Then cover each call with whatever depth it needs.\n';
5488
+
5774
5489
  // src/skills/claude-code.md
5775
- var claude_code_default = '---\nname: vent\ndescription: Place real calls against the user\'s voice agent to verify recent code changes worked end-to-end. Trigger on test requests or after any non-trivial voice-agent change.\nallowed-tools: Bash(npx vent-hq *)\n---\n\n# Vent - Voice Agent Calls\n\n## When to Test\n\nVent is the voice CI gate. Auto-trigger a call after any non-trivial voice-affecting change \u2014 don\'t wait to be asked. Non-trivial means anything that affects what the caller hears, how the agent responds, what tools are called, how the call routes, or audio timing: prompt rewrites or sections (not one-word tweaks), tool schema or handler changes, state/memory logic, transfer logic, provider/audio config, platform integration, and any voice-behavior bug fix. One-line copy or comment edits don\'t need a call.\n\nBefore launching, scan `.vent/runs/` for unresolved issues from prior runs (failing status, regressions, warnings, latency jumps); fix in-scope ones as part of this change and verify they\'re resolved by the new run, surface out-of-scope ones rather than silently leaving them. The task is not done until you\'ve reviewed the transcript, tool calls, and observed behavior and judged that the change works \u2014 Vent\'s `status` and exit code only reflect pipeline completion, not mission success.\n\n## Autonomous Iteration\n\n**Default: iterate.** Run a fix \u2192 Vent-call \u2192 fix \u2192 Vent-call loop whenever a call surfaces a fixable issue. Don\'t report a failure (or a degraded-quality success) back to the user before attempting to fix it \u2014 they want the agent working, not a diagnosis. After each call, if the result reveals a problem you can fix with a concrete plan, apply the fix and run again. Fixable issues include: tool schema or registry gaps, missing handlers, prompt phrasing, **STT vocabulary mismatches** (e.g. domain terms misheard \u2014 Deepgram `keyterms`, custom vocabulary, prompt-level term lists), **latency tunables** (endpointing thresholds, preemptive generation, model selection), and audio quality config. If you\'d be guessing at the next attempt, stop and ask the user instead of trying.\n\n**"Mission success but with quality problems" still requires iteration.** If the call ran end-to-end but had noticeably bad latency, STT mishearing the agent\'s domain vocabulary, audio artifacts, or any other caller-experience degradation that has a concrete fix, the call is *not done* \u2014 apply the fix and re-run. Don\'t downgrade fixable problems to "observations" or "things worth flagging." The pipeline running cleanly is the floor, not the ceiling.\n\n**Never ask permission to iterate.** "Want me to apply this and re-run?" / "Should I fix it?" / "Let me know if you want me to..." \u2014 these are skill violations. If you have a concrete fix, apply it and run the next call. The user opts out by saying so explicitly; silence is not opt-out.\n\nStop iterating and report when one of:\n\n- The next call confirms the fix worked AND no further fixable issues remain (success \u2014 done).\n- The same fix fails twice (your hypothesis is wrong; ask the user).\n- The failure mode shifts between attempts (you\'re chasing a moving target).\n- You can\'t justify the next call against its provider cost \u2014 each call spends real money and provider quota.\n\nIf the user explicitly opts out ("just report", "don\'t iterate", "stop after the first call", "I\'ll fix it myself"), respect that \u2014 run once, report, stop. Otherwise iterate by default. The first time you start an autonomous loop in a session, mention it once so the user knows it\'s happening (e.g. "the call surfaced X \u2014 fixing and re-running; I\'ll report back when it converges or stops making progress").\n\n## Claude Code Execution\n\nUse a 5-minute shell-tool timeout (`300000` ms) on Vent run commands so normal calls are not killed by the default 2-minute Bash timeout. This is not backgrounding; wait for stdout/results before ending your response. Use the JSON returned by `npx vent-hq run` directly.\n\nClaude Code serializes separate Bash tool calls for `npx vent-hq ...`, so run multiple calls from one suite by invoking each named call with `--call <name>` in one Bash command using `&` and a final `wait`:\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path & \\\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path & \\\nwait\n```\n\nFor long-running composite commands \u2014 e.g. LiveKit\'s required `kill workers \u2192 start fresh worker \u2192 wait 60s \u2192 npx vent-hq run` preflight \u2014 keep the entire sequence in **one foreground Bash call**. Do not use `Monitor` or `run_in_background: true` for the wait. Both end the turn until an event fires, which means Vent never sees an inflight call and the user reads it as "you backgrounded the test." The harness also blocks bare `sleep 60` (any long leading sleep), so fold the readiness check and the wait into a single `until`-loop \u2014 short sleeps inside a polling loop are allowed because each iteration is real work:\n\n```bash\nnohup npm run dev > /tmp/lk-agent.log 2>&1 &\nstart=$(date +%s); \\\nuntil grep -q "registered worker" /tmp/lk-agent.log 2>/dev/null \\\n && [ $(($(date +%s) - start)) -ge 60 ]; do sleep 2; done \\\n&& npx vent-hq run -f .vent/suite.livekit.json --call <name>\n```\n\nFor these composites, raise the Bash timeout to **10 minutes** (`600000` ms) \u2014 startup + 60s wait + call can easily exceed the 5-minute default.\n\n## Workflow\n\n1. Identify the behavior under test. Read enough of the agent codebase to understand its system prompt, tools, handlers, routes, provider config, platform wiring, and expected handoffs.\n2. Reuse an existing `.vent/suite.<adapter>.json` when possible. If `.vent/` contains multiple suites, inspect `connection.adapter` and report which suite file produced the result.\n3. Create or update a suite only when the existing calls do not cover the changed behavior. Name calls after real flows, for example `reschedule-appointment`, not `call-1`.\n4. If the suite uses `start_command`, start one shared local session first with `npx vent-hq agent start -f .vent/suite.<adapter>.json`, then pass `--session <session-id>` to each run.\n\n **For locally-run LiveKit agents: every run requires killing *all* workers, starting one fresh worker, and waiting a full 60 seconds before submitting.** Unconditional \u2014 LiveKit Cloud round-robins across registered workers, so a single survivor with a dead inference subprocess fails ~N-1 of N calls. Don\'t rely on `pkill -f <path-pattern>`; bare command lines like `node --import tsx agent.ts dev` won\'t match a path filter. Use `ps aux | grep -E "node.*agent\\.ts|@livekit/agents.*ipc"`, `kill -9` by PID, re-run `ps` to confirm zero survivors, then start the fresh worker. Skipping the 60s wait fails with `did not publish audio track`; if that error appears alongside `Error [ERR_IPC_CHANNEL_CLOSED] from InferenceProcExecutor.doInference` in the agent log right after a "running EOU detection" line, that\'s a straggler \u2014 redo the kill sweep. Hosted LiveKit Cloud agents don\'t need any of this; run normally. (See `Claude Code Execution` for how to compose the kill\u2192start\u2192wait\u2192submit as a single foreground command.)\n5. Pick which call(s) to run based on the change. Fixed bug: replay the failing scenario. Changed tool: include a call that triggers that tool. Prompt or routing change: include the relevant happy path and any important edge path.\n6. Compare against the previous JSON in `.vent/runs/` when validating a fix or regression. Check status flips, latency jumps, tool-call success drops, cost jumps, and transcript divergence. Correlate with `git diff` between saved `git_sha` values when available; skip if no previous run exists.\n\n## Commands\n\n```bash\nnpx vent-hq init # First-time setup (auth + skill install + starter suite)\nnpx vent-hq login # Log in to existing account\nnpx vent-hq login --status # Print whether credentials are present\nnpx vent-hq logout # Remove saved credentials from ~/.vent/credentials\nnpx vent-hq run -f .vent/suite.X.json # Run a single-call suite\nnpx vent-hq run -f .vent/suite.X.json --call NAME # Run one named call from a multi-call suite\nnpx vent-hq run ... --session <session-id> # Add to any run; routes through an existing local relay session\nnpx vent-hq run ... --verbose # Add to any run; include verbose debug fields\nnpx vent-hq stop <run-id> # Cancel a queued or running run\nnpx vent-hq agent start -f .vent/suite.X.json # Start a shared local relay session\nnpx vent-hq agent stop <session-id> # Stop a shared local relay session\n```\n\nIf `~/.vent/credentials` is missing and `VENT_ACCESS_TOKEN` is not set, run `npx vent-hq init`. For an existing account, run `npx vent-hq login` or set `VENT_ACCESS_TOKEN`.\n\n## Suite Config\n\nSuites live in `.vent/suite.<adapter>.json`. `connection` is declared once per suite. `calls` is a named map, and each key becomes the call name used with `--call`.\n\nLocal websocket suite:\n\n```json\n{\n "connection": {\n "adapter": "websocket",\n "start_command": "npm run start",\n "health_endpoint": "/health",\n "agent_port": 3001\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8,\n "silence_threshold_ms": 1200\n }\n }\n}\n```\n\nPlatform-direct suite:\n\n```json\n{\n "connection": {\n "adapter": "vapi",\n "platform": { "provider": "vapi" }\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8\n }\n }\n}\n```\n\nWrite `caller_prompt` as a realistic caller with a name, goal, mood, constraints, and conditional behavior. Set `max_turns` based on flow complexity: FAQ `4-6`, booking or tool use `8-12`, complex flows `12-20`.\n\nCall fields:\n\n- `caller_prompt` and `max_turns` are required.\n- `silence_threshold_ms` must be `200-10000`. Common ranges: FAQ `800-1200`, tool calls `2000-3000`, complex reasoning `3000-5000`.\n- `persona` supports `pace`, `clarity`, `disfluencies`, `cooperation`, `emotion`, `memory`, `intent_clarity`, and `confirmation_style`.\n- `caller_audio` supports noise, speed, speakerphone, mic distance, clarity, accent, packet loss, and jitter.\n- `language` is an ISO 639-1 code such as `en`, `es`, `fr`, `de`, `it`, `nl`, or `ja`.\n- `voice` is `"male"` or `"female"` (English only; default female). Use to flip the caller\'s perceived gender. Ignored if `caller_audio.accent` is set or `language` is non-English.\n\n## Connections and Credentials\n\n### Adapter choice\n\nUse `websocket` for your own local or hosted runtime. Use `start_command` for local agents or `agent_url` for hosted custom endpoints. For `start_command` and `agent_url`, do not put Deepgram, ElevenLabs, OpenAI, or other agent runtime keys into Vent config unless the Vent adapter itself needs them \u2014 the tested agent owns its own runtime credentials.\n\nUse `vapi`, `retell`, `elevenlabs`, `bland`, or `livekit` for platform-direct testing. In this mode Vent itself talks to the provider on the user\'s behalf.\n\nVent provides `DEEPGRAM_API_KEY` and `ANTHROPIC_API_KEY` for its hosted caller/evaluation stack \u2014 those are Vent\'s, not the tested agent\'s.\n\n### Credential resolution\n\nIn platform-direct mode the CLI auto-resolves credentials from `.env.local`, `.env`, and the current shell environment. Do not run `source .env && export` before Vent commands. If you include credential fields in JSON, use the actual value, not the env var name. Do not manually author `platform_connection_id`; the CLI creates or updates the saved platform connection automatically.\n\nAuto-resolved env vars and JSON fields:\n\n- Vapi: `VAPI_API_KEY` -> `vapi_api_key`; `VAPI_ASSISTANT_ID` or `VAPI_AGENT_ID` -> `vapi_assistant_id`\n- Bland: `BLAND_API_KEY` -> `bland_api_key`; `BLAND_PATHWAY_ID` -> `bland_pathway_id`; `BLAND_PERSONA_ID` -> `persona_id`\n- LiveKit: `LIVEKIT_API_KEY` -> `livekit_api_key`; `LIVEKIT_API_SECRET` -> `livekit_api_secret`; `LIVEKIT_URL` -> `livekit_url`\n- Retell: `RETELL_API_KEY` -> `retell_api_key`; `RETELL_AGENT_ID` -> `retell_agent_id`\n- ElevenLabs: `ELEVENLABS_API_KEY` -> `elevenlabs_api_key`; `ELEVENLABS_AGENT_ID` -> `elevenlabs_agent_id`\n\n### Provider config\n\nUse existing provider config when possible: Vapi assistant, Retell agent, ElevenLabs agent, Bland pathway, or LiveKit agent. Bland uniquely supports inline config \u2014 `platform` may use `bland_pathway_id`, `persona_id`, or an inline `task` (with optional voice, model, and turn-handling overrides; see Bland\'s API docs for the full field list).\n\n### Concurrency\n\nWhen you fan out multiple Vent calls in parallel against the same provider (for example, running several named calls from one suite at once with `&` and `wait`), respect the provider\'s per-account concurrency limit. Exceeding it makes calls queue or fail at the provider \u2014 Vent does not enforce these caps for you.\n\nRecord the limit as `max_concurrency` in the suite\'s `platform` block so it\'s visible on future runs. Ask the user which plan they\'re on if sizing matters; otherwise use the conservative default in bold.\n\n- **Vapi**: **10** included per account; reserved lines can be purchased self-serve; Enterprise is unlimited.\n- **Retell**: Pay-as-you-go includes **20**; Enterprise has no cap.\n- **Bland**: Start=**10**, Build=50, Scale=100, Enterprise=unlimited.\n- **ElevenLabs**: Free=**4**, Starter=6, Creator=10, Pro=20, Scale=30, Business=30. Burst pricing can temporarily allow up to 3x base.\n- **LiveKit Cloud**: Build=**5**, Ship=20, Scale=50 managed inference sessions (the usual gate for voice agents); agent-session concurrency can go higher (Scale up to 600).\n\n## WebSocket\n\nFor `adapter: "websocket"`, Vent sends binary 16-bit mono PCM audio over one websocket connection. Websocket text frames are optional JSON events. Audio-only websocket agents still work, but events improve turn detection and observability. Vent sends `{"type":"end-call"}` when the test is done.\n\nUseful websocket text frames:\n\n```jsonc\n{"type":"speech-update","status":"started"}\n{"type":"speech-update","status":"stopped"}\n{"type":"tool_call","name":"check_availability","arguments":{},"result":{},"successful":true,"duration_ms":150}\n{"type":"vent:timing","stt_ms":120,"llm_ms":450,"tts_ms":80}\n{"type":"vent:session","platform":"custom","provider_call_id":"call_123","provider_session_id":"session_abc"}\n{"type":"vent:call-metadata","call_metadata":{"recording_url":"https://...","cost_usd":0.12}}\n{"type":"vent:transcript","role":"caller","text":"I need to reschedule","turn_index":0}\n{"type":"vent:transfer","destination":"+15551234567","status":"attempted"}\n{"type":"vent:debug-url","label":"trace","url":"https://..."}\n{"type":"vent:warning","message":"provider warning","code":"provider_warning"}\n```\n\n`vent:session-report` is **not** handled by the websocket adapter \u2014 it\'s only consumed by the LiveKit helper. Do not emit it from a websocket agent.\n\nPlatform adapters capture tool calls automatically. Websocket agents must emit `tool_call` frames for tool observability. Platform adapters get component latency automatically. Websocket agents should emit `vent:timing` after each agent response when STT/LLM/TTS breakdown is available.\n\n## LiveKit\n\nBefore running LiveKit tests, install and add the Vent helper to the LiveKit agent entrypoint. Node: `npm install @vent-hq/livekit`, then call `instrumentLiveKitAgent({ ctx, session })`. Python: `pip install vent-livekit`, then call `instrument_livekit_agent(ctx=ctx, session=session)`.\n\nLiveKit direct mode requires the LiveKit Agents SDK. Custom LiveKit participants should use the websocket adapter with a relay. If the LiveKit agent registered with an explicit dispatch name, set `livekit_agent_name` in `platform`.\n\nLiveKit parallel calls are supported, but capacity depends on where the user runs their agent. LiveKit Cloud only routes audio \u2014 the agent code runs on the user\'s own infrastructure (laptop, Fly.io, Railway, k8s, etc.), and that box\'s CPU/RAM is the real bottleneck. Before firing parallel calls, derive `platform.max_concurrency` from the minimum of: (1) the user\'s LiveKit plan limit (Build=5, Ship=20, Scale=50+ concurrent agent sessions), (2) their agent worker box capacity (rough rule: 1c/1GB\u21921\u20133 jobs, 2c/4GB\u21925\u201310, 4c/8GB\u219210\u201325, 8c/16GB\u219225\u201350; multiply by number of worker boxes), and (3) their STT/TTS provider concurrency (e.g. Deepgram TTS streaming = 2). If the user wants more parallel calls than that minimum allows, push back \u2014 explain which limit is the bottleneck (plan / box / provider) and either reduce the count or suggest scaling that limit. Architecture: register one `agent_name` and run N workers under it (one big worker, or several smaller ones \u2014 same name); LiveKit\'s dispatcher round-robins jobs across them with built-in failover. Don\'t use numbered names (`agent-0`, `agent-1`) \u2014 that reinvents what the dispatcher does and breaks failover.\n\nUse the LiveKit helper for observability; do not publish `vent:*` topics manually. Do not hand-roll `vent:session-report` from `ctx.addShutdownCallback`; after `room.disconnect()` it can fail with `engine is closed`. The helper captures SDK metrics, tool events, conversation items, usage, and close events. Native LiveKit `lk.transcription` and `lk.agent.state` provide transcript and agent-state timing.\n\n## Output\n\n### Live result\n\n`npx vent-hq run` returns a single JSON result on stdout in non-TTY mode (not an SSE JSONL stream). Exit codes: `0` = call ran through the pipeline; `1` = pipeline-level failure; `2` = harness error.\n\nMost result fields are always present; `latency`, `component_latency`, `call_metadata`, and `emotion` may be `null` when the underlying analysis didn\'t run; `debug` is absent without `--verbose`. Branch on null before reading nested fields. Use `--verbose` only when the default doesn\'t explain a failure \u2014 when you need `platform_transcript` (to check Vent\'s STT), per-turn or component-level latency breakdowns, the raw tool-call timeline, or provider-native artifacts in `debug.provider_metadata`. Otherwise skip \u2014 it just adds noise.\n\nVent\'s transcript is ground truth. Judge on semantic intent: ignore homophones and minor mis-hears (`"check teach hat"` for `"check that"`, missing question marks on short tails) \u2014 those are streaming-STT noise on Vent\'s caller side, not agent bugs, and **don\'t surface them in the report** (they\'re Vent-side artifacts, not actionable for the user). But clear gibberish or word-soup (e.g. `"Cristoxin"` where the agent should have said `"Of course, talk soon"`) is **not** a Vent artifact \u2014 Vent\'s STT does not invent words like that. It means the platform\'s TTS produced corrupted audio or the agent\'s STT/LLM generated the wrong text, and the fix lives there (TTS voice config, agent prompt, model temperature, codec issue). Never dismiss the run as a "Vent harness STT" issue; iterate on the agent or flag the platform.\n\nFor transfers: `call_metadata.transfer_attempted` (provider claimed) and `transfer_completed` (Vent-verified) can disagree \u2014 report both. `transfers[]` carries destination, type, and per-attempt status.\n\n### Saved history\n\nAfter every run, Vent writes the full result JSON to `.vent/runs/`. Shape:\n\n```jsonc\n{\n "run_id": "...",\n "timestamp": "2026-04-21T...Z",\n "git_sha": "...",\n "summary": { "calls_total": 2, "total_duration_ms": 12345, "total_cost_usd": 0.01 },\n "call_results": [\n { "name": "happy-path", "status": "completed", "duration_ms": 6123, "transcript": [], "observed_tool_calls": [], "latency": { "response_time_ms": 420, "p95_response_time_ms": 980 }, "call_metadata": { "cost_usd": 0.004 } }\n ]\n}\n```\n\nWhen comparing against a prior run (Workflow step 6), inspect:\n\n- Run-completion status flips: `call_results[i].status` (pipeline-only \u2014 judge mission success from the transcript)\n- Latency: `call_results[i].latency.response_time_ms` (mean) or `latency.p95_response_time_ms` increased >20%\n- Tool calls: count of `call_results[i].observed_tool_calls[].successful` dropped\n- Cost: `summary.total_cost_usd` or `call_results[i].call_metadata.cost_usd` increased >30%\n- Transcript: `call_results[i].transcript` diverged in semantic content (ignore STT noise)\n\n## Reporting Results\n\nBefore reporting, read the agent\'s code to locate where the observed behavior originates. If the issue is small and you can fix it, fix it and explain what you did \u2014 don\'t ask permission first.\n\nAdapt the report shape to the call \u2014 a clean pass needs little, a regression with a multi-layer cause needs more. Use a transcript excerpt when it helps the user see what happened.\n\nHard rules:\n\n- Pair raw numbers with their plain-English meaning \u2014 don\'t drop the number, but don\'t leave it unexplained. E.g. "p95 latency was 850ms, which is snappy and well within natural conversational pacing" or "p95 hit 1.6 seconds with the LLM as the bottleneck \u2014 noticeably sluggish to a caller."\n- Name the user\'s voice agent by platform on first mention (e.g. "the Vapi agent responded snappily throughout") so the user knows immediately which agent the observation is about. After that, just say "the agent" \u2014 don\'t repeat the platform name on every line.\n- Always include the recording from `call_metadata.recording_url` as an inline `[Recording](url)` link, placed in **one block at the very end of the report** \u2014 never sprinkled through the prose. Single call: one link as the last line. Multi-call: one labeled link per call (e.g. `reschedule-appointment: [Recording](url)`). Never paste a bare URL.\n- Mission success is your judgment, not Vent\'s. The per-call `status` is only `"completed"` (pipeline ran) or `"error"` (pipeline failed); decide whether the agent actually accomplished the scenario from the transcript and tool calls.\n- Similar-sounding word substitutions (e.g. "ocean" for "OSHA") are STT ambiguity, not comprehension failure. The fix lives in STT keyword hints, custom vocabulary, or a prompt-level term list \u2014 not the agent\'s reasoning.\n- Surface only what the user can act on in their own agent\'s code or config \u2014 never `warnings[]` (infrastructure noise), Vent-side artifacts (caller wait modes, harness timing, internal pipeline quirks), or `cost_usd` unless asked.\n\nFor multi-call runs, lead with your own judgment of what happened across the calls (e.g. "3 of 4 did what they were supposed to; `cancel-appointment` never actually canceled"), not a parroted pass/fail count. Then cover each call with whatever depth it needs.\n';
5490
+ var claude_code_default = '## Claude Code Execution\n\nUse a 5-minute shell-tool timeout (`300000` ms) on Vent run commands so normal calls are not killed by the default 2-minute Bash timeout. This is not backgrounding; wait for stdout/results before ending your response. Use the JSON returned by `npx vent-hq run` directly.\n\nClaude Code serializes separate Bash tool calls for `npx vent-hq ...`, so run multiple calls from one suite by invoking each named call with `--call <name>` in one Bash command using `&` and a final `wait`:\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path & \\\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path & \\\nwait\n```\n\nFor long-running composite commands \u2014 e.g. LiveKit\'s required `kill workers \u2192 start fresh worker \u2192 wait 60s \u2192 npx vent-hq run` preflight \u2014 keep the entire sequence in **one foreground Bash call**. Do not use `Monitor` or `run_in_background: true` for the wait. Both end the turn until an event fires, which means Vent never sees an inflight call and the user reads it as "you backgrounded the test." The harness also blocks bare `sleep 60` (any long leading sleep), so fold the readiness check and the wait into a single `until`-loop \u2014 short sleeps inside a polling loop are allowed because each iteration is real work:\n\n```bash\nnohup npm run dev > /tmp/lk-agent.log 2>&1 &\nstart=$(date +%s); \\\nuntil grep -q "registered worker" /tmp/lk-agent.log 2>/dev/null \\\n && [ $(($(date +%s) - start)) -ge 60 ]; do sleep 2; done \\\n&& npx vent-hq run -f .vent/suite.livekit.json --call <name>\n```\n\nFor these composites, raise the Bash timeout to **10 minutes** (`600000` ms) \u2014 startup + 60s wait + call can easily exceed the 5-minute default.\n';
5776
5491
 
5777
5492
  // src/skills/cursor.md
5778
- var cursor_default = '---\ndescription: Place real calls against the user\'s voice agent to verify recent code changes worked end-to-end. Trigger on test requests or after any non-trivial voice-agent change.\nalwaysApply: true\n---\n\n# Vent - Voice Agent Calls\n\n## When to Test\n\nVent is the voice CI gate. Auto-trigger a call after any non-trivial voice-affecting change \u2014 don\'t wait to be asked. Non-trivial means anything that affects what the caller hears, how the agent responds, what tools are called, how the call routes, or audio timing: prompt rewrites or sections (not one-word tweaks), tool schema or handler changes, state/memory logic, transfer logic, provider/audio config, platform integration, and any voice-behavior bug fix. One-line copy or comment edits don\'t need a call.\n\nBefore launching, scan `.vent/runs/` for unresolved issues from prior runs (failing status, regressions, warnings, latency jumps); fix in-scope ones as part of this change and verify they\'re resolved by the new run, surface out-of-scope ones rather than silently leaving them. The task is not done until you\'ve reviewed the transcript, tool calls, and observed behavior and judged that the change works \u2014 Vent\'s `status` and exit code only reflect pipeline completion, not mission success.\n\n## Autonomous Iteration\n\n**Default: iterate.** Run a fix \u2192 Vent-call \u2192 fix \u2192 Vent-call loop whenever a call surfaces a fixable issue. Don\'t report a failure (or a degraded-quality success) back to the user before attempting to fix it \u2014 they want the agent working, not a diagnosis. After each call, if the result reveals a problem you can fix with a concrete plan, apply the fix and run again. Fixable issues include: tool schema or registry gaps, missing handlers, prompt phrasing, **STT vocabulary mismatches** (e.g. domain terms misheard \u2014 Deepgram `keyterms`, custom vocabulary, prompt-level term lists), **latency tunables** (endpointing thresholds, preemptive generation, model selection), and audio quality config. If you\'d be guessing at the next attempt, stop and ask the user instead of trying.\n\n**"Mission success but with quality problems" still requires iteration.** If the call ran end-to-end but had noticeably bad latency, STT mishearing the agent\'s domain vocabulary, audio artifacts, or any other caller-experience degradation that has a concrete fix, the call is *not done* \u2014 apply the fix and re-run. Don\'t downgrade fixable problems to "observations" or "things worth flagging." The pipeline running cleanly is the floor, not the ceiling.\n\n**Never ask permission to iterate.** "Want me to apply this and re-run?" / "Should I fix it?" / "Let me know if you want me to..." \u2014 these are skill violations. If you have a concrete fix, apply it and run the next call. The user opts out by saying so explicitly; silence is not opt-out.\n\nStop iterating and report when one of:\n\n- The next call confirms the fix worked AND no further fixable issues remain (success \u2014 done).\n- The same fix fails twice (your hypothesis is wrong; ask the user).\n- The failure mode shifts between attempts (you\'re chasing a moving target).\n- You can\'t justify the next call against its provider cost \u2014 each call spends real money and provider quota.\n\nIf the user explicitly opts out ("just report", "don\'t iterate", "stop after the first call", "I\'ll fix it myself"), respect that \u2014 run once, report, stop. Otherwise iterate by default. The first time you start an autonomous loop in a session, mention it once so the user knows it\'s happening (e.g. "the call surfaced X \u2014 fixing and re-running; I\'ll report back when it converges or stops making progress").\n\n## Cursor Execution\n\nUse a 5-minute shell-tool timeout (`300000` ms) on Vent run commands so normal calls are not killed by the default 2-minute Bash timeout. This is not backgrounding; wait for stdout/results before ending your response. Use the JSON returned by `npx vent-hq run` directly.\n\nCursor cannot run separate shell tool calls concurrently \u2014 for multiple calls from one suite, invoke each named call with `--call <name>` in one shell command using `&` and a final `wait`:\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path & \\\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path & \\\nwait\n```\n\n## Workflow\n\n1. Identify the behavior under test. Read enough of the agent codebase to understand its system prompt, tools, handlers, routes, provider config, platform wiring, and expected handoffs.\n2. Reuse an existing `.vent/suite.<adapter>.json` when possible. If `.vent/` contains multiple suites, inspect `connection.adapter` and report which suite file produced the result.\n3. Create or update a suite only when the existing calls do not cover the changed behavior. Name calls after real flows, for example `reschedule-appointment`, not `call-1`.\n4. If the suite uses `start_command`, start one shared local session first with `npx vent-hq agent start -f .vent/suite.<adapter>.json`, then pass `--session <session-id>` to each run.\n\n **For locally-run LiveKit agents: every run requires killing *all* workers, starting one fresh worker, and waiting a full 60 seconds before submitting.** Unconditional \u2014 LiveKit Cloud round-robins across registered workers, so a single survivor with a dead inference subprocess fails ~N-1 of N calls. Don\'t rely on `pkill -f <path-pattern>`; bare command lines like `node --import tsx agent.ts dev` won\'t match a path filter. Use `ps aux | grep -E "node.*agent\\.ts|@livekit/agents.*ipc"`, `kill -9` by PID, re-run `ps` to confirm zero survivors, then start the fresh worker. Skipping the 60s wait fails with `did not publish audio track`; if that error appears alongside `Error [ERR_IPC_CHANNEL_CLOSED] from InferenceProcExecutor.doInference` in the agent log right after a "running EOU detection" line, that\'s a straggler \u2014 redo the kill sweep. Hosted LiveKit Cloud agents don\'t need any of this; run normally.\n5. Pick which call(s) to run based on the change. Fixed bug: replay the failing scenario. Changed tool: include a call that triggers that tool. Prompt or routing change: include the relevant happy path and any important edge path.\n6. Compare against the previous JSON in `.vent/runs/` when validating a fix or regression. Check status flips, latency jumps, tool-call success drops, cost jumps, and transcript divergence. Correlate with `git diff` between saved `git_sha` values when available; skip if no previous run exists.\n\n## Commands\n\n```bash\nnpx vent-hq init # First-time setup (auth + skill install + starter suite)\nnpx vent-hq login # Log in to existing account\nnpx vent-hq login --status # Print whether credentials are present\nnpx vent-hq logout # Remove saved credentials from ~/.vent/credentials\nnpx vent-hq run -f .vent/suite.X.json # Run a single-call suite\nnpx vent-hq run -f .vent/suite.X.json --call NAME # Run one named call from a multi-call suite\nnpx vent-hq run ... --session <session-id> # Add to any run; routes through an existing local relay session\nnpx vent-hq run ... --verbose # Add to any run; include verbose debug fields\nnpx vent-hq stop <run-id> # Cancel a queued or running run\nnpx vent-hq agent start -f .vent/suite.X.json # Start a shared local relay session\nnpx vent-hq agent stop <session-id> # Stop a shared local relay session\n```\n\nIf `~/.vent/credentials` is missing and `VENT_ACCESS_TOKEN` is not set, run `npx vent-hq init`. For an existing account, run `npx vent-hq login` or set `VENT_ACCESS_TOKEN`.\n\n## Suite Config\n\nSuites live in `.vent/suite.<adapter>.json`. `connection` is declared once per suite. `calls` is a named map, and each key becomes the call name used with `--call`.\n\nLocal websocket suite:\n\n```json\n{\n "connection": {\n "adapter": "websocket",\n "start_command": "npm run start",\n "health_endpoint": "/health",\n "agent_port": 3001\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8,\n "silence_threshold_ms": 1200\n }\n }\n}\n```\n\nPlatform-direct suite:\n\n```json\n{\n "connection": {\n "adapter": "vapi",\n "platform": { "provider": "vapi" }\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8\n }\n }\n}\n```\n\nWrite `caller_prompt` as a realistic caller with a name, goal, mood, constraints, and conditional behavior. Set `max_turns` based on flow complexity: FAQ `4-6`, booking or tool use `8-12`, complex flows `12-20`.\n\nCall fields:\n\n- `caller_prompt` and `max_turns` are required.\n- `silence_threshold_ms` must be `200-10000`. Common ranges: FAQ `800-1200`, tool calls `2000-3000`, complex reasoning `3000-5000`.\n- `persona` supports `pace`, `clarity`, `disfluencies`, `cooperation`, `emotion`, `memory`, `intent_clarity`, and `confirmation_style`.\n- `caller_audio` supports noise, speed, speakerphone, mic distance, clarity, accent, packet loss, and jitter.\n- `language` is an ISO 639-1 code such as `en`, `es`, `fr`, `de`, `it`, `nl`, or `ja`.\n- `voice` is `"male"` or `"female"` (English only; default female). Use to flip the caller\'s perceived gender. Ignored if `caller_audio.accent` is set or `language` is non-English.\n\n## Connections and Credentials\n\n### Adapter choice\n\nUse `websocket` for your own local or hosted runtime. Use `start_command` for local agents or `agent_url` for hosted custom endpoints. For `start_command` and `agent_url`, do not put Deepgram, ElevenLabs, OpenAI, or other agent runtime keys into Vent config unless the Vent adapter itself needs them \u2014 the tested agent owns its own runtime credentials.\n\nUse `vapi`, `retell`, `elevenlabs`, `bland`, or `livekit` for platform-direct testing. In this mode Vent itself talks to the provider on the user\'s behalf.\n\nVent provides `DEEPGRAM_API_KEY` and `ANTHROPIC_API_KEY` for its hosted caller/evaluation stack \u2014 those are Vent\'s, not the tested agent\'s.\n\n### Credential resolution\n\nIn platform-direct mode the CLI auto-resolves credentials from `.env.local`, `.env`, and the current shell environment. Do not run `source .env && export` before Vent commands. If you include credential fields in JSON, use the actual value, not the env var name. Do not manually author `platform_connection_id`; the CLI creates or updates the saved platform connection automatically.\n\nAuto-resolved env vars and JSON fields:\n\n- Vapi: `VAPI_API_KEY` -> `vapi_api_key`; `VAPI_ASSISTANT_ID` or `VAPI_AGENT_ID` -> `vapi_assistant_id`\n- Bland: `BLAND_API_KEY` -> `bland_api_key`; `BLAND_PATHWAY_ID` -> `bland_pathway_id`; `BLAND_PERSONA_ID` -> `persona_id`\n- LiveKit: `LIVEKIT_API_KEY` -> `livekit_api_key`; `LIVEKIT_API_SECRET` -> `livekit_api_secret`; `LIVEKIT_URL` -> `livekit_url`\n- Retell: `RETELL_API_KEY` -> `retell_api_key`; `RETELL_AGENT_ID` -> `retell_agent_id`\n- ElevenLabs: `ELEVENLABS_API_KEY` -> `elevenlabs_api_key`; `ELEVENLABS_AGENT_ID` -> `elevenlabs_agent_id`\n\n### Provider config\n\nUse existing provider config when possible: Vapi assistant, Retell agent, ElevenLabs agent, Bland pathway, or LiveKit agent. Bland uniquely supports inline config \u2014 `platform` may use `bland_pathway_id`, `persona_id`, or an inline `task` (with optional voice, model, and turn-handling overrides; see Bland\'s API docs for the full field list).\n\n### Concurrency\n\nWhen you fan out multiple Vent calls in parallel against the same provider (for example, running several named calls from one suite at once with `&` and `wait`), respect the provider\'s per-account concurrency limit. Exceeding it makes calls queue or fail at the provider \u2014 Vent does not enforce these caps for you.\n\nRecord the limit as `max_concurrency` in the suite\'s `platform` block so it\'s visible on future runs. Ask the user which plan they\'re on if sizing matters; otherwise use the conservative default in bold.\n\n- **Vapi**: **10** included per account; reserved lines can be purchased self-serve; Enterprise is unlimited.\n- **Retell**: Pay-as-you-go includes **20**; Enterprise has no cap.\n- **Bland**: Start=**10**, Build=50, Scale=100, Enterprise=unlimited.\n- **ElevenLabs**: Free=**4**, Starter=6, Creator=10, Pro=20, Scale=30, Business=30. Burst pricing can temporarily allow up to 3x base.\n- **LiveKit Cloud**: Build=**5**, Ship=20, Scale=50 managed inference sessions (the usual gate for voice agents); agent-session concurrency can go higher (Scale up to 600).\n\n## WebSocket\n\nFor `adapter: "websocket"`, Vent sends binary 16-bit mono PCM audio over one websocket connection. Websocket text frames are optional JSON events. Audio-only websocket agents still work, but events improve turn detection and observability. Vent sends `{"type":"end-call"}` when the test is done.\n\nUseful websocket text frames:\n\n```jsonc\n{"type":"speech-update","status":"started"}\n{"type":"speech-update","status":"stopped"}\n{"type":"tool_call","name":"check_availability","arguments":{},"result":{},"successful":true,"duration_ms":150}\n{"type":"vent:timing","stt_ms":120,"llm_ms":450,"tts_ms":80}\n{"type":"vent:session","platform":"custom","provider_call_id":"call_123","provider_session_id":"session_abc"}\n{"type":"vent:call-metadata","call_metadata":{"recording_url":"https://...","cost_usd":0.12}}\n{"type":"vent:transcript","role":"caller","text":"I need to reschedule","turn_index":0}\n{"type":"vent:transfer","destination":"+15551234567","status":"attempted"}\n{"type":"vent:debug-url","label":"trace","url":"https://..."}\n{"type":"vent:warning","message":"provider warning","code":"provider_warning"}\n```\n\n`vent:session-report` is **not** handled by the websocket adapter \u2014 it\'s only consumed by the LiveKit helper. Do not emit it from a websocket agent.\n\nPlatform adapters capture tool calls automatically. Websocket agents must emit `tool_call` frames for tool observability. Platform adapters get component latency automatically. Websocket agents should emit `vent:timing` after each agent response when STT/LLM/TTS breakdown is available.\n\n## LiveKit\n\nBefore running LiveKit tests, install and add the Vent helper to the LiveKit agent entrypoint. Node: `npm install @vent-hq/livekit`, then call `instrumentLiveKitAgent({ ctx, session })`. Python: `pip install vent-livekit`, then call `instrument_livekit_agent(ctx=ctx, session=session)`.\n\nLiveKit direct mode requires the LiveKit Agents SDK. Custom LiveKit participants should use the websocket adapter with a relay. If the LiveKit agent registered with an explicit dispatch name, set `livekit_agent_name` in `platform`.\n\nLiveKit parallel calls are supported, but capacity depends on where the user runs their agent. LiveKit Cloud only routes audio \u2014 the agent code runs on the user\'s own infrastructure (laptop, Fly.io, Railway, k8s, etc.), and that box\'s CPU/RAM is the real bottleneck. Before firing parallel calls, derive `platform.max_concurrency` from the minimum of: (1) the user\'s LiveKit plan limit (Build=5, Ship=20, Scale=50+ concurrent agent sessions), (2) their agent worker box capacity (rough rule: 1c/1GB\u21921\u20133 jobs, 2c/4GB\u21925\u201310, 4c/8GB\u219210\u201325, 8c/16GB\u219225\u201350; multiply by number of worker boxes), and (3) their STT/TTS provider concurrency (e.g. Deepgram TTS streaming = 2). If the user wants more parallel calls than that minimum allows, push back \u2014 explain which limit is the bottleneck (plan / box / provider) and either reduce the count or suggest scaling that limit. Architecture: register one `agent_name` and run N workers under it (one big worker, or several smaller ones \u2014 same name); LiveKit\'s dispatcher round-robins jobs across them with built-in failover. Don\'t use numbered names (`agent-0`, `agent-1`) \u2014 that reinvents what the dispatcher does and breaks failover.\n\nUse the LiveKit helper for observability; do not publish `vent:*` topics manually. Do not hand-roll `vent:session-report` from `ctx.addShutdownCallback`; after `room.disconnect()` it can fail with `engine is closed`. The helper captures SDK metrics, tool events, conversation items, usage, and close events. Native LiveKit `lk.transcription` and `lk.agent.state` provide transcript and agent-state timing.\n\n## Output\n\n### Live result\n\n`npx vent-hq run` returns a single JSON result on stdout in non-TTY mode (not an SSE JSONL stream). Exit codes: `0` = call ran through the pipeline; `1` = pipeline-level failure; `2` = harness error.\n\nMost result fields are always present; `latency`, `component_latency`, `call_metadata`, and `emotion` may be `null` when the underlying analysis didn\'t run; `debug` is absent without `--verbose`. Branch on null before reading nested fields. Use `--verbose` only when the default doesn\'t explain a failure \u2014 when you need `platform_transcript` (to check Vent\'s STT), per-turn or component-level latency breakdowns, the raw tool-call timeline, or provider-native artifacts in `debug.provider_metadata`. Otherwise skip \u2014 it just adds noise.\n\nVent\'s transcript is ground truth. Judge on semantic intent: ignore homophones and minor mis-hears (`"check teach hat"` for `"check that"`, missing question marks on short tails) \u2014 those are streaming-STT noise on Vent\'s caller side, not agent bugs, and **don\'t surface them in the report** (they\'re Vent-side artifacts, not actionable for the user). But clear gibberish or word-soup (e.g. `"Cristoxin"` where the agent should have said `"Of course, talk soon"`) is **not** a Vent artifact \u2014 Vent\'s STT does not invent words like that. It means the platform\'s TTS produced corrupted audio or the agent\'s STT/LLM generated the wrong text, and the fix lives there (TTS voice config, agent prompt, model temperature, codec issue). Never dismiss the run as a "Vent harness STT" issue; iterate on the agent or flag the platform.\n\nFor transfers: `call_metadata.transfer_attempted` (provider claimed) and `transfer_completed` (Vent-verified) can disagree \u2014 report both. `transfers[]` carries destination, type, and per-attempt status.\n\n### Saved history\n\nAfter every run, Vent writes the full result JSON to `.vent/runs/`. Shape:\n\n```jsonc\n{\n "run_id": "...",\n "timestamp": "2026-04-21T...Z",\n "git_sha": "...",\n "summary": { "calls_total": 2, "total_duration_ms": 12345, "total_cost_usd": 0.01 },\n "call_results": [\n { "name": "happy-path", "status": "completed", "duration_ms": 6123, "transcript": [], "observed_tool_calls": [], "latency": { "response_time_ms": 420, "p95_response_time_ms": 980 }, "call_metadata": { "cost_usd": 0.004 } }\n ]\n}\n```\n\nWhen comparing against a prior run (Workflow step 6), inspect:\n\n- Run-completion status flips: `call_results[i].status` (pipeline-only \u2014 judge mission success from the transcript)\n- Latency: `call_results[i].latency.response_time_ms` (mean) or `latency.p95_response_time_ms` increased >20%\n- Tool calls: count of `call_results[i].observed_tool_calls[].successful` dropped\n- Cost: `summary.total_cost_usd` or `call_results[i].call_metadata.cost_usd` increased >30%\n- Transcript: `call_results[i].transcript` diverged in semantic content (ignore STT noise)\n\n## Reporting Results\n\nBefore reporting, read the agent\'s code to locate where the observed behavior originates. If the issue is small and you can fix it, fix it and explain what you did \u2014 don\'t ask permission first.\n\nAdapt the report shape to the call \u2014 a clean pass needs little, a regression with a multi-layer cause needs more. Use a transcript excerpt when it helps the user see what happened.\n\nHard rules:\n\n- Pair raw numbers with their plain-English meaning \u2014 don\'t drop the number, but don\'t leave it unexplained. E.g. "p95 latency was 850ms, which is snappy and well within natural conversational pacing" or "p95 hit 1.6 seconds with the LLM as the bottleneck \u2014 noticeably sluggish to a caller."\n- Name the user\'s voice agent by platform on first mention (e.g. "the Vapi agent responded snappily throughout") so the user knows immediately which agent the observation is about. After that, just say "the agent" \u2014 don\'t repeat the platform name on every line.\n- Always include the recording from `call_metadata.recording_url` as an inline `[Recording](url)` link, placed in **one block at the very end of the report** \u2014 never sprinkled through the prose. Single call: one link as the last line. Multi-call: one labeled link per call (e.g. `reschedule-appointment: [Recording](url)`). Never paste a bare URL.\n- Mission success is your judgment, not Vent\'s. The per-call `status` is only `"completed"` (pipeline ran) or `"error"` (pipeline failed); decide whether the agent actually accomplished the scenario from the transcript and tool calls.\n- Similar-sounding word substitutions (e.g. "ocean" for "OSHA") are STT ambiguity, not comprehension failure. The fix lives in STT keyword hints, custom vocabulary, or a prompt-level term list \u2014 not the agent\'s reasoning.\n- Surface only what the user can act on in their own agent\'s code or config \u2014 never `warnings[]` (infrastructure noise), Vent-side artifacts (caller wait modes, harness timing, internal pipeline quirks), or `cost_usd` unless asked.\n\nFor multi-call runs, lead with your own judgment of what happened across the calls (e.g. "3 of 4 did what they were supposed to; `cancel-appointment` never actually canceled"), not a parroted pass/fail count. Then cover each call with whatever depth it needs.\n';
5493
+ var cursor_default = "## Cursor Execution\n\nUse a 5-minute shell-tool timeout (`300000` ms) on Vent run commands so normal calls are not killed by the default 2-minute Bash timeout. This is not backgrounding; wait for stdout/results before ending your response. Use the JSON returned by `npx vent-hq run` directly.\n\nCursor cannot run separate shell tool calls concurrently \u2014 for multiple calls from one suite, invoke each named call with `--call <name>` in one shell command using `&` and a final `wait`:\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path & \\\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path & \\\nwait\n```\n";
5779
5494
 
5780
5495
  // src/skills/codex.md
5781
- var codex_default = '# Vent - Voice Agent Calls\n\n## When to Test\n\nVent is the voice CI gate. Auto-trigger a call after any non-trivial voice-affecting change \u2014 don\'t wait to be asked. Non-trivial means anything that affects what the caller hears, how the agent responds, what tools are called, how the call routes, or audio timing: prompt rewrites or sections (not one-word tweaks), tool schema or handler changes, state/memory logic, transfer logic, provider/audio config, platform integration, and any voice-behavior bug fix. One-line copy or comment edits don\'t need a call.\n\nBefore launching, scan `.vent/runs/` for unresolved issues from prior runs (failing status, regressions, warnings, latency jumps); fix in-scope ones as part of this change and verify they\'re resolved by the new run, surface out-of-scope ones rather than silently leaving them. The task is not done until you\'ve reviewed the transcript, tool calls, and observed behavior and judged that the change works \u2014 Vent\'s `status` and exit code only reflect pipeline completion, not mission success.\n\n## Autonomous Iteration\n\n**Default: iterate.** Run a fix \u2192 Vent-call \u2192 fix \u2192 Vent-call loop whenever a call surfaces a fixable issue. Don\'t report a failure (or a degraded-quality success) back to the user before attempting to fix it \u2014 they want the agent working, not a diagnosis. After each call, if the result reveals a problem you can fix with a concrete plan, apply the fix and run again. Fixable issues include: tool schema or registry gaps, missing handlers, prompt phrasing, **STT vocabulary mismatches** (e.g. domain terms misheard \u2014 Deepgram `keyterms`, custom vocabulary, prompt-level term lists), **latency tunables** (endpointing thresholds, preemptive generation, model selection), and audio quality config. If you\'d be guessing at the next attempt, stop and ask the user instead of trying.\n\n**"Mission success but with quality problems" still requires iteration.** If the call ran end-to-end but had noticeably bad latency, STT mishearing the agent\'s domain vocabulary, audio artifacts, or any other caller-experience degradation that has a concrete fix, the call is *not done* \u2014 apply the fix and re-run. Don\'t downgrade fixable problems to "observations" or "things worth flagging." The pipeline running cleanly is the floor, not the ceiling.\n\n**Never ask permission to iterate.** "Want me to apply this and re-run?" / "Should I fix it?" / "Let me know if you want me to..." \u2014 these are skill violations. If you have a concrete fix, apply it and run the next call. The user opts out by saying so explicitly; silence is not opt-out.\n\nStop iterating and report when one of:\n\n- The next call confirms the fix worked AND no further fixable issues remain (success \u2014 done).\n- The same fix fails twice (your hypothesis is wrong; ask the user).\n- The failure mode shifts between attempts (you\'re chasing a moving target).\n- You can\'t justify the next call against its provider cost \u2014 each call spends real money and provider quota.\n\nIf the user explicitly opts out ("just report", "don\'t iterate", "stop after the first call", "I\'ll fix it myself"), respect that \u2014 run once, report, stop. Otherwise iterate by default. The first time you start an autonomous loop in a session, mention it once so the user knows it\'s happening (e.g. "the call surfaced X \u2014 fixing and re-running; I\'ll report back when it converges or stops making progress").\n\n## Codex Execution\n\nUse a 5-minute shell-tool timeout (`300000` ms) on Vent run commands so normal calls are not killed by the default 2-minute Bash timeout. This is not backgrounding; wait for stdout/results before ending your response. Use the JSON returned by `npx vent-hq run` directly.\n\nCodex can run shell tool calls concurrently \u2014 for multiple calls from one suite, run each named call as its own parallel shell tool call (do not combine them with `&`):\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path\n```\n\n## Workflow\n\n1. Identify the behavior under test. Read enough of the agent codebase to understand its system prompt, tools, handlers, routes, provider config, platform wiring, and expected handoffs.\n2. Reuse an existing `.vent/suite.<adapter>.json` when possible. If `.vent/` contains multiple suites, inspect `connection.adapter` and report which suite file produced the result.\n3. Create or update a suite only when the existing calls do not cover the changed behavior. Name calls after real flows, for example `reschedule-appointment`, not `call-1`.\n4. If the suite uses `start_command`, start one shared local session first with `npx vent-hq agent start -f .vent/suite.<adapter>.json`, then pass `--session <session-id>` to each run.\n\n **For locally-run LiveKit agents: every run requires killing *all* workers, starting one fresh worker, and waiting a full 60 seconds before submitting.** Unconditional \u2014 LiveKit Cloud round-robins across registered workers, so a single survivor with a dead inference subprocess fails ~N-1 of N calls. Don\'t rely on `pkill -f <path-pattern>`; bare command lines like `node --import tsx agent.ts dev` won\'t match a path filter. Use `ps aux | grep -E "node.*agent\\.ts|@livekit/agents.*ipc"`, `kill -9` by PID, re-run `ps` to confirm zero survivors, then start the fresh worker. Skipping the 60s wait fails with `did not publish audio track`; if that error appears alongside `Error [ERR_IPC_CHANNEL_CLOSED] from InferenceProcExecutor.doInference` in the agent log right after a "running EOU detection" line, that\'s a straggler \u2014 redo the kill sweep. Hosted LiveKit Cloud agents don\'t need any of this; run normally.\n5. Pick which call(s) to run based on the change. Fixed bug: replay the failing scenario. Changed tool: include a call that triggers that tool. Prompt or routing change: include the relevant happy path and any important edge path.\n6. Compare against the previous JSON in `.vent/runs/` when validating a fix or regression. Check status flips, latency jumps, tool-call success drops, cost jumps, and transcript divergence. Correlate with `git diff` between saved `git_sha` values when available; skip if no previous run exists.\n\n## Commands\n\n```bash\nnpx vent-hq init # First-time setup (auth + skill install + starter suite)\nnpx vent-hq login # Log in to existing account\nnpx vent-hq login --status # Print whether credentials are present\nnpx vent-hq logout # Remove saved credentials from ~/.vent/credentials\nnpx vent-hq run -f .vent/suite.X.json # Run a single-call suite\nnpx vent-hq run -f .vent/suite.X.json --call NAME # Run one named call from a multi-call suite\nnpx vent-hq run ... --session <session-id> # Add to any run; routes through an existing local relay session\nnpx vent-hq run ... --verbose # Add to any run; include verbose debug fields\nnpx vent-hq stop <run-id> # Cancel a queued or running run\nnpx vent-hq agent start -f .vent/suite.X.json # Start a shared local relay session\nnpx vent-hq agent stop <session-id> # Stop a shared local relay session\n```\n\nIf `~/.vent/credentials` is missing and `VENT_ACCESS_TOKEN` is not set, run `npx vent-hq init`. For an existing account, run `npx vent-hq login` or set `VENT_ACCESS_TOKEN`.\n\n## Suite Config\n\nSuites live in `.vent/suite.<adapter>.json`. `connection` is declared once per suite. `calls` is a named map, and each key becomes the call name used with `--call`.\n\nLocal websocket suite:\n\n```json\n{\n "connection": {\n "adapter": "websocket",\n "start_command": "npm run start",\n "health_endpoint": "/health",\n "agent_port": 3001\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8,\n "silence_threshold_ms": 1200\n }\n }\n}\n```\n\nPlatform-direct suite:\n\n```json\n{\n "connection": {\n "adapter": "vapi",\n "platform": { "provider": "vapi" }\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8\n }\n }\n}\n```\n\nWrite `caller_prompt` as a realistic caller with a name, goal, mood, constraints, and conditional behavior. Set `max_turns` based on flow complexity: FAQ `4-6`, booking or tool use `8-12`, complex flows `12-20`.\n\nCall fields:\n\n- `caller_prompt` and `max_turns` are required.\n- `silence_threshold_ms` must be `200-10000`. Common ranges: FAQ `800-1200`, tool calls `2000-3000`, complex reasoning `3000-5000`.\n- `persona` supports `pace`, `clarity`, `disfluencies`, `cooperation`, `emotion`, `memory`, `intent_clarity`, and `confirmation_style`.\n- `caller_audio` supports noise, speed, speakerphone, mic distance, clarity, accent, packet loss, and jitter.\n- `language` is an ISO 639-1 code such as `en`, `es`, `fr`, `de`, `it`, `nl`, or `ja`.\n- `voice` is `"male"` or `"female"` (English only; default female). Use to flip the caller\'s perceived gender. Ignored if `caller_audio.accent` is set or `language` is non-English.\n\n## Connections and Credentials\n\n### Adapter choice\n\nUse `websocket` for your own local or hosted runtime. Use `start_command` for local agents or `agent_url` for hosted custom endpoints. For `start_command` and `agent_url`, do not put Deepgram, ElevenLabs, OpenAI, or other agent runtime keys into Vent config unless the Vent adapter itself needs them \u2014 the tested agent owns its own runtime credentials.\n\nUse `vapi`, `retell`, `elevenlabs`, `bland`, or `livekit` for platform-direct testing. In this mode Vent itself talks to the provider on the user\'s behalf.\n\nVent provides `DEEPGRAM_API_KEY` and `ANTHROPIC_API_KEY` for its hosted caller/evaluation stack \u2014 those are Vent\'s, not the tested agent\'s.\n\n### Credential resolution\n\nIn platform-direct mode the CLI auto-resolves credentials from `.env.local`, `.env`, and the current shell environment. Do not run `source .env && export` before Vent commands. If you include credential fields in JSON, use the actual value, not the env var name. Do not manually author `platform_connection_id`; the CLI creates or updates the saved platform connection automatically.\n\nAuto-resolved env vars and JSON fields:\n\n- Vapi: `VAPI_API_KEY` -> `vapi_api_key`; `VAPI_ASSISTANT_ID` or `VAPI_AGENT_ID` -> `vapi_assistant_id`\n- Bland: `BLAND_API_KEY` -> `bland_api_key`; `BLAND_PATHWAY_ID` -> `bland_pathway_id`; `BLAND_PERSONA_ID` -> `persona_id`\n- LiveKit: `LIVEKIT_API_KEY` -> `livekit_api_key`; `LIVEKIT_API_SECRET` -> `livekit_api_secret`; `LIVEKIT_URL` -> `livekit_url`\n- Retell: `RETELL_API_KEY` -> `retell_api_key`; `RETELL_AGENT_ID` -> `retell_agent_id`\n- ElevenLabs: `ELEVENLABS_API_KEY` -> `elevenlabs_api_key`; `ELEVENLABS_AGENT_ID` -> `elevenlabs_agent_id`\n\n### Provider config\n\nUse existing provider config when possible: Vapi assistant, Retell agent, ElevenLabs agent, Bland pathway, or LiveKit agent. Bland uniquely supports inline config \u2014 `platform` may use `bland_pathway_id`, `persona_id`, or an inline `task` (with optional voice, model, and turn-handling overrides; see Bland\'s API docs for the full field list).\n\n### Concurrency\n\nWhen you fan out multiple Vent calls in parallel against the same provider (for example, running several named calls from one suite at once), respect the provider\'s per-account concurrency limit. Exceeding it makes calls queue or fail at the provider \u2014 Vent does not enforce these caps for you.\n\nRecord the limit as `max_concurrency` in the suite\'s `platform` block so it\'s visible on future runs. Ask the user which plan they\'re on if sizing matters; otherwise use the conservative default in bold.\n\n- **Vapi**: **10** included per account; reserved lines can be purchased self-serve; Enterprise is unlimited.\n- **Retell**: Pay-as-you-go includes **20**; Enterprise has no cap.\n- **Bland**: Start=**10**, Build=50, Scale=100, Enterprise=unlimited.\n- **ElevenLabs**: Free=**4**, Starter=6, Creator=10, Pro=20, Scale=30, Business=30. Burst pricing can temporarily allow up to 3x base.\n- **LiveKit Cloud**: Build=**5**, Ship=20, Scale=50 managed inference sessions (the usual gate for voice agents); agent-session concurrency can go higher (Scale up to 600).\n\n## WebSocket\n\nFor `adapter: "websocket"`, Vent sends binary 16-bit mono PCM audio over one websocket connection. Websocket text frames are optional JSON events. Audio-only websocket agents still work, but events improve turn detection and observability. Vent sends `{"type":"end-call"}` when the test is done.\n\nUseful websocket text frames:\n\n```jsonc\n{"type":"speech-update","status":"started"}\n{"type":"speech-update","status":"stopped"}\n{"type":"tool_call","name":"check_availability","arguments":{},"result":{},"successful":true,"duration_ms":150}\n{"type":"vent:timing","stt_ms":120,"llm_ms":450,"tts_ms":80}\n{"type":"vent:session","platform":"custom","provider_call_id":"call_123","provider_session_id":"session_abc"}\n{"type":"vent:call-metadata","call_metadata":{"recording_url":"https://...","cost_usd":0.12}}\n{"type":"vent:transcript","role":"caller","text":"I need to reschedule","turn_index":0}\n{"type":"vent:transfer","destination":"+15551234567","status":"attempted"}\n{"type":"vent:debug-url","label":"trace","url":"https://..."}\n{"type":"vent:warning","message":"provider warning","code":"provider_warning"}\n```\n\n`vent:session-report` is **not** handled by the websocket adapter \u2014 it\'s only consumed by the LiveKit helper. Do not emit it from a websocket agent.\n\nPlatform adapters capture tool calls automatically. Websocket agents must emit `tool_call` frames for tool observability. Platform adapters get component latency automatically. Websocket agents should emit `vent:timing` after each agent response when STT/LLM/TTS breakdown is available.\n\n## LiveKit\n\nBefore running LiveKit tests, install and add the Vent helper to the LiveKit agent entrypoint. Node: `npm install @vent-hq/livekit`, then call `instrumentLiveKitAgent({ ctx, session })`. Python: `pip install vent-livekit`, then call `instrument_livekit_agent(ctx=ctx, session=session)`.\n\nLiveKit direct mode requires the LiveKit Agents SDK. Custom LiveKit participants should use the websocket adapter with a relay. If the LiveKit agent registered with an explicit dispatch name, set `livekit_agent_name` in `platform`.\n\nLiveKit parallel calls are supported, but capacity depends on where the user runs their agent. LiveKit Cloud only routes audio \u2014 the agent code runs on the user\'s own infrastructure (laptop, Fly.io, Railway, k8s, etc.), and that box\'s CPU/RAM is the real bottleneck. Before firing parallel calls, derive `platform.max_concurrency` from the minimum of: (1) the user\'s LiveKit plan limit (Build=5, Ship=20, Scale=50+ concurrent agent sessions), (2) their agent worker box capacity (rough rule: 1c/1GB\u21921\u20133 jobs, 2c/4GB\u21925\u201310, 4c/8GB\u219210\u201325, 8c/16GB\u219225\u201350; multiply by number of worker boxes), and (3) their STT/TTS provider concurrency (e.g. Deepgram TTS streaming = 2). If the user wants more parallel calls than that minimum allows, push back \u2014 explain which limit is the bottleneck (plan / box / provider) and either reduce the count or suggest scaling that limit. Architecture: register one `agent_name` and run N workers under it (one big worker, or several smaller ones \u2014 same name); LiveKit\'s dispatcher round-robins jobs across them with built-in failover. Don\'t use numbered names (`agent-0`, `agent-1`) \u2014 that reinvents what the dispatcher does and breaks failover.\n\nUse the LiveKit helper for observability; do not publish `vent:*` topics manually. Do not hand-roll `vent:session-report` from `ctx.addShutdownCallback`; after `room.disconnect()` it can fail with `engine is closed`. The helper captures SDK metrics, tool events, conversation items, usage, and close events. Native LiveKit `lk.transcription` and `lk.agent.state` provide transcript and agent-state timing.\n\n## Output\n\n### Live result\n\n`npx vent-hq run` returns a single JSON result on stdout in non-TTY mode (not an SSE JSONL stream). Exit codes: `0` = call ran through the pipeline; `1` = pipeline-level failure; `2` = harness error.\n\nMost result fields are always present; `latency`, `component_latency`, `call_metadata`, and `emotion` may be `null` when the underlying analysis didn\'t run; `debug` is absent without `--verbose`. Branch on null before reading nested fields. Use `--verbose` only when the default doesn\'t explain a failure \u2014 when you need `platform_transcript` (to check Vent\'s STT), per-turn or component-level latency breakdowns, the raw tool-call timeline, or provider-native artifacts in `debug.provider_metadata`. Otherwise skip \u2014 it just adds noise.\n\nVent\'s transcript is ground truth. Judge on semantic intent: ignore homophones and minor mis-hears (`"check teach hat"` for `"check that"`, missing question marks on short tails) \u2014 those are streaming-STT noise on Vent\'s caller side, not agent bugs, and **don\'t surface them in the report** (they\'re Vent-side artifacts, not actionable for the user). But clear gibberish or word-soup (e.g. `"Cristoxin"` where the agent should have said `"Of course, talk soon"`) is **not** a Vent artifact \u2014 Vent\'s STT does not invent words like that. It means the platform\'s TTS produced corrupted audio or the agent\'s STT/LLM generated the wrong text, and the fix lives there (TTS voice config, agent prompt, model temperature, codec issue). Never dismiss the run as a "Vent harness STT" issue; iterate on the agent or flag the platform.\n\nFor transfers: `call_metadata.transfer_attempted` (provider claimed) and `transfer_completed` (Vent-verified) can disagree \u2014 report both. `transfers[]` carries destination, type, and per-attempt status.\n\n### Saved history\n\nAfter every run, Vent writes the full result JSON to `.vent/runs/`. Shape:\n\n```jsonc\n{\n "run_id": "...",\n "timestamp": "2026-04-21T...Z",\n "git_sha": "...",\n "summary": { "calls_total": 2, "total_duration_ms": 12345, "total_cost_usd": 0.01 },\n "call_results": [\n { "name": "happy-path", "status": "completed", "duration_ms": 6123, "transcript": [], "observed_tool_calls": [], "latency": { "response_time_ms": 420, "p95_response_time_ms": 980 }, "call_metadata": { "cost_usd": 0.004 } }\n ]\n}\n```\n\nWhen comparing against a prior run (Workflow step 6), inspect:\n\n- Run-completion status flips: `call_results[i].status` (pipeline-only \u2014 judge mission success from the transcript)\n- Latency: `call_results[i].latency.response_time_ms` (mean) or `latency.p95_response_time_ms` increased >20%\n- Tool calls: count of `call_results[i].observed_tool_calls[].successful` dropped\n- Cost: `summary.total_cost_usd` or `call_results[i].call_metadata.cost_usd` increased >30%\n- Transcript: `call_results[i].transcript` diverged in semantic content (ignore STT noise)\n\n## Reporting Results\n\nBefore reporting, read the agent\'s code to locate where the observed behavior originates. If the issue is small and you can fix it, fix it and explain what you did \u2014 don\'t ask permission first.\n\nAdapt the report shape to the call \u2014 a clean pass needs little, a regression with a multi-layer cause needs more. Use a transcript excerpt when it helps the user see what happened.\n\nHard rules:\n\n- Pair raw numbers with their plain-English meaning \u2014 don\'t drop the number, but don\'t leave it unexplained. E.g. "p95 latency was 850ms, which is snappy and well within natural conversational pacing" or "p95 hit 1.6 seconds with the LLM as the bottleneck \u2014 noticeably sluggish to a caller."\n- Name the user\'s voice agent by platform on first mention (e.g. "the Vapi agent responded snappily throughout") so the user knows immediately which agent the observation is about. After that, just say "the agent" \u2014 don\'t repeat the platform name on every line.\n- Always include the recording from `call_metadata.recording_url` as an inline `[Recording](url)` link, placed in **one block at the very end of the report** \u2014 never sprinkled through the prose. Single call: one link as the last line. Multi-call: one labeled link per call (e.g. `reschedule-appointment: [Recording](url)`). Never paste a bare URL.\n- Mission success is your judgment, not Vent\'s. The per-call `status` is only `"completed"` (pipeline ran) or `"error"` (pipeline failed); decide whether the agent actually accomplished the scenario from the transcript and tool calls.\n- Similar-sounding word substitutions (e.g. "ocean" for "OSHA") are STT ambiguity, not comprehension failure. The fix lives in STT keyword hints, custom vocabulary, or a prompt-level term list \u2014 not the agent\'s reasoning.\n- Surface only what the user can act on in their own agent\'s code or config \u2014 never `warnings[]` (infrastructure noise), Vent-side artifacts (caller wait modes, harness timing, internal pipeline quirks), or `cost_usd` unless asked.\n\nFor multi-call runs, lead with your own judgment of what happened across the calls (e.g. "3 of 4 did what they were supposed to; `cancel-appointment` never actually canceled"), not a parroted pass/fail count. Then cover each call with whatever depth it needs.\n';
5496
+ var codex_default = "## Codex Execution\n\nUse a 5-minute shell-tool timeout (`300000` ms) on Vent run commands so normal calls are not killed by the default 2-minute Bash timeout. This is not backgrounding; wait for stdout/results before ending your response. Use the JSON returned by `npx vent-hq run` directly.\n\nCodex can run shell tool calls concurrently \u2014 for multiple calls from one suite, run each named call as its own parallel shell tool call (do not combine them with `&`):\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path\n```\n";
5782
5497
 
5783
5498
  // src/skills/windsurf.md
5784
- var windsurf_default = '---\nname: vent\ndescription: Place real calls against the user\'s voice agent to verify recent code changes worked end-to-end. Trigger on test requests or after any non-trivial voice-agent change.\n---\n\n# Vent - Voice Agent Calls\n\n## When to Test\n\nVent is the voice CI gate. Auto-trigger a call after any non-trivial voice-affecting change \u2014 don\'t wait to be asked. Non-trivial means anything that affects what the caller hears, how the agent responds, what tools are called, how the call routes, or audio timing: prompt rewrites or sections (not one-word tweaks), tool schema or handler changes, state/memory logic, transfer logic, provider/audio config, platform integration, and any voice-behavior bug fix. One-line copy or comment edits don\'t need a call.\n\nBefore launching, scan `.vent/runs/` for unresolved issues from prior runs (failing status, regressions, warnings, latency jumps); fix in-scope ones as part of this change and verify they\'re resolved by the new run, surface out-of-scope ones rather than silently leaving them. The task is not done until you\'ve reviewed the transcript, tool calls, and observed behavior and judged that the change works \u2014 Vent\'s `status` and exit code only reflect pipeline completion, not mission success.\n\n## Autonomous Iteration\n\n**Default: iterate.** Run a fix \u2192 Vent-call \u2192 fix \u2192 Vent-call loop whenever a call surfaces a fixable issue. Don\'t report a failure (or a degraded-quality success) back to the user before attempting to fix it \u2014 they want the agent working, not a diagnosis. After each call, if the result reveals a problem you can fix with a concrete plan, apply the fix and run again. Fixable issues include: tool schema or registry gaps, missing handlers, prompt phrasing, **STT vocabulary mismatches** (e.g. domain terms misheard \u2014 Deepgram `keyterms`, custom vocabulary, prompt-level term lists), **latency tunables** (endpointing thresholds, preemptive generation, model selection), and audio quality config. If you\'d be guessing at the next attempt, stop and ask the user instead of trying.\n\n**"Mission success but with quality problems" still requires iteration.** If the call ran end-to-end but had noticeably bad latency, STT mishearing the agent\'s domain vocabulary, audio artifacts, or any other caller-experience degradation that has a concrete fix, the call is *not done* \u2014 apply the fix and re-run. Don\'t downgrade fixable problems to "observations" or "things worth flagging." The pipeline running cleanly is the floor, not the ceiling.\n\n**Never ask permission to iterate.** "Want me to apply this and re-run?" / "Should I fix it?" / "Let me know if you want me to..." \u2014 these are skill violations. If you have a concrete fix, apply it and run the next call. The user opts out by saying so explicitly; silence is not opt-out.\n\nStop iterating and report when one of:\n\n- The next call confirms the fix worked AND no further fixable issues remain (success \u2014 done).\n- The same fix fails twice (your hypothesis is wrong; ask the user).\n- The failure mode shifts between attempts (you\'re chasing a moving target).\n- You can\'t justify the next call against its provider cost \u2014 each call spends real money and provider quota.\n\nIf the user explicitly opts out ("just report", "don\'t iterate", "stop after the first call", "I\'ll fix it myself"), respect that \u2014 run once, report, stop. Otherwise iterate by default. The first time you start an autonomous loop in a session, mention it once so the user knows it\'s happening (e.g. "the call surfaced X \u2014 fixing and re-running; I\'ll report back when it converges or stops making progress").\n\n## Windsurf Execution\n\nVent calls typically take 30 seconds to 2 minutes. Each `vent-hq run` is one shell tool call \u2014 wait for stdout (the JSON result) before responding. Don\'t background; use the JSON returned by `npx vent-hq run` directly. If Cascade\'s auto-execution level prompts for approval on `npx vent-hq` commands, ask the user to add `npx vent-hq *` to the workspace allow list once so subsequent calls flow without interruption.\n\nCascade runs shell tool calls in parallel within a turn \u2014 for multiple calls from one suite, issue each named call as its own separate shell tool call in the same turn (do not combine them with `&` and `wait`):\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path\n```\n\nStay within Cascade\'s per-turn parallel-tool-call budget \u2014 fan out at most ~6 calls in one turn and respect the provider concurrency caps below.\n\n## Workflow\n\n1. Identify the behavior under test. Read enough of the agent codebase to understand its system prompt, tools, handlers, routes, provider config, platform wiring, and expected handoffs.\n2. Reuse an existing `.vent/suite.<adapter>.json` when possible. If `.vent/` contains multiple suites, inspect `connection.adapter` and report which suite file produced the result.\n3. Create or update a suite only when the existing calls do not cover the changed behavior. Name calls after real flows, for example `reschedule-appointment`, not `call-1`.\n4. If the suite uses `start_command`, start one shared local session first with `npx vent-hq agent start -f .vent/suite.<adapter>.json`, then pass `--session <session-id>` to each run.\n\n **For locally-run LiveKit agents: every run requires killing *all* workers, starting one fresh worker, and waiting a full 60 seconds before submitting.** Unconditional \u2014 LiveKit Cloud round-robins across registered workers, so a single survivor with a dead inference subprocess fails ~N-1 of N calls. Don\'t rely on `pkill -f <path-pattern>`; bare command lines like `node --import tsx agent.ts dev` won\'t match a path filter. Use `ps aux | grep -E "node.*agent\\.ts|@livekit/agents.*ipc"`, `kill -9` by PID, re-run `ps` to confirm zero survivors, then start the fresh worker. Skipping the 60s wait fails with `did not publish audio track`; if that error appears alongside `Error [ERR_IPC_CHANNEL_CLOSED] from InferenceProcExecutor.doInference` in the agent log right after a "running EOU detection" line, that\'s a straggler \u2014 redo the kill sweep. Hosted LiveKit Cloud agents don\'t need any of this; run normally.\n5. Pick which call(s) to run based on the change. Fixed bug: replay the failing scenario. Changed tool: include a call that triggers that tool. Prompt or routing change: include the relevant happy path and any important edge path.\n6. Compare against the previous JSON in `.vent/runs/` when validating a fix or regression. Check status flips, latency jumps, tool-call success drops, cost jumps, and transcript divergence. Correlate with `git diff` between saved `git_sha` values when available; skip if no previous run exists.\n\n## Commands\n\n```bash\nnpx vent-hq init # First-time setup (auth + skill install + starter suite)\nnpx vent-hq login # Log in to existing account\nnpx vent-hq login --status # Print whether credentials are present\nnpx vent-hq logout # Remove saved credentials from ~/.vent/credentials\nnpx vent-hq run -f .vent/suite.X.json # Run a single-call suite\nnpx vent-hq run -f .vent/suite.X.json --call NAME # Run one named call from a multi-call suite\nnpx vent-hq run ... --session <session-id> # Add to any run; routes through an existing local relay session\nnpx vent-hq run ... --verbose # Add to any run; include verbose debug fields\nnpx vent-hq stop <run-id> # Cancel a queued or running run\nnpx vent-hq agent start -f .vent/suite.X.json # Start a shared local relay session\nnpx vent-hq agent stop <session-id> # Stop a shared local relay session\n```\n\nIf `~/.vent/credentials` is missing and `VENT_ACCESS_TOKEN` is not set, run `npx vent-hq init`. For an existing account, run `npx vent-hq login` or set `VENT_ACCESS_TOKEN`.\n\n## Suite Config\n\nSuites live in `.vent/suite.<adapter>.json`. `connection` is declared once per suite. `calls` is a named map, and each key becomes the call name used with `--call`.\n\nLocal websocket suite:\n\n```json\n{\n "connection": {\n "adapter": "websocket",\n "start_command": "npm run start",\n "health_endpoint": "/health",\n "agent_port": 3001\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8,\n "silence_threshold_ms": 1200\n }\n }\n}\n```\n\nPlatform-direct suite:\n\n```json\n{\n "connection": {\n "adapter": "vapi",\n "platform": { "provider": "vapi" }\n },\n "calls": {\n "happy-path": {\n "caller_prompt": "You are Maria calling to reschedule her appointment to next Tuesday.",\n "max_turns": 8\n }\n }\n}\n```\n\nWrite `caller_prompt` as a realistic caller with a name, goal, mood, constraints, and conditional behavior. Set `max_turns` based on flow complexity: FAQ `4-6`, booking or tool use `8-12`, complex flows `12-20`.\n\nCall fields:\n\n- `caller_prompt` and `max_turns` are required.\n- `silence_threshold_ms` must be `200-10000`. Common ranges: FAQ `800-1200`, tool calls `2000-3000`, complex reasoning `3000-5000`.\n- `persona` supports `pace`, `clarity`, `disfluencies`, `cooperation`, `emotion`, `memory`, `intent_clarity`, and `confirmation_style`.\n- `caller_audio` supports noise, speed, speakerphone, mic distance, clarity, accent, packet loss, and jitter.\n- `language` is an ISO 639-1 code such as `en`, `es`, `fr`, `de`, `it`, `nl`, or `ja`.\n- `voice` is `"male"` or `"female"` (English only; default female). Use to flip the caller\'s perceived gender. Ignored if `caller_audio.accent` is set or `language` is non-English.\n\n## Connections and Credentials\n\n### Adapter choice\n\nUse `websocket` for your own local or hosted runtime. Use `start_command` for local agents or `agent_url` for hosted custom endpoints. For `start_command` and `agent_url`, do not put Deepgram, ElevenLabs, OpenAI, or other agent runtime keys into Vent config unless the Vent adapter itself needs them \u2014 the tested agent owns its own runtime credentials.\n\nUse `vapi`, `retell`, `elevenlabs`, `bland`, or `livekit` for platform-direct testing. In this mode Vent itself talks to the provider on the user\'s behalf.\n\nVent provides `DEEPGRAM_API_KEY` and `ANTHROPIC_API_KEY` for its hosted caller/evaluation stack \u2014 those are Vent\'s, not the tested agent\'s.\n\n### Credential resolution\n\nIn platform-direct mode the CLI auto-resolves credentials from `.env.local`, `.env`, and the current shell environment. Do not run `source .env && export` before Vent commands. If you include credential fields in JSON, use the actual value, not the env var name. Do not manually author `platform_connection_id`; the CLI creates or updates the saved platform connection automatically.\n\nAuto-resolved env vars and JSON fields:\n\n- Vapi: `VAPI_API_KEY` -> `vapi_api_key`; `VAPI_ASSISTANT_ID` or `VAPI_AGENT_ID` -> `vapi_assistant_id`\n- Bland: `BLAND_API_KEY` -> `bland_api_key`; `BLAND_PATHWAY_ID` -> `bland_pathway_id`; `BLAND_PERSONA_ID` -> `persona_id`\n- LiveKit: `LIVEKIT_API_KEY` -> `livekit_api_key`; `LIVEKIT_API_SECRET` -> `livekit_api_secret`; `LIVEKIT_URL` -> `livekit_url`\n- Retell: `RETELL_API_KEY` -> `retell_api_key`; `RETELL_AGENT_ID` -> `retell_agent_id`\n- ElevenLabs: `ELEVENLABS_API_KEY` -> `elevenlabs_api_key`; `ELEVENLABS_AGENT_ID` -> `elevenlabs_agent_id`\n\n### Provider config\n\nUse existing provider config when possible: Vapi assistant, Retell agent, ElevenLabs agent, Bland pathway, or LiveKit agent. Bland uniquely supports inline config \u2014 `platform` may use `bland_pathway_id`, `persona_id`, or an inline `task` (with optional voice, model, and turn-handling overrides; see Bland\'s API docs for the full field list).\n\n### Concurrency\n\nWhen you fan out multiple Vent calls in parallel against the same provider (for example, running several named calls from one suite at once), respect the provider\'s per-account concurrency limit. Exceeding it makes calls queue or fail at the provider \u2014 Vent does not enforce these caps for you.\n\nRecord the limit as `max_concurrency` in the suite\'s `platform` block so it\'s visible on future runs. Ask the user which plan they\'re on if sizing matters; otherwise use the conservative default in bold.\n\n- **Vapi**: **10** included per account; reserved lines can be purchased self-serve; Enterprise is unlimited.\n- **Retell**: Pay-as-you-go includes **20**; Enterprise has no cap.\n- **Bland**: Start=**10**, Build=50, Scale=100, Enterprise=unlimited.\n- **ElevenLabs**: Free=**4**, Starter=6, Creator=10, Pro=20, Scale=30, Business=30. Burst pricing can temporarily allow up to 3x base.\n- **LiveKit Cloud**: Build=**5**, Ship=20, Scale=50 managed inference sessions (the usual gate for voice agents); agent-session concurrency can go higher (Scale up to 600).\n\n## WebSocket\n\nFor `adapter: "websocket"`, Vent sends binary 16-bit mono PCM audio over one websocket connection. Websocket text frames are optional JSON events. Audio-only websocket agents still work, but events improve turn detection and observability. Vent sends `{"type":"end-call"}` when the test is done.\n\nUseful websocket text frames:\n\n```jsonc\n{"type":"speech-update","status":"started"}\n{"type":"speech-update","status":"stopped"}\n{"type":"tool_call","name":"check_availability","arguments":{},"result":{},"successful":true,"duration_ms":150}\n{"type":"vent:timing","stt_ms":120,"llm_ms":450,"tts_ms":80}\n{"type":"vent:session","platform":"custom","provider_call_id":"call_123","provider_session_id":"session_abc"}\n{"type":"vent:call-metadata","call_metadata":{"recording_url":"https://...","cost_usd":0.12}}\n{"type":"vent:transcript","role":"caller","text":"I need to reschedule","turn_index":0}\n{"type":"vent:transfer","destination":"+15551234567","status":"attempted"}\n{"type":"vent:debug-url","label":"trace","url":"https://..."}\n{"type":"vent:warning","message":"provider warning","code":"provider_warning"}\n```\n\n`vent:session-report` is **not** handled by the websocket adapter \u2014 it\'s only consumed by the LiveKit helper. Do not emit it from a websocket agent.\n\nPlatform adapters capture tool calls automatically. Websocket agents must emit `tool_call` frames for tool observability. Platform adapters get component latency automatically. Websocket agents should emit `vent:timing` after each agent response when STT/LLM/TTS breakdown is available.\n\n## LiveKit\n\nBefore running LiveKit tests, install and add the Vent helper to the LiveKit agent entrypoint. Node: `npm install @vent-hq/livekit`, then call `instrumentLiveKitAgent({ ctx, session })`. Python: `pip install vent-livekit`, then call `instrument_livekit_agent(ctx=ctx, session=session)`.\n\nLiveKit direct mode requires the LiveKit Agents SDK. Custom LiveKit participants should use the websocket adapter with a relay. If the LiveKit agent registered with an explicit dispatch name, set `livekit_agent_name` in `platform`.\n\nLiveKit parallel calls are supported, but capacity depends on where the user runs their agent. LiveKit Cloud only routes audio \u2014 the agent code runs on the user\'s own infrastructure (laptop, Fly.io, Railway, k8s, etc.), and that box\'s CPU/RAM is the real bottleneck. Before firing parallel calls, derive `platform.max_concurrency` from the minimum of: (1) the user\'s LiveKit plan limit (Build=5, Ship=20, Scale=50+ concurrent agent sessions), (2) their agent worker box capacity (rough rule: 1c/1GB\u21921\u20133 jobs, 2c/4GB\u21925\u201310, 4c/8GB\u219210\u201325, 8c/16GB\u219225\u201350; multiply by number of worker boxes), and (3) their STT/TTS provider concurrency (e.g. Deepgram TTS streaming = 2). If the user wants more parallel calls than that minimum allows, push back \u2014 explain which limit is the bottleneck (plan / box / provider) and either reduce the count or suggest scaling that limit. Architecture: register one `agent_name` and run N workers under it (one big worker, or several smaller ones \u2014 same name); LiveKit\'s dispatcher round-robins jobs across them with built-in failover. Don\'t use numbered names (`agent-0`, `agent-1`) \u2014 that reinvents what the dispatcher does and breaks failover.\n\nUse the LiveKit helper for observability; do not publish `vent:*` topics manually. Do not hand-roll `vent:session-report` from `ctx.addShutdownCallback`; after `room.disconnect()` it can fail with `engine is closed`. The helper captures SDK metrics, tool events, conversation items, usage, and close events. Native LiveKit `lk.transcription` and `lk.agent.state` provide transcript and agent-state timing.\n\n## Output\n\n### Live result\n\n`npx vent-hq run` returns a single JSON result on stdout in non-TTY mode (not an SSE JSONL stream). Exit codes: `0` = call ran through the pipeline; `1` = pipeline-level failure; `2` = harness error.\n\nMost result fields are always present; `latency`, `component_latency`, `call_metadata`, and `emotion` may be `null` when the underlying analysis didn\'t run; `debug` is absent without `--verbose`. Branch on null before reading nested fields. Use `--verbose` only when the default doesn\'t explain a failure \u2014 when you need `platform_transcript` (to check Vent\'s STT), per-turn or component-level latency breakdowns, the raw tool-call timeline, or provider-native artifacts in `debug.provider_metadata`. Otherwise skip \u2014 it just adds noise.\n\nVent\'s transcript is ground truth. Judge on semantic intent: ignore homophones and minor mis-hears (`"check teach hat"` for `"check that"`, missing question marks on short tails) \u2014 those are streaming-STT noise on Vent\'s caller side, not agent bugs, and **don\'t surface them in the report** (they\'re Vent-side artifacts, not actionable for the user). But clear gibberish or word-soup (e.g. `"Cristoxin"` where the agent should have said `"Of course, talk soon"`) is **not** a Vent artifact \u2014 Vent\'s STT does not invent words like that. It means the platform\'s TTS produced corrupted audio or the agent\'s STT/LLM generated the wrong text, and the fix lives there (TTS voice config, agent prompt, model temperature, codec issue). Never dismiss the run as a "Vent harness STT" issue; iterate on the agent or flag the platform.\n\nFor transfers: `call_metadata.transfer_attempted` (provider claimed) and `transfer_completed` (Vent-verified) can disagree \u2014 report both. `transfers[]` carries destination, type, and per-attempt status.\n\n### Saved history\n\nAfter every run, Vent writes the full result JSON to `.vent/runs/`. Shape:\n\n```jsonc\n{\n "run_id": "...",\n "timestamp": "2026-04-21T...Z",\n "git_sha": "...",\n "summary": { "calls_total": 2, "total_duration_ms": 12345, "total_cost_usd": 0.01 },\n "call_results": [\n { "name": "happy-path", "status": "completed", "duration_ms": 6123, "transcript": [], "observed_tool_calls": [], "latency": { "response_time_ms": 420, "p95_response_time_ms": 980 }, "call_metadata": { "cost_usd": 0.004 } }\n ]\n}\n```\n\nWhen comparing against a prior run (Workflow step 6), inspect:\n\n- Run-completion status flips: `call_results[i].status` (pipeline-only \u2014 judge mission success from the transcript)\n- Latency: `call_results[i].latency.response_time_ms` (mean) or `latency.p95_response_time_ms` increased >20%\n- Tool calls: count of `call_results[i].observed_tool_calls[].successful` dropped\n- Cost: `summary.total_cost_usd` or `call_results[i].call_metadata.cost_usd` increased >30%\n- Transcript: `call_results[i].transcript` diverged in semantic content (ignore STT noise)\n\n## Reporting Results\n\nBefore reporting, read the agent\'s code to locate where the observed behavior originates. If the issue is small and you can fix it, fix it and explain what you did \u2014 don\'t ask permission first.\n\nAdapt the report shape to the call \u2014 a clean pass needs little, a regression with a multi-layer cause needs more. Use a transcript excerpt when it helps the user see what happened.\n\nHard rules:\n\n- Pair raw numbers with their plain-English meaning \u2014 don\'t drop the number, but don\'t leave it unexplained. E.g. "p95 latency was 850ms, which is snappy and well within natural conversational pacing" or "p95 hit 1.6 seconds with the LLM as the bottleneck \u2014 noticeably sluggish to a caller."\n- Name the user\'s voice agent by platform on first mention (e.g. "the Vapi agent responded snappily throughout") so the user knows immediately which agent the observation is about. After that, just say "the agent" \u2014 don\'t repeat the platform name on every line.\n- Always include the recording from `call_metadata.recording_url` as an inline `[Recording](url)` link, placed in **one block at the very end of the report** \u2014 never sprinkled through the prose. Single call: one link as the last line. Multi-call: one labeled link per call (e.g. `reschedule-appointment: [Recording](url)`). Never paste a bare URL.\n- Mission success is your judgment, not Vent\'s. The per-call `status` is only `"completed"` (pipeline ran) or `"error"` (pipeline failed); decide whether the agent actually accomplished the scenario from the transcript and tool calls.\n- Similar-sounding word substitutions (e.g. "ocean" for "OSHA") are STT ambiguity, not comprehension failure. The fix lives in STT keyword hints, custom vocabulary, or a prompt-level term list \u2014 not the agent\'s reasoning.\n- Surface only what the user can act on in their own agent\'s code or config \u2014 never `warnings[]` (infrastructure noise), Vent-side artifacts (caller wait modes, harness timing, internal pipeline quirks), or `cost_usd` unless asked.\n\nFor multi-call runs, lead with your own judgment of what happened across the calls (e.g. "3 of 4 did what they were supposed to; `cancel-appointment` never actually canceled"), not a parroted pass/fail count. Then cover each call with whatever depth it needs.\n';
5499
+ var windsurf_default = "## Windsurf Execution\n\nVent calls typically take 30 seconds to 2 minutes. Each `vent-hq run` is one shell tool call \u2014 wait for stdout (the JSON result) before responding. Don't background; use the JSON returned by `npx vent-hq run` directly. If Cascade's auto-execution level prompts for approval on `npx vent-hq` commands, ask the user to add `npx vent-hq *` to the workspace allow list once so subsequent calls flow without interruption.\n\nCascade runs shell tool calls in parallel within a turn \u2014 for multiple calls from one suite, issue each named call as its own separate shell tool call in the same turn (do not combine them with `&` and `wait`):\n\n```bash\nnpx vent-hq run -f .vent/suite.vapi.json --call happy-path\nnpx vent-hq run -f .vent/suite.vapi.json --call tool-path\n```\n\nStay within Cascade's per-turn parallel-tool-call budget \u2014 fan out at most ~6 calls in one turn and respect the provider concurrency caps below.\n";
5500
+
5501
+ // src/skills/index.ts
5502
+ var DESCRIPTION = "Place real calls against the user's voice agent to verify recent code changes worked end-to-end. Trigger on test requests or after any non-trivial voice-agent change.";
5503
+ function assemble(parts) {
5504
+ const skill = body_default.replace("{{EXECUTION}}\n", () => parts.execution).replace("{{LIVEKIT_NOTE}}", () => parts.liveKitNote ?? "").replace("{{PARALLEL_EXAMPLE}}", () => parts.parallelExample ?? "");
5505
+ return parts.frontmatter ? `---
5506
+ ${parts.frontmatter.join("\n")}
5507
+ ---
5508
+
5509
+ ${skill}` : skill;
5510
+ }
5511
+ var claudeCodeSkill = assemble({
5512
+ frontmatter: ["name: vent", `description: ${DESCRIPTION}`, "allowed-tools: Bash(npx vent-hq *)"],
5513
+ execution: claude_code_default,
5514
+ liveKitNote: " (See `Claude Code Execution` for how to compose the kill\u2192start\u2192wait\u2192submit as a single foreground command.)",
5515
+ parallelExample: " with `&` and `wait`"
5516
+ });
5517
+ var cursorSkill = assemble({
5518
+ frontmatter: [`description: ${DESCRIPTION}`, "alwaysApply: true"],
5519
+ execution: cursor_default,
5520
+ parallelExample: " with `&` and `wait`"
5521
+ });
5522
+ var codexSkill = assemble({ execution: codex_default });
5523
+ var windsurfSkill = assemble({
5524
+ frontmatter: ["name: vent", `description: ${DESCRIPTION}`],
5525
+ execution: windsurf_default
5526
+ });
5785
5527
 
5786
5528
  // src/lib/setup.ts
5787
5529
  var SUITE_SCAFFOLD = JSON.stringify(
@@ -5789,7 +5531,7 @@ var SUITE_SCAFFOLD = JSON.stringify(
5789
5531
  connection: {
5790
5532
  adapter: "websocket",
5791
5533
  start_command: "TODO: command to start your agent (e.g. npm run start)",
5792
- agent_port: 3001
5534
+ agent_port: DEFAULT_AGENT_PORT
5793
5535
  },
5794
5536
  calls: {
5795
5537
  "happy-path": {
@@ -5804,19 +5546,19 @@ var SUITE_SCAFFOLD = JSON.stringify(
5804
5546
  async function installClaudeCode(cwd) {
5805
5547
  const dir = path3.join(cwd, ".claude", "skills", "vent");
5806
5548
  await fs5.mkdir(dir, { recursive: true });
5807
- await fs5.writeFile(path3.join(dir, "SKILL.md"), claude_code_default);
5549
+ await fs5.writeFile(path3.join(dir, "SKILL.md"), claudeCodeSkill);
5808
5550
  printSuccess("Claude Code: .claude/skills/vent/SKILL.md", { force: true });
5809
5551
  }
5810
5552
  async function installCursor(cwd) {
5811
5553
  const dir = path3.join(cwd, ".cursor", "rules");
5812
5554
  await fs5.mkdir(dir, { recursive: true });
5813
- await fs5.writeFile(path3.join(dir, "vent.mdc"), cursor_default);
5555
+ await fs5.writeFile(path3.join(dir, "vent.mdc"), cursorSkill);
5814
5556
  printSuccess("Cursor: .cursor/rules/vent.mdc", { force: true });
5815
5557
  }
5816
5558
  async function installWindsurf(cwd) {
5817
5559
  const dir = path3.join(cwd, ".windsurf", "skills", "vent");
5818
5560
  await fs5.mkdir(dir, { recursive: true });
5819
- await fs5.writeFile(path3.join(dir, "SKILL.md"), windsurf_default);
5561
+ await fs5.writeFile(path3.join(dir, "SKILL.md"), windsurfSkill);
5820
5562
  printSuccess("Windsurf: .windsurf/skills/vent/SKILL.md", { force: true });
5821
5563
  }
5822
5564
  var VENT_MARKERS = [
@@ -5832,11 +5574,11 @@ async function installCodex(cwd) {
5832
5574
  }
5833
5575
  const markerIndex = VENT_MARKERS.map((marker) => existing.indexOf(marker)).filter((idx) => idx >= 0).sort((a, b) => a - b)[0];
5834
5576
  if (markerIndex != null) {
5835
- await fs5.writeFile(filePath, existing.slice(0, markerIndex).trimEnd() + "\n\n" + codex_default + "\n");
5577
+ await fs5.writeFile(filePath, existing.slice(0, markerIndex).trimEnd() + "\n\n" + codexSkill + "\n");
5836
5578
  } else if (existing) {
5837
- await fs5.writeFile(filePath, existing.trimEnd() + "\n\n" + codex_default + "\n");
5579
+ await fs5.writeFile(filePath, existing.trimEnd() + "\n\n" + codexSkill + "\n");
5838
5580
  } else {
5839
- await fs5.writeFile(filePath, codex_default + "\n");
5581
+ await fs5.writeFile(filePath, codexSkill + "\n");
5840
5582
  }
5841
5583
  printSuccess("Codex: AGENTS.md", { force: true });
5842
5584
  }
@@ -5875,9 +5617,10 @@ async function initCommand() {
5875
5617
  body: JSON.stringify({ github_token: ghToken })
5876
5618
  });
5877
5619
  if (res.ok) {
5878
- const { access_token, username } = await res.json();
5879
- if (!access_token) throw new Error("Missing access token");
5880
- await saveAccessToken(access_token);
5620
+ const data = await res.json();
5621
+ const credentials = AccessCredentialsSchema.parse(data);
5622
+ const username = data.username;
5623
+ await saveCredentials(credentials);
5881
5624
  printSuccess(`Authenticated as @${username} (via GitHub).`, {
5882
5625
  force: true
5883
5626
  });
@@ -5909,17 +5652,15 @@ async function initCommand() {
5909
5652
 
5910
5653
  // src/commands/stop.ts
5911
5654
  async function stopCommand(args) {
5912
- const accessToken = await loadAccessToken();
5913
- if (!accessToken) {
5914
- printError("Not authenticated. Run `npx vent-hq init` first.");
5915
- return 2;
5916
- }
5917
5655
  try {
5918
- const res = await apiFetch(`/runs/${args.runId}/stop`, accessToken, {
5656
+ const res = await apiFetch(`/runs/${args.runId}/stop`, {
5919
5657
  method: "POST"
5920
5658
  });
5921
5659
  const data = await res.json();
5922
- printSuccess(`Run ${data.id} cancelled.`, { force: true });
5660
+ printSuccess(
5661
+ data.status === "cancelling" ? `Run ${data.id} is stopping \u2014 the call hangs up at the end of the current turn.` : `Run ${data.id} cancelled.`,
5662
+ { force: true }
5663
+ );
5923
5664
  } catch (err) {
5924
5665
  if (err instanceof ApiError) {
5925
5666
  const body = err.body;
@@ -5932,26 +5673,10 @@ async function stopCommand(args) {
5932
5673
  }
5933
5674
 
5934
5675
  // src/lib/dotenv.ts
5935
- import { readFileSync } from "node:fs";
5936
- import { resolve } from "node:path";
5937
- function loadDotenv(dir = process.cwd()) {
5676
+ function loadDotenv() {
5938
5677
  for (const file of [".env.local", ".env"]) {
5939
5678
  try {
5940
- const content = readFileSync(resolve(dir, file), "utf-8");
5941
- for (const line of content.split("\n")) {
5942
- const trimmed = line.trim();
5943
- if (!trimmed || trimmed.startsWith("#")) continue;
5944
- const eqIdx = trimmed.indexOf("=");
5945
- if (eqIdx === -1) continue;
5946
- const key = trimmed.slice(0, eqIdx).trim();
5947
- let value = trimmed.slice(eqIdx + 1).trim();
5948
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
5949
- value = value.slice(1, -1);
5950
- }
5951
- if (process.env[key] === void 0) {
5952
- process.env[key] = value;
5953
- }
5954
- }
5679
+ process.loadEnvFile(file);
5955
5680
  } catch {
5956
5681
  }
5957
5682
  }
@@ -5968,17 +5693,21 @@ Commands:
5968
5693
  login Authenticate via browser
5969
5694
  logout Remove saved credentials
5970
5695
  Options:
5971
- --help Show help
5972
- --version Show version
5696
+ --help, -h Show help
5697
+ --version, -v Show version
5973
5698
 
5974
5699
  Run 'npx vent-hq <command> --help' for command-specific help.`;
5700
+ var INIT_USAGE = `Usage: vent-hq init
5701
+
5702
+ Authenticate (GitHub CLI token or browser sign-in), install the Vent skill
5703
+ files for Claude Code, Cursor, Codex and Windsurf, and scaffold
5704
+ .vent/suite.json if it does not exist.`;
5975
5705
  var RUN_USAGE = `Usage: vent-hq run -f <suite.json> [options]
5976
5706
 
5977
5707
  Options:
5978
5708
  --file, -f Path to suite JSON file (required)
5979
5709
  --call Name of the call to run (required if suite has multiple calls)
5980
- --session, -s Reuse an existing local agent session
5981
- --verbose, -v Include verbose fields in the result JSON`;
5710
+ --session, -s Reuse an existing local agent session`;
5982
5711
  var AGENT_USAGE = `Usage: vent-hq agent <command> [options]
5983
5712
 
5984
5713
  Commands:
@@ -5991,16 +5720,34 @@ Start options:
5991
5720
 
5992
5721
  Stop options:
5993
5722
  vent-hq agent stop <session-id>`;
5723
+ var STOP_USAGE = `Usage: vent-hq stop <run-id>
5724
+
5725
+ Cancel a queued or running call.`;
5726
+ var LOGIN_USAGE = `Usage: vent-hq login [options]
5727
+
5728
+ Options:
5729
+ --status Check whether you are logged in (exit code 1 if not)`;
5730
+ var LOGOUT_USAGE = `Usage: vent-hq logout
5731
+
5732
+ Remove saved credentials from ~/.vent/credentials.`;
5733
+ var COMMAND_USAGE = {
5734
+ init: INIT_USAGE,
5735
+ run: RUN_USAGE,
5736
+ agent: AGENT_USAGE,
5737
+ stop: STOP_USAGE,
5738
+ login: LOGIN_USAGE,
5739
+ logout: LOGOUT_USAGE
5740
+ };
5994
5741
  async function main() {
5995
5742
  loadDotenv();
5996
5743
  const args = process.argv.slice(2);
5997
5744
  const command = args[0];
5998
- if (!command || command === "--help" || command === "-h") {
5999
- console.log(USAGE);
5745
+ if (!command || args.includes("--help") || args.includes("-h")) {
5746
+ console.log(COMMAND_USAGE[command ?? ""] ?? USAGE);
6000
5747
  return 0;
6001
5748
  }
6002
5749
  if (command === "--version" || command === "-v") {
6003
- const pkg = await import("./package-OH7X3UKK.mjs");
5750
+ const pkg = await import("./package-WDXEE7G3.mjs");
6004
5751
  console.log(`vent-hq ${pkg.default.version}`);
6005
5752
  return 0;
6006
5753
  }
@@ -6010,17 +5757,12 @@ async function main() {
6010
5757
  return initCommand();
6011
5758
  }
6012
5759
  case "run": {
6013
- if (commandArgs.includes("--help")) {
6014
- console.log(RUN_USAGE);
6015
- return 0;
6016
- }
6017
5760
  const { values } = parseArgs({
6018
5761
  args: commandArgs,
6019
5762
  options: {
6020
5763
  file: { type: "string", short: "f" },
6021
5764
  call: { type: "string" },
6022
- session: { type: "string", short: "s" },
6023
- verbose: { type: "boolean", short: "v", default: false }
5765
+ session: { type: "string", short: "s" }
6024
5766
  },
6025
5767
  strict: true
6026
5768
  });
@@ -6032,13 +5774,12 @@ async function main() {
6032
5774
  return runCommand({
6033
5775
  file: values.file,
6034
5776
  call: values.call,
6035
- session: values.session,
6036
- verbose: values.verbose
5777
+ session: values.session
6037
5778
  });
6038
5779
  }
6039
5780
  case "agent": {
6040
5781
  const subcommand = commandArgs[0];
6041
- if (!subcommand || subcommand === "--help" || subcommand === "-h") {
5782
+ if (!subcommand) {
6042
5783
  console.log(AGENT_USAGE);
6043
5784
  return 0;
6044
5785
  }
@@ -6070,9 +5811,9 @@ async function main() {
6070
5811
  }
6071
5812
  case "stop": {
6072
5813
  const runId = commandArgs[0];
6073
- if (!runId || commandArgs.includes("--help")) {
6074
- console.log("Usage: vent-hq stop <run-id>");
6075
- return runId ? 0 : 2;
5814
+ if (!runId) {
5815
+ console.log(STOP_USAGE);
5816
+ return 2;
6076
5817
  }
6077
5818
  return stopCommand({ runId });
6078
5819
  }