@vellumai/assistant 0.3.8 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
  3. package/src/__tests__/approval-routes-http.test.ts +704 -0
  4. package/src/__tests__/call-controller.test.ts +835 -0
  5. package/src/__tests__/call-state.test.ts +24 -24
  6. package/src/__tests__/ipc-snapshot.test.ts +14 -0
  7. package/src/__tests__/relay-server.test.ts +9 -9
  8. package/src/__tests__/run-orchestrator.test.ts +399 -3
  9. package/src/__tests__/runtime-runs.test.ts +12 -4
  10. package/src/__tests__/session-init.benchmark.test.ts +3 -3
  11. package/src/__tests__/voice-session-bridge.test.ts +869 -0
  12. package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
  13. package/src/calls/call-domain.ts +21 -21
  14. package/src/calls/call-state.ts +12 -12
  15. package/src/calls/guardian-dispatch.ts +43 -3
  16. package/src/calls/relay-server.ts +34 -39
  17. package/src/calls/twilio-routes.ts +3 -3
  18. package/src/calls/voice-session-bridge.ts +244 -0
  19. package/src/config/defaults.ts +5 -0
  20. package/src/config/notifications-schema.ts +15 -0
  21. package/src/config/schema.ts +13 -0
  22. package/src/config/types.ts +1 -0
  23. package/src/daemon/ipc-contract/notifications.ts +9 -0
  24. package/src/daemon/ipc-contract-inventory.json +2 -0
  25. package/src/daemon/ipc-contract.ts +4 -1
  26. package/src/daemon/lifecycle.ts +84 -1
  27. package/src/daemon/session-agent-loop.ts +4 -0
  28. package/src/daemon/session-process.ts +51 -0
  29. package/src/daemon/session-runtime-assembly.ts +32 -0
  30. package/src/daemon/session.ts +5 -0
  31. package/src/memory/db-init.ts +80 -0
  32. package/src/memory/guardian-action-store.ts +2 -2
  33. package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
  34. package/src/memory/migrations/index.ts +1 -0
  35. package/src/memory/migrations/registry.ts +5 -0
  36. package/src/memory/schema-migration.ts +1 -0
  37. package/src/memory/schema.ts +59 -0
  38. package/src/notifications/README.md +134 -0
  39. package/src/notifications/adapters/macos.ts +55 -0
  40. package/src/notifications/adapters/telegram.ts +65 -0
  41. package/src/notifications/broadcaster.ts +175 -0
  42. package/src/notifications/copy-composer.ts +118 -0
  43. package/src/notifications/decision-engine.ts +391 -0
  44. package/src/notifications/decisions-store.ts +158 -0
  45. package/src/notifications/deliveries-store.ts +130 -0
  46. package/src/notifications/destination-resolver.ts +54 -0
  47. package/src/notifications/deterministic-checks.ts +187 -0
  48. package/src/notifications/emit-signal.ts +191 -0
  49. package/src/notifications/events-store.ts +145 -0
  50. package/src/notifications/preference-extractor.ts +223 -0
  51. package/src/notifications/preference-summary.ts +110 -0
  52. package/src/notifications/preferences-store.ts +142 -0
  53. package/src/notifications/runtime-dispatch.ts +100 -0
  54. package/src/notifications/signal.ts +24 -0
  55. package/src/notifications/types.ts +75 -0
  56. package/src/runtime/http-server.ts +10 -0
  57. package/src/runtime/pending-interactions.ts +73 -0
  58. package/src/runtime/routes/approval-routes.ts +179 -0
  59. package/src/runtime/routes/channel-inbound-routes.ts +39 -4
  60. package/src/runtime/routes/conversation-routes.ts +31 -1
  61. package/src/runtime/routes/run-routes.ts +1 -1
  62. package/src/runtime/run-orchestrator.ts +157 -2
  63. package/src/tools/browser/browser-manager.ts +1 -1
  64. package/src/__tests__/call-orchestrator.test.ts +0 -1496
@@ -35,6 +35,11 @@ import {
35
35
  handleRunSecret,
36
36
  handleAddTrustRule,
37
37
  } from './routes/run-routes.js';
38
+ import {
39
+ handleConfirm,
40
+ handleSecret,
41
+ handleTrustRule,
42
+ } from './routes/approval-routes.js';
38
43
  import {
39
44
  handleDeleteConversation,
40
45
  handleChannelInbound,
@@ -566,6 +571,11 @@ export class RuntimeHttpServer {
566
571
  });
567
572
  }
568
573
 
574
+ // Standalone approval endpoints — keyed by requestId, orthogonal to message sending
575
+ if (endpoint === 'confirm' && req.method === 'POST') return await handleConfirm(req);
576
+ if (endpoint === 'secret' && req.method === 'POST') return await handleSecret(req);
577
+ if (endpoint === 'trust-rules' && req.method === 'POST') return await handleTrustRule(req);
578
+
569
579
  if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req);
570
580
  if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req);
571
581
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * In-memory tracker that maps requestId to session info for pending
3
+ * confirmation and secret interactions.
4
+ *
5
+ * When the agent loop emits a confirmation_request or secret_request,
6
+ * the onEvent callback registers the interaction here. Standalone HTTP
7
+ * endpoints (/v1/confirm, /v1/secret, /v1/trust-rules) look up the
8
+ * session from this tracker to resolve the interaction.
9
+ */
10
+
11
+ import type { Session } from '../daemon/session.js';
12
+
13
+ export interface ConfirmationDetails {
14
+ toolName: string;
15
+ input: Record<string, unknown>;
16
+ riskLevel: string;
17
+ executionTarget?: 'sandbox' | 'host';
18
+ allowlistOptions: Array<{ label: string; description: string; pattern: string }>;
19
+ scopeOptions: Array<{ label: string; scope: string }>;
20
+ persistentDecisionsAllowed?: boolean;
21
+ }
22
+
23
+ export interface PendingInteraction {
24
+ session: Session;
25
+ conversationId: string;
26
+ kind: 'confirmation' | 'secret';
27
+ confirmationDetails?: ConfirmationDetails;
28
+ }
29
+
30
+ const pending = new Map<string, PendingInteraction>();
31
+
32
+ export function register(requestId: string, interaction: PendingInteraction): void {
33
+ pending.set(requestId, interaction);
34
+ }
35
+
36
+ /**
37
+ * Remove and return the pending interaction for the given requestId.
38
+ * Returns undefined if no interaction is registered.
39
+ */
40
+ export function resolve(requestId: string): PendingInteraction | undefined {
41
+ const interaction = pending.get(requestId);
42
+ if (interaction) {
43
+ pending.delete(requestId);
44
+ }
45
+ return interaction;
46
+ }
47
+
48
+ /**
49
+ * Return the pending interaction without removing it.
50
+ * Used by trust-rule endpoint which doesn't resolve the confirmation itself.
51
+ */
52
+ export function get(requestId: string): PendingInteraction | undefined {
53
+ return pending.get(requestId);
54
+ }
55
+
56
+ /**
57
+ * Return all pending interactions for a given conversation.
58
+ * Needed by channel approval migration (PR 3).
59
+ */
60
+ export function getByConversation(conversationId: string): Array<{ requestId: string } & PendingInteraction> {
61
+ const results: Array<{ requestId: string } & PendingInteraction> = [];
62
+ for (const [requestId, interaction] of pending) {
63
+ if (interaction.conversationId === conversationId) {
64
+ results.push({ requestId, ...interaction });
65
+ }
66
+ }
67
+ return results;
68
+ }
69
+
70
+ /** Clear all pending interactions. Useful for testing. */
71
+ export function clear(): void {
72
+ pending.clear();
73
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Route handlers for standalone approval endpoints.
3
+ *
4
+ * These endpoints resolve pending confirmations, secrets, and trust rules
5
+ * by requestId — orthogonal to message sending.
6
+ */
7
+ import * as pendingInteractions from '../pending-interactions.js';
8
+ import { addRule } from '../../permissions/trust-store.js';
9
+ import { getTool } from '../../tools/registry.js';
10
+ import { getLogger } from '../../util/logger.js';
11
+
12
+ const log = getLogger('approval-routes');
13
+
14
+ /**
15
+ * POST /v1/confirm — resolve a pending confirmation by requestId.
16
+ */
17
+ export async function handleConfirm(req: Request): Promise<Response> {
18
+ const body = await req.json() as {
19
+ requestId?: string;
20
+ decision?: string;
21
+ };
22
+
23
+ const { requestId, decision } = body;
24
+
25
+ if (!requestId || typeof requestId !== 'string') {
26
+ return Response.json({ error: 'requestId is required' }, { status: 400 });
27
+ }
28
+
29
+ if (decision !== 'allow' && decision !== 'deny') {
30
+ return Response.json(
31
+ { error: 'decision must be "allow" or "deny"' },
32
+ { status: 400 },
33
+ );
34
+ }
35
+
36
+ const interaction = pendingInteractions.resolve(requestId);
37
+ if (!interaction) {
38
+ return Response.json(
39
+ { error: 'No pending interaction found for this requestId' },
40
+ { status: 404 },
41
+ );
42
+ }
43
+
44
+ interaction.session.handleConfirmationResponse(requestId, decision);
45
+ return Response.json({ accepted: true });
46
+ }
47
+
48
+ /**
49
+ * POST /v1/secret — resolve a pending secret request by requestId.
50
+ */
51
+ export async function handleSecret(req: Request): Promise<Response> {
52
+ const body = await req.json() as {
53
+ requestId?: string;
54
+ value?: string;
55
+ delivery?: string;
56
+ };
57
+
58
+ const { requestId, value, delivery } = body;
59
+
60
+ if (!requestId || typeof requestId !== 'string') {
61
+ return Response.json({ error: 'requestId is required' }, { status: 400 });
62
+ }
63
+
64
+ if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
65
+ return Response.json(
66
+ { error: 'delivery must be "store" or "transient_send"' },
67
+ { status: 400 },
68
+ );
69
+ }
70
+
71
+ const interaction = pendingInteractions.resolve(requestId);
72
+ if (!interaction) {
73
+ return Response.json(
74
+ { error: 'No pending interaction found for this requestId' },
75
+ { status: 404 },
76
+ );
77
+ }
78
+
79
+ interaction.session.handleSecretResponse(
80
+ requestId,
81
+ value,
82
+ delivery as 'store' | 'transient_send' | undefined,
83
+ );
84
+ return Response.json({ accepted: true });
85
+ }
86
+
87
+ /**
88
+ * POST /v1/trust-rules — add a trust rule for a pending confirmation.
89
+ *
90
+ * Does NOT resolve the confirmation itself (the client still needs to
91
+ * POST /v1/confirm to approve/deny). Validates the pattern and scope
92
+ * against the server-provided allowlist options from the original
93
+ * confirmation_request.
94
+ */
95
+ export async function handleTrustRule(req: Request): Promise<Response> {
96
+ const body = await req.json() as {
97
+ requestId?: string;
98
+ pattern?: string;
99
+ scope?: string;
100
+ decision?: string;
101
+ };
102
+
103
+ const { requestId, pattern, scope, decision } = body;
104
+
105
+ if (!requestId || typeof requestId !== 'string') {
106
+ return Response.json({ error: 'requestId is required' }, { status: 400 });
107
+ }
108
+
109
+ if (!pattern || typeof pattern !== 'string') {
110
+ return Response.json({ error: 'pattern is required' }, { status: 400 });
111
+ }
112
+
113
+ if (!scope || typeof scope !== 'string') {
114
+ return Response.json({ error: 'scope is required' }, { status: 400 });
115
+ }
116
+
117
+ if (decision !== 'allow' && decision !== 'deny') {
118
+ return Response.json({ error: 'decision must be "allow" or "deny"' }, { status: 400 });
119
+ }
120
+
121
+ // Look up without removing — trust rule doesn't resolve the confirmation
122
+ const interaction = pendingInteractions.get(requestId);
123
+ if (!interaction) {
124
+ return Response.json(
125
+ { error: 'No pending interaction found for this requestId' },
126
+ { status: 404 },
127
+ );
128
+ }
129
+
130
+ if (!interaction.confirmationDetails) {
131
+ return Response.json(
132
+ { error: 'No confirmation details available for this request' },
133
+ { status: 409 },
134
+ );
135
+ }
136
+
137
+ const confirmation = interaction.confirmationDetails;
138
+
139
+ if (confirmation.persistentDecisionsAllowed === false) {
140
+ return Response.json(
141
+ { error: 'Persistent trust rules are not allowed for this tool invocation' },
142
+ { status: 403 },
143
+ );
144
+ }
145
+
146
+ // Validate pattern against server-provided allowlist options
147
+ const validPatterns = (confirmation.allowlistOptions ?? []).map((o) => o.pattern);
148
+ if (!validPatterns.includes(pattern)) {
149
+ return Response.json(
150
+ { error: 'pattern does not match any server-provided allowlist option' },
151
+ { status: 403 },
152
+ );
153
+ }
154
+
155
+ // Validate scope against server-provided scope options
156
+ const validScopes = (confirmation.scopeOptions ?? []).map((o) => o.scope);
157
+ if (!validScopes.includes(scope)) {
158
+ return Response.json(
159
+ { error: 'scope does not match any server-provided scope option' },
160
+ { status: 403 },
161
+ );
162
+ }
163
+
164
+ try {
165
+ const tool = getTool(confirmation.toolName);
166
+ const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
167
+ addRule(confirmation.toolName, pattern, scope, decision, undefined, {
168
+ executionTarget,
169
+ });
170
+ log.info(
171
+ { tool: confirmation.toolName, pattern, scope, decision, requestId },
172
+ 'Trust rule added via HTTP (bound to pending confirmation)',
173
+ );
174
+ return Response.json({ accepted: true });
175
+ } catch (err) {
176
+ log.error({ err }, 'Failed to add trust rule');
177
+ return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
178
+ }
179
+ }
@@ -45,6 +45,8 @@ import type {
45
45
  } from '../http-types.js';
46
46
  import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
47
47
  import { refreshThreadEscalation } from '../../memory/inbox-escalation-projection.js';
48
+ import { getConfig } from '../../config/loader.js';
49
+ import { emitNotificationSignal } from '../../notifications/emit-signal.js';
48
50
  import {
49
51
  type GuardianContext,
50
52
  verifyGatewayOrigin,
@@ -360,9 +362,37 @@ export async function handleChannelInbound(
360
362
  // Update inbox thread escalation state so the desktop UI badge is accurate
361
363
  refreshThreadEscalation(result.conversationId, assistantId);
362
364
 
363
- // Notify the guardian about the pending escalation via channel delivery
365
+ // Emit notification signal through the unified pipeline (fire-and-forget).
366
+ // This lets the decision engine route escalation alerts to all configured
367
+ // channels, supplementing the direct guardian notification below.
368
+ void emitNotificationSignal({
369
+ sourceEventName: 'ingress.escalation',
370
+ sourceChannel: sourceChannel,
371
+ sourceSessionId: result.conversationId,
372
+ assistantId,
373
+ attentionHints: {
374
+ requiresAction: true,
375
+ urgency: 'high',
376
+ isAsyncBackground: false,
377
+ visibleInSourceNow: false,
378
+ },
379
+ contextPayload: {
380
+ conversationId: result.conversationId,
381
+ sourceChannel,
382
+ externalChatId,
383
+ senderIdentifier: body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender',
384
+ eventId: result.eventId,
385
+ },
386
+ dedupeKey: `escalation:${result.eventId}`,
387
+ });
388
+
389
+ // Notify the guardian about the pending escalation via channel delivery.
390
+ // When the notification system is fully active it handles channel delivery,
391
+ // so skip the legacy path to avoid duplicate alerts.
392
+ const notifCfg = getConfig().notifications;
393
+ const notificationsActive = notifCfg.enabled && !notifCfg.shadowMode;
364
394
  const senderIdentifier = body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender';
365
- if (body.replyCallbackUrl) {
395
+ if (!notificationsActive && body.replyCallbackUrl) {
366
396
  try {
367
397
  const notificationText = await composeApprovalMessageGenerative(
368
398
  {
@@ -388,8 +418,13 @@ export async function handleChannelInbound(
388
418
  // the pending escalation even if channel notification failed.
389
419
  log.error({ err, conversationId: result.conversationId, guardianChatId: binding.guardianDeliveryChatId }, 'Failed to notify guardian of ingress escalation');
390
420
  }
391
- } else {
421
+ } else if (!notificationsActive) {
392
422
  log.warn({ conversationId: result.conversationId }, 'Ingress escalation created but no replyCallbackUrl to notify guardian');
423
+ } else {
424
+ log.info(
425
+ { conversationId: result.conversationId },
426
+ 'Skipping legacy guardian escalation callback delivery — notification pipeline active',
427
+ );
393
428
  }
394
429
 
395
430
  return Response.json({ accepted: true, escalated: true, reason: 'policy_escalate' });
@@ -886,7 +921,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
886
921
  assistantMessageChannel: sourceChannel,
887
922
  };
888
923
 
889
- const run = await orchestrator.startRun(
924
+ const { run } = await orchestrator.startRun(
890
925
  conversationId,
891
926
  content,
892
927
  attachmentIds,
@@ -22,6 +22,7 @@ import type {
22
22
  } from '../http-types.js';
23
23
  import type { ServerMessage } from '../../daemon/ipc-protocol.js';
24
24
  import { buildAssistantEvent } from '../assistant-event.js';
25
+ import * as pendingInteractions from '../pending-interactions.js';
25
26
  import { getLogger } from '../../util/logger.js';
26
27
 
27
28
  const log = getLogger('conversation-routes');
@@ -143,13 +144,42 @@ export function handleListMessages(
143
144
  /**
144
145
  * Build an `onEvent` callback that publishes every outbound event to the
145
146
  * assistant event hub, maintaining ordered delivery through a serial chain.
147
+ *
148
+ * Also registers pending interactions when confirmation_request or
149
+ * secret_request events flow through, so standalone approval endpoints
150
+ * can look up the session by requestId.
146
151
  */
147
152
  function makeHubPublisher(
148
153
  deps: SendMessageDeps,
149
154
  conversationId: string,
155
+ session: import('../../daemon/session.js').Session,
150
156
  ): (msg: ServerMessage) => void {
151
157
  let hubChain: Promise<void> = Promise.resolve();
152
158
  return (msg: ServerMessage) => {
159
+ // Register pending interactions for approval events
160
+ if (msg.type === 'confirmation_request') {
161
+ pendingInteractions.register(msg.requestId, {
162
+ session,
163
+ conversationId,
164
+ kind: 'confirmation',
165
+ confirmationDetails: {
166
+ toolName: msg.toolName,
167
+ input: msg.input,
168
+ riskLevel: msg.riskLevel,
169
+ executionTarget: msg.executionTarget,
170
+ allowlistOptions: msg.allowlistOptions,
171
+ scopeOptions: msg.scopeOptions,
172
+ persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
173
+ },
174
+ });
175
+ } else if (msg.type === 'secret_request') {
176
+ pendingInteractions.register(msg.requestId, {
177
+ session,
178
+ conversationId,
179
+ kind: 'secret',
180
+ });
181
+ }
182
+
153
183
  const msgRecord = msg as unknown as Record<string, unknown>;
154
184
  const msgSessionId =
155
185
  'sessionId' in msg && typeof msgRecord.sessionId === 'string'
@@ -243,7 +273,7 @@ export async function handleSendMessage(
243
273
  if (deps.sendMessageDeps) {
244
274
  const smDeps = deps.sendMessageDeps;
245
275
  const session = await smDeps.getOrCreateSession(mapping.conversationId);
246
- const onEvent = makeHubPublisher(smDeps, mapping.conversationId);
276
+ const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
247
277
 
248
278
  const attachments = hasAttachments
249
279
  ? smDeps.resolveAttachments(attachmentIds)
@@ -66,7 +66,7 @@ export async function handleCreateRun(
66
66
  const mapping = getOrCreateConversation(conversationKey);
67
67
 
68
68
  try {
69
- const run = await runOrchestrator.startRun(
69
+ const { run } = await runOrchestrator.startRun(
70
70
  mapping.conversationId,
71
71
  content ?? '',
72
72
  hasAttachments ? attachmentIds : undefined,
@@ -34,6 +34,29 @@ const log = getLogger('run-orchestrator');
34
34
  // Types
35
35
  // ---------------------------------------------------------------------------
36
36
 
37
+ /**
38
+ * Real-time event sink for voice TTS streaming. When provided to startRun(),
39
+ * agent-loop events are forwarded here alongside the existing assistantEventHub
40
+ * publication. This enables voice relay to receive streaming text deltas for
41
+ * real-time text-to-speech without modifying the standard channel path.
42
+ */
43
+ export interface VoiceRunEventSink {
44
+ onTextDelta(text: string): void;
45
+ onMessageComplete(): void;
46
+ onError(message: string): void;
47
+ onToolUse(toolName: string, input: Record<string, unknown>): void;
48
+ }
49
+
50
+ /**
51
+ * Handle returned by startRun() that allows callers to abort an in-flight
52
+ * run. Used by voice barge-in to cancel the current turn without crashing
53
+ * session state.
54
+ */
55
+ export interface RunHandle {
56
+ run: Run;
57
+ abort: () => void;
58
+ }
59
+
37
60
  interface PendingRunState {
38
61
  prompterRequestId: string;
39
62
  session: Session;
@@ -92,6 +115,36 @@ export interface RunStartOptions {
92
115
  commandIntent?: { type: string; payload?: string; languageCode?: string };
93
116
  /** Resolved channel context for this turn. */
94
117
  turnChannelContext?: TurnChannelContext;
118
+ /**
119
+ * When provided, agent-loop events are forwarded to this sink in real time.
120
+ * Used by voice relay for streaming TTS token delivery.
121
+ */
122
+ eventSink?: VoiceRunEventSink;
123
+ /**
124
+ * When true, any confirmation_request from the prompter is immediately
125
+ * auto-denied instead of being stored for client polling. Used by the
126
+ * voice path when forceStrictSideEffects is active: the voice transport
127
+ * has no interactive approval UI, so without this flag the run would
128
+ * stall for the full permission timeout (300s by default).
129
+ */
130
+ voiceAutoDenyConfirmations?: boolean;
131
+ /**
132
+ * When true, confirmation_request events are auto-approved immediately.
133
+ * Used for verified-guardian voice turns where there is no interactive
134
+ * approval UI but parity with guardian chat permissions is required.
135
+ */
136
+ voiceAutoAllowConfirmations?: boolean;
137
+ /**
138
+ * When true, secret_request events are resolved immediately with a null
139
+ * value so voice turns do not stall waiting for a secret-entry UI that
140
+ * voice does not provide.
141
+ */
142
+ voiceAutoResolveSecrets?: boolean;
143
+ /**
144
+ * Call-control protocol prompt injected into each voice turn so the
145
+ * model knows to emit control markers ([ASK_GUARDIAN:], [END_CALL], etc.).
146
+ */
147
+ voiceCallControlPrompt?: string;
95
148
  }
96
149
 
97
150
  // ---------------------------------------------------------------------------
@@ -116,13 +169,16 @@ export class RunOrchestrator {
116
169
  /**
117
170
  * Start a new run: persist the user message, create a run record,
118
171
  * and fire the agent loop in the background.
172
+ *
173
+ * Returns a RunHandle containing the Run record and an abort() function
174
+ * that can cancel the in-flight agent loop (e.g. for voice barge-in).
119
175
  */
120
176
  async startRun(
121
177
  conversationId: string,
122
178
  content: string,
123
179
  attachmentIds?: string[],
124
180
  options?: RunStartOptions,
125
- ): Promise<Run> {
181
+ ): Promise<RunHandle> {
126
182
  // Block inbound content that contains secrets — mirrors the IPC check in sessions.ts
127
183
  const ingressCheck = checkIngressForSecrets(content);
128
184
  if (ingressCheck.blocked) {
@@ -176,6 +232,7 @@ export class RunOrchestrator {
176
232
  // (e.g. attachment scope) match the actual transport rather than always
177
233
  // defaulting to 'macos'.
178
234
  session.setChannelCapabilities(resolveChannelCapabilities(options?.sourceChannel ?? 'macos'));
235
+ session.setVoiceCallControlPrompt(options?.voiceCallControlPrompt ?? null);
179
236
 
180
237
  // Serialized publish chain so hub subscribers observe events in order.
181
238
  let hubChain: Promise<void> = Promise.resolve();
@@ -202,9 +259,55 @@ export class RunOrchestrator {
202
259
  // When the prompter sends one of these, we record it in the run store so
203
260
  // the client can poll and submit a decision/secret via the respective endpoint.
204
261
  // Do NOT set hasNoClient — run sessions have a client (the HTTP caller).
262
+ const autoDeny = options?.voiceAutoDenyConfirmations === true;
263
+ const autoAllow = !autoDeny && options?.voiceAutoAllowConfirmations === true;
264
+ const autoResolveSecrets = options?.voiceAutoResolveSecrets === true;
205
265
  let lastError: string | null = null;
206
266
  session.updateClient((msg: ServerMessage) => {
207
267
  if (msg.type === 'confirmation_request') {
268
+ if (autoDeny) {
269
+ // Voice path with strict side effects: immediately deny the
270
+ // confirmation request so the agent loop resumes without
271
+ // waiting for the full permission timeout (300s). The voice
272
+ // transport has no interactive approval UI, so polling would
273
+ // just stall. Security is preserved — the tool call is denied.
274
+ log.info(
275
+ { runId: run.id, toolName: msg.toolName },
276
+ 'Auto-denying confirmation request for voice turn (forceStrictSideEffects)',
277
+ );
278
+ session.handleConfirmationResponse(
279
+ msg.requestId,
280
+ 'deny',
281
+ undefined,
282
+ undefined,
283
+ `Permission denied for "${msg.toolName}": this voice call does not have interactive approval capabilities. Side-effect tools are not available for non-guardian voice callers. In your next assistant reply, explain briefly that this action requires guardian-level access and cannot be performed during this call.`,
284
+ );
285
+ // Still publish to hub for observability, but skip run-store
286
+ // bookkeeping since the confirmation is already resolved.
287
+ publishToHub(msg);
288
+ return;
289
+ }
290
+ if (autoAllow) {
291
+ // Verified guardian voice turn: auto-approve so voice has the same
292
+ // permission capabilities as guardian chat despite lacking an
293
+ // interactive confirmation UI.
294
+ log.info(
295
+ { runId: run.id, toolName: msg.toolName },
296
+ 'Auto-approving confirmation request for guardian voice turn',
297
+ );
298
+ session.handleConfirmationResponse(
299
+ msg.requestId,
300
+ 'allow',
301
+ undefined,
302
+ undefined,
303
+ `Permission approved for "${msg.toolName}": this is a verified guardian voice call.`,
304
+ );
305
+ // Publish for observability, but skip run-store pending state since
306
+ // the request is already resolved.
307
+ publishToHub(msg);
308
+ return;
309
+ }
310
+
208
311
  runsStore.setRunConfirmation(run.id, {
209
312
  toolName: msg.toolName,
210
313
  toolUseId: msg.requestId,
@@ -220,6 +323,18 @@ export class RunOrchestrator {
220
323
  session,
221
324
  });
222
325
  } else if (msg.type === 'secret_request') {
326
+ if (autoResolveSecrets) {
327
+ // Voice has no secret-entry UI, so resolve immediately to avoid
328
+ // waiting for the full secret prompt timeout.
329
+ log.info(
330
+ { runId: run.id, service: msg.service, field: msg.field },
331
+ 'Auto-resolving secret request for voice turn (no secret-entry UI)',
332
+ );
333
+ session.handleSecretResponse(msg.requestId, undefined, 'store');
334
+ publishToHub(msg);
335
+ return;
336
+ }
337
+
223
338
  runsStore.setRunSecret(run.id, {
224
339
  requestId: msg.requestId,
225
340
  service: msg.service,
@@ -249,6 +364,7 @@ export class RunOrchestrator {
249
364
  session.setGuardianContext(null);
250
365
  session.setCommandIntent(null);
251
366
  session.setAssistantId('self');
367
+ session.setVoiceCallControlPrompt(null);
252
368
  // Reset the session's client callback to a no-op so the stale
253
369
  // closure doesn't intercept events from future runs on the same session.
254
370
  // Set hasNoClient=true here since the run is done and no HTTP caller
@@ -256,6 +372,8 @@ export class RunOrchestrator {
256
372
  session.updateClient(() => {}, true);
257
373
  };
258
374
 
375
+ const eventSink = options?.eventSink;
376
+
259
377
  void (async () => {
260
378
  try {
261
379
  await session.runAgentLoop(content, messageId, (msg: ServerMessage) => {
@@ -270,6 +388,27 @@ export class RunOrchestrator {
270
388
  // prompter (confirmation_request). Both paths must publish so SSE
271
389
  // consumers receive the full response stream.
272
390
  publishToHub(msg);
391
+
392
+ // Forward voice-relevant events to the real-time event sink when
393
+ // provided. This runs in addition to (not instead of) the hub
394
+ // publication above so both paths remain active.
395
+ if (eventSink) {
396
+ if (msg.type === 'assistant_text_delta') {
397
+ eventSink.onTextDelta(msg.text);
398
+ } else if (msg.type === 'message_complete') {
399
+ eventSink.onMessageComplete();
400
+ } else if (msg.type === 'generation_cancelled') {
401
+ // Treat cancellation as a completed turn so the voice
402
+ // turnComplete promise settles instead of hanging forever.
403
+ eventSink.onMessageComplete();
404
+ } else if (msg.type === 'error') {
405
+ eventSink.onError(msg.message);
406
+ } else if (msg.type === 'session_error') {
407
+ eventSink.onError(msg.userMessage);
408
+ } else if (msg.type === 'tool_use_start') {
409
+ eventSink.onToolUse(msg.toolName, msg.input);
410
+ }
411
+ }
273
412
  });
274
413
  if (lastError) {
275
414
  log.error({ runId: run.id, error: lastError }, 'Run failed (error event from agent loop)');
@@ -281,12 +420,28 @@ export class RunOrchestrator {
281
420
  const message = err instanceof Error ? err.message : String(err);
282
421
  log.error({ err, runId: run.id }, 'Run failed');
283
422
  runsStore.failRun(run.id, message);
423
+ // Notify the voice event sink so the caller's turnComplete
424
+ // promise settles instead of hanging on unhandled exceptions.
425
+ if (eventSink) {
426
+ eventSink.onError(message);
427
+ }
284
428
  } finally {
285
429
  cleanup();
286
430
  }
287
431
  })();
288
432
 
289
- return run;
433
+ return {
434
+ run,
435
+ // Scope the abort to this specific run by capturing the requestId.
436
+ // If the session has moved on to a new turn (different currentRequestId),
437
+ // this abort is stale and becomes a no-op — preventing voice barge-in
438
+ // from cancelling unrelated turns.
439
+ abort: () => {
440
+ if (session.currentRequestId === requestId) {
441
+ session.abort();
442
+ }
443
+ },
444
+ };
290
445
  }
291
446
 
292
447
  /** Read current run state from the store. */
@@ -792,7 +792,7 @@ class BrowserManager {
792
792
  // Check if an unconsumed download already completed for this session
793
793
  const existing = this.downloads.get(sessionId);
794
794
  if (existing && existing.length > 0) {
795
- const info = existing.pop()!;
795
+ const info = existing.shift()!;
796
796
  if (existing.length === 0) this.downloads.delete(sessionId);
797
797
  return Promise.resolve(info);
798
798
  }