@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -30,7 +30,8 @@ import { promisify } from "node:util";
30
30
  import { ThinkingLevel } from "@gajae-code/agent-core";
31
31
  import type { ImageContent, TextContent, Tool } from "@gajae-code/ai";
32
32
  import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
33
- import { logger, postmortem, prompt, VERSION } from "@gajae-code/utils";
33
+ import { logger, postmortem, VERSION } from "@gajae-code/utils";
34
+ import { projectModelProfileCatalog } from "../../config/model-profile-contract";
34
35
  import { Settings } from "../../config/settings";
35
36
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
36
37
  import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
@@ -40,7 +41,6 @@ import {
40
41
  type WorkflowGateTerminalController,
41
42
  type WorkflowGateTerminalProof,
42
43
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
43
- import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
44
44
  import type { AgentSessionEvent } from "../../session/agent-session";
45
45
  import { parseThinkingLevel } from "../../thinking";
46
46
  import type {
@@ -60,13 +60,13 @@ import { SessionSdkHost, shouldHostSdk } from "../host";
60
60
  import { type ControlSurface, dispatchControl } from "../host/control";
61
61
  import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
62
62
  import { projectQ10Models } from "../models.js";
63
+ import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status";
63
64
  import { OPERATIONS } from "../protocol/operation-registry";
64
65
  import {
65
66
  lifecycleStartupCapabilityForApi,
66
67
  normalizeSdkStartupFailure,
67
68
  type SdkStartupFailure,
68
69
  } from "../startup-capability";
69
-
70
70
  import { registerTelegramFileSink } from "./attachment-registry";
71
71
  import { ensureDiscordDaemon, ensureSlackDaemon } from "./chat-daemon-control";
72
72
  import {
@@ -82,6 +82,7 @@ import {
82
82
  import { telegramControlCommandUsage } from "./config-commands";
83
83
  import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
84
84
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
85
+ import { createPromptReconciliation, sanitizePromptFailure } from "./prompt-reconciliation";
85
86
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
86
87
  import {
87
88
  ASK_SELECTED_ACK_CAPABILITY,
@@ -402,6 +403,7 @@ interface UnattendedGatePresentation {
402
403
  question: string;
403
404
  options: string[];
404
405
  controls: readonly AskRemoteControl[];
406
+ recommendedIndex?: number;
405
407
  multi: boolean;
406
408
  allowEmpty: boolean;
407
409
  navigationLabel?: "Next" | "Done";
@@ -410,6 +412,14 @@ interface UnattendedGatePresentation {
410
412
  onActivated?: (actionId: string, lease: { actionId: string; registrationEpoch: number }) => void;
411
413
  onClosed?: () => void;
412
414
  }
415
+ function recommendedIndexFromGateOptions(options: readonly unknown[]): number | undefined {
416
+ const descriptions = options.map(option => (option as { description?: unknown }).description);
417
+ const recommended = descriptions.filter(description => description === "recommended");
418
+ return recommended.length === 1 &&
419
+ descriptions.every(description => description === undefined || description === "recommended")
420
+ ? descriptions.indexOf("recommended")
421
+ : undefined;
422
+ }
413
423
 
414
424
  type RetireStatus = "retired" | "already_terminal" | "claimed" | "stale";
415
425
  type DirectControlOutcome = "accepted" | "rejected" | "unknown";
@@ -740,6 +750,9 @@ export class PresentationArbiter {
740
750
  ? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
741
751
  : presentation.question,
742
752
  options: presentation.options,
753
+ ...(presentation.recommendedIndex === undefined
754
+ ? {}
755
+ : { recommendedIndex: presentation.recommendedIndex }),
743
756
  controls: presentation.multi
744
757
  ? [
745
758
  {
@@ -907,6 +920,13 @@ interface SessionRuntime {
907
920
  /** Records a correlated prompt terminal boundary after agent unwind. */
908
921
  /** Atomically claims a correlated prompt terminal boundary after agent unwind. */
909
922
  recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
923
+ /** Transitions the authoritative reconciliation record at lifecycle ingress; terminal outcomes settle once. */
924
+ notePromptReconciliation: (
925
+ correlation: { commandId: string; turnId: string } | undefined,
926
+ frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
927
+ ) => void;
928
+ /** Settles and emits one sanitized correlated prompt failure. */
929
+ emitPromptFailure: (correlation: { commandId: string; turnId: string }, error: unknown) => void;
910
930
  /** Records correlated lifecycle frames for replay and delivers them only to the accepted requester after acknowledgement. */
911
931
  emitPromptLifecycle: (
912
932
  correlation: { commandId: string; turnId: string } | undefined,
@@ -940,6 +960,12 @@ interface SessionRuntime {
940
960
  turnClosed?: boolean;
941
961
  /** Finalized while provisional policy was held; flush exactly once on stable activation. */
942
962
  pendingFinal?: { text?: string; messageRef?: string };
963
+ /**
964
+ * Lean-mode deferred final answer: latest assistant text observed at `turn_end`,
965
+ * emitted once at `agent_end` so intermediate tool-turn narration does not flood
966
+ * remote clients. Cleared after flush or when replaced by a newer turn.
967
+ */
968
+ pendingSettled?: { text: string; messageRef?: string };
943
969
  /** Durable gates emitted while ownership is provisional; presented only after stable activation. */
944
970
  deferredGatePresentations: Array<() => void>;
945
971
  /** SDK control frames received during provisional ownership; replayed only after stable activation. */
@@ -955,7 +981,6 @@ interface SessionRuntime {
955
981
  const SENSITIVE_MODEL_LABEL =
956
982
  /(?:\b(?:https?|wss?):\/\/|\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b|\b(?:api[-_ ]?key|access[-_ ]?token|bearer|secret|password|account(?:\s*id)?|email|exception|stack trace)\b|\b(?:sk|pk|rk)-[A-Za-z0-9_-]{12,}\b)/i;
957
983
  const TOOL_SUMMARY_MAX = 280;
958
- const EMPTY_CAPABILITIES: ReadonlySet<string> = new Set();
959
984
 
960
985
  /** Stable projection of the tool-owned safe-display seam (never the full Tool surface). */
961
986
  type SafeSummaryTool = Pick<Tool, "safeSummary" | "safeSummaryFields">;
@@ -1020,6 +1045,14 @@ function pushSessionFrame(
1020
1045
  runtime.server.pushFrame(JSON.stringify(frame));
1021
1046
  }
1022
1047
 
1048
+ async function pushTerminalSessionFrame(
1049
+ runtime: Pick<SessionRuntime, "server" | "host">,
1050
+ frame: { type: "session_closed"; sessionId: string },
1051
+ ): Promise<boolean> {
1052
+ runtime.host.emitEvent({ kind: frame.type, payload: frame });
1053
+ return await runtime.server.pushFrameAndWait(JSON.stringify(frame), 1_000);
1054
+ }
1055
+
1023
1056
  function pushFileAttachment(
1024
1057
  runtime: Pick<SessionRuntime, "server" | "host">,
1025
1058
  frame: { type: "file_attachment"; sessionId: string; name: string; mime?: string; caption?: string },
@@ -1594,6 +1627,7 @@ function registerInteractiveAnswerSource(
1594
1627
  question: request.question,
1595
1628
  options: request.options,
1596
1629
  controls: request.controls,
1630
+ recommendedIndex: request.recommendedIndex,
1597
1631
  multi: false,
1598
1632
  allowEmpty: false,
1599
1633
  selectedOptions: [],
@@ -1756,6 +1790,7 @@ function sdkQuerySurface(
1756
1790
  followupQueueDepth: 0,
1757
1791
  }),
1758
1792
  configOverrides: ReadonlyMap<string, unknown> = new Map(),
1793
+ promptStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown,
1759
1794
  ): SessionSurface {
1760
1795
  const metadata = () => ({
1761
1796
  sessionId: id,
@@ -1823,6 +1858,8 @@ function sdkQuerySurface(
1823
1858
  const currentThinkingLevel = api.getThinkingLevel();
1824
1859
  return projectQ10Models({ models, currentModel, currentThinkingLevel });
1825
1860
  },
1861
+ getModelProfiles: () =>
1862
+ projectModelProfileCatalog(ctx.modelRegistry.getModelProfiles(), ctx.modelRegistry.getError()),
1826
1863
  getSkillState: () => ctx.getSkillState(),
1827
1864
  getGates: () => {
1828
1865
  const workflowGate = ctx.workflowGate;
@@ -1862,6 +1899,8 @@ function sdkQuerySurface(
1862
1899
  getExtensions: () => ctx.getExtensions(),
1863
1900
  getArtifactRange: (id, offset, length) => ctx.getArtifactRange?.(id, offset, length),
1864
1901
  getJobs: () => ctx.getJobs(),
1902
+ getPromptStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
1903
+ promptStatusLookup(selector),
1865
1904
  installedQueries: installedOperations(ctx, "query"),
1866
1905
  };
1867
1906
  }
@@ -1887,10 +1926,14 @@ function sdkControlSurface(
1887
1926
  onPromptAccepted: (
1888
1927
  correlation: { commandId: string; turnId: string },
1889
1928
  requesterConnectionId?: string,
1929
+ clientRef?: string,
1930
+ trackReconciliation?: boolean,
1890
1931
  ) => void = () => {},
1891
1932
  onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
1892
1933
  acceptGateResolution: () => boolean,
1893
1934
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
1935
+ admitPrompt: (clientRef?: string) => void,
1936
+ releasePromptAdmission: (clientRef?: string) => void,
1894
1937
  settings?: Settings,
1895
1938
  configOverrides: Map<string, unknown> = new Map(),
1896
1939
  configRevision: { current: number } = { current: 0 },
@@ -1967,19 +2010,35 @@ function sdkControlSurface(
1967
2010
  deliverAs?: "steer" | "followUp",
1968
2011
  rejectWhenBusy = false,
1969
2012
  requesterConnectionId?: string,
2013
+ clientRef?: string,
2014
+ trackReconciliation = false,
1970
2015
  ) => {
1971
- if (forceFresh && isSessionBusy()) {
1972
- throw Object.assign(new Error("Previous turn did not finish aborting before replacement prompt submission."), {
1973
- code: "busy",
2016
+ const trimmedClientRef = typeof clientRef === "string" ? clientRef.trim() : undefined;
2017
+ if (clientRef !== undefined && (!trimmedClientRef || trimmedClientRef.length > PROMPT_CLIENT_REF_MAX_LENGTH))
2018
+ throw Object.assign(new Error("clientRef must be a non-empty string of at most 128 characters."), {
2019
+ code: "invalid_input",
1974
2020
  });
2021
+ if (trackReconciliation) admitPrompt(trimmedClientRef);
2022
+ try {
2023
+ if (forceFresh && isSessionBusy()) {
2024
+ throw Object.assign(
2025
+ new Error("Previous turn did not finish aborting before replacement prompt submission."),
2026
+ {
2027
+ code: "busy",
2028
+ },
2029
+ );
2030
+ }
2031
+ if (rejectWhenBusy && isSessionBusy())
2032
+ throw Object.assign(
2033
+ new Error("turn.prompt is unavailable while the agent is busy; use turn.steer explicitly."),
2034
+ {
2035
+ code: "busy",
2036
+ },
2037
+ );
2038
+ } catch (error) {
2039
+ if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
2040
+ throw error;
1975
2041
  }
1976
- if (rejectWhenBusy && isSessionBusy())
1977
- throw Object.assign(
1978
- new Error("turn.prompt is unavailable while the agent is busy; use turn.steer explicitly."),
1979
- {
1980
- code: "busy",
1981
- },
1982
- );
1983
2042
  const promptImages = Array.isArray(images) ? (images as { data: string; mimeType?: string }[]) : [];
1984
2043
  const content: string | (TextContent | ImageContent)[] =
1985
2044
  promptImages.length > 0
@@ -2011,7 +2070,7 @@ function sdkControlSurface(
2011
2070
  const onPreflightAccepted = () => {
2012
2071
  if (preflightSettled) return;
2013
2072
  accepted = true;
2014
- onPromptAccepted(correlation, requesterConnectionId);
2073
+ onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
2015
2074
  settlePreflight({ status: "accepted" });
2016
2075
  };
2017
2076
  // Do not acknowledge the prompt until AgentSession's async preflight
@@ -2048,13 +2107,17 @@ function sdkControlSurface(
2048
2107
  try {
2049
2108
  const result = await preflight.promise;
2050
2109
  if (result.status === "rejected") throw result.error;
2051
- return { commandId, turnId, accepted: true };
2110
+ return { commandId, turnId, accepted: true, ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}) };
2111
+ } catch (error) {
2112
+ if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
2113
+ throw error;
2052
2114
  } finally {
2053
2115
  pendingPreflightCancellations.delete(cancelPreflight);
2054
2116
  }
2055
2117
  };
2056
2118
  const surface: ControlSurface & { cancelPendingPreflights(): void } = {
2057
- prompt: (text, images) => submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore()),
2119
+ prompt: (text, images, clientRef) =>
2120
+ submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
2058
2121
  steer: text => sendSteer(text),
2059
2122
  followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
2060
2123
  abort: () => {
@@ -2778,7 +2841,7 @@ export function createNotificationsExtension(
2778
2841
  sdkHostModeSupported?: boolean;
2779
2842
 
2780
2843
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2781
- runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2844
+ runBtwTurn?: (question: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2782
2845
  /** Observes settlement of optional session-branch startup after reconciliation completes. */
2783
2846
  onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
2784
2847
  readNotificationFile?: (path: string) => Promise<Buffer>;
@@ -2840,7 +2903,6 @@ export function createNotificationsExtension(
2840
2903
  "session.branch",
2841
2904
  ]);
2842
2905
  const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
2843
- const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
2844
2906
 
2845
2907
  async function stopSession(
2846
2908
  id: string,
@@ -2915,6 +2977,12 @@ export function createNotificationsExtension(
2915
2977
  try {
2916
2978
  rt.workflowGate?.setRuntimeTurnProvider?.(null);
2917
2979
  } catch {}
2980
+ try {
2981
+ const delivered = await pushTerminalSessionFrame(rt, { type: "session_closed", sessionId: id });
2982
+ if (!delivered) logger.warn("notifications: session_closed socket delivery was not acknowledged");
2983
+ } catch (e) {
2984
+ logger.warn(`notifications: session_closed failed: ${String(e)}`);
2985
+ }
2918
2986
  await rt.waitForGateResolutionQuiescence();
2919
2987
  try {
2920
2988
  rt.disposeAckRecoveryParticipant();
@@ -2956,14 +3024,6 @@ export function createNotificationsExtension(
2956
3024
  ownerReleaseFailures.push(e);
2957
3025
  logger.warn(`sdk query snapshots: close failed: ${String(e)}`);
2958
3026
  }
2959
- let closeFrameSent = false;
2960
- try {
2961
- pushSessionFrame(rt, { type: "session_closed", sessionId: id });
2962
- closeFrameSent = true;
2963
- } catch (e) {
2964
- logger.warn(`notifications: session_closed failed: ${String(e)}`);
2965
- }
2966
- if (closeFrameSent) await sleep(100);
2967
3027
  let serverStopped = rt.serverStopped;
2968
3028
  if (!serverStopped) {
2969
3029
  try {
@@ -3143,11 +3203,20 @@ export function createNotificationsExtension(
3143
3203
  failed: boolean;
3144
3204
  error: unknown;
3145
3205
  terminal: boolean;
3206
+ retainCorrelation: boolean;
3146
3207
  createdAt: number;
3147
3208
  bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
3148
3209
  };
3149
3210
  const promptSubmissions = new Map<string, PromptSubmission>();
3150
3211
  const promptTerminalTombstones = new Map<string, number>();
3212
+ // Authoritative bounded reconciliation state for Q26 turn.prompt_status
3213
+ // (contract documented in ./prompt-reconciliation and ../prompt-status).
3214
+ // Active records never age into terminal; documented TTL/capacity
3215
+ // eviction is the only removal, after which lookups report `unknown`.
3216
+ const reconciliation = createPromptReconciliation();
3217
+ const admitPromptSubmission = reconciliation.admit;
3218
+ const notePromptReconciliationAccepted = reconciliation.noteAccepted;
3219
+ const lookupPromptStatus = reconciliation.lookup;
3151
3220
  const removePendingPromptCorrelation = (correlation: { commandId: string; turnId: string }) => {
3152
3221
  const pendingIndex = pendingPromptCorrelations.findIndex(
3153
3222
  candidate => candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId,
@@ -3165,14 +3234,20 @@ export function createNotificationsExtension(
3165
3234
  removePendingPromptCorrelation(correlation);
3166
3235
  addTerminalTombstone(key);
3167
3236
  };
3237
+ const expirePromptDelivery = (key: string, submission: PromptSubmission) => {
3238
+ promptSubmissions.delete(key);
3239
+ if (!submission.terminal && submission.retainCorrelation) return;
3240
+ const [commandId, turnId] = key.split(":", 2);
3241
+ if (!commandId || !turnId) return;
3242
+ removePendingPromptCorrelation({ commandId, turnId });
3243
+ addTerminalTombstone(key);
3244
+ };
3168
3245
  const cleanupPromptRecords = (now = Date.now()) => {
3246
+ reconciliation.cleanup();
3169
3247
  for (const [key, expiresAt] of promptTerminalTombstones)
3170
3248
  if (expiresAt <= now) promptTerminalTombstones.delete(key);
3171
3249
  for (const [key, submission] of promptSubmissions)
3172
- if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now) {
3173
- const [commandId, turnId] = key.split(":", 2);
3174
- if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
3175
- }
3250
+ if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now) expirePromptDelivery(key, submission);
3176
3251
  };
3177
3252
  const abandonPrompt = (submission: PromptSubmission) => {
3178
3253
  submission.abandoned = true;
@@ -3183,6 +3258,7 @@ export function createNotificationsExtension(
3183
3258
  frame: PromptLifecycleFrame,
3184
3259
  ) => {
3185
3260
  cleanupPromptRecords();
3261
+
3186
3262
  if (!correlation || !runtime) {
3187
3263
  emitAgentLifecycle(runtime!, frame as Extract<PromptLifecycleFrame, { type: "agent_start" | "agent_end" }>);
3188
3264
  return;
@@ -3248,15 +3324,21 @@ export function createNotificationsExtension(
3248
3324
  const recordPromptAccepted = (
3249
3325
  correlation: { commandId: string; turnId: string },
3250
3326
  requesterConnectionId?: string,
3327
+ clientRef?: string,
3328
+ trackReconciliation = false,
3251
3329
  ) => {
3252
- if (!requesterConnectionId) return;
3330
+ if (!requesterConnectionId) {
3331
+ // No delivery owner: tracked prompts cannot be reconciled. Release
3332
+ // their admission reservation instead of leaking the active slot.
3333
+ if (trackReconciliation) reconciliation.releaseAdmission(clientRef);
3334
+ return;
3335
+ }
3336
+ if (trackReconciliation) notePromptReconciliationAccepted(correlation, clientRef);
3253
3337
  cleanupPromptRecords();
3254
3338
  while (promptSubmissions.size >= PROMPT_SUBMISSION_CAPACITY) {
3255
3339
  const oldest = promptSubmissions.entries().next().value as [string, PromptSubmission] | undefined;
3256
3340
  if (!oldest) break;
3257
- const [key] = oldest;
3258
- const [commandId, turnId] = key.split(":", 2);
3259
- if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
3341
+ expirePromptDelivery(oldest[0], oldest[1]);
3260
3342
  }
3261
3343
  pendingPromptCorrelations.push(correlation);
3262
3344
  promptSubmissions.set(promptSubmissionKey(correlation), {
@@ -3266,6 +3348,7 @@ export function createNotificationsExtension(
3266
3348
  failed: false,
3267
3349
  error: undefined,
3268
3350
  terminal: false,
3351
+ retainCorrelation: trackReconciliation,
3269
3352
  createdAt: Date.now(),
3270
3353
  bufferedFrames: [],
3271
3354
  });
@@ -3281,17 +3364,15 @@ export function createNotificationsExtension(
3281
3364
  return true;
3282
3365
  };
3283
3366
  const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
3367
+ const sanitized = sanitizePromptFailure(error);
3368
+ reconciliation.noteTransition(correlation, { type: "agent_failed", error: sanitized });
3284
3369
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3285
3370
  if (!submission || !runtime || !recordPromptTerminal(correlation)) return;
3286
- const candidate = error as { code?: unknown; message?: unknown };
3287
3371
  emitPromptLifecycle(correlation, {
3288
3372
  type: "agent_failed",
3289
3373
  sessionId: runtime.id,
3290
3374
  ...correlation,
3291
- error: {
3292
- code: typeof candidate.code === "string" ? candidate.code : "internal",
3293
- message: typeof candidate.message === "string" ? candidate.message : "Prompt submission failed.",
3294
- },
3375
+ error: sanitized,
3295
3376
  });
3296
3377
  };
3297
3378
  const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
@@ -3334,6 +3415,7 @@ export function createNotificationsExtension(
3334
3415
  };
3335
3416
  },
3336
3417
  configOverrides,
3418
+ lookupPromptStatus,
3337
3419
  ),
3338
3420
  id,
3339
3421
  revisions,
@@ -3349,6 +3431,8 @@ export function createNotificationsExtension(
3349
3431
  recordPromptFailure,
3350
3432
  () => runtime?.stopping !== true,
3351
3433
  trackGateResolution,
3434
+ admitPromptSubmission,
3435
+ reconciliation.releaseAdmission,
3352
3436
  settings,
3353
3437
  configOverrides,
3354
3438
  configRevision,
@@ -3407,7 +3491,7 @@ export function createNotificationsExtension(
3407
3491
  stateRoot,
3408
3492
  token,
3409
3493
  sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
3410
- connectionCapabilities: connectionId => hostCapCache.get(connectionId) ?? EMPTY_CAPABILITIES,
3494
+ connectionCapabilities: connectionId => hostCapCache.get(connectionId),
3411
3495
  installProviderDefinitions,
3412
3496
  onProviderDefinitionsRemoved: removeProviderDefinitions,
3413
3497
  onFrame: handler => {
@@ -3479,6 +3563,7 @@ export function createNotificationsExtension(
3479
3563
  },
3480
3564
  ),
3481
3565
  );
3566
+
3482
3567
  if (rotatesIdentity && response.ok !== true) {
3483
3568
  identityControlInFlight = false;
3484
3569
  deferredIdentityRotation = undefined;
@@ -3548,6 +3633,8 @@ export function createNotificationsExtension(
3548
3633
  pendingPromptCorrelations,
3549
3634
  activePromptCorrelation: undefined,
3550
3635
  recordPromptTerminal,
3636
+ notePromptReconciliation: reconciliation.noteTransition,
3637
+ emitPromptFailure,
3551
3638
  emitPromptLifecycle,
3552
3639
  emitPromptEvent,
3553
3640
  pendingInbound: new Set<number>(),
@@ -3587,10 +3674,10 @@ export function createNotificationsExtension(
3587
3674
  };
3588
3675
 
3589
3676
  const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3590
- if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3677
+ if (!options.runBtwTurn) throw new Error("Ephemeral turns are unavailable.");
3591
3678
  const generation = initializedRuntime.policyGeneration;
3592
3679
  if (initializedRuntime.policySuspended) throw new Error("Notification policy is provisional.");
3593
- const result = await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3680
+ const result = await options.runBtwTurn(question, signal);
3594
3681
  if (
3595
3682
  initializedRuntime.policySuspended ||
3596
3683
  initializedRuntime.policyGeneration !== generation ||
@@ -3609,6 +3696,13 @@ export function createNotificationsExtension(
3609
3696
  if (!frame || typeof frame !== "object") return;
3610
3697
  const typedFrame = frame as Record<string, unknown>;
3611
3698
  if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return;
3699
+ if (typedFrame.type === "event_replay") {
3700
+ const capabilities = Array.isArray(typedFrame.capabilities) ? typedFrame.capabilities : [];
3701
+ hostCapCache.set(
3702
+ inbound.connectionId,
3703
+ new Set(capabilities.filter((capability): capability is string => typeof capability === "string")),
3704
+ );
3705
+ }
3612
3706
  inboundSdkFrame?.(inbound.connectionId, typedFrame);
3613
3707
  } catch {}
3614
3708
  });
@@ -3749,7 +3843,7 @@ export function createNotificationsExtension(
3749
3843
  }
3750
3844
  const gate = runtime?.workflowGate;
3751
3845
  const workflowGateActive =
3752
- gate?.isUnattended?.() === true &&
3846
+ gate?.supportsRemoteGateAnswers?.() === true &&
3753
3847
  typeof gate.onGateEmitted === "function" &&
3754
3848
  typeof gate.resolveGateFromNotification === "function";
3755
3849
  const gateId = gatePresentations.routeFor(reply.id);
@@ -4202,7 +4296,9 @@ export function createNotificationsExtension(
4202
4296
  ? Gate
4203
4297
  : never,
4204
4298
  ): void => {
4205
- const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
4299
+ const rawGateOptions = g.options ?? [];
4300
+ const options = rawGateOptions.map(o => String((o as { label?: unknown }).label ?? ""));
4301
+ const recommendedIndex = recommendedIndexFromGateOptions(rawGateOptions);
4206
4302
  gateOptions.set(g.gate_id, options);
4207
4303
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
4208
4304
  const question =
@@ -4219,6 +4315,7 @@ export function createNotificationsExtension(
4219
4315
  sessionId: id,
4220
4316
  question,
4221
4317
  options,
4318
+ ...(recommendedIndex === undefined ? {} : { recommendedIndex }),
4222
4319
  controls: [],
4223
4320
  multi: stageState.multi === true,
4224
4321
  allowEmpty: stageState.allow_empty === true,
@@ -4577,22 +4674,45 @@ export function createNotificationsExtension(
4577
4674
  if (!pending) return;
4578
4675
  rt.pendingFinal = undefined;
4579
4676
  if (pending.text && rt.notificationsActive && !rt.redact) {
4580
- try {
4581
- pushSessionFrame(rt, {
4582
- type: "turn_stream",
4583
- sessionId: id,
4584
- phase: "finalized",
4585
- finalAnswer: true,
4677
+ // Under lean, hold intermediate finals until agent_end when the agent is still
4678
+ // running so provisional-policy activation cannot reintroduce per-turn spam.
4679
+ if (rt.verbosity === "lean" && rt.busy) {
4680
+ rt.pendingSettled = {
4586
4681
  text: pending.text,
4587
4682
  ...(pending.messageRef ? { messageRef: pending.messageRef } : {}),
4588
- });
4589
- } catch (error) {
4590
- logger.warn(`notifications: pushFrame (pending turn) failed: ${String(error)}`);
4683
+ };
4684
+ } else {
4685
+ try {
4686
+ pushSessionFrame(rt, {
4687
+ type: "turn_stream",
4688
+ sessionId: id,
4689
+ phase: "finalized",
4690
+ finalAnswer: true,
4691
+ text: pending.text,
4692
+ ...(pending.messageRef ? { messageRef: pending.messageRef } : {}),
4693
+ });
4694
+ } catch (error) {
4695
+ logger.warn(`notifications: pushFrame (pending turn) failed: ${String(error)}`);
4696
+ }
4591
4697
  }
4592
4698
  }
4593
4699
  resetTurnStreamState(rt);
4594
4700
  };
4595
4701
 
4702
+ /** Emit the deferred lean settled answer exactly once (agent_end / idle). */
4703
+ const flushPendingSettled = (rt: SessionRuntime, id: string): void => {
4704
+ const settled = rt.pendingSettled;
4705
+ rt.pendingSettled = undefined;
4706
+ if (!settled?.text || !rt.notificationsActive || rt.redact || rt.policySuspended) return;
4707
+ const previousLiveRef = rt.liveRef;
4708
+ if (settled.messageRef) rt.liveRef = settled.messageRef;
4709
+ try {
4710
+ flushTurnText(rt, id, settled.text, true);
4711
+ } finally {
4712
+ if (!settled.messageRef) rt.liveRef = previousLiveRef;
4713
+ }
4714
+ };
4715
+
4596
4716
  // Drive the live typing indicator: mark busy when the agent loop starts so
4597
4717
  // the daemon shows "typing…" in the thread while the agent is thinking,
4598
4718
  // before any turn output exists. Cleared on `agent_end` below.
@@ -4605,6 +4725,7 @@ export function createNotificationsExtension(
4605
4725
  rt.busy = true;
4606
4726
  const correlation = rt.pendingPromptCorrelations.shift();
4607
4727
  rt.activePromptCorrelation = correlation;
4728
+ rt.notePromptReconciliation(correlation, { type: "agent_start" });
4608
4729
  rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
4609
4730
  try {
4610
4731
  // `activity` is the native live-host lifecycle surface. The separately
@@ -4649,8 +4770,35 @@ export function createNotificationsExtension(
4649
4770
  rt.busy = false;
4650
4771
  const correlation = rt.activePromptCorrelation;
4651
4772
  if (correlation) {
4652
- if (rt.recordPromptTerminal(correlation))
4653
- rt.emitPromptLifecycle(correlation, { type: "agent_end", sessionId: id, ...correlation });
4773
+ const terminalAssistant = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).find(
4774
+ message =>
4775
+ message &&
4776
+ typeof message === "object" &&
4777
+ (message as { role?: unknown }).role === "assistant" &&
4778
+ ((message as { stopReason?: unknown }).stopReason === "error" ||
4779
+ (message as { stopReason?: unknown }).stopReason === "aborted"),
4780
+ ) as { stopReason?: "error" | "aborted"; errorMessage?: unknown } | undefined;
4781
+ const failureCode =
4782
+ event.stopReason === "cancelled"
4783
+ ? "cancelled"
4784
+ : terminalAssistant?.stopReason === "error"
4785
+ ? "agent_error"
4786
+ : terminalAssistant?.stopReason === "aborted"
4787
+ ? "aborted"
4788
+ : undefined;
4789
+ if (failureCode)
4790
+ rt.emitPromptFailure(correlation, {
4791
+ code: failureCode,
4792
+ message:
4793
+ typeof terminalAssistant?.errorMessage === "string"
4794
+ ? terminalAssistant.errorMessage
4795
+ : "Prompt submission failed.",
4796
+ });
4797
+ else {
4798
+ rt.notePromptReconciliation(correlation, { type: "agent_end" });
4799
+ if (rt.recordPromptTerminal(correlation))
4800
+ rt.emitPromptLifecycle(correlation, { type: "agent_end", sessionId: id, ...correlation });
4801
+ }
4654
4802
  } else {
4655
4803
  rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
4656
4804
  }
@@ -4694,9 +4842,14 @@ export function createNotificationsExtension(
4694
4842
  logger.warn(`notifications: noteIdle failed: ${String(e)}`);
4695
4843
  }
4696
4844
 
4845
+ // Lean: emit the latest deferred assistant answer exactly once at idle.
4846
+ // Intermediate tool-turn narration was held on turn_end; ask lead-ins were
4847
+ // already flushed immediately and deduped via preAskFlushedText.
4848
+ flushPendingSettled(rt, id);
4849
+
4697
4850
  // On idle, stream a context update with metadata (token/model usage +
4698
- // working-tree diff) unless redaction is on. The agent's last message is
4699
- // NOT repeated here — it is already streamed once via `turn_stream`.
4851
+ // working-tree diff) unless redaction is on. Under verbose the agent's last
4852
+ // message is already streamed per turn_end; lean flushes it just above.
4700
4853
  if (!rt.redact && rt.verbosity === "verbose") {
4701
4854
  const usage = (
4702
4855
  ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
@@ -4725,17 +4878,20 @@ export function createNotificationsExtension(
4725
4878
  }
4726
4879
  });
4727
4880
 
4728
- // Stream viable agent output per turn (the live thread mirror). Unlike idle,
4729
- // turn output is expected to be multiple messages — one per turn that
4730
- // produced assistant text. Tool-only turns yield no text and are skipped.
4731
- // Redaction suppresses streamed content (only the one-time identity header
4732
- // survives redaction). The daemon coalesces/throttles these via its shared
4733
- // rate-limit pool before sending to Telegram.
4881
+ // Stream viable agent output. Verbose mirrors each turn that produced assistant
4882
+ // text. Lean defers the latest answer until agent_end (idle) so tool-heavy runs
4883
+ // do not flood remote clients with intermediate narration; ask lead-ins still
4884
+ // flush immediately. Tool-only turns yield no text and are skipped. Redaction
4885
+ // suppresses streamed content (only the one-time identity header survives).
4886
+ // The daemon coalesces/throttles these via its shared rate-limit pool.
4734
4887
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
4735
4888
  // against what was already flushed for this turn (the pre-ask lead-in).
4736
4889
  const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
4737
4890
  if (!text || text === rt.preAskFlushedText || !rt.notificationsActive || rt.policySuspended) return;
4738
4891
  rt.preAskFlushedText = text;
4892
+ // Ask lead-ins supersede any deferred lean settled answer from earlier turns
4893
+ // so agent_end does not re-emit stale intermediate narration (#2863 review).
4894
+ if (!finalAnswer) rt.pendingSettled = undefined;
4739
4895
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
4740
4896
  // live message, never a fresh (rich-promotable) send. If live frames were
4741
4897
  // async-queued and none landed before this flush, reuse the per-turn ref
@@ -4876,19 +5032,37 @@ export function createNotificationsExtension(
4876
5032
  rt.turnClosed = true;
4877
5033
  return;
4878
5034
  }
4879
- if (text) flushTurnText(rt, id, text, true);
5035
+ if (text) {
5036
+ if (rt.verbosity === "verbose") {
5037
+ // Verbose: one finalized turn_stream per turn with assistant text.
5038
+ flushTurnText(rt, id, text, true);
5039
+ } else if (text !== rt.preAskFlushedText) {
5040
+ // Lean: hold the latest answer until agent_end. Skip when this turn
5041
+ // already flushed the same text as an ask lead-in (no duplicate at idle).
5042
+ rt.pendingSettled = {
5043
+ text,
5044
+ ...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
5045
+ };
5046
+ } else {
5047
+ // Lead-in already flushed: drop any older deferred settled text so idle
5048
+ // does not re-emit intermediate narration after the ask prompt (#2863).
5049
+ rt.pendingSettled = undefined;
5050
+ }
5051
+ }
4880
5052
  resetTurnStreamState(rt);
4881
5053
  });
4882
5054
 
4883
- // Live streaming (opt-in): push throttled in-progress assistant text as
4884
- // non-finalized turn_stream frames so remote clients edit one message as the
4885
- // turn streams. The finalized frame (turn_end) carries the same messageRef and
4886
- // lands the authoritative text. Suppressed under redaction.
5055
+ // Live streaming (opt-in + verbose only): push throttled in-progress assistant
5056
+ // text as non-finalized turn_stream frames so remote clients edit one message
5057
+ // as the turn streams. Lean keeps settled-answer-only delivery; live frames
5058
+ // are suppressed even when the stream preference is on. The finalized frame
5059
+ // (turn_end / agent_end) carries the same messageRef and lands the authoritative
5060
+ // text. Suppressed under redaction.
4887
5061
  api.on("message_update", (event, ctx) => {
4888
5062
  const id = sessionId(ctx);
4889
5063
  const rt = runtimes.get(id);
4890
5064
  rt?.emitPromptEvent(event);
4891
- if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
5065
+ if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed || rt.verbosity !== "verbose") return;
4892
5066
  if ((event.message as { role?: unknown }).role !== "assistant") return;
4893
5067
  if (rt.liveRef === undefined && rt.turnSeq !== undefined) {
4894
5068
  rt.liveRef = String(rt.turnSeq);