@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.3

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 (189) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/knip.json +3 -0
  7. package/openapi.yaml +136 -76
  8. package/package.json +1 -1
  9. package/scripts/write-plugin-api-shim.ts +10 -0
  10. package/src/__tests__/app-control-flow.test.ts +1 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -2
  12. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  13. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  14. package/src/__tests__/compaction-events.test.ts +8 -10
  15. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  16. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  17. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  18. package/src/__tests__/conversation-queue.test.ts +39 -62
  19. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  20. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  22. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  25. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  29. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  31. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  37. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  39. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  40. package/src/__tests__/document-sync-tags.test.ts +0 -75
  41. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  42. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  43. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  44. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  45. package/src/__tests__/media-generate-image.test.ts +131 -21
  46. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  47. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  48. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  49. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  50. package/src/__tests__/qdrant-manager.test.ts +14 -1
  51. package/src/__tests__/run-due-schedules.test.ts +21 -0
  52. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  53. package/src/__tests__/schedule-routes.test.ts +23 -0
  54. package/src/__tests__/schedule-store.test.ts +17 -0
  55. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  56. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  57. package/src/__tests__/starter-task-flow.test.ts +5 -4
  58. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  59. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  61. package/src/__tests__/subagent-tools.test.ts +81 -101
  62. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  63. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  64. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  65. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  66. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  67. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  68. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  69. package/src/acp/session-manager.ts +8 -1
  70. package/src/api/surfaces.ts +5 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  72. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  73. package/src/calls/voice-session-bridge.ts +44 -25
  74. package/src/calls/voice-triage-escalate.ts +1 -0
  75. package/src/cli/bundled-modules.ts +29 -0
  76. package/src/cli/commands/db/repair.ts +4 -8
  77. package/src/cli/commands/domain.ts +6 -3
  78. package/src/cli/commands/email.ts +6 -3
  79. package/src/cli/commands/keys.ts +8 -3
  80. package/src/cli/commands/plugins.ts +85 -36
  81. package/src/cli/commands/schedules.ts +35 -1
  82. package/src/cli/lib/bundled-marketplace.json +1 -1
  83. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  84. package/src/config/assistant-feature-flags.ts +36 -15
  85. package/src/config/balanced-model-experiment.ts +35 -0
  86. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  87. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  89. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  90. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  91. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  92. package/src/config/call-site-defaults.ts +7 -0
  93. package/src/config/default-profile-catalog.ts +96 -4
  94. package/src/config/feature-flag-registry.json +11 -11
  95. package/src/config/skills.ts +9 -2
  96. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  97. package/src/daemon/conversation-agent-loop.ts +14 -13
  98. package/src/daemon/conversation-notifiers.ts +11 -9
  99. package/src/daemon/conversation-process.ts +0 -27
  100. package/src/daemon/conversation-store.ts +4 -4
  101. package/src/daemon/conversation-surfaces.ts +27 -13
  102. package/src/daemon/conversation-tool-setup.ts +2 -4
  103. package/src/daemon/conversation.ts +81 -44
  104. package/src/daemon/doordash-steps.ts +2 -2
  105. package/src/daemon/lifecycle.ts +14 -1
  106. package/src/daemon/process-message.ts +0 -13
  107. package/src/daemon/windows-compiled-entry.ts +4 -0
  108. package/src/documents/document-store.ts +5 -235
  109. package/src/hooks/types.ts +5 -0
  110. package/src/ipc/gateway-flag-listener.ts +17 -3
  111. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  112. package/src/live-voice/live-voice-manager.ts +16 -3
  113. package/src/live-voice/live-voice-session.ts +63 -9
  114. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  115. package/src/monitoring/control.ts +1 -0
  116. package/src/monitoring/db-integrity-sample.ts +4 -5
  117. package/src/permissions/prompter.ts +1 -5
  118. package/src/persistence/conversation-queries.ts +66 -16
  119. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  120. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  121. package/src/persistence/schema/documents.ts +4 -4
  122. package/src/plugin-api/constants.ts +8 -0
  123. package/src/plugin-api/index.ts +5 -1
  124. package/src/plugin-api/webhook-url.ts +13 -11
  125. package/src/plugins/defaults/main.ts +6 -7
  126. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  127. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  128. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  129. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  130. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  131. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  132. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  133. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  134. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  135. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  136. package/src/plugins/mtime-cache.ts +17 -0
  137. package/src/prompts/templates/system-sections.ts +0 -7
  138. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  139. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  140. package/src/providers/openai/chat-completions-provider.ts +11 -1
  141. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  142. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  143. package/src/routes/control.ts +1 -0
  144. package/src/routes/route-host-client.ts +1 -0
  145. package/src/runtime/AGENTS.md +16 -17
  146. package/src/runtime/agent-wake.ts +15 -12
  147. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  148. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  149. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  150. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  151. package/src/runtime/routes/conversation-routes.ts +11 -13
  152. package/src/runtime/routes/documents-routes.ts +3 -222
  153. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  154. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  155. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  156. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  157. package/src/runtime/routes/schedule-routes.ts +94 -7
  158. package/src/runtime/routes/workspace-routes.ts +0 -9
  159. package/src/runtime/routes/workspace-utils.ts +3 -13
  160. package/src/runtime/services/conversation-serializer.ts +7 -2
  161. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  162. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  163. package/src/schedule/plugin-schedule-availability.ts +58 -0
  164. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  165. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  166. package/src/schedule/schedule-store.ts +5 -1
  167. package/src/schedule/scheduler.ts +9 -4
  168. package/src/schedule/worker-control.ts +1 -0
  169. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  170. package/src/subagent/consult-context.ts +11 -11
  171. package/src/subagent/consult-prompt.ts +26 -35
  172. package/src/subagent/manager.ts +20 -37
  173. package/src/subagent/notify.ts +7 -1
  174. package/src/subagent/types.ts +8 -6
  175. package/src/tools/acp/spawn.ts +6 -4
  176. package/src/tools/skills/scaffold-managed.ts +25 -7
  177. package/src/tools/subagent/spawn.ts +28 -88
  178. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  179. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  180. package/src/util/logger.ts +16 -0
  181. package/src/util/worker-process.ts +37 -4
  182. package/src/windows-compiled-cli.ts +32 -0
  183. package/src/windows-compiled-entry.ts +4 -0
  184. package/src/windows-compiled-logger.ts +6 -0
  185. package/src/windows-compiled-worker-entry.ts +29 -0
  186. package/src/__tests__/document-workspace-file.test.ts +0 -467
  187. package/src/daemon/interactive-turn-sender.ts +0 -59
  188. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  189. package/src/subagent/consult-transcript.ts +0 -90
@@ -3,8 +3,10 @@
3
3
  * the Conversation constructor and dispose/abort methods.
4
4
  *
5
5
  * Notifier callbacks read from the provided context object at invocation
6
- * time (not registration time), so they always see the latest sendToClient
7
- * and messages references even after updateClient().
6
+ * time (not registration time), so they always see the latest messages
7
+ * reference. They emit through the conversation, whose sink is fixed for its
8
+ * life, so an out-of-turn notification reaches every subscribed client
9
+ * without any per-turn wiring.
8
10
  */
9
11
 
10
12
  import { createAssistantMessage } from "../agent/message-types.js";
@@ -32,7 +34,7 @@ import { restingTrust } from "./trust-context-types.js";
32
34
  * invocation time. Properties are read lazily from this reference.
33
35
  */
34
36
  export interface NotifierConversationContext {
35
- sendToClient: (msg: AssistantEvent) => void;
37
+ emit: (msg: AssistantEvent) => void;
36
38
  messages: Message[];
37
39
  trustContext?: TrustContext;
38
40
  }
@@ -77,12 +79,12 @@ export function registerConversationNotifiers(
77
79
 
78
80
  ctx.messages.push(createAssistantMessage(questionText));
79
81
 
80
- ctx.sendToClient({
82
+ ctx.emit({
81
83
  type: "assistant_text_delta",
82
84
  text: questionText,
83
85
  conversationId: conversationId,
84
86
  });
85
- ctx.sendToClient({
87
+ ctx.emit({
86
88
  type: "message_complete",
87
89
  conversationId: conversationId,
88
90
  messageId: msg.id,
@@ -97,12 +99,12 @@ export function registerConversationNotifiers(
97
99
  const speakerLabel = speaker === "caller" ? "Caller" : "Assistant";
98
100
  const transcriptText = `**Live call transcript**\n${speakerLabel}: ${text}`;
99
101
 
100
- ctx.sendToClient({
102
+ ctx.emit({
101
103
  type: "assistant_text_delta",
102
104
  text: transcriptText,
103
105
  conversationId: conversationId,
104
106
  });
105
- ctx.sendToClient({
107
+ ctx.emit({
106
108
  type: "message_complete",
107
109
  conversationId: conversationId,
108
110
  source: "aux",
@@ -113,12 +115,12 @@ export function registerConversationNotifiers(
113
115
  registerCallCompletionNotifier(conversationId, (callSessionId: string) => {
114
116
  const summaryText = buildCallCompletionMessage(callSessionId);
115
117
 
116
- ctx.sendToClient({
118
+ ctx.emit({
117
119
  type: "assistant_text_delta",
118
120
  text: summaryText,
119
121
  conversationId: conversationId,
120
122
  });
121
- ctx.sendToClient({
123
+ ctx.emit({
122
124
  type: "message_complete",
123
125
  conversationId: conversationId,
124
126
  source: "aux",
@@ -60,7 +60,6 @@ import {
60
60
  } from "./conversation-slash.js";
61
61
  import { getModelInfo } from "./handlers/config-model.js";
62
62
  import { preactivateHostProxySkills } from "./host-proxy-preactivation.js";
63
- import { bindInteractiveTurnSender } from "./interactive-turn-sender.js";
64
63
  import type { UserMessageAttachment } from "./message-protocol.js";
65
64
  import { buildTransportHints } from "./transport-hints.js";
66
65
  import { sameTrustIdentity, type TrustContext } from "./trust-context-types.js";
@@ -1293,26 +1292,11 @@ async function drainSingleMessage(
1293
1292
  drainLoopOptions.isHiddenPrompt = true;
1294
1293
  }
1295
1294
 
1296
- // By the time the drain runs, the conversation-level sender is usually the
1297
- // no-op the just-finished interactive turn's restore (or conversation
1298
- // birth) left behind, and the drain bypasses `processMessage`, so without
1299
- // a fresh bind the PermissionPrompter's confirmation_request (which reads
1300
- // the conversation-level sender, not the turn's `onEvent`) would be
1301
- // emitted into nothing and hang until the permission timeout. Same
1302
- // bind/restore contract as `processMessage`.
1303
- const restoreSender =
1304
- next.isInteractive === true
1305
- ? bindInteractiveTurnSender(conversation)
1306
- : undefined;
1307
-
1308
1295
  conversation
1309
1296
  .runAgentLoop(agentLoopContent, userMessageId, {
1310
1297
  ...drainLoopOptions,
1311
1298
  onEvent: next.onEvent,
1312
1299
  })
1313
- .finally(() => {
1314
- restoreSender?.();
1315
- })
1316
1300
  .catch((err) => {
1317
1301
  const message = err instanceof Error ? err.message : String(err);
1318
1302
  log.error(
@@ -1795,14 +1779,6 @@ async function drainBatch(
1795
1779
  drainLoopOptions.isHiddenPrompt = true;
1796
1780
  }
1797
1781
 
1798
- // Same sender contract as drainSingleMessage: an interactive drained batch
1799
- // must rebind the conversation-level sender or prompter confirmations
1800
- // raised during this turn reach no client.
1801
- const restoreSender =
1802
- drainLoopOptions.isInteractive === true
1803
- ? bindInteractiveTurnSender(conversation)
1804
- : undefined;
1805
-
1806
1782
  // Fire-and-forget: runAgentLoop's finally block recursively calls drainQueue
1807
1783
  // when this run completes. Mirrors drainSingleMessage.
1808
1784
  conversation
@@ -1810,9 +1786,6 @@ async function drainBatch(
1810
1786
  ...drainLoopOptions,
1811
1787
  onEvent: fanOutOnEvent,
1812
1788
  })
1813
- .finally(() => {
1814
- restoreSender?.();
1815
- })
1816
1789
  .catch((err) => {
1817
1790
  const message = err instanceof Error ? err.message : String(err);
1818
1791
  log.error(
@@ -27,6 +27,7 @@ import {
27
27
  } from "../providers/connection-resolution.js";
28
28
  import { RateLimitProvider } from "../providers/ratelimit.js";
29
29
  import { listProviders } from "../providers/registry.js";
30
+ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
30
31
  import { getSubagentManager } from "../subagent/index.js";
31
32
  import { getSandboxWorkingDir } from "../util/platform.js";
32
33
  import { Conversation } from "./conversation.js";
@@ -156,7 +157,6 @@ export async function getOrCreateConversation(
156
157
  options?: ConversationCreateOptions,
157
158
  ): Promise<Conversation> {
158
159
  let conversation = findConversation(conversationId);
159
- const sendToClient = () => {};
160
160
 
161
161
  // `taskRunId` and `ephemeral` are per-call scopes, not durable conversation
162
162
  // metadata, so they are stripped before the remaining options are merged
@@ -232,7 +232,9 @@ export async function getOrCreateConversation(
232
232
  conversationId,
233
233
  provider,
234
234
  systemPrompt,
235
- sendToClient,
235
+ // Top-level conversations deliver to the SSE hub for their whole life,
236
+ // so every subscribed client sees every event with no per-turn wiring.
237
+ broadcastMessage,
236
238
  workingDir,
237
239
  {
238
240
  maxTokens,
@@ -240,8 +242,6 @@ export async function getOrCreateConversation(
240
242
  modelOverride: storedOptions?.modelOverride,
241
243
  },
242
244
  );
243
- newConversation.updateClient(sendToClient, true);
244
-
245
245
  // Ensure the conversations row exists before hydrating from DB.
246
246
  // `getOrCreateConversation` builds the in-memory Conversation, but
247
247
  // the persisted row is what `loadFromDb` reads for conversationType,
@@ -138,6 +138,16 @@ const ModelActionSchema = SurfaceActionSchema.extend({
138
138
 
139
139
  const MAX_UNDO_DEPTH = 10;
140
140
 
141
+ /**
142
+ * Whether a surface-action turn (a user clicked a button or submitted a form on
143
+ * a UI surface) runs with a human declared present. The handler does not yet
144
+ * learn the actor's interface, so it cannot declare presence the way the send
145
+ * route does; the turn runs non-interactive, which is what the state-derived
146
+ * fallback resolved to between turns. Deriving it from the actor's interface
147
+ * is a policy change tracked separately.
148
+ */
149
+ const SURFACE_ACTION_TURN_IS_INTERACTIVE = false;
150
+
141
151
  /**
142
152
  * Pending surface types that do not hold the one-interactive-surface-at-a-time
143
153
  * lock. Each renders content the user reads (or settles on its own) rather
@@ -1372,7 +1382,7 @@ export function openChannelSetupPanel(
1372
1382
  data: safeParseSurfaceData("channel_setup", data) ?? {},
1373
1383
  });
1374
1384
 
1375
- ctx.sendToClient({
1385
+ ctx.emit({
1376
1386
  type: "open_panel",
1377
1387
  panelType: "channel_setup",
1378
1388
  data,
@@ -1510,7 +1520,7 @@ function pushUndoState(
1510
1520
  export function handleSurfaceUndo(ctx: Conversation, surfaceId: string): void {
1511
1521
  const stack = ctx.surfaceUndoStacks.get(surfaceId);
1512
1522
  if (!stack || stack.length === 0) {
1513
- ctx.sendToClient({
1523
+ ctx.emit({
1514
1524
  type: "ui_surface_undo_result",
1515
1525
  conversationId: ctx.conversationId,
1516
1526
  surfaceId,
@@ -1523,7 +1533,7 @@ export function handleSurfaceUndo(ctx: Conversation, surfaceId: string): void {
1523
1533
  const previousHtml = stack.pop()!;
1524
1534
  const stored = ctx.surfaceState.get(surfaceId);
1525
1535
  if (!stored || stored.surfaceType !== "dynamic_page") {
1526
- ctx.sendToClient({
1536
+ ctx.emit({
1527
1537
  type: "ui_surface_undo_result",
1528
1538
  conversationId: ctx.conversationId,
1529
1539
  surfaceId,
@@ -1557,7 +1567,7 @@ export function handleSurfaceUndo(ctx: Conversation, surfaceId: string): void {
1557
1567
  html: previousHtml,
1558
1568
  };
1559
1569
  s.data = revertedData;
1560
- ctx.sendToClient({
1570
+ ctx.emit({
1561
1571
  type: "ui_surface_update",
1562
1572
  conversationId: ctx.conversationId,
1563
1573
  surfaceId: sid,
@@ -1594,7 +1604,7 @@ export function handleSurfaceUndo(ctx: Conversation, surfaceId: string): void {
1594
1604
  html: previousHtml,
1595
1605
  };
1596
1606
  stored.data = revertedData;
1597
- ctx.sendToClient({
1607
+ ctx.emit({
1598
1608
  type: "ui_surface_update",
1599
1609
  conversationId: ctx.conversationId,
1600
1610
  surfaceId,
@@ -1602,7 +1612,7 @@ export function handleSurfaceUndo(ctx: Conversation, surfaceId: string): void {
1602
1612
  });
1603
1613
  }
1604
1614
 
1605
- ctx.sendToClient({
1615
+ ctx.emit({
1606
1616
  type: "ui_surface_undo_result",
1607
1617
  conversationId: ctx.conversationId,
1608
1618
  surfaceId,
@@ -2212,6 +2222,7 @@ export async function handleSurfaceAction(
2212
2222
  activeSurfaceId: surfaceId,
2213
2223
  displayContent,
2214
2224
  sourceActorPrincipalId,
2225
+ isInteractive: SURFACE_ACTION_TURN_IS_INTERACTIVE,
2215
2226
  // Rides the metadata bag rather than a typed option: the queue
2216
2227
  // round-trips `metadata` but not `PersistMessageOptions`.
2217
2228
  metadata: { scripted: isSyntheticSurfaceActionContent(content) },
@@ -2274,6 +2285,7 @@ export async function handleSurfaceAction(
2274
2285
  activeSurfaceId: surfaceId,
2275
2286
  displayContent,
2276
2287
  sourceActorPrincipalId,
2288
+ isInteractive: SURFACE_ACTION_TURN_IS_INTERACTIVE,
2277
2289
  scripted: isSyntheticSurfaceActionContent(content),
2278
2290
  })
2279
2291
  .catch((err) => {
@@ -2460,6 +2472,7 @@ export async function handleSurfaceAction(
2460
2472
  activeSurfaceId: surfaceId,
2461
2473
  displayContent,
2462
2474
  sourceActorPrincipalId,
2475
+ isInteractive: SURFACE_ACTION_TURN_IS_INTERACTIVE,
2463
2476
  // Rides the metadata bag rather than a typed option: the queue
2464
2477
  // round-trips `metadata` but not `PersistMessageOptions`.
2465
2478
  metadata: { scripted: isSyntheticSurfaceActionContent(content) },
@@ -2532,6 +2545,7 @@ export async function handleSurfaceAction(
2532
2545
  activeSurfaceId: surfaceId,
2533
2546
  displayContent,
2534
2547
  sourceActorPrincipalId,
2548
+ isInteractive: SURFACE_ACTION_TURN_IS_INTERACTIVE,
2535
2549
  scripted: isSyntheticSurfaceActionContent(content),
2536
2550
  })
2537
2551
  .catch((err) => {
@@ -2596,7 +2610,7 @@ export function refreshSurfacesForApp(
2596
2610
  }
2597
2611
 
2598
2612
  // Push the update to the client
2599
- ctx.sendToClient({
2613
+ ctx.emit({
2600
2614
  type: "ui_surface_update",
2601
2615
  conversationId: ctx.conversationId,
2602
2616
  surfaceId,
@@ -3433,7 +3447,7 @@ export async function surfaceProxyResolver(
3433
3447
  "Sending ui_surface_show to client",
3434
3448
  );
3435
3449
 
3436
- ctx.sendToClient({
3450
+ ctx.emit({
3437
3451
  type: "ui_surface_show",
3438
3452
  conversationId: ctx.conversationId,
3439
3453
  surfaceId,
@@ -3552,7 +3566,7 @@ export async function surfaceProxyResolver(
3552
3566
  mergedData = patch;
3553
3567
  }
3554
3568
 
3555
- ctx.sendToClient({
3569
+ ctx.emit({
3556
3570
  type: "ui_surface_update",
3557
3571
  conversationId: ctx.conversationId,
3558
3572
  surfaceId,
@@ -3610,7 +3624,7 @@ export async function surfaceProxyResolver(
3610
3624
  isError: true,
3611
3625
  };
3612
3626
  }
3613
- ctx.sendToClient({
3627
+ ctx.emit({
3614
3628
  type: "ui_surface_complete",
3615
3629
  conversationId: ctx.conversationId,
3616
3630
  surfaceId,
@@ -3618,7 +3632,7 @@ export async function surfaceProxyResolver(
3618
3632
  submittedData: lastAction.data,
3619
3633
  });
3620
3634
  } else {
3621
- ctx.sendToClient({
3635
+ ctx.emit({
3622
3636
  type: "ui_surface_dismiss",
3623
3637
  conversationId: ctx.conversationId,
3624
3638
  surfaceId,
@@ -3720,7 +3734,7 @@ export async function surfaceProxyResolver(
3720
3734
  // Inline-only preview card emitted during app_create — do not open a
3721
3735
  // workspace panel and do not register surface state. The client renders
3722
3736
  // this as a tappable inline card that opens the app on demand.
3723
- ctx.sendToClient({
3737
+ ctx.emit({
3724
3738
  type: "ui_surface_show",
3725
3739
  conversationId: ctx.conversationId,
3726
3740
  surfaceId,
@@ -3750,7 +3764,7 @@ export async function surfaceProxyResolver(
3750
3764
  title: app.name,
3751
3765
  });
3752
3766
 
3753
- ctx.sendToClient({
3767
+ ctx.emit({
3754
3768
  type: "ui_surface_show",
3755
3769
  conversationId: ctx.conversationId,
3756
3770
  surfaceId,
@@ -245,9 +245,7 @@ export function createToolExecutor(
245
245
  toolUseId?: string,
246
246
  ) => Promise<ToolExecutionResult> {
247
247
  // Register the conversation's sendToClient for browser screencast surface messages
248
- registerConversationSender(ctx.conversationId, (msg) =>
249
- ctx.sendToClient(msg),
250
- );
248
+ registerConversationSender(ctx.conversationId, (msg) => ctx.emit(msg));
251
249
 
252
250
  // Execution-layer allowlist gate (`subagentToolGateMode === "execution"`,
253
251
  // see {@link SubagentToolGateMode}): rejects non-allowlisted calls BEFORE
@@ -440,7 +438,7 @@ export function createToolExecutor(
440
438
  sendToClient: (msg) => {
441
439
  // Tool context's sendToClient uses a loose { type: string; [key: string]: unknown }
442
440
  // signature, but at runtime these are always AssistantEvent instances.
443
- ctx.sendToClient(msg as AssistantEvent);
441
+ ctx.emit(msg as AssistantEvent);
444
442
  if (msg.type === "ui_surface_show") {
445
443
  // The tool-context sendToClient signature is loose, so the show
446
444
  // message's fields are untyped here; map them through the same
@@ -319,7 +319,21 @@ export class Conversation {
319
319
  /** @internal */ prompter: PermissionPrompter;
320
320
  /** @internal */ secretPrompter: SecretPrompter;
321
321
  private executor: ToolExecutor;
322
- /** @internal */ sendToClient: (msg: AssistantEvent) => void;
322
+ /**
323
+ * The conversation's event sink, fixed for its whole life. Top-level
324
+ * conversations deliver to the SSE hub, so every subscribed client sees
325
+ * every event without any per-turn wiring; a subagent's sink re-envelopes
326
+ * its events under the parent conversation. Reached only through
327
+ * {@link emit}, which also notifies {@link addEventObserver} observers.
328
+ */
329
+ private readonly sendToClient: (msg: AssistantEvent) => void;
330
+ /**
331
+ * Observers notified after every {@link emit}, in registration order. An
332
+ * observer sees the event after the sink delivered it, so anything it does
333
+ * in response (e.g. voice auto-resolving a confirmation) lands on the wire
334
+ * after the event itself.
335
+ */
336
+ private readonly eventObservers = new Set<(msg: AssistantEvent) => void>();
323
337
  /** @internal */ workingDir: string;
324
338
  /** @internal */ allowedToolNames?: Set<string>;
325
339
  /**
@@ -435,7 +449,18 @@ export class Conversation {
435
449
  * @internal
436
450
  */
437
451
  currentCallSite?: LLMCallSite;
438
- /** @internal */ hasNoClient = false;
452
+ /**
453
+ * Whether no human is present to see UI or answer prompts. Derived from the
454
+ * in-flight turn's interactivity ({@link currentTurnIsNonInteractive}); a
455
+ * conversation with no turn in flight has no client. Presence is a property
456
+ * of the turn, never of where events are delivered, so there is no setter:
457
+ * dispatch paths declare interactivity per turn (`isInteractive` on
458
+ * `runAgentLoop`, or a wake's pin), and this reads it.
459
+ * @internal
460
+ */
461
+ get hasNoClient(): boolean {
462
+ return this.currentTurnIsNonInteractive ?? true;
463
+ }
439
464
  /**
440
465
  * For subagent conversations, the id of the parent that spawned this one; set
441
466
  * once at construction and never reassigned. `undefined` for top-level
@@ -757,10 +782,10 @@ export class Conversation {
757
782
  this.workingDir = workingDir;
758
783
  this.sendToClient = sendToClient;
759
784
  this.graphMemory = new ConversationGraphMemory(conversationId);
760
- this.prompter = new PermissionPrompter(sendToClient);
785
+ // The prompter emits through the conversation so its confirmation_request
786
+ // reaches the sink and every observer (voice policy) like any other event.
787
+ this.prompter = new PermissionPrompter((msg) => this.emit(msg));
761
788
  this.prompter.setOnStateChanged((requestId, state, source, toolUseId) => {
762
- // Route through emitConfirmationStateChanged so the event reaches
763
- // the client via sendToClient (wired to the SSE hub for HTTP conversations).
764
789
  this.emitConfirmationStateChanged({
765
790
  conversationId: this.conversationId,
766
791
  requestId,
@@ -1489,31 +1514,55 @@ export class Conversation {
1489
1514
  await this.loadFromDb();
1490
1515
  }
1491
1516
 
1492
- updateClient(
1493
- sendToClient: (msg: AssistantEvent) => void,
1494
- hasNoClient = false,
1495
- ): void {
1496
- this.sendToClient = sendToClient;
1497
- this.hasNoClient = hasNoClient;
1498
- this.prompter.updateSender(sendToClient);
1499
-
1500
- // Replay last activity state so a reconnecting client sees the current phase
1501
- // instead of being stuck on the last state it received before disconnection.
1502
- if (!hasNoClient && this.lastActivityStateMsg) {
1517
+ /**
1518
+ * Deliver an event through the conversation's sink, then to every observer.
1519
+ * The single emission point for conversation-level events (activity state,
1520
+ * confirmation prompts and state, notifier output, out-of-turn pushes); the
1521
+ * agent loop's own stream rides its per-turn `onEvent`, which defaults to
1522
+ * this when the caller passes none.
1523
+ */
1524
+ readonly emit = (msg: AssistantEvent): void => {
1525
+ try {
1526
+ this.sendToClient(msg);
1527
+ } catch (err) {
1528
+ log.warn(
1529
+ { err, conversationId: this.conversationId, type: msg.type },
1530
+ "conversation sink threw",
1531
+ );
1532
+ }
1533
+ for (const observer of this.eventObservers) {
1503
1534
  try {
1504
- sendToClient(this.lastActivityStateMsg);
1535
+ observer(msg);
1505
1536
  } catch (err) {
1506
1537
  log.warn(
1507
- { err, conversationId: this.conversationId },
1508
- "Failed to replay activity state on client reconnection",
1538
+ { err, conversationId: this.conversationId, type: msg.type },
1539
+ "conversation event observer threw",
1509
1540
  );
1510
1541
  }
1511
1542
  }
1543
+ };
1544
+
1545
+ /**
1546
+ * Observe every event this conversation emits, after the sink delivered it.
1547
+ * For policy layered on delivery (voice auto-resolves approval prompts it
1548
+ * has no UI for), not for delivery itself. Returns the disposer.
1549
+ */
1550
+ addEventObserver(observer: (msg: AssistantEvent) => void): () => void {
1551
+ this.eventObservers.add(observer);
1552
+ return () => {
1553
+ this.eventObservers.delete(observer);
1554
+ };
1512
1555
  }
1513
1556
 
1514
- /** Returns the current sendToClient reference for identity comparison. */
1515
- getCurrentSender(): (msg: AssistantEvent) => void {
1516
- return this.sendToClient;
1557
+ /**
1558
+ * Re-emit the last activity state so a client that reconnected mid-phase
1559
+ * sees the current phase instead of the last one it received before
1560
+ * disconnecting. The send route calls this on every interactive send.
1561
+ */
1562
+ replayActivityState(): void {
1563
+ if (this.lastActivityStateMsg) {
1564
+ this.emit(this.lastActivityStateMsg);
1565
+ }
1517
1566
  }
1518
1567
 
1519
1568
  setSubagentAllowedTools(tools: Set<string> | undefined): void {
@@ -1801,7 +1850,7 @@ export class Conversation {
1801
1850
  } {
1802
1851
  return enqueueMessageImpl(this, {
1803
1852
  ...options,
1804
- onEvent: options.onEvent ?? this.sendToClient,
1853
+ onEvent: options.onEvent ?? this.emit,
1805
1854
  });
1806
1855
  }
1807
1856
 
@@ -1976,14 +2025,7 @@ export class Conversation {
1976
2025
  type: "confirmation_state_changed",
1977
2026
  ...params,
1978
2027
  } as AssistantEvent;
1979
- try {
1980
- this.sendToClient(msg);
1981
- } catch (err) {
1982
- log.warn(
1983
- { err, conversationId: this.conversationId },
1984
- "sendToClient threw in emitConfirmationStateChanged",
1985
- );
1986
- }
2028
+ this.emit(msg);
1987
2029
  }
1988
2030
 
1989
2031
  emitActivityState(
@@ -2008,14 +2050,7 @@ export class Conversation {
2008
2050
  ...(statusText ? { statusText } : {}),
2009
2051
  } as AssistantEvent;
2010
2052
  this.lastActivityStateMsg = msg;
2011
- try {
2012
- this.sendToClient(msg);
2013
- } catch (err) {
2014
- log.warn(
2015
- { err, conversationId: this.conversationId },
2016
- "sendToClient threw in emitActivityState",
2017
- );
2018
- }
2053
+ this.emit(msg);
2019
2054
  }
2020
2055
 
2021
2056
  /**
@@ -2069,7 +2104,7 @@ export class Conversation {
2069
2104
  onEvent?: (msg: AssistantEvent) => void,
2070
2105
  ): void {
2071
2106
  try {
2072
- (onEvent ?? this.sendToClient)({
2107
+ (onEvent ?? this.emit)({
2073
2108
  type: "context_window_usage",
2074
2109
  conversationId: this.conversationId,
2075
2110
  tokens,
@@ -2421,11 +2456,11 @@ export class Conversation {
2421
2456
  ) {
2422
2457
  await this.agentLoop.compactionCircuit.recordOutcome(
2423
2458
  result.summaryFailed,
2424
- this.sendToClient,
2459
+ this.emit,
2425
2460
  );
2426
2461
  }
2427
2462
  if (result.compacted) {
2428
- await applyCompactionResult(this, result, this.sendToClient, null, {
2463
+ await applyCompactionResult(this, result, this.emit, null, {
2429
2464
  slackContextCompactionWatermarkTs:
2430
2465
  fixedBoundarySlackWatermarkTs ??
2431
2466
  getSlackCompactionWatermarkForPrefix(
@@ -2681,6 +2716,8 @@ export class Conversation {
2681
2716
  titleText?: string;
2682
2717
  /** See {@link runAgentLoopImpl} — hidden machine-signal turn marker. */
2683
2718
  isHiddenPrompt?: boolean;
2719
+ /** See {@link runAgentLoopImpl}: triggering row's daemon-authored kind. */
2720
+ messageKind?: string;
2684
2721
  /**
2685
2722
  * See {@link runAgentLoopImpl}: the row the end-of-turn reply
2686
2723
  * notification treats as the prompt this turn answers.
@@ -2722,7 +2759,7 @@ export class Conversation {
2722
2759
  this,
2723
2760
  content,
2724
2761
  userMessageId,
2725
- onEvent ?? this.sendToClient,
2762
+ onEvent ?? this.emit,
2726
2763
  rest,
2727
2764
  );
2728
2765
  }
@@ -2748,7 +2785,7 @@ export class Conversation {
2748
2785
  this.cacheWarmAbort = undefined;
2749
2786
  return processMessageImpl(this, {
2750
2787
  ...options,
2751
- onEvent: options.onEvent ?? this.sendToClient,
2788
+ onEvent: options.onEvent ?? this.emit,
2752
2789
  });
2753
2790
  }
2754
2791
 
@@ -130,7 +130,7 @@ function pushStepsUpdate(
130
130
  };
131
131
  const updatedData = { ...card, templateData: updatedTemplateData };
132
132
  stored.data = updatedData;
133
- ctx.sendToClient({
133
+ ctx.emit({
134
134
  type: "ui_surface_update",
135
135
  conversationId: ctx.conversationId,
136
136
  surfaceId: SURFACE_ID,
@@ -218,7 +218,7 @@ export function updateDoordashProgress(
218
218
  },
219
219
  } satisfies CardSurfaceData;
220
220
  ctx.surfaceState.set(SURFACE_ID, { surfaceType: "card", data });
221
- ctx.sendToClient({
221
+ ctx.emit({
222
222
  type: "ui_surface_show",
223
223
  conversationId: ctx.conversationId,
224
224
  surfaceId: SURFACE_ID,
@@ -4,6 +4,7 @@ import { reconcileCallsOnStartup } from "../calls/call-recovery.js";
4
4
  import { TwilioVoiceProvider } from "../calls/twilio-provider.js";
5
5
  import { expireInteractionBoundGuardianRequests } from "../channels/gateway-guardian-requests.js";
6
6
  import { initFeatureFlagOverrides } from "../config/assistant-feature-flags.js";
7
+ import { getBalancedModelExperimentArm } from "../config/balanced-model-experiment.js";
7
8
  import { setIngressPublicBaseUrl, validateEnv } from "../config/env.js";
8
9
  import {
9
10
  hasPendingDefaultWorkspaceConfig,
@@ -224,9 +225,21 @@ export async function runDaemon(): Promise<void> {
224
225
  // a failed fetch leaves the cache unset and resolves `os-beta` to its
225
226
  // registry default `false`, which would remove the user's profile and reset
226
227
  // their selection.
228
+ // A balanced-model experiment arm arriving in this same load gets the same
229
+ // invalidation. HTTP binds before this resolves, so a client that fetched
230
+ // profiles in that window holds the shipped model; the arm moves nothing on
231
+ // disk, so the reconcile above would not report a change and the listener's
232
+ // own comparison sees the arm on both sides of its refresh.
233
+ const balancedArmBeforeInit = getBalancedModelExperimentArm();
227
234
  void initFeatureFlagOverrides()
228
235
  .then((loaded) => {
229
- if (loaded && reconcileFlagGatedProfiles()) {
236
+ if (!loaded) {
237
+ return;
238
+ }
239
+ const profilesChanged = reconcileFlagGatedProfiles();
240
+ const balancedArmChanged =
241
+ getBalancedModelExperimentArm() !== balancedArmBeforeInit;
242
+ if (profilesChanged || balancedArmChanged) {
230
243
  publishConfigChanged();
231
244
  }
232
245
  })
@@ -69,7 +69,6 @@ import {
69
69
  preactivateHostProxySkills,
70
70
  shouldAttachHostProxyForCapability,
71
71
  } from "./host-proxy-preactivation.js";
72
- import { bindInteractiveTurnSender } from "./interactive-turn-sender.js";
73
72
  import type { SubagentToolGateMode } from "./tool-setup-types.js";
74
73
  import { restingTrust } from "./trust-context-types.js";
75
74
 
@@ -709,11 +708,6 @@ export async function processMessage(
709
708
  };
710
709
  }
711
710
 
712
- const restoreSender =
713
- options?.isInteractive === true
714
- ? bindInteractiveTurnSender(conversation)
715
- : undefined;
716
-
717
711
  const restoreToolScope = applyTurnToolAllowlist(conversation, options);
718
712
  try {
719
713
  await conversation.runAgentLoop(resolvedContent, messageId, {
@@ -731,7 +725,6 @@ export async function processMessage(
731
725
  });
732
726
  } finally {
733
727
  restoreToolScope();
734
- restoreSender?.();
735
728
  }
736
729
 
737
730
  // Read the just-finished turn's outcome from the stamp `runAgentLoop`'s
@@ -790,11 +783,6 @@ export async function processMessageInBackground(
790
783
  return { messageId };
791
784
  }
792
785
 
793
- const restoreSender =
794
- options?.isInteractive === true
795
- ? bindInteractiveTurnSender(conversation)
796
- : undefined;
797
-
798
786
  const restoreToolScope = applyTurnToolAllowlist(conversation, options);
799
787
  conversation
800
788
  .runAgentLoop(content, messageId, {
@@ -809,7 +797,6 @@ export async function processMessageInBackground(
809
797
  })
810
798
  .finally(() => {
811
799
  restoreToolScope();
812
- restoreSender?.();
813
800
  })
814
801
  .catch((err) => {
815
802
  log.error({ err, conversationId }, "Background agent loop failed");
@@ -0,0 +1,4 @@
1
+ import "../windows-compiled-logger.js";
2
+ import "../live-voice/windows-compiled-live-voice.js";
3
+
4
+ await import("./main.js");