vent-hq 0.14.0 → 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
@@ -94,7 +94,7 @@ function decodeJwt(jwt) {
94
94
  }
95
95
 
96
96
  // ../shared/src/constants.ts
97
- var RUNNER_CALLBACK_MAX_SKEW_MS = 5 * 6e4;
97
+ var DEFAULT_AGENT_PORT = 3001;
98
98
  var CALLER_ACCENTS = [
99
99
  "american",
100
100
  "british",
@@ -4152,122 +4152,92 @@ var coerce = {
4152
4152
  };
4153
4153
  var NEVER = INVALID;
4154
4154
 
4155
- // ../shared/src/schemas.ts
4156
- var RefreshTokenRequestSchema = external_exports.object({
4157
- refresh_token: external_exports.string().regex(/^vent_[0-9a-f]{48}$/)
4158
- });
4159
- var AccessCredentialsSchema = external_exports.object({
4160
- access_token: external_exports.string().min(1),
4161
- refresh_token: RefreshTokenRequestSchema.shape.refresh_token
4162
- });
4163
- var CallerPersonaSchema = external_exports.object({
4164
- pace: external_exports.enum(["slow", "normal", "fast"]).optional(),
4165
- clarity: external_exports.enum(["clear", "vague", "rambling"]).optional(),
4166
- disfluencies: external_exports.boolean().optional(),
4167
- cooperation: external_exports.enum(["cooperative", "reluctant", "hostile"]).optional(),
4168
- emotion: external_exports.enum(["neutral", "cheerful", "confused", "frustrated", "skeptical", "rushed"]).optional(),
4169
- memory: external_exports.enum(["reliable", "unreliable"]).optional(),
4170
- intent_clarity: external_exports.enum(["clear", "indirect", "vague"]).optional(),
4171
- confirmation_style: external_exports.enum(["explicit", "vague"]).optional()
4172
- }).optional();
4173
- var CallerAudioEffectsSchema = external_exports.object({
4174
- noise: external_exports.object({
4175
- type: external_exports.enum(["babble", "white", "pink"]),
4176
- snr_db: external_exports.number().min(0).max(40).default(10)
4177
- }).optional(),
4178
- speed: external_exports.number().min(0.5).max(2).optional(),
4179
- speakerphone: external_exports.boolean().optional(),
4180
- mic_distance: external_exports.enum(["close", "normal", "far"]).optional(),
4181
- clarity: external_exports.number().min(0).max(1).optional(),
4182
- accent: external_exports.enum(CALLER_ACCENTS).optional(),
4183
- packet_loss: external_exports.number().min(0).max(0.3).optional(),
4184
- jitter_ms: external_exports.number().min(0).max(100).optional()
4185
- });
4186
- var ConversationCallSpecSchema = external_exports.object({
4187
- name: external_exports.string().optional(),
4188
- caller_prompt: external_exports.string().min(1),
4189
- max_turns: external_exports.number().int().min(1).max(50).default(6),
4190
- persona: CallerPersonaSchema,
4191
- caller_audio: CallerAudioEffectsSchema.optional(),
4192
- /** ISO 639-1 language code for multilingual calls (e.g., "es", "fr", "de"). Caller speaks this language, STT transcribes it, judge evaluates in it. */
4193
- language: external_exports.string().min(2).max(5).optional(),
4194
- /** Caller voice gender (English only; default female). Ignored if caller_audio.accent is set or language is non-English. */
4195
- voice: external_exports.enum(["male", "female"]).optional()
4196
- });
4197
- var CallSpecSchema = external_exports.object({
4198
- call: ConversationCallSpecSchema
4199
- });
4200
- var AdapterTypeSchema = external_exports.enum(["websocket", "livekit", "vapi", "retell", "elevenlabs", "bland"]);
4201
- var PlatformProviderSchema = external_exports.enum(["bland", "livekit", "vapi", "retell", "elevenlabs"]);
4202
- var ObservedToolCallSchema = external_exports.object({
4203
- name: external_exports.string(),
4204
- arguments: external_exports.record(external_exports.unknown()),
4205
- result: external_exports.unknown().optional(),
4206
- successful: external_exports.boolean().optional(),
4207
- provider_tool_type: external_exports.string().optional(),
4208
- timestamp_ms: external_exports.number().optional(),
4209
- latency_ms: external_exports.number().optional(),
4210
- turn_index: external_exports.number().int().min(0).optional()
4211
- });
4212
- var ToolCallMetricsSchema = external_exports.object({
4213
- total: external_exports.number().int().min(0),
4214
- successful: external_exports.number().int().min(0),
4215
- failed: external_exports.number().int().min(0),
4216
- mean_latency_ms: external_exports.number().optional(),
4217
- names: external_exports.array(external_exports.string())
4218
- });
4155
+ // ../shared/src/schemas/platforms.ts
4156
+ var PLATFORM_PROVIDERS = ["vapi", "retell", "elevenlabs", "livekit", "bland"];
4219
4157
  var BasePlatformSchema = external_exports.object({
4158
+ /** Max concurrent calls (platform-dependent) */
4220
4159
  max_concurrency: external_exports.number().int().min(1).optional()
4221
4160
  });
4222
4161
  var BlandPlatformSchema = BasePlatformSchema.extend({
4223
4162
  provider: external_exports.literal("bland"),
4163
+ /** Bland API key (the CLI reads BLAND_API_KEY from the user's env) */
4224
4164
  bland_api_key: external_exports.string().optional(),
4165
+ /** Bland pathway ID (the CLI reads BLAND_PATHWAY_ID from the user's env) */
4225
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) */
4226
4168
  persona_id: external_exports.string().optional(),
4169
+ /** Task prompt — used instead of pathway_id for simple agents */
4227
4170
  task: external_exports.string().optional(),
4171
+ /** Tool definitions (inline objects) or tool IDs (TL-xxx strings) */
4228
4172
  tools: external_exports.array(external_exports.unknown()).optional(),
4173
+ /** Voice name ("maya", "josh") or custom voice clone UUID */
4229
4174
  voice: external_exports.string().optional(),
4175
+ /** Model: "base" (full features), "turbo" (fastest, limited features) */
4230
4176
  model: external_exports.string().optional(),
4177
+ /** Opening sentence — overrides any greeting in the task/pathway */
4231
4178
  first_sentence: external_exports.string().optional(),
4179
+ /** If true, agent waits for callee to speak first (default: false) */
4232
4180
  wait_for_greeting: external_exports.boolean().optional(),
4181
+ /** Max call duration in minutes (default: 30) */
4233
4182
  max_duration: external_exports.number().optional(),
4183
+ /** Temperature 0-1 (default: 0.7) */
4234
4184
  temperature: external_exports.number().min(0).max(1).optional(),
4185
+ /** Language code e.g. "babel-en", "babel-es" */
4235
4186
  language: external_exports.string().optional(),
4187
+ /** How quickly agent stops speaking when interrupted, in ms (default: 500) */
4236
4188
  interruption_threshold: external_exports.number().optional(),
4189
+ /** When true, agent ignores user interruptions entirely */
4237
4190
  block_interruptions: external_exports.boolean().optional(),
4191
+ /** When true, enable Bland's noise filtering on caller audio */
4238
4192
  noise_cancellation: external_exports.boolean().optional(),
4193
+ /** Background audio: "office", "cafe", "restaurant", "none", or null (default phone static) */
4239
4194
  background_track: external_exports.string().nullable().optional(),
4195
+ /** Boost transcription accuracy for specific words. Supports "word:boost_factor" */
4240
4196
  keywords: external_exports.array(external_exports.string()).optional(),
4197
+ /** Key-value pairs accessible as {{variable}} in agent prompts/pathways */
4241
4198
  request_data: external_exports.record(external_exports.unknown()).optional(),
4199
+ /** Pronunciation overrides */
4242
4200
  pronunciation_guide: external_exports.array(external_exports.object({
4243
4201
  word: external_exports.string(),
4244
4202
  pronunciation: external_exports.string(),
4245
4203
  case_sensitive: external_exports.boolean().optional(),
4246
4204
  spaced: external_exports.boolean().optional()
4247
4205
  })).optional(),
4206
+ /** Start pathway from a specific node instead of the default */
4248
4207
  start_node_id: external_exports.string().optional(),
4208
+ /** Specific pathway version to test (default: production) */
4249
4209
  pathway_version: external_exports.number().optional()
4250
4210
  });
4251
4211
  var LiveKitPlatformSchema = BasePlatformSchema.extend({
4252
4212
  provider: external_exports.literal("livekit"),
4213
+ /** LiveKit API key (the CLI reads LIVEKIT_API_KEY from the user's env) */
4253
4214
  livekit_api_key: external_exports.string().optional(),
4215
+ /** LiveKit API secret (the CLI reads LIVEKIT_API_SECRET from the user's env) */
4254
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) */
4255
4218
  livekit_url: external_exports.string().optional(),
4219
+ /** Explicit agent dispatch — agent_name from WorkerOptions. Omit for automatic dispatch. */
4256
4220
  livekit_agent_name: external_exports.string().optional()
4257
4221
  }).strict();
4258
4222
  var VapiPlatformSchema = BasePlatformSchema.extend({
4259
4223
  provider: external_exports.literal("vapi"),
4224
+ /** Vapi API key (the CLI reads VAPI_API_KEY from the user's env) */
4260
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) */
4261
4227
  vapi_assistant_id: external_exports.string().optional()
4262
4228
  });
4263
4229
  var RetellPlatformSchema = BasePlatformSchema.extend({
4264
4230
  provider: external_exports.literal("retell"),
4231
+ /** Retell API key (the CLI reads RETELL_API_KEY from the user's env) */
4265
4232
  retell_api_key: external_exports.string().optional(),
4233
+ /** Retell agent ID (the CLI reads RETELL_AGENT_ID from the user's env) */
4266
4234
  retell_agent_id: external_exports.string().optional()
4267
4235
  });
4268
4236
  var ElevenLabsPlatformSchema = BasePlatformSchema.extend({
4269
4237
  provider: external_exports.literal("elevenlabs"),
4238
+ /** ElevenLabs API key (the CLI reads ELEVENLABS_API_KEY from the user's env) */
4270
4239
  elevenlabs_api_key: external_exports.string().optional(),
4240
+ /** ElevenLabs agent ID (the CLI reads ELEVENLABS_AGENT_ID from the user's env) */
4271
4241
  elevenlabs_agent_id: external_exports.string().optional()
4272
4242
  });
4273
4243
  var PlatformConfigSchema = external_exports.discriminatedUnion("provider", [
@@ -4277,89 +4247,118 @@ var PlatformConfigSchema = external_exports.discriminatedUnion("provider", [
4277
4247
  RetellPlatformSchema,
4278
4248
  ElevenLabsPlatformSchema
4279
4249
  ]);
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()
4277
+ });
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()
4310
+ });
4280
4311
  var ConversationTurnSchema = external_exports.object({
4281
4312
  role: external_exports.enum(["caller", "agent"]),
4282
4313
  text: external_exports.string(),
4283
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. */
4284
4316
  caller_decision_mode: external_exports.enum(["continue", "wait", "closing", "end_now"]).optional(),
4285
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). */
4286
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). */
4287
4321
  ttfw_ms: external_exports.number().optional(),
4322
+ /** Dead audio before speech starts: ttfw_ms - ttfb_ms */
4288
4323
  silence_pad_ms: external_exports.number().optional(),
4289
4324
  stt_confidence: external_exports.number().optional(),
4325
+ /** Harness TTS synthesis time for this turn's caller audio (ms) */
4290
4326
  tts_ms: external_exports.number().optional(),
4291
- stt_ms: external_exports.number().optional(),
4292
- component_latency: external_exports.object({
4293
- stt_ms: external_exports.number().optional(),
4294
- llm_ms: external_exports.number().optional(),
4295
- tts_ms: external_exports.number().optional(),
4296
- speech_duration_ms: external_exports.number().optional()
4297
- }).optional(),
4298
- 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()
4299
4329
  });
4300
- var LatencyMetricsSchema = external_exports.object({
4301
- ttfb_per_turn_ms: external_exports.array(external_exports.number()),
4302
- p50_ttfb_ms: external_exports.number().optional(),
4303
- p90_ttfb_ms: external_exports.number().optional(),
4304
- p95_ttfb_ms: external_exports.number().optional(),
4305
- p99_ttfb_ms: external_exports.number().optional(),
4306
- first_turn_ttfb_ms: external_exports.number().optional(),
4307
- ttfw_per_turn_ms: external_exports.array(external_exports.number()).optional(),
4308
- p50_ttfw_ms: external_exports.number().optional(),
4309
- p90_ttfw_ms: external_exports.number().optional(),
4310
- p95_ttfw_ms: external_exports.number().optional(),
4311
- p99_ttfw_ms: external_exports.number().optional(),
4312
- first_turn_ttfw_ms: external_exports.number().optional(),
4313
- mean_silence_pad_ms: external_exports.number().optional(),
4314
- mouth_to_ear_est_ms: external_exports.number().optional(),
4315
- 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()
4316
4334
  });
4317
- var HarnessOverheadSchema = external_exports.object({
4318
- tts_per_turn_ms: external_exports.array(external_exports.number()),
4319
- stt_per_turn_ms: external_exports.array(external_exports.number()),
4320
- mean_tts_ms: external_exports.number(),
4321
- 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()
4322
4344
  });
4323
4345
  var SignalQualityMetricsSchema = external_exports.object({
4346
+ /** Mean signal-to-noise ratio across turns (dB). >20 good, <10 bad */
4324
4347
  mean_snr_db: external_exports.number(),
4348
+ /** Max clipping ratio across turns (0-1). >0.01 = distortion */
4325
4349
  max_clipping_ratio: external_exports.number(),
4350
+ /** Mean energy consistency across turns (0-1). <0.5 = unstable volume */
4326
4351
  energy_consistency: external_exports.number(),
4352
+ /** Total sudden volume drops across all turns */
4327
4353
  sudden_drops: external_exports.number().int().min(0),
4354
+ /** Total sudden volume spikes across all turns */
4328
4355
  sudden_spikes: external_exports.number().int().min(0),
4356
+ /** All turns have clean start/end (no clicks) */
4329
4357
  clean_edges: external_exports.boolean()
4330
4358
  });
4331
- var ComponentLatencySchema = external_exports.object({
4332
- stt_ms: external_exports.number().optional(),
4333
- llm_ms: external_exports.number().optional(),
4334
- tts_ms: external_exports.number().optional(),
4335
- speech_duration_ms: external_exports.number().optional()
4336
- });
4337
- var ComponentLatencyMetricsSchema = external_exports.object({
4338
- per_turn: external_exports.array(ComponentLatencySchema),
4339
- mean_stt_ms: external_exports.number().optional(),
4340
- mean_llm_ms: external_exports.number().optional(),
4341
- mean_tts_ms: external_exports.number().optional(),
4342
- p95_stt_ms: external_exports.number().optional(),
4343
- p95_llm_ms: external_exports.number().optional(),
4344
- p95_tts_ms: external_exports.number().optional(),
4345
- bottleneck: external_exports.enum(["stt", "llm", "tts"]).optional()
4346
- });
4347
- var UsageEntrySchema = external_exports.object({
4348
- type: external_exports.string(),
4349
- provider: external_exports.string(),
4350
- model: external_exports.string(),
4351
- input_tokens: external_exports.number().int().optional(),
4352
- output_tokens: external_exports.number().int().optional()
4353
- });
4354
- var CostBreakdownSchema = external_exports.object({
4355
- stt_usd: external_exports.number().optional(),
4356
- llm_usd: external_exports.number().optional(),
4357
- tts_usd: external_exports.number().optional(),
4358
- transport_usd: external_exports.number().optional(),
4359
- platform_usd: external_exports.number().optional(),
4360
- total_usd: external_exports.number().optional(),
4361
- llm_prompt_tokens: external_exports.number().int().optional(),
4362
- 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()
4363
4362
  });
4364
4363
  var ProviderWarningSchema = external_exports.object({
4365
4364
  message: external_exports.string().optional(),
@@ -4375,56 +4374,63 @@ var CallTransferSchema = external_exports.object({
4375
4374
  });
4376
4375
  var CallMetadataSchema = external_exports.object({
4377
4376
  platform: external_exports.string(),
4377
+ /** Provider-visible call/conversation identifier for dashboard/API lookup. */
4378
4378
  provider_call_id: external_exports.string().optional(),
4379
+ /** Secondary runtime/session identifier when distinct from the call ID. */
4379
4380
  provider_session_id: external_exports.string().optional(),
4380
4381
  ended_reason: external_exports.string().optional(),
4381
- cost_usd: external_exports.number().nullable().optional(),
4382
- cost_breakdown: CostBreakdownSchema.optional(),
4382
+ cost: CallCostSchema.optional(),
4383
4383
  recording_url: external_exports.string().optional(),
4384
+ /** Provider-specific alternate recording artifacts keyed by variant name. */
4384
4385
  recording_variants: external_exports.record(external_exports.string()).optional(),
4386
+ /** Provider-specific debug and deep-link URLs keyed by purpose. */
4385
4387
  provider_debug_urls: external_exports.record(external_exports.string()).optional(),
4386
- variables: external_exports.record(external_exports.unknown()).optional(),
4387
- usage: external_exports.array(UsageEntrySchema).optional(),
4388
+ /** Provider/runtime warnings retained in a normalized structure. */
4388
4389
  provider_warnings: external_exports.array(ProviderWarningSchema).optional(),
4389
- provider_metadata: external_exports.record(external_exports.unknown()).optional(),
4390
+ /** Call transfers that occurred during the call */
4390
4391
  transfers: external_exports.array(CallTransferSchema).optional()
4391
4392
  });
4392
4393
  var ConversationMetricsSchema = external_exports.object({
4393
- mean_ttfb_ms: external_exports.number().optional(),
4394
- mean_ttfw_ms: external_exports.number().optional(),
4395
- latency: LatencyMetricsSchema.optional(),
4396
- 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. */
4397
4397
  signal_quality: SignalQualityMetricsSchema.optional(),
4398
- harness_overhead: HarnessOverheadSchema.optional(),
4399
- 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()
4400
4402
  });
4401
4403
  var ConversationCallResultSchema = external_exports.object({
4402
4404
  name: external_exports.string().optional(),
4403
4405
  caller_prompt: external_exports.string(),
4404
4406
  status: external_exports.enum(["completed", "error"]),
4407
+ /** How a completed call ended, when Vent ended it or the connection closed. */
4405
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. */
4406
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(),
4407
4413
  observed_tool_calls: external_exports.array(ObservedToolCallSchema).optional(),
4408
4414
  duration_ms: external_exports.number(),
4409
4415
  metrics: ConversationMetricsSchema,
4410
4416
  error: external_exports.string().optional(),
4417
+ /** Platform call metadata (cost, ended reason, recording, analysis) */
4411
4418
  call_metadata: CallMetadataSchema.optional()
4412
4419
  });
4413
- var RunAggregateV2Schema = external_exports.object({
4414
- conversation_calls: external_exports.object({
4415
- total: external_exports.number(),
4416
- passed: external_exports.number(),
4417
- failed: external_exports.number()
4418
- }).default({ total: 0, passed: 0, failed: 0 }),
4419
- total_duration_ms: external_exports.number(),
4420
- 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
4421
4428
  });
4422
- var RUNNER_TERMINAL_STATUSES = ["pass", "fail", "cancelled"];
4423
- var RunnerCallbackV2Schema = external_exports.object({
4429
+ var RUN_TERMINAL_STATUSES = ["pass", "fail", "cancelled"];
4430
+ var WorkerResultCallbackSchema = external_exports.object({
4424
4431
  run_id: external_exports.string().uuid(),
4425
- status: external_exports.enum(RUNNER_TERMINAL_STATUSES),
4432
+ status: external_exports.enum(RUN_TERMINAL_STATUSES),
4426
4433
  conversation_result: ConversationCallResultSchema,
4427
- aggregate: RunAggregateV2Schema,
4428
4434
  error_text: external_exports.string().optional()
4429
4435
  });
4430
4436
  var RunFailedCallbackSchema = external_exports.object({
@@ -4437,253 +4443,31 @@ var RunEventCallbackSchema = external_exports.object({
4437
4443
  message: external_exports.string().max(2e3),
4438
4444
  metadata_json: external_exports.record(external_exports.unknown()).optional()
4439
4445
  });
4440
- var CallProgressCallbackSchema = external_exports.object({
4446
+ var RunJobSchema = external_exports.object({
4441
4447
  run_id: external_exports.string().uuid(),
4442
- completed: external_exports.number().int().nonnegative(),
4443
- total: external_exports.number().int().positive(),
4444
- call_type: external_exports.literal("conversation"),
4445
- call_name: external_exports.string().min(1).max(256),
4446
- status: external_exports.enum(["pass", "fail", "completed", "error"]),
4447
- duration_ms: external_exports.number().nonnegative(),
4448
- 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()
4449
4461
  });
4450
-
4451
- // ../shared/src/format-result.ts
4452
- function formatConversationResult(raw, options = {}) {
4453
- if (!raw || typeof raw !== "object") return null;
4454
- const r = raw;
4455
- if (typeof r.caller_prompt !== "string") return null;
4456
- const verbose = options.verbose ?? false;
4457
- const debug = verbose ? formatDebug(r) : void 0;
4458
- const warnings = dedupeStrings([
4459
- ...formatProviderWarningMessages(r.call_metadata?.provider_warnings)
4460
- ]);
4461
- const result = {
4462
- name: r.name ?? null,
4463
- status: r.status,
4464
- duration_ms: r.duration_ms,
4465
- error: r.error ?? null,
4466
- end_note: r.end_note ?? null,
4467
- transcript: formatTranscript(r.transcript, options),
4468
- latency: r.metrics?.latency ? formatLatency(r.metrics.latency, r.metrics, verbose) : null,
4469
- tool_calls: formatToolCalls(r.metrics?.tool_calls, r.observed_tool_calls, verbose),
4470
- component_latency: formatComponentLatency(r.metrics?.component_latency, verbose),
4471
- call_metadata: formatCallMetadata(r.call_metadata, verbose)
4472
- };
4473
- if (warnings.length > 0) result.warnings = warnings;
4474
- if (verbose) result.caller_prompt = r.caller_prompt;
4475
- if (debug) result.debug = debug;
4476
- return result;
4477
- }
4478
- function formatTranscript(turns, options) {
4479
- if (!turns) return [];
4480
- return turns.map((t) => {
4481
- const turn = {
4482
- role: t.role,
4483
- text: t.text
4484
- };
4485
- if (t.ttfb_ms != null) turn.ttfb_ms = t.ttfb_ms;
4486
- if (t.ttfw_ms != null) turn.ttfw_ms = t.ttfw_ms;
4487
- if (t.audio_duration_ms != null) turn.audio_duration_ms = t.audio_duration_ms;
4488
- if (options.verbose) {
4489
- const debug = compactUnknownRecord({
4490
- timestamp_ms: t.timestamp_ms,
4491
- caller_decision_mode: t.caller_decision_mode,
4492
- silence_pad_ms: t.silence_pad_ms,
4493
- stt_confidence: t.stt_confidence,
4494
- harness_tts_ms: t.tts_ms,
4495
- harness_stt_ms: t.stt_ms,
4496
- component_latency: t.component_latency,
4497
- platform_transcript: t.platform_transcript
4498
- });
4499
- if (debug && Object.keys(debug).length > 0) {
4500
- turn.debug = debug;
4501
- }
4502
- }
4503
- return turn;
4504
- });
4505
- }
4506
- function formatLatency(latency, metrics, verbose) {
4507
- const hasTtfw = metrics.mean_ttfw_ms != null && latency.p50_ttfw_ms != null && latency.p95_ttfw_ms != null;
4508
- const responseTimeSource = hasTtfw ? "ttfw" : "ttfb";
4509
- const firstResponse = hasTtfw ? latency.first_turn_ttfw_ms ?? latency.first_turn_ttfb_ms : latency.first_turn_ttfb_ms;
4510
- if (firstResponse == null || !hasTtfw && (metrics.mean_ttfb_ms == null || latency.p95_ttfb_ms == null)) return null;
4511
- const result = {
4512
- response_time_ms: hasTtfw ? metrics.mean_ttfw_ms : metrics.mean_ttfb_ms,
4513
- response_time_source: responseTimeSource,
4514
- p95_response_time_ms: hasTtfw ? latency.p95_ttfw_ms : latency.p95_ttfb_ms,
4515
- first_response_time_ms: firstResponse
4516
- };
4517
- if (!verbose) return result;
4518
- result.p50_response_time_ms = hasTtfw ? latency.p50_ttfw_ms : latency.p50_ttfb_ms;
4519
- result.p90_response_time_ms = hasTtfw ? latency.p90_ttfw_ms ?? latency.p90_ttfb_ms : latency.p90_ttfb_ms;
4520
- result.p99_response_time_ms = hasTtfw ? latency.p99_ttfw_ms ?? latency.p99_ttfb_ms : latency.p99_ttfb_ms;
4521
- if (hasTtfw) {
4522
- result.mean_ttfw_ms = metrics.mean_ttfw_ms;
4523
- result.p50_ttfw_ms = latency.p50_ttfw_ms;
4524
- result.p90_ttfw_ms = latency.p90_ttfw_ms ?? latency.p90_ttfb_ms;
4525
- result.p95_ttfw_ms = latency.p95_ttfw_ms;
4526
- result.p99_ttfw_ms = latency.p99_ttfw_ms ?? latency.p99_ttfb_ms;
4527
- result.first_turn_ttfw_ms = latency.first_turn_ttfw_ms ?? latency.first_turn_ttfb_ms;
4528
- }
4529
- if (latency.drift_slope_ms_per_turn != null) result.drift_slope_ms_per_turn = latency.drift_slope_ms_per_turn;
4530
- if (latency.mean_silence_pad_ms != null) result.mean_silence_pad_ms = latency.mean_silence_pad_ms;
4531
- if (latency.mouth_to_ear_est_ms != null) result.mouth_to_ear_est_ms = latency.mouth_to_ear_est_ms;
4532
- return result;
4533
- }
4534
- function formatToolCalls(summary, observed, verbose) {
4535
- return {
4536
- total: summary?.total ?? observed?.length ?? 0,
4537
- successful: summary?.successful ?? observed?.filter((c) => c.successful).length ?? 0,
4538
- failed: summary?.failed ?? observed?.filter((c) => c.successful === false).length ?? 0,
4539
- mean_latency_ms: summary?.mean_latency_ms,
4540
- names: summary?.names ?? [...new Set((observed ?? []).map((c) => c.name))],
4541
- observed: (observed ?? []).map((c) => {
4542
- const entry = {
4543
- name: c.name,
4544
- arguments: stripExecutionMessage(c.arguments),
4545
- result: verbose ? c.result : truncateLargeArrays(c.result),
4546
- successful: c.successful,
4547
- latency_ms: c.latency_ms,
4548
- turn_index: c.turn_index
4549
- };
4550
- if (c.provider_tool_type && c.provider_tool_type !== "custom") {
4551
- entry.provider_tool_type = c.provider_tool_type;
4552
- }
4553
- return entry;
4554
- })
4555
- };
4556
- }
4557
- var TOOL_RESULT_ARRAY_LIMIT = 5;
4558
- function truncateLargeArrays(value) {
4559
- if (Array.isArray(value)) {
4560
- if (value.length <= TOOL_RESULT_ARRAY_LIMIT) {
4561
- return value.map(truncateLargeArrays);
4562
- }
4563
- return [
4564
- ...value.slice(0, TOOL_RESULT_ARRAY_LIMIT).map(truncateLargeArrays),
4565
- { _truncated: `${value.length - TOOL_RESULT_ARRAY_LIMIT} more entries omitted; use --verbose for full result` }
4566
- ];
4567
- }
4568
- if (value && typeof value === "object") {
4569
- const out = {};
4570
- for (const [k, v] of Object.entries(value)) {
4571
- out[k] = truncateLargeArrays(v);
4572
- }
4573
- return out;
4574
- }
4575
- return value;
4576
- }
4577
- function stripExecutionMessage(args) {
4578
- if (!args || typeof args !== "object") return args;
4579
- const { execution_message: _drop, ...rest } = args;
4580
- return rest;
4581
- }
4582
- function formatComponentLatency(cl, verbose) {
4583
- if (!cl) return null;
4584
- const result = {
4585
- mean_stt_ms: cl.mean_stt_ms,
4586
- mean_llm_ms: cl.mean_llm_ms,
4587
- mean_tts_ms: cl.mean_tts_ms,
4588
- bottleneck: cl.bottleneck
4589
- };
4590
- if (!verbose) return result;
4591
- const speechDurations = cl.per_turn.map((t) => t.speech_duration_ms).filter((v) => v != null);
4592
- const meanSpeech = speechDurations.length > 0 ? Math.round(speechDurations.reduce((a, b) => a + b, 0) / speechDurations.length) : void 0;
4593
- result.p95_stt_ms = cl.p95_stt_ms;
4594
- result.p95_llm_ms = cl.p95_llm_ms;
4595
- result.p95_tts_ms = cl.p95_tts_ms;
4596
- result.mean_speech_duration_ms = meanSpeech;
4597
- return result;
4598
- }
4599
- function formatCallMetadata(meta, verbose) {
4600
- if (!meta) return null;
4601
- const transfers = meta.transfers?.map((transfer) => {
4602
- const formattedTransfer = {
4603
- type: transfer.type,
4604
- destination: transfer.destination,
4605
- status: transfer.status,
4606
- sources: transfer.sources
4607
- };
4608
- if (transfer.timestamp_ms != null) {
4609
- formattedTransfer.timestamp_ms = transfer.timestamp_ms;
4610
- }
4611
- return formattedTransfer;
4612
- });
4613
- const result = {
4614
- platform: meta.platform,
4615
- provider_call_id: meta.provider_call_id,
4616
- provider_session_id: meta.provider_session_id,
4617
- ended_reason: meta.ended_reason,
4618
- cost_usd: meta.cost_usd,
4619
- recording_url: meta.recording_url
4620
- };
4621
- if (verbose) {
4622
- result.cost_breakdown = meta.cost_breakdown;
4623
- result.usage = meta.usage && meta.usage.length > 0 ? meta.usage : void 0;
4624
- result.recording_variants = meta.recording_variants;
4625
- result.provider_debug_urls = meta.provider_debug_urls;
4626
- result.variables = meta.variables;
4627
- }
4628
- if (transfers && transfers.length > 0) {
4629
- const completedTransferCount = transfers.filter((transfer) => transfer.status === "completed").length;
4630
- const transferCompleted = completedTransferCount > 0;
4631
- result.transfer_attempted = true;
4632
- result.transfer_completed = transferCompleted;
4633
- result.escalated = transferCompleted;
4634
- result.transfer_count = transfers.length;
4635
- result.completed_transfer_count = completedTransferCount;
4636
- result.transfers = transfers;
4637
- }
4638
- return result;
4639
- }
4640
- function formatDebug(result) {
4641
- const debug = compactUnknownRecord({
4642
- signal_quality: result.metrics?.signal_quality,
4643
- harness_overhead: result.metrics?.harness_overhead,
4644
- provider_warnings: nonEmptyArray(result.call_metadata?.provider_warnings),
4645
- component_latency_per_turn: nonEmptyArray(result.metrics?.component_latency?.per_turn),
4646
- observed_tool_calls: formatDebugToolCalls(result.observed_tool_calls),
4647
- provider_metadata: result.call_metadata?.provider_metadata
4648
- });
4649
- return debug && Object.keys(debug).length > 0 ? debug : void 0;
4650
- }
4651
- function formatDebugToolCalls(observed) {
4652
- if (!observed || observed.length === 0) return void 0;
4653
- return observed.map((call) => ({
4654
- name: call.name,
4655
- arguments: call.arguments,
4656
- result: call.result,
4657
- successful: call.successful,
4658
- provider_tool_type: call.provider_tool_type,
4659
- timestamp_ms: call.timestamp_ms,
4660
- latency_ms: call.latency_ms,
4661
- turn_index: call.turn_index
4662
- }));
4663
- }
4664
- function nonEmptyArray(value) {
4665
- return value && value.length > 0 ? value : void 0;
4666
- }
4667
- function formatProviderWarningMessages(warnings) {
4668
- if (!warnings || warnings.length === 0) return [];
4669
- return warnings.map((warning) => warning.message ?? warning.code).filter((message) => typeof message === "string" && message.length > 0);
4670
- }
4671
- function dedupeStrings(values) {
4672
- return [...new Set(values)];
4673
- }
4674
- function compactUnknownRecord(record) {
4675
- const entries = Object.entries(record).filter(([, value]) => value != null);
4676
- return entries.length > 0 ? Object.fromEntries(entries) : void 0;
4677
- }
4678
4462
 
4679
4463
  // ../shared/src/callback-auth.ts
4680
4464
  import { createHmac, timingSafeEqual } from "node:crypto";
4465
+ var RUNNER_CALLBACK_MAX_SKEW_MS = 5 * 6e4;
4681
4466
 
4682
4467
  // src/lib/config.ts
4683
4468
  var CONFIG_DIR = path.join(homedir(), ".vent");
4684
4469
  var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials");
4685
4470
  var API_BASE = process.env.VENT_API_URL ?? "https://api.venthq.dev";
4686
- var DASHBOARD_URL = process.env.VENT_DASHBOARD_URL ?? "https://venthq.dev";
4687
4471
  var refreshes = /* @__PURE__ */ new Map();
4688
4472
  async function readCredentials() {
4689
4473
  const accessToken = process.env.VENT_ACCESS_TOKEN;
@@ -4751,6 +4535,11 @@ async function loadAccessToken() {
4751
4535
  if (source.refreshToken && !usableAccessToken(source.accessToken, 6e4)) return refresh(source);
4752
4536
  return usableAccessToken(source.accessToken) ? source.accessToken : null;
4753
4537
  }
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;
4542
+ }
4754
4543
  async function refreshAccessToken(rejectedToken) {
4755
4544
  const source = await readCredentials();
4756
4545
  if (!source?.refreshToken) return null;
@@ -4785,9 +4574,9 @@ var ApiError = class extends Error {
4785
4574
  this.body = body;
4786
4575
  }
4787
4576
  };
4788
- async function authenticatedFetch(path4, apiKey, options = {}) {
4577
+ async function authenticatedFetch(path4, options = {}) {
4789
4578
  const url = `${API_BASE}${path4}`;
4790
- const token = await loadAccessToken() ?? apiKey;
4579
+ const token = await requireAccessToken();
4791
4580
  const send = (accessToken) => {
4792
4581
  const headers = new Headers(options.headers);
4793
4582
  headers.set("Authorization", `Bearer ${accessToken}`);
@@ -4801,8 +4590,8 @@ async function authenticatedFetch(path4, apiKey, options = {}) {
4801
4590
  await response.body?.cancel();
4802
4591
  return send(refreshed);
4803
4592
  }
4804
- async function apiFetch(path4, apiKey, options = {}) {
4805
- const res = await authenticatedFetch(path4, apiKey, options);
4593
+ async function apiFetch(path4, options = {}) {
4594
+ const res = await authenticatedFetch(path4, options);
4806
4595
  if (!res.ok) {
4807
4596
  const text = await res.text();
4808
4597
  let body = text;
@@ -4818,15 +4607,15 @@ async function apiFetch(path4, apiKey, options = {}) {
4818
4607
  // src/lib/submit-run.ts
4819
4608
  import { randomUUID as randomUUID2 } from "node:crypto";
4820
4609
  var RETRY_DELAY_MS = 2e3;
4821
- async function submitRun(accessToken, payload) {
4610
+ async function submitRun(payload) {
4822
4611
  const body = JSON.stringify({ ...payload, idempotency_key: randomUUID2() });
4823
- const send = () => apiFetch("/runs/submit", accessToken, { method: "POST", body });
4612
+ const send = () => apiFetch("/runs/submit", { method: "POST", body });
4824
4613
  try {
4825
4614
  return await send();
4826
4615
  } catch (err) {
4827
4616
  const answerMayBeLost = !(err instanceof ApiError) || [502, 503, 504].includes(err.status);
4828
4617
  if (!answerMayBeLost) throw err;
4829
- await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
4618
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
4830
4619
  return send();
4831
4620
  }
4832
4621
  }
@@ -4834,7 +4623,7 @@ async function submitRun(accessToken, payload) {
4834
4623
  // src/lib/sse.ts
4835
4624
  var MAX_RETRIES = 5;
4836
4625
  var RETRY_DELAY_MS2 = 2e3;
4837
- async function* streamRunEvents(runId, apiKey, signal) {
4626
+ async function* streamRunEvents(runId, signal) {
4838
4627
  const path4 = `/runs/${runId}/stream`;
4839
4628
  const seenIds = /* @__PURE__ */ new Set();
4840
4629
  let retries = 0;
@@ -4844,9 +4633,7 @@ async function* streamRunEvents(runId, apiKey, signal) {
4844
4633
  }
4845
4634
  let res;
4846
4635
  try {
4847
- res = await authenticatedFetch(path4, apiKey, {
4848
- signal
4849
- });
4636
+ res = await authenticatedFetch(path4, { signal });
4850
4637
  } catch (err) {
4851
4638
  if (err.name === "AbortError") throw err;
4852
4639
  retries++;
@@ -4933,29 +4720,13 @@ var red = (s) => isTTY ? `\x1B[31m${s}\x1B[0m` : s;
4933
4720
  var yellow = (s) => isTTY ? `\x1B[33m${s}\x1B[0m` : s;
4934
4721
  var blue = (s) => isTTY ? `\x1B[34m${s}\x1B[0m` : s;
4935
4722
  function printEvent(event) {
4936
- if (!isTTY) return;
4937
- const meta = event.metadata_json ?? {};
4938
- switch (event.event_type) {
4939
- case "call_completed":
4940
- printCallResult(meta);
4941
- break;
4942
- case "run_complete":
4943
- printRunComplete(meta);
4944
- break;
4945
- case "call_started": {
4946
- const name = meta.call_name ?? "call";
4947
- stdoutSync(dim(` \u25B8 ${name}\u2026`) + "\n");
4948
- break;
4949
- }
4950
- default:
4951
- stdoutSync(dim(` [${event.event_type}]`) + "\n");
4952
- }
4723
+ if (!isTTY || event.event_type === "run_complete") return;
4724
+ stdoutSync(dim(` [${event.event_type}]`) + "\n");
4953
4725
  }
4954
- function printCallResult(meta) {
4955
- const result = meta.result;
4956
- const callName = result?.name ?? meta.call_name ?? "call";
4957
- const callStatus = result?.status ?? meta.status;
4958
- 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;
4959
4730
  const statusIcon = callStatus === "completed" ? green("\u25CF") : red("\u25CF");
4960
4731
  const duration = durationMs != null ? (durationMs / 1e3).toFixed(1) + "s" : "\u2014";
4961
4732
  const parts = [statusIcon, bold(callName), dim(duration)];
@@ -4985,113 +4756,21 @@ function printCallResult(meta) {
4985
4756
  }
4986
4757
  }
4987
4758
  }
4988
- function printRunComplete(meta) {
4989
- const agg = meta.aggregate;
4990
- const counts = agg?.conversation_calls;
4991
- const total = meta.total_calls ?? counts?.total;
4992
- stdoutSync("\n");
4993
- stdoutSync(bold("Run complete") + dim(" \u2014 Vent does not judge mission success; review the calls above.") + "\n");
4994
- if (total != null) {
4995
- stdoutSync(`${total} call${total === 1 ? "" : "s"} ran
4996
- `);
4997
- }
4998
- if (typeof meta.error_text === "string") {
4999
- stdoutSync(red(`Run ${String(meta.status ?? "ended")}: ${meta.error_text}`) + "\n");
5000
- }
5001
- }
5002
- function printSummary(callResults, runComplete, runId, options = {}) {
5003
- const allCalls = options.rawCalls ? formatRawCalls(options.rawCalls, options.verbose ?? false) : callResults.map((e) => {
5004
- const meta = e.metadata_json ?? {};
5005
- const r = meta.result;
5006
- if (r) return r;
5007
- return {
5008
- name: meta.call_name ?? "call",
5009
- status: meta.status ?? "unknown",
5010
- duration_ms: meta.duration_ms,
5011
- error: null
5012
- };
5013
- });
5014
- const agg = runComplete.aggregate;
5015
- const counts = agg?.conversation_calls;
5016
- const summaryData = buildRunSummaryJson({
5017
- runId,
5018
- status: runComplete.status,
5019
- total: runComplete.total_calls ?? counts?.total,
5020
- formattedCalls: allCalls,
5021
- verbose: options.verbose,
5022
- runDetails: options.runDetails ?? {
5023
- aggregate: runComplete.aggregate,
5024
- error_text: runComplete.error_text
5025
- }
5026
- });
4759
+ function printRunOutput(output) {
5027
4760
  if (!isTTY) {
5028
- stdoutSync(JSON.stringify(summaryData, null, 2) + "\n");
4761
+ stdoutSync(JSON.stringify(output, null, 2) + "\n");
5029
4762
  return;
5030
4763
  }
5031
- const errored = allCalls.filter((t) => t.status && t.status !== "completed");
5032
- if (errored.length > 0) {
5033
- stdoutSync("\n" + bold("Pipeline errors:") + "\n");
5034
- for (const t of errored) {
5035
- const ms = typeof t.duration_ms === "number" ? t.duration_ms : null;
5036
- const duration = ms != null ? (ms / 1e3).toFixed(1) + "s" : "\u2014";
5037
- const name = typeof t.name === "string" ? t.name : "call";
5038
- const parts = [red("\u25CF"), bold(name), dim(duration)];
5039
- stdoutSync(" " + parts.join(" ") + "\n");
5040
- }
5041
- }
5042
- stdoutSync(dim(`Run ID: ${runId}`) + "\n");
5043
- }
5044
- function buildRunSummaryJson(options) {
5045
- const calls = options.rawCalls ? formatRawCalls(options.rawCalls, options.verbose ?? false) : options.formattedCalls ?? [];
5046
- const summaryData = {
5047
- run_id: options.runId,
5048
- ...options.status != null ? { status: options.status } : {},
5049
- total: options.total,
5050
- ...options.passed != null ? { passed: options.passed } : {},
5051
- ...options.failed != null ? { failed: options.failed } : {},
5052
- calls
5053
- };
5054
- const details = options.runDetails;
5055
- if (details?.created_at != null) summaryData["created_at"] = details.created_at;
5056
- if (details?.started_at != null) summaryData["started_at"] = details.started_at;
5057
- if (details?.finished_at != null) summaryData["finished_at"] = details.finished_at;
5058
- if (details?.duration_ms != null) summaryData["duration_ms"] = details.duration_ms;
5059
- if (details?.error_text != null) summaryData["error_text"] = details.error_text;
5060
- if (details?.aggregate != null) {
5061
- summaryData["aggregate"] = stripPassFailFromAggregate(details.aggregate);
5062
- }
5063
- return summaryData;
5064
- }
5065
- function stripPassFailFromAggregate(aggregate) {
5066
- if (!aggregate || typeof aggregate !== "object" || Array.isArray(aggregate)) return aggregate;
5067
- const out = {};
5068
- for (const [k, v] of Object.entries(aggregate)) {
5069
- if (k === "conversation_calls" && v && typeof v === "object") {
5070
- const cc = v;
5071
- const filtered = {};
5072
- for (const [ck, cv] of Object.entries(cc)) {
5073
- if (ck === "passed" || ck === "failed") continue;
5074
- filtered[ck] = cv;
5075
- }
5076
- out[k] = filtered;
5077
- } else {
5078
- out[k] = v;
5079
- }
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");
5080
4772
  }
5081
- return out;
5082
- }
5083
- function formatRawCalls(rawCalls, verbose) {
5084
- return rawCalls.map((raw) => {
5085
- const formatted = formatConversationResult(raw, { verbose });
5086
- if (formatted) return formatted;
5087
- const fallback = raw;
5088
- return {
5089
- name: typeof fallback["name"] === "string" ? fallback["name"] : "call",
5090
- status: typeof fallback["status"] === "string" ? fallback["status"] : "unknown",
5091
- duration_ms: typeof fallback["duration_ms"] === "number" ? fallback["duration_ms"] : void 0,
5092
- error: typeof fallback["error"] === "string" ? fallback["error"] : null
5093
- };
5094
- });
4773
+ stdoutSync(dim(`Run ID: ${String(output.run_id)}`) + "\n");
5095
4774
  }
5096
4775
  function printError(message) {
5097
4776
  const line = red(bold("error")) + ` ${message}
@@ -5128,35 +4807,25 @@ function gitSha() {
5128
4807
  return null;
5129
4808
  }
5130
4809
  }
5131
- async function saveRunHistory(runId, callResults, runCompleteData) {
4810
+ async function saveRunHistory(output) {
5132
4811
  try {
4812
+ const runId = output.run_id;
5133
4813
  const dir = path2.join(process.cwd(), ".vent", "runs");
5134
4814
  await fs2.mkdir(dir, { recursive: true });
5135
4815
  const now = /* @__PURE__ */ new Date();
5136
4816
  const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
5137
4817
  const shortId = runId.slice(0, 8);
5138
- const aggregate = runCompleteData.aggregate;
5139
- const convCalls = aggregate?.conversation_calls;
5140
- const total = convCalls?.total ?? 0;
5141
- const passed = convCalls?.passed ?? 0;
5142
- const failed = convCalls?.failed ?? 0;
4818
+ const calls = output.calls;
5143
4819
  const entry = {
5144
4820
  run_id: runId,
5145
4821
  timestamp: now.toISOString(),
5146
4822
  git_sha: gitSha(),
5147
4823
  summary: {
5148
- status: runCompleteData.status ?? "unknown",
5149
- calls_total: total,
5150
- calls_passed: passed,
5151
- calls_failed: failed,
5152
- total_duration_ms: aggregate?.total_duration_ms,
5153
- 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)
5154
4827
  },
5155
- call_results: callResults.map((e) => {
5156
- const meta = e.metadata_json ?? {};
5157
- const result = meta.result ?? {};
5158
- return { ...result, status: meta.status };
5159
- })
4828
+ call_results: calls
5160
4829
  };
5161
4830
  const filename = `${timestamp}_${shortId}.json`;
5162
4831
  const filepath = path2.join(dir, filename);
@@ -5181,55 +4850,16 @@ function isRemotePlatformAdapter(adapter) {
5181
4850
  function looksLikeEnvReference(value) {
5182
4851
  return typeof value === "string" && /^[A-Z][A-Z0-9_]+$/.test(value);
5183
4852
  }
5184
- function isMissingResolvedValue(value) {
5185
- 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}`;
5186
4857
  }
5187
- function requireField(platform) {
5188
- switch (platform.provider) {
5189
- case "vapi":
5190
- if (isMissingResolvedValue(platform.vapi_api_key)) {
5191
- throw new Error("Missing VAPI_API_KEY in .env");
5192
- }
5193
- if (isMissingResolvedValue(platform.vapi_assistant_id)) {
5194
- throw new Error("Missing VAPI_ASSISTANT_ID or VAPI_AGENT_ID in .env");
5195
- }
5196
- return;
5197
- case "retell":
5198
- if (isMissingResolvedValue(platform.retell_api_key)) {
5199
- throw new Error("Missing RETELL_API_KEY in .env");
5200
- }
5201
- if (isMissingResolvedValue(platform.retell_agent_id)) {
5202
- throw new Error("Missing RETELL_AGENT_ID in .env");
5203
- }
5204
- return;
5205
- case "elevenlabs":
5206
- if (isMissingResolvedValue(platform.elevenlabs_api_key)) {
5207
- throw new Error("Missing ELEVENLABS_API_KEY in .env");
5208
- }
5209
- if (isMissingResolvedValue(platform.elevenlabs_agent_id)) {
5210
- throw new Error("Missing ELEVENLABS_AGENT_ID in .env");
5211
- }
5212
- return;
5213
- case "livekit":
5214
- if (isMissingResolvedValue(platform.livekit_api_key)) {
5215
- throw new Error("Missing LIVEKIT_API_KEY in .env");
5216
- }
5217
- if (isMissingResolvedValue(platform.livekit_api_secret)) {
5218
- throw new Error("Missing LIVEKIT_API_SECRET in .env");
5219
- }
5220
- if (isMissingResolvedValue(platform.livekit_url)) {
5221
- throw new Error("Missing LIVEKIT_URL in .env");
5222
- }
5223
- return;
5224
- case "bland":
5225
- if (isMissingResolvedValue(platform.bland_api_key)) {
5226
- throw new Error("Missing BLAND_API_KEY in .env");
5227
- }
5228
- if (isMissingResolvedValue(platform.bland_pathway_id) && isMissingResolvedValue(platform.task) && isMissingResolvedValue(platform.persona_id)) {
5229
- throw new Error("Missing BLAND_PATHWAY_ID, BLAND_PERSONA_ID, or platform.task in .env");
5230
- }
5231
- return;
5232
- }
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)`);
5233
4863
  }
5234
4864
  function resolveRemotePlatformConfig(config) {
5235
4865
  const cfg = config;
@@ -5247,12 +4877,8 @@ function resolveRemotePlatformConfig(config) {
5247
4877
  const envFields = PLATFORM_ENV_MAP[adapter];
5248
4878
  for (const [field, envVar] of Object.entries(envFields)) {
5249
4879
  const currentValue = resolved[field];
5250
- const needsResolve = !currentValue || currentValue === envVar || looksLikeEnvReference(currentValue);
5251
- if (needsResolve) {
5252
- const envValue = process.env[envVar];
5253
- if (envValue) {
5254
- resolved[field] = envValue;
5255
- }
4880
+ if (!currentValue || looksLikeEnvReference(currentValue)) {
4881
+ resolved[field] = process.env[envVar] || void 0;
5256
4882
  }
5257
4883
  }
5258
4884
  if (adapter === "vapi" && !resolved.vapi_assistant_id) {
@@ -5260,7 +4886,7 @@ function resolveRemotePlatformConfig(config) {
5260
4886
  if (alt) resolved.vapi_assistant_id = alt;
5261
4887
  }
5262
4888
  const platform = PlatformConfigSchema.parse(resolved);
5263
- requireField(platform);
4889
+ requireFields(adapter, platform);
5264
4890
  return platform;
5265
4891
  }
5266
4892
 
@@ -5320,15 +4946,10 @@ async function runCommand(args) {
5320
4946
  );
5321
4947
  return 2;
5322
4948
  }
5323
- const accessToken = await loadAccessToken();
5324
- if (!accessToken) {
5325
- printError("No Vent credentials found. Set VENT_REFRESH_TOKEN or run `npx vent-hq login`.");
5326
- return 2;
5327
- }
5328
4949
  printInfo("Submitting run\u2026");
5329
4950
  let submitResult;
5330
4951
  try {
5331
- const res = await submitRun(accessToken, { config, agent_session_id: args.session });
4952
+ const res = await submitRun({ config, agent_session_id: args.session });
5332
4953
  submitResult = await res.json();
5333
4954
  } catch (err) {
5334
4955
  if (err instanceof ApiError && err.status === 401) {
@@ -5347,14 +4968,13 @@ async function runCommand(args) {
5347
4968
  printInfo(`Streaming results for run ${run_id}\u2026`);
5348
4969
  const abortController = new AbortController();
5349
4970
  let exitCode = 0;
5350
- const callResults = [];
5351
- let runCompleteData = null;
4971
+ let runOutput = null;
5352
4972
  let stopRequested = false;
5353
4973
  let stopPromise = null;
5354
4974
  const onSignal = () => {
5355
4975
  if (!stopRequested) {
5356
4976
  stopRequested = true;
5357
- stopPromise = apiFetch(`/runs/${run_id}/stop`, accessToken, {
4977
+ stopPromise = apiFetch(`/runs/${run_id}/stop`, {
5358
4978
  method: "POST",
5359
4979
  signal: AbortSignal.timeout(5e3)
5360
4980
  }).catch(() => {
@@ -5365,15 +4985,10 @@ async function runCommand(args) {
5365
4985
  process.on("SIGINT", onSignal);
5366
4986
  process.on("SIGTERM", onSignal);
5367
4987
  try {
5368
- for await (const event of streamRunEvents(run_id, accessToken, abortController.signal)) {
5369
- const meta = event.metadata_json ?? {};
4988
+ for await (const event of streamRunEvents(run_id, abortController.signal)) {
5370
4989
  printEvent(event);
5371
- if (event.event_type === "call_completed") {
5372
- callResults.push(event);
5373
- }
5374
4990
  if (event.event_type === "run_complete") {
5375
- runCompleteData = meta;
5376
- exitCode = meta["status"] === "pass" ? 0 : 1;
4991
+ runOutput = event.result ?? null;
5377
4992
  }
5378
4993
  if (event.event_type === "error") {
5379
4994
  printError(event.message ?? "Stream connection lost");
@@ -5389,34 +5004,22 @@ async function runCommand(args) {
5389
5004
  process.off("SIGINT", onSignal);
5390
5005
  process.off("SIGTERM", onSignal);
5391
5006
  }
5392
- if (runCompleteData) {
5393
- let rawRunDetails = null;
5394
- if (args.verbose && !isTTY2 || callResults.length === 0) {
5395
- try {
5396
- const res = await apiFetch(`/runs/${run_id}`, accessToken);
5397
- rawRunDetails = await res.json();
5398
- } catch {
5399
- printWarn("Run result fetch failed; showing the streamed summary only.");
5400
- }
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.");
5401
5014
  }
5402
- printSummary(callResults, runCompleteData, run_id, {
5403
- verbose: args.verbose,
5404
- rawCalls: Array.isArray(rawRunDetails?.["results"]) ? rawRunDetails["results"] : void 0,
5405
- runDetails: rawRunDetails ? {
5406
- created_at: rawRunDetails["created_at"],
5407
- started_at: rawRunDetails["started_at"],
5408
- finished_at: rawRunDetails["finished_at"],
5409
- duration_ms: rawRunDetails["duration_ms"],
5410
- error_text: rawRunDetails["error_text"],
5411
- aggregate: rawRunDetails["aggregate"]
5412
- } : void 0
5413
- });
5414
5015
  }
5415
- if (runCompleteData) {
5416
- const savedPath = await saveRunHistory(run_id, callResults, runCompleteData);
5417
- if (savedPath) {
5418
- printInfo(`Run saved to ${savedPath}`);
5419
- }
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;
5420
5023
  }
5421
5024
  if (stopPromise) {
5422
5025
  await stopPromise;
@@ -5431,7 +5034,11 @@ import * as net from "node:net";
5431
5034
  // src/lib/relay.ts
5432
5035
  import { spawn } from "node:child_process";
5433
5036
 
5434
- // ../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
+ }
5435
5042
  var RelayClient = class {
5436
5043
  controlWs = null;
5437
5044
  localConnections = /* @__PURE__ */ new Map();
@@ -5451,10 +5058,10 @@ var RelayClient = class {
5451
5058
  handler(...args);
5452
5059
  }
5453
5060
  }
5454
- async connect(timeoutMs = 3e4) {
5061
+ async connect() {
5455
5062
  const wsBase = this.config.apiUrl.replace(/^http/, "ws");
5456
5063
  const controlUrl = `${wsBase}/relay/control?session_id=${this.config.sessionId}&token=${this.config.relayToken}`;
5457
- return new Promise((resolve2, reject) => {
5064
+ return new Promise((resolve, reject) => {
5458
5065
  const ws = new WebSocket(controlUrl);
5459
5066
  ws.binaryType = "arraybuffer";
5460
5067
  let settled = false;
@@ -5468,29 +5075,28 @@ var RelayClient = class {
5468
5075
  const hardTimeout = setTimeout(() => {
5469
5076
  settle(() => {
5470
5077
  ws.close();
5471
- 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}`));
5472
5079
  });
5473
- }, timeoutMs);
5080
+ }, CONNECT_TIMEOUT_MS);
5474
5081
  ws.addEventListener("open", () => {
5475
5082
  this.controlWs = ws;
5476
5083
  this.setupControlHandlers(ws);
5477
- this.emit("connected");
5478
5084
  });
5479
- this.on("ready", () => settle(() => resolve2()));
5085
+ this.on("ready", () => settle(() => resolve()));
5480
5086
  ws.addEventListener("close", (ev) => {
5481
5087
  settle(() => reject(new Error(`Relay rejected the connection (${ev.code}${ev.reason ? `: ${ev.reason}` : ""})`)));
5482
5088
  });
5483
5089
  ws.addEventListener("error", (ev) => {
5484
5090
  if (!this.controlWs) {
5485
- 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"}`)));
5486
5092
  }
5487
5093
  });
5488
5094
  });
5489
5095
  }
5490
5096
  async disconnect() {
5491
5097
  this.closed = true;
5492
- for (const [connId, conn] of this.localConnections) {
5493
- 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();
5494
5100
  this.localConnections.delete(connId);
5495
5101
  }
5496
5102
  if (this.controlWs) {
@@ -5523,12 +5129,12 @@ var RelayClient = class {
5523
5129
  if (frameType !== 1 && frameType !== 2) return;
5524
5130
  const connId = new TextDecoder().decode(data.subarray(1, 37));
5525
5131
  const payload = data.subarray(37);
5526
- const conn = this.localConnections.get(connId);
5527
- if (conn?.local.readyState === WebSocket.OPEN) {
5132
+ const local = this.localConnections.get(connId);
5133
+ if (local?.readyState === WebSocket.OPEN) {
5528
5134
  if (frameType === 2) {
5529
- conn.local.send(new TextDecoder().decode(payload));
5135
+ local.send(new TextDecoder().decode(payload));
5530
5136
  } else {
5531
- conn.local.send(payload);
5137
+ local.send(payload);
5532
5138
  }
5533
5139
  }
5534
5140
  return;
@@ -5541,8 +5147,8 @@ var RelayClient = class {
5541
5147
  } else if (msg.type === "new_connection" && msg.conn_id) {
5542
5148
  this.handleNewConnection(msg.conn_id);
5543
5149
  } else if (msg.type === "close" && msg.conn_id) {
5544
- const conn = this.localConnections.get(msg.conn_id);
5545
- 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();
5546
5152
  this.localConnections.delete(msg.conn_id);
5547
5153
  }
5548
5154
  } catch {
@@ -5559,58 +5165,44 @@ var RelayClient = class {
5559
5165
  }
5560
5166
  });
5561
5167
  ws.addEventListener("error", (ev) => {
5562
- this.emit("error", new Error(ev.message ?? "WebSocket error"));
5168
+ this.emit("error", new Error(errorEventMessage(ev) ?? "WebSocket error"));
5563
5169
  });
5564
5170
  }
5565
5171
  handleNewConnection(connId) {
5566
- const agentUrl = `ws://localhost:${this.config.agentPort}`;
5567
- this.emit("log", `[relay] new_connection ${connId} \u2192 connecting to ${agentUrl}`);
5568
- try {
5569
- const localWs = new WebSocket(agentUrl);
5570
- localWs.binaryType = "arraybuffer";
5571
- localWs.addEventListener("open", () => {
5572
- this.emit("log", `[relay] local WS open for ${connId}`);
5573
- this.sendControlMessage({ type: "open_ack", conn_id: connId });
5574
- this.localConnections.set(connId, { local: localWs, connId });
5575
- });
5576
- localWs.addEventListener("message", (event) => {
5577
- if (event.data instanceof ArrayBuffer) {
5578
- this.sendDataFrame(connId, new Uint8Array(event.data), 1);
5579
- } else {
5580
- this.sendDataFrame(connId, new TextEncoder().encode(event.data), 2);
5581
- }
5582
- });
5583
- let cleaned = false;
5584
- const cleanup = (reason) => {
5585
- if (cleaned) return;
5586
- cleaned = true;
5587
- this.emit("log", `[relay] local WS cleanup for ${connId}: ${reason ?? "unknown"}`);
5588
- if (localWs.readyState !== WebSocket.CLOSED) localWs.close();
5589
- this.localConnections.delete(connId);
5590
- this.sendControlMessage({ type: "close", conn_id: connId });
5591
- };
5592
- localWs.addEventListener("close", () => cleanup("close"));
5593
- localWs.addEventListener("error", (ev) => {
5594
- const msg = ev.message ?? "unknown error";
5595
- this.emit("log", `[relay] local WS error for ${connId}: ${msg}`);
5596
- cleanup(`error: ${msg}`);
5597
- });
5598
- } catch (err) {
5599
- this.emit("log", `[relay] Failed to connect local agent for ${connId}: ${err}`);
5600
- }
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);
5601
5195
  }
5602
5196
  };
5603
5197
 
5604
5198
  // src/lib/relay.ts
5605
5199
  async function startAgentSession(relayConfig) {
5606
- const clientConfig = {
5200
+ const client = new RelayClient({
5607
5201
  apiUrl: relayConfig.api_url,
5608
5202
  sessionId: relayConfig.session_id,
5609
5203
  relayToken: relayConfig.relay_token,
5610
- agentPort: relayConfig.agent_port,
5611
- healthEndpoint: relayConfig.health_endpoint
5612
- };
5613
- const client = new RelayClient(clientConfig);
5204
+ agentPort: relayConfig.agent_port
5205
+ });
5614
5206
  await client.connect();
5615
5207
  let agentProcess = null;
5616
5208
  if (relayConfig.start_command) {
@@ -5626,8 +5218,6 @@ async function startAgentSession(relayConfig) {
5626
5218
  process.stdout.write(`Agent process error: ${err.message}
5627
5219
  `);
5628
5220
  });
5629
- }
5630
- if (relayConfig.start_command) {
5631
5221
  await waitForHealth(relayConfig.agent_port, relayConfig.health_endpoint);
5632
5222
  }
5633
5223
  const cleanup = async () => {
@@ -5654,11 +5244,6 @@ async function waitForHealth(port, endpoint, timeoutMs = 3e4) {
5654
5244
 
5655
5245
  // src/commands/agent.ts
5656
5246
  async function agentStartCommand(args) {
5657
- const accessToken = await loadAccessToken();
5658
- if (!accessToken) {
5659
- printError("No Vent credentials found. Set VENT_REFRESH_TOKEN or run `npx vent-hq login`.");
5660
- return 2;
5661
- }
5662
5247
  let parsedConfig;
5663
5248
  try {
5664
5249
  if (args.file) {
@@ -5687,7 +5272,7 @@ async function agentStartCommand(args) {
5687
5272
  const closeSession = async () => {
5688
5273
  if (!session) return;
5689
5274
  try {
5690
- await apiFetch(`/agent-sessions/${session.session_id}/close`, accessToken, {
5275
+ await apiFetch(`/agent-sessions/${session.session_id}/close`, {
5691
5276
  method: "POST"
5692
5277
  });
5693
5278
  } catch {
@@ -5695,11 +5280,11 @@ async function agentStartCommand(args) {
5695
5280
  };
5696
5281
  try {
5697
5282
  printInfo("Creating agent session\u2026");
5698
- const createRes = await apiFetch("/agent-sessions", accessToken, {
5283
+ const createRes = await apiFetch("/agent-sessions", {
5699
5284
  method: "POST",
5700
5285
  body: JSON.stringify({ config: connection })
5701
5286
  });
5702
- session = await createRes.json();
5287
+ session = AgentSessionResponseSchema.parse(await createRes.json());
5703
5288
  printInfo("Connecting relay for local agent\u2026");
5704
5289
  relay = await startAgentSession(session);
5705
5290
  const sessionData = {
@@ -5725,13 +5310,8 @@ async function agentStartCommand(args) {
5725
5310
  }
5726
5311
  }
5727
5312
  async function agentStopCommand(args) {
5728
- const accessToken = await loadAccessToken();
5729
- if (!accessToken) {
5730
- printError("No Vent credentials found. Set VENT_REFRESH_TOKEN or run `npx vent-hq login`.");
5731
- return 2;
5732
- }
5733
5313
  try {
5734
- await apiFetch(`/agent-sessions/${args.sessionId}/close`, accessToken, {
5314
+ await apiFetch(`/agent-sessions/${args.sessionId}/close`, {
5735
5315
  method: "POST"
5736
5316
  });
5737
5317
  printSuccess(`Agent session closed: ${args.sessionId}`, { force: true });
@@ -5742,7 +5322,7 @@ async function agentStopCommand(args) {
5742
5322
  }
5743
5323
  }
5744
5324
  async function waitForSessionExit(relay) {
5745
- return new Promise((resolve2) => {
5325
+ return new Promise((resolve) => {
5746
5326
  let settled = false;
5747
5327
  const agentProcess = relay.agentProcess;
5748
5328
  const finish = (code) => {
@@ -5753,7 +5333,7 @@ async function waitForSessionExit(relay) {
5753
5333
  if (agentProcess) {
5754
5334
  agentProcess.off("exit", handleAgentExit);
5755
5335
  }
5756
- resolve2(code);
5336
+ resolve(code);
5757
5337
  };
5758
5338
  const handleSignal = () => finish(0);
5759
5339
  const handleAgentExit = (code, signal) => {
@@ -5782,12 +5362,12 @@ async function waitForSessionExit(relay) {
5782
5362
  });
5783
5363
  }
5784
5364
  function findFreePort() {
5785
- return new Promise((resolve2, reject) => {
5365
+ return new Promise((resolve, reject) => {
5786
5366
  const server = net.createServer();
5787
5367
  server.listen(0, () => {
5788
5368
  const addr = server.address();
5789
5369
  const port = addr.port;
5790
- server.close(() => resolve2(port));
5370
+ server.close(() => resolve(port));
5791
5371
  });
5792
5372
  server.on("error", reject);
5793
5373
  });
@@ -5840,7 +5420,7 @@ async function deviceAuthFlow() {
5840
5420
  const parsed = AccessCredentialsSchema.safeParse(data);
5841
5421
  if (!parsed.success) return { ok: false, error: "Vent returned invalid sign-in credentials." };
5842
5422
  await saveCredentials(parsed.data);
5843
- return { ok: true, accessToken: parsed.data.access_token };
5423
+ return { ok: true };
5844
5424
  }
5845
5425
  if (data.status === "expired") {
5846
5426
  return { ok: false, error: "Session expired. Run `npx vent-hq login` again." };
@@ -5903,17 +5483,47 @@ function detectGitHubToken() {
5903
5483
  import * as fs5 from "node:fs/promises";
5904
5484
  import * as path3 from "node:path";
5905
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
+
5906
5489
  // src/skills/claude-code.md
5907
- 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 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` and `max_turns` are required.\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. 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` 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` = 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`, `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). 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\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, 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 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';
5908
5491
 
5909
5492
  // src/skills/cursor.md
5910
- 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 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` and `max_turns` are required.\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. 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` 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` = 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`, `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). 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\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, 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 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";
5911
5494
 
5912
5495
  // src/skills/codex.md
5913
- 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 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` and `max_turns` are required.\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. 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` 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` = 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`, `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). 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\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, 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 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";
5914
5497
 
5915
5498
  // src/skills/windsurf.md
5916
- 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 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` and `max_turns` are required.\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. 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` 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` = 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`, `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). 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\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, 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 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
+ });
5917
5527
 
5918
5528
  // src/lib/setup.ts
5919
5529
  var SUITE_SCAFFOLD = JSON.stringify(
@@ -5921,7 +5531,7 @@ var SUITE_SCAFFOLD = JSON.stringify(
5921
5531
  connection: {
5922
5532
  adapter: "websocket",
5923
5533
  start_command: "TODO: command to start your agent (e.g. npm run start)",
5924
- agent_port: 3001
5534
+ agent_port: DEFAULT_AGENT_PORT
5925
5535
  },
5926
5536
  calls: {
5927
5537
  "happy-path": {
@@ -5936,19 +5546,19 @@ var SUITE_SCAFFOLD = JSON.stringify(
5936
5546
  async function installClaudeCode(cwd) {
5937
5547
  const dir = path3.join(cwd, ".claude", "skills", "vent");
5938
5548
  await fs5.mkdir(dir, { recursive: true });
5939
- await fs5.writeFile(path3.join(dir, "SKILL.md"), claude_code_default);
5549
+ await fs5.writeFile(path3.join(dir, "SKILL.md"), claudeCodeSkill);
5940
5550
  printSuccess("Claude Code: .claude/skills/vent/SKILL.md", { force: true });
5941
5551
  }
5942
5552
  async function installCursor(cwd) {
5943
5553
  const dir = path3.join(cwd, ".cursor", "rules");
5944
5554
  await fs5.mkdir(dir, { recursive: true });
5945
- await fs5.writeFile(path3.join(dir, "vent.mdc"), cursor_default);
5555
+ await fs5.writeFile(path3.join(dir, "vent.mdc"), cursorSkill);
5946
5556
  printSuccess("Cursor: .cursor/rules/vent.mdc", { force: true });
5947
5557
  }
5948
5558
  async function installWindsurf(cwd) {
5949
5559
  const dir = path3.join(cwd, ".windsurf", "skills", "vent");
5950
5560
  await fs5.mkdir(dir, { recursive: true });
5951
- await fs5.writeFile(path3.join(dir, "SKILL.md"), windsurf_default);
5561
+ await fs5.writeFile(path3.join(dir, "SKILL.md"), windsurfSkill);
5952
5562
  printSuccess("Windsurf: .windsurf/skills/vent/SKILL.md", { force: true });
5953
5563
  }
5954
5564
  var VENT_MARKERS = [
@@ -5964,11 +5574,11 @@ async function installCodex(cwd) {
5964
5574
  }
5965
5575
  const markerIndex = VENT_MARKERS.map((marker) => existing.indexOf(marker)).filter((idx) => idx >= 0).sort((a, b) => a - b)[0];
5966
5576
  if (markerIndex != null) {
5967
- 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");
5968
5578
  } else if (existing) {
5969
- await fs5.writeFile(filePath, existing.trimEnd() + "\n\n" + codex_default + "\n");
5579
+ await fs5.writeFile(filePath, existing.trimEnd() + "\n\n" + codexSkill + "\n");
5970
5580
  } else {
5971
- await fs5.writeFile(filePath, codex_default + "\n");
5581
+ await fs5.writeFile(filePath, codexSkill + "\n");
5972
5582
  }
5973
5583
  printSuccess("Codex: AGENTS.md", { force: true });
5974
5584
  }
@@ -6042,13 +5652,8 @@ async function initCommand() {
6042
5652
 
6043
5653
  // src/commands/stop.ts
6044
5654
  async function stopCommand(args) {
6045
- const accessToken = await loadAccessToken();
6046
- if (!accessToken) {
6047
- printError("Not authenticated. Run `npx vent-hq init` first.");
6048
- return 2;
6049
- }
6050
5655
  try {
6051
- const res = await apiFetch(`/runs/${args.runId}/stop`, accessToken, {
5656
+ const res = await apiFetch(`/runs/${args.runId}/stop`, {
6052
5657
  method: "POST"
6053
5658
  });
6054
5659
  const data = await res.json();
@@ -6068,26 +5673,10 @@ async function stopCommand(args) {
6068
5673
  }
6069
5674
 
6070
5675
  // src/lib/dotenv.ts
6071
- import { readFileSync } from "node:fs";
6072
- import { resolve } from "node:path";
6073
- function loadDotenv(dir = process.cwd()) {
5676
+ function loadDotenv() {
6074
5677
  for (const file of [".env.local", ".env"]) {
6075
5678
  try {
6076
- const content = readFileSync(resolve(dir, file), "utf-8");
6077
- for (const line of content.split("\n")) {
6078
- const trimmed = line.trim();
6079
- if (!trimmed || trimmed.startsWith("#")) continue;
6080
- const eqIdx = trimmed.indexOf("=");
6081
- if (eqIdx === -1) continue;
6082
- const key = trimmed.slice(0, eqIdx).trim();
6083
- let value = trimmed.slice(eqIdx + 1).trim();
6084
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
6085
- value = value.slice(1, -1);
6086
- }
6087
- if (process.env[key] === void 0) {
6088
- process.env[key] = value;
6089
- }
6090
- }
5679
+ process.loadEnvFile(file);
6091
5680
  } catch {
6092
5681
  }
6093
5682
  }
@@ -6104,17 +5693,21 @@ Commands:
6104
5693
  login Authenticate via browser
6105
5694
  logout Remove saved credentials
6106
5695
  Options:
6107
- --help Show help
6108
- --version Show version
5696
+ --help, -h Show help
5697
+ --version, -v Show version
6109
5698
 
6110
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.`;
6111
5705
  var RUN_USAGE = `Usage: vent-hq run -f <suite.json> [options]
6112
5706
 
6113
5707
  Options:
6114
5708
  --file, -f Path to suite JSON file (required)
6115
5709
  --call Name of the call to run (required if suite has multiple calls)
6116
- --session, -s Reuse an existing local agent session
6117
- --verbose, -v Include verbose fields in the result JSON`;
5710
+ --session, -s Reuse an existing local agent session`;
6118
5711
  var AGENT_USAGE = `Usage: vent-hq agent <command> [options]
6119
5712
 
6120
5713
  Commands:
@@ -6127,16 +5720,34 @@ Start options:
6127
5720
 
6128
5721
  Stop options:
6129
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
+ };
6130
5741
  async function main() {
6131
5742
  loadDotenv();
6132
5743
  const args = process.argv.slice(2);
6133
5744
  const command = args[0];
6134
- if (!command || command === "--help" || command === "-h") {
6135
- console.log(USAGE);
5745
+ if (!command || args.includes("--help") || args.includes("-h")) {
5746
+ console.log(COMMAND_USAGE[command ?? ""] ?? USAGE);
6136
5747
  return 0;
6137
5748
  }
6138
5749
  if (command === "--version" || command === "-v") {
6139
- const pkg = await import("./package-ZEOXQ7DY.mjs");
5750
+ const pkg = await import("./package-WDXEE7G3.mjs");
6140
5751
  console.log(`vent-hq ${pkg.default.version}`);
6141
5752
  return 0;
6142
5753
  }
@@ -6146,17 +5757,12 @@ async function main() {
6146
5757
  return initCommand();
6147
5758
  }
6148
5759
  case "run": {
6149
- if (commandArgs.includes("--help")) {
6150
- console.log(RUN_USAGE);
6151
- return 0;
6152
- }
6153
5760
  const { values } = parseArgs({
6154
5761
  args: commandArgs,
6155
5762
  options: {
6156
5763
  file: { type: "string", short: "f" },
6157
5764
  call: { type: "string" },
6158
- session: { type: "string", short: "s" },
6159
- verbose: { type: "boolean", short: "v", default: false }
5765
+ session: { type: "string", short: "s" }
6160
5766
  },
6161
5767
  strict: true
6162
5768
  });
@@ -6168,13 +5774,12 @@ async function main() {
6168
5774
  return runCommand({
6169
5775
  file: values.file,
6170
5776
  call: values.call,
6171
- session: values.session,
6172
- verbose: values.verbose
5777
+ session: values.session
6173
5778
  });
6174
5779
  }
6175
5780
  case "agent": {
6176
5781
  const subcommand = commandArgs[0];
6177
- if (!subcommand || subcommand === "--help" || subcommand === "-h") {
5782
+ if (!subcommand) {
6178
5783
  console.log(AGENT_USAGE);
6179
5784
  return 0;
6180
5785
  }
@@ -6206,9 +5811,9 @@ async function main() {
6206
5811
  }
6207
5812
  case "stop": {
6208
5813
  const runId = commandArgs[0];
6209
- if (!runId || commandArgs.includes("--help")) {
6210
- console.log("Usage: vent-hq stop <run-id>");
6211
- return runId ? 0 : 2;
5814
+ if (!runId) {
5815
+ console.log(STOP_USAGE);
5816
+ return 2;
6212
5817
  }
6213
5818
  return stopCommand({ runId });
6214
5819
  }