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

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 (209) 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/docs/guardian-request-flow.md +35 -0
  7. package/knip.json +3 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  10. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  11. package/openapi.yaml +136 -76
  12. package/package.json +1 -1
  13. package/scripts/write-plugin-api-shim.ts +10 -0
  14. package/src/__tests__/app-control-flow.test.ts +1 -0
  15. package/src/__tests__/approval-routes-http.test.ts +2 -2
  16. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  17. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  18. package/src/__tests__/compaction-events.test.ts +8 -10
  19. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  21. package/src/__tests__/conversation-load-history-repair.test.ts +209 -0
  22. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  23. package/src/__tests__/conversation-queue.test.ts +39 -62
  24. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  25. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  27. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  28. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  29. package/src/__tests__/conversation-runtime-assembly.test.ts +53 -0
  30. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  31. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  37. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  38. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  39. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  40. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  41. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  42. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  43. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  44. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  45. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  46. package/src/__tests__/document-sync-tags.test.ts +0 -75
  47. package/src/__tests__/file-ops-service.test.ts +163 -30
  48. package/src/__tests__/filesystem-tools.test.ts +23 -24
  49. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  50. package/src/__tests__/host-file-read-tool.test.ts +16 -19
  51. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  52. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  53. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  54. package/src/__tests__/media-generate-image.test.ts +131 -21
  55. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  56. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  57. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  58. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  59. package/src/__tests__/qdrant-manager.test.ts +14 -1
  60. package/src/__tests__/run-due-schedules.test.ts +21 -0
  61. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  62. package/src/__tests__/schedule-routes.test.ts +23 -0
  63. package/src/__tests__/schedule-store.test.ts +17 -0
  64. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  65. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  66. package/src/__tests__/starter-task-flow.test.ts +5 -4
  67. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  68. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  69. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  70. package/src/__tests__/subagent-tools.test.ts +81 -101
  71. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  72. package/src/__tests__/tool-executor.test.ts +5 -1
  73. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  74. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  75. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  76. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  77. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  78. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  79. package/src/acp/session-manager.ts +8 -1
  80. package/src/api/events/host-file.ts +2 -2
  81. package/src/api/surfaces.ts +5 -0
  82. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  83. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  84. package/src/calls/voice-session-bridge.ts +44 -25
  85. package/src/calls/voice-triage-escalate.ts +1 -0
  86. package/src/cli/bundled-modules.ts +29 -0
  87. package/src/cli/commands/db/repair.ts +4 -8
  88. package/src/cli/commands/domain.ts +6 -3
  89. package/src/cli/commands/email.ts +6 -3
  90. package/src/cli/commands/keys.ts +8 -3
  91. package/src/cli/commands/plugins.ts +85 -36
  92. package/src/cli/commands/schedules.ts +35 -1
  93. package/src/cli/lib/bundled-marketplace.json +1 -1
  94. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  95. package/src/config/assistant-feature-flags.ts +36 -15
  96. package/src/config/balanced-model-experiment.ts +35 -0
  97. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  98. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  99. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  100. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  101. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  102. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  103. package/src/config/call-site-defaults.ts +7 -0
  104. package/src/config/default-profile-catalog.ts +96 -4
  105. package/src/config/feature-flag-registry.json +11 -11
  106. package/src/config/skills.ts +9 -2
  107. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  108. package/src/daemon/conversation-agent-loop.ts +14 -13
  109. package/src/daemon/conversation-notifiers.ts +11 -9
  110. package/src/daemon/conversation-process.ts +0 -27
  111. package/src/daemon/conversation-runtime-assembly.ts +9 -2
  112. package/src/daemon/conversation-store.ts +4 -4
  113. package/src/daemon/conversation-surfaces.ts +27 -13
  114. package/src/daemon/conversation-tool-setup.ts +2 -4
  115. package/src/daemon/conversation.ts +104 -51
  116. package/src/daemon/doordash-steps.ts +2 -2
  117. package/src/daemon/lifecycle.ts +14 -1
  118. package/src/daemon/process-message.ts +0 -13
  119. package/src/daemon/windows-compiled-entry.ts +4 -0
  120. package/src/documents/document-store.ts +5 -235
  121. package/src/hooks/types.ts +5 -0
  122. package/src/ipc/gateway-flag-listener.ts +17 -3
  123. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  124. package/src/live-voice/live-voice-manager.ts +16 -3
  125. package/src/live-voice/live-voice-session.ts +63 -9
  126. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  127. package/src/monitoring/control.ts +1 -0
  128. package/src/monitoring/db-integrity-sample.ts +4 -5
  129. package/src/notifications/AGENTS.md +2 -0
  130. package/src/notifications/approval-card-data.ts +33 -0
  131. package/src/permissions/prompter.ts +1 -5
  132. package/src/persistence/conversation-queries.ts +66 -16
  133. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  134. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  135. package/src/persistence/schema/documents.ts +4 -4
  136. package/src/plugin-api/constants.ts +8 -0
  137. package/src/plugin-api/index.ts +5 -1
  138. package/src/plugin-api/webhook-url.ts +13 -11
  139. package/src/plugins/defaults/main.ts +6 -7
  140. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  141. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  142. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  143. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  144. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  145. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  146. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  147. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  148. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  149. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  150. package/src/plugins/mtime-cache.ts +17 -0
  151. package/src/prompts/templates/system-sections.ts +0 -7
  152. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  153. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  154. package/src/providers/openai/chat-completions-provider.ts +11 -1
  155. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  156. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  157. package/src/routes/control.ts +1 -0
  158. package/src/routes/route-host-client.ts +1 -0
  159. package/src/runtime/AGENTS.md +16 -17
  160. package/src/runtime/agent-wake.ts +15 -12
  161. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  162. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  163. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  164. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  165. package/src/runtime/routes/conversation-routes.ts +11 -13
  166. package/src/runtime/routes/documents-routes.ts +3 -222
  167. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  168. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  169. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  170. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  171. package/src/runtime/routes/schedule-routes.ts +94 -7
  172. package/src/runtime/routes/workspace-routes.ts +0 -9
  173. package/src/runtime/routes/workspace-utils.ts +3 -13
  174. package/src/runtime/services/conversation-serializer.ts +7 -2
  175. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  176. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  177. package/src/schedule/plugin-schedule-availability.ts +58 -0
  178. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  179. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  180. package/src/schedule/schedule-store.ts +5 -1
  181. package/src/schedule/scheduler.ts +9 -4
  182. package/src/schedule/worker-control.ts +1 -0
  183. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  184. package/src/subagent/consult-context.ts +11 -11
  185. package/src/subagent/consult-prompt.ts +26 -35
  186. package/src/subagent/manager.ts +20 -37
  187. package/src/subagent/notify.ts +7 -1
  188. package/src/subagent/types.ts +15 -13
  189. package/src/tools/__tests__/tool-input-schemas.test.ts +7 -7
  190. package/src/tools/acp/spawn.ts +6 -4
  191. package/src/tools/filesystem/read.ts +27 -10
  192. package/src/tools/host-filesystem/read.ts +27 -15
  193. package/src/tools/shared/filesystem/file-ops-service.ts +63 -35
  194. package/src/tools/shared/filesystem/legacy-read-args.ts +22 -0
  195. package/src/tools/shared/filesystem/types.ts +5 -5
  196. package/src/tools/skills/scaffold-managed.ts +25 -7
  197. package/src/tools/subagent/spawn.ts +28 -88
  198. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  199. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  200. package/src/util/logger.ts +16 -0
  201. package/src/util/worker-process.ts +37 -4
  202. package/src/windows-compiled-cli.ts +32 -0
  203. package/src/windows-compiled-entry.ts +4 -0
  204. package/src/windows-compiled-logger.ts +6 -0
  205. package/src/windows-compiled-worker-entry.ts +29 -0
  206. package/src/__tests__/document-workspace-file.test.ts +0 -467
  207. package/src/daemon/interactive-turn-sender.ts +0 -59
  208. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  209. 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(
@@ -44,6 +44,7 @@ import {
44
44
  type RenderedSlackTranscriptMessage,
45
45
  renderSlackTranscriptWithProvenance,
46
46
  } from "../messaging/providers/slack/render-transcript.js";
47
+ import { isGuardianCardRow } from "../notifications/approval-card-data.js";
47
48
  import {
48
49
  getMessages as defaultGetMessages,
49
50
  type MessageRow,
@@ -520,7 +521,7 @@ function injectActiveSurfaceContext(
520
521
  "RULES FOR WORKSPACE MODIFICATION:",
521
522
  `1. Use \`file_edit\` to make surgical changes to app files. The file path is \`${getAppDirPath(ctx.appId)}/<path>\`.`,
522
523
  "2. Use `file_write` to create new files or rewrite files.",
523
- "3. Use `file_read` to read any file with line numbers before editing.",
524
+ "3. Use `file_read` to read any file before editing, and `code_search` when you need a line reference.",
524
525
  "4. Use `bash ls` to see all files in the app directory.",
525
526
  `5. Call \`app_refresh\` with app_id "${ctx.appId}" ONCE after all changes are complete.`,
526
527
  "6. NEVER respond with only text — the user expects a visual update.",
@@ -1513,10 +1514,16 @@ export function loadSlackChronologicalContext(
1513
1514
  allRows,
1514
1515
  options.trustClass,
1515
1516
  );
1517
+ // Applied after the compaction boundary, never before it: that filter
1518
+ // indexes into the row list when no watermark is set, so dropping rows
1519
+ // earlier would shift what it cuts. Same rule and same ordering constraint
1520
+ // as `Conversation.loadFromDb` -- Slack builds the provider history from
1521
+ // rows rather than `this.messages`, so a rule applied only there would
1522
+ // exempt this channel entirely.
1516
1523
  const rows = filterRowsAfterSlackCompactionBoundary(
1517
1524
  messageRowsToSlackTranscriptRows(scopedRows),
1518
1525
  options,
1519
- );
1526
+ ).filter((row) => !isGuardianCardRow(row.content));
1520
1527
  return assembleSlackChronologicalContext(rows, capabilities, {
1521
1528
  contextSummary: resolveCapabilities(options.trustClass).canAccessMemory
1522
1529
  ? options.contextSummary
@@ -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
@@ -41,6 +41,7 @@ import {
41
41
  derefToolResultReReads,
42
42
  postTurnTruncateToolResults,
43
43
  } from "../context/post-turn-tool-result-truncation.js";
44
+ import { isGuardianCardRow } from "../notifications/approval-card-data.js";
44
45
  import { PermissionPrompter } from "../permissions/prompter.js";
45
46
  import { SecretPrompter } from "../permissions/secret-prompter.js";
46
47
  import type { UserDecision } from "../permissions/types.js";
@@ -319,7 +320,21 @@ export class Conversation {
319
320
  /** @internal */ prompter: PermissionPrompter;
320
321
  /** @internal */ secretPrompter: SecretPrompter;
321
322
  private executor: ToolExecutor;
322
- /** @internal */ sendToClient: (msg: AssistantEvent) => void;
323
+ /**
324
+ * The conversation's event sink, fixed for its whole life. Top-level
325
+ * conversations deliver to the SSE hub, so every subscribed client sees
326
+ * every event without any per-turn wiring; a subagent's sink re-envelopes
327
+ * its events under the parent conversation. Reached only through
328
+ * {@link emit}, which also notifies {@link addEventObserver} observers.
329
+ */
330
+ private readonly sendToClient: (msg: AssistantEvent) => void;
331
+ /**
332
+ * Observers notified after every {@link emit}, in registration order. An
333
+ * observer sees the event after the sink delivered it, so anything it does
334
+ * in response (e.g. voice auto-resolving a confirmation) lands on the wire
335
+ * after the event itself.
336
+ */
337
+ private readonly eventObservers = new Set<(msg: AssistantEvent) => void>();
323
338
  /** @internal */ workingDir: string;
324
339
  /** @internal */ allowedToolNames?: Set<string>;
325
340
  /**
@@ -435,7 +450,18 @@ export class Conversation {
435
450
  * @internal
436
451
  */
437
452
  currentCallSite?: LLMCallSite;
438
- /** @internal */ hasNoClient = false;
453
+ /**
454
+ * Whether no human is present to see UI or answer prompts. Derived from the
455
+ * in-flight turn's interactivity ({@link currentTurnIsNonInteractive}); a
456
+ * conversation with no turn in flight has no client. Presence is a property
457
+ * of the turn, never of where events are delivered, so there is no setter:
458
+ * dispatch paths declare interactivity per turn (`isInteractive` on
459
+ * `runAgentLoop`, or a wake's pin), and this reads it.
460
+ * @internal
461
+ */
462
+ get hasNoClient(): boolean {
463
+ return this.currentTurnIsNonInteractive ?? true;
464
+ }
439
465
  /**
440
466
  * For subagent conversations, the id of the parent that spawned this one; set
441
467
  * once at construction and never reassigned. `undefined` for top-level
@@ -757,10 +783,10 @@ export class Conversation {
757
783
  this.workingDir = workingDir;
758
784
  this.sendToClient = sendToClient;
759
785
  this.graphMemory = new ConversationGraphMemory(conversationId);
760
- this.prompter = new PermissionPrompter(sendToClient);
786
+ // The prompter emits through the conversation so its confirmation_request
787
+ // reaches the sink and every observer (voice policy) like any other event.
788
+ this.prompter = new PermissionPrompter((msg) => this.emit(msg));
761
789
  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
790
  this.emitConfirmationStateChanged({
765
791
  conversationId: this.conversationId,
766
792
  requestId,
@@ -1316,6 +1342,16 @@ export class Conversation {
1316
1342
  parsedMessages.length,
1317
1343
  );
1318
1344
  for (const [index, message] of parsedMessages.entries()) {
1345
+ // Applied after the compaction slice, never before it: the slice and
1346
+ // `rowToHistoryIndex` are both computed against the full row list, so
1347
+ // dropping earlier would shift them. A dropped row maps to a null
1348
+ // history index exactly like a fully-injected user row that strips to
1349
+ // nothing. `index` is shared with `slicedDbMessages`, which
1350
+ // `parsedMessages` maps 1:1.
1351
+ if (isGuardianCardRow(slicedDbMessages[index]?.content)) {
1352
+ preRepairIndexBySlicedRow[index] = null;
1353
+ continue;
1354
+ }
1319
1355
  const stripped =
1320
1356
  index < preStrippedCount
1321
1357
  ? stripInjectionsForCompaction([message])
@@ -1418,7 +1454,7 @@ export class Conversation {
1418
1454
  messageCount: this.messages.length,
1419
1455
  });
1420
1456
 
1421
- this.restoreSurfaceStateFromHistory();
1457
+ this.restoreSurfaceStateFromHistory(parsedMessages);
1422
1458
  this.graphMemory.restoreState();
1423
1459
 
1424
1460
  // Row→history correspondence for this load: slice offset, then the
@@ -1450,14 +1486,19 @@ export class Conversation {
1450
1486
  * populate surfaceState so that findConversationBySurfaceId works for
1451
1487
  * surfaces restored from history (e.g. after daemon restart).
1452
1488
  *
1453
- * Only scans live (non-compacted) messages in this.messages — not all DB
1454
- * rows — because surface IDs are not globally unique and restoring stale
1455
- * compacted surfaces would let findConversationBySurfaceId route actions
1456
- * to the wrong conversation.
1489
+ * Scans the live (non-compacted) window only, never all DB rows, because
1490
+ * surface IDs are not globally unique and restoring stale compacted
1491
+ * surfaces would let findConversationBySurfaceId route actions to the wrong
1492
+ * conversation.
1493
+ *
1494
+ * Takes that window as rows rather than reading `this.messages`, because a
1495
+ * surface's lifecycle and the model's context are different questions. A
1496
+ * guardian card is absent from `this.messages`, but it is exactly the card
1497
+ * whose Approve/Reject buttons must still route after a restart.
1457
1498
  */
1458
- private restoreSurfaceStateFromHistory(): void {
1499
+ private restoreSurfaceStateFromHistory(liveWindow: Message[]): void {
1459
1500
  this.surfaceState.clear();
1460
- for (const msg of this.messages) {
1501
+ for (const msg of liveWindow) {
1461
1502
  if (!Array.isArray(msg.content)) {
1462
1503
  continue;
1463
1504
  }
@@ -1489,31 +1530,55 @@ export class Conversation {
1489
1530
  await this.loadFromDb();
1490
1531
  }
1491
1532
 
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) {
1533
+ /**
1534
+ * Deliver an event through the conversation's sink, then to every observer.
1535
+ * The single emission point for conversation-level events (activity state,
1536
+ * confirmation prompts and state, notifier output, out-of-turn pushes); the
1537
+ * agent loop's own stream rides its per-turn `onEvent`, which defaults to
1538
+ * this when the caller passes none.
1539
+ */
1540
+ readonly emit = (msg: AssistantEvent): void => {
1541
+ try {
1542
+ this.sendToClient(msg);
1543
+ } catch (err) {
1544
+ log.warn(
1545
+ { err, conversationId: this.conversationId, type: msg.type },
1546
+ "conversation sink threw",
1547
+ );
1548
+ }
1549
+ for (const observer of this.eventObservers) {
1503
1550
  try {
1504
- sendToClient(this.lastActivityStateMsg);
1551
+ observer(msg);
1505
1552
  } catch (err) {
1506
1553
  log.warn(
1507
- { err, conversationId: this.conversationId },
1508
- "Failed to replay activity state on client reconnection",
1554
+ { err, conversationId: this.conversationId, type: msg.type },
1555
+ "conversation event observer threw",
1509
1556
  );
1510
1557
  }
1511
1558
  }
1559
+ };
1560
+
1561
+ /**
1562
+ * Observe every event this conversation emits, after the sink delivered it.
1563
+ * For policy layered on delivery (voice auto-resolves approval prompts it
1564
+ * has no UI for), not for delivery itself. Returns the disposer.
1565
+ */
1566
+ addEventObserver(observer: (msg: AssistantEvent) => void): () => void {
1567
+ this.eventObservers.add(observer);
1568
+ return () => {
1569
+ this.eventObservers.delete(observer);
1570
+ };
1512
1571
  }
1513
1572
 
1514
- /** Returns the current sendToClient reference for identity comparison. */
1515
- getCurrentSender(): (msg: AssistantEvent) => void {
1516
- return this.sendToClient;
1573
+ /**
1574
+ * Re-emit the last activity state so a client that reconnected mid-phase
1575
+ * sees the current phase instead of the last one it received before
1576
+ * disconnecting. The send route calls this on every interactive send.
1577
+ */
1578
+ replayActivityState(): void {
1579
+ if (this.lastActivityStateMsg) {
1580
+ this.emit(this.lastActivityStateMsg);
1581
+ }
1517
1582
  }
1518
1583
 
1519
1584
  setSubagentAllowedTools(tools: Set<string> | undefined): void {
@@ -1801,7 +1866,7 @@ export class Conversation {
1801
1866
  } {
1802
1867
  return enqueueMessageImpl(this, {
1803
1868
  ...options,
1804
- onEvent: options.onEvent ?? this.sendToClient,
1869
+ onEvent: options.onEvent ?? this.emit,
1805
1870
  });
1806
1871
  }
1807
1872
 
@@ -1976,14 +2041,7 @@ export class Conversation {
1976
2041
  type: "confirmation_state_changed",
1977
2042
  ...params,
1978
2043
  } 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
- }
2044
+ this.emit(msg);
1987
2045
  }
1988
2046
 
1989
2047
  emitActivityState(
@@ -2008,14 +2066,7 @@ export class Conversation {
2008
2066
  ...(statusText ? { statusText } : {}),
2009
2067
  } as AssistantEvent;
2010
2068
  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
- }
2069
+ this.emit(msg);
2019
2070
  }
2020
2071
 
2021
2072
  /**
@@ -2069,7 +2120,7 @@ export class Conversation {
2069
2120
  onEvent?: (msg: AssistantEvent) => void,
2070
2121
  ): void {
2071
2122
  try {
2072
- (onEvent ?? this.sendToClient)({
2123
+ (onEvent ?? this.emit)({
2073
2124
  type: "context_window_usage",
2074
2125
  conversationId: this.conversationId,
2075
2126
  tokens,
@@ -2421,11 +2472,11 @@ export class Conversation {
2421
2472
  ) {
2422
2473
  await this.agentLoop.compactionCircuit.recordOutcome(
2423
2474
  result.summaryFailed,
2424
- this.sendToClient,
2475
+ this.emit,
2425
2476
  );
2426
2477
  }
2427
2478
  if (result.compacted) {
2428
- await applyCompactionResult(this, result, this.sendToClient, null, {
2479
+ await applyCompactionResult(this, result, this.emit, null, {
2429
2480
  slackContextCompactionWatermarkTs:
2430
2481
  fixedBoundarySlackWatermarkTs ??
2431
2482
  getSlackCompactionWatermarkForPrefix(
@@ -2681,6 +2732,8 @@ export class Conversation {
2681
2732
  titleText?: string;
2682
2733
  /** See {@link runAgentLoopImpl} — hidden machine-signal turn marker. */
2683
2734
  isHiddenPrompt?: boolean;
2735
+ /** See {@link runAgentLoopImpl}: triggering row's daemon-authored kind. */
2736
+ messageKind?: string;
2684
2737
  /**
2685
2738
  * See {@link runAgentLoopImpl}: the row the end-of-turn reply
2686
2739
  * notification treats as the prompt this turn answers.
@@ -2722,7 +2775,7 @@ export class Conversation {
2722
2775
  this,
2723
2776
  content,
2724
2777
  userMessageId,
2725
- onEvent ?? this.sendToClient,
2778
+ onEvent ?? this.emit,
2726
2779
  rest,
2727
2780
  );
2728
2781
  }
@@ -2748,7 +2801,7 @@ export class Conversation {
2748
2801
  this.cacheWarmAbort = undefined;
2749
2802
  return processMessageImpl(this, {
2750
2803
  ...options,
2751
- onEvent: options.onEvent ?? this.sendToClient,
2804
+ onEvent: options.onEvent ?? this.emit,
2752
2805
  });
2753
2806
  }
2754
2807
 
@@ -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,