@gajae-code/coding-agent 0.12.0 → 0.12.2

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 (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -30,7 +30,7 @@ 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, VERSION } from "@gajae-code/utils";
33
+ import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
34
34
  import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract";
35
35
  import { isAuthenticated, kNoAuth } from "../../config/model-registry";
36
36
  import { Settings } from "../../config/settings";
@@ -44,9 +44,11 @@ import {
44
44
  type WorkflowGateTerminalProof,
45
45
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
46
46
  import type { AgentSessionEvent } from "../../session/agent-session";
47
+ import type { ClientBridge } from "../../session/client-bridge";
47
48
  import { parseThinkingLevel } from "../../thinking";
48
49
  import type {
49
50
  AskAnswerRequest,
51
+ AskAnswerSource,
50
52
  AskAnswerSourceResult,
51
53
  AskRemoteControl,
52
54
  AskRemoteInteraction,
@@ -63,7 +65,7 @@ import { SessionSdkHost, shouldHostSdk } from "../host";
63
65
  import { type ControlSurface, dispatchControl } from "../host/control";
64
66
  import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
65
67
  import { projectQ10Models } from "../models.js";
66
- import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status";
68
+ import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status";
67
69
  import { OPERATIONS } from "../protocol/operation-registry";
68
70
  import {
69
71
  lifecycleStartupCapabilityForApi,
@@ -644,7 +646,31 @@ export class PresentationArbiter {
644
646
  ) {}
645
647
 
646
648
  retain(presentation: UnattendedGatePresentation): void {
647
- const alreadyPresent = this.presentations.has(presentation.gateId);
649
+ const existing = this.presentations.get(presentation.gateId);
650
+ if (
651
+ existing &&
652
+ this.active?.gateId === presentation.gateId &&
653
+ (existing.options.length !== presentation.options.length ||
654
+ existing.options.some((option, index) => presentation.options[index] !== option))
655
+ ) {
656
+ const active = this.active;
657
+ let status: RetireStatus;
658
+ try {
659
+ status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
660
+ } catch (error) {
661
+ logger.warn(`interactive presentation replay retirement failed: ${String(error)}`);
662
+ return;
663
+ }
664
+ if (!isTerminalProof(status)) return;
665
+ this.routes.delete(active.actionId);
666
+ this.active = undefined;
667
+ }
668
+ const alreadyPresent = existing !== undefined;
669
+ if (existing?.multi && presentation.multi) {
670
+ presentation.selectedOptions = existing.selectedOptions.filter(option =>
671
+ presentation.options.includes(option),
672
+ );
673
+ }
648
674
  if (!alreadyPresent) this.queue.push(presentation.gateId);
649
675
  this.presentations.set(presentation.gateId, presentation);
650
676
  // A fresh durable replay is explicit production recovery after transient N-API exhaustion.
@@ -773,6 +799,13 @@ export class PresentationArbiter {
773
799
  ? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
774
800
  : presentation.question,
775
801
  options: presentation.options,
802
+ ...(presentation.multi
803
+ ? {
804
+ selectedOptionIndices: presentation.options.flatMap((option, index) =>
805
+ presentation.selectedOptions.includes(option) ? [index] : [],
806
+ ),
807
+ }
808
+ : {}),
776
809
  ...(presentation.recommendedIndex === undefined
777
810
  ? {}
778
811
  : { recommendedIndex: presentation.recommendedIndex }),
@@ -944,6 +977,19 @@ interface SessionRuntime {
944
977
  pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
945
978
  /** Identity bound to the agent lifecycle currently in flight. */
946
979
  activePromptCorrelation?: { commandId: string; turnId: string };
980
+ /** Binds the executing Agent run to a correlated prompt so cleanup targets only it. */
981
+ bindPromptExecutionHandle: (correlation: { commandId: string; turnId: string }, handle: string | undefined) => void;
982
+ /** Reads the durable non-terminal claim for a correlated prompt, if any. */
983
+ peekPromptPendingOutcome: (correlation: {
984
+ commandId: string;
985
+ turnId: string;
986
+ }) => SdkPromptTerminalOutcome | undefined;
987
+ /** Claims, fences, finalizes, and publishes exactly one normalized prompt terminal. */
988
+ terminalizePrompt: (
989
+ correlation: { commandId: string; turnId: string },
990
+ outcome: SdkPromptTerminalOutcome,
991
+ extra?: { finalText?: string },
992
+ ) => Promise<void>;
947
993
  /** Records a correlated prompt terminal boundary after agent unwind. */
948
994
  /** Atomically claims a correlated prompt terminal boundary after agent unwind. */
949
995
  recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
@@ -964,6 +1010,7 @@ interface SessionRuntime {
964
1010
  commandId?: string;
965
1011
  turnId?: string;
966
1012
  finalText?: string;
1013
+ outcome?: SdkPromptTerminalOutcome;
967
1014
  }
968
1015
  | {
969
1016
  type: "agent_failed";
@@ -971,6 +1018,7 @@ interface SessionRuntime {
971
1018
  commandId: string;
972
1019
  turnId: string;
973
1020
  error: { code: string; message: string };
1021
+ outcome?: SdkPromptTerminalOutcome;
974
1022
  },
975
1023
  ) => void;
976
1024
  /** Publishes one canonical agent-wire event to the client that owns the active prompt. */
@@ -1134,6 +1182,7 @@ const defaultConfig: NotificationConfig = {
1134
1182
  redact: false,
1135
1183
  verbosity: "lean",
1136
1184
  sessionScope: "all",
1185
+ sound: "all",
1137
1186
  idleTimeoutMs: 60_000,
1138
1187
  rich: { enabled: true },
1139
1188
  richDraft: { enabled: false },
@@ -1143,8 +1192,17 @@ const defaultConfig: NotificationConfig = {
1143
1192
  btw: { enabled: true },
1144
1193
  };
1145
1194
 
1195
+ /**
1196
+ * Whether the notifications control channel is enabled.
1197
+ *
1198
+ * Trusted sources only: enabling it opens the session control/answer channel, so
1199
+ * a repository must not be able to turn it on. `$env` merges the caller's
1200
+ * `cwd/.env` into `process.env`; the sibling resolvers in `config.ts` and
1201
+ * `session-control.ts` already read an injected env record rather than the merged
1202
+ * view, and this direct read was the outlier.
1203
+ */
1146
1204
  export function notificationsEnabled(): boolean {
1147
- return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
1205
+ return $credentialEnv("GJC_NOTIFICATIONS") === "1" || Boolean($credentialEnv("GJC_NOTIFICATIONS_TOKEN"));
1148
1206
  }
1149
1207
 
1150
1208
  function streamIntervalMs(): number {
@@ -1617,6 +1675,80 @@ async function requestRecoveredSelectedAck(
1617
1675
  }
1618
1676
  }
1619
1677
 
1678
+ function createSdkUiAskAnswerSource(
1679
+ requestElicitation: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>,
1680
+ ): AskAnswerSource {
1681
+ const awaitAnswerRequest = async (
1682
+ request: AskAnswerRequest,
1683
+ signal?: AbortSignal,
1684
+ ): Promise<AskAnswerSourceResult> => {
1685
+ if (signal?.aborted) return undefined;
1686
+ const choices = new Map<string, AskRemoteInteraction>();
1687
+ const oneOf =
1688
+ request.interaction === "selector"
1689
+ ? [
1690
+ ...request.options.map((label, index) => {
1691
+ const value = `option:${index}`;
1692
+ choices.set(value, { kind: "value", value: label });
1693
+ return { const: value, title: label };
1694
+ }),
1695
+ ...request.controls
1696
+ .filter(control => control.enabled)
1697
+ .map(control => {
1698
+ const value = `control:${control.id}`;
1699
+ choices.set(value, { kind: "control", controlId: control.id });
1700
+ return { const: value, title: control.label };
1701
+ }),
1702
+ ]
1703
+ : [];
1704
+ const response = await requestElicitation(
1705
+ {
1706
+ mode: "form",
1707
+ message: request.question,
1708
+ requestedSchema: {
1709
+ type: "object",
1710
+ properties: {
1711
+ value: { type: "string", ...(oneOf.length > 0 ? { oneOf } : {}) },
1712
+ },
1713
+ required: ["value"],
1714
+ },
1715
+ },
1716
+ signal,
1717
+ );
1718
+ if (signal?.aborted || !isRecord(response) || response.action !== "accept") return undefined;
1719
+ const value = isRecord(response.content) ? response.content.value : undefined;
1720
+ if (typeof value !== "string") return undefined;
1721
+ if (request.interaction !== "selector") return value;
1722
+ const interaction = choices.get(value);
1723
+ if (!interaction) return undefined;
1724
+ if (interaction.kind === "value") return interaction.value;
1725
+ let settled: Promise<AskSettlementResult> | undefined;
1726
+ return {
1727
+ source: "remote",
1728
+ interaction,
1729
+ settle(settlement) {
1730
+ if (!settled) {
1731
+ settled = Promise.resolve(
1732
+ settlement.kind === "commit"
1733
+ ? { kind: "committed", ack: { status: "failed", reason: "unsupported" } }
1734
+ : settlement.kind === "invalid"
1735
+ ? { kind: "invalid_closed" }
1736
+ : { kind: "resolved_without_commit" },
1737
+ );
1738
+ }
1739
+ return settled;
1740
+ },
1741
+ };
1742
+ };
1743
+ return {
1744
+ async awaitAnswer(question, options, signal) {
1745
+ const answer = await awaitAnswerRequest({ question, options, interaction: "selector", controls: [] }, signal);
1746
+ if (!answer || typeof answer === "string") return answer;
1747
+ return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
1748
+ },
1749
+ awaitAnswerRequest,
1750
+ };
1751
+ }
1620
1752
  /** Register the interactive `ask` answer source for a session (the ask tool
1621
1753
  * races the local UI against a remote reply). Returns the deregister disposer. */
1622
1754
  function registerInteractiveAnswerSource(
@@ -1947,6 +2079,7 @@ function sdkQuerySurface(
1947
2079
  getCapabilities: () => ({
1948
2080
  operations: [...installedOperations(ctx, "control"), ...installedOperations(ctx, "query")],
1949
2081
  hostTools: getInstalledDefinitions("host_tools") !== undefined,
2082
+ promptTerminalOutcomeVersion: 1,
1950
2083
  }),
1951
2084
  getAuthProviders: () => [...new Set(ctx.modelRegistry.getAll().map(model => model.provider))],
1952
2085
  getTools: () => {
@@ -1990,13 +2123,21 @@ function sdkControlSurface(
1990
2123
  trackReconciliation?: boolean,
1991
2124
  ) => void | Promise<void> = () => {},
1992
2125
  onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
2126
+ onPromptAcceptFailed: (correlation: { commandId: string; turnId: string }) => void = () => {},
1993
2127
  acceptGateResolution: () => boolean,
1994
2128
  trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
1995
2129
  admitPrompt: (clientRef?: string) => void,
1996
2130
  releasePromptAdmission: (clientRef?: string) => void,
2131
+ awaitReconciliationReady: () => Promise<void> = async () => {},
1997
2132
  settings?: Settings,
1998
2133
  configOverrides: Map<string, unknown> = new Map(),
1999
2134
  configRevision: { current: number } = { current: 0 },
2135
+ abortOwnedPrompt: (
2136
+ connectionId: string | undefined,
2137
+ ) => Promise<{ aborted: true; disposition: "cancelled" | "already_terminal" | "idle" }> = async () => ({
2138
+ aborted: true,
2139
+ disposition: "idle",
2140
+ }),
2000
2141
  skillRecon?: {
2001
2142
  admit: (clientRef?: string) => void;
2002
2143
  release: (clientRef?: string) => void;
@@ -2011,7 +2152,10 @@ function sdkControlSurface(
2011
2152
  ) => Promise<void>;
2012
2153
  lookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
2013
2154
  },
2014
- ): ControlSurface {
2155
+ ): ControlSurface & {
2156
+ cancelPendingPreflights(): void;
2157
+ cancelPendingPreflightsForConnection(connectionId: string): void;
2158
+ } {
2015
2159
  const unavailable = (operation: string, reason: string) => () => {
2016
2160
  throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
2017
2161
  };
@@ -2065,9 +2209,15 @@ function sdkControlSurface(
2065
2209
  return unavailable(operation, "no typed session seam is installed")();
2066
2210
  return ctx.sdkControl(operation, input);
2067
2211
  };
2068
- const pendingPreflightCancellations = new Set<() => void>();
2212
+ const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => void }>();
2213
+ const preflightKey = (connectionId: string | undefined, correlation: { commandId: string; turnId: string }) =>
2214
+ `${connectionId ?? ""}\0${correlation.commandId}\0${correlation.turnId}`;
2069
2215
  const cancelPendingPreflights = () => {
2070
- for (const cancel of pendingPreflightCancellations) cancel();
2216
+ for (const { cancel } of pendingPreflightCancellations.values()) cancel();
2217
+ };
2218
+ const cancelPendingPreflightsForConnection = (connectionId: string) => {
2219
+ for (const entry of pendingPreflightCancellations.values())
2220
+ if (entry.connectionId === connectionId) entry.cancel();
2071
2221
  };
2072
2222
  const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
2073
2223
  const awaitAbortReady = async () => {
@@ -2092,7 +2242,18 @@ function sdkControlSurface(
2092
2242
  throw Object.assign(new Error("clientRef must be a non-empty string of at most 128 characters."), {
2093
2243
  code: "invalid_input",
2094
2244
  });
2095
- if (trackReconciliation) admitPrompt(trimmedClientRef);
2245
+ if (trackReconciliation) {
2246
+ // Restart recovery must be committed before a tracked prompt reserves capacity,
2247
+ // otherwise it can admit against pre-hydration state or a lost clientRef.
2248
+ try {
2249
+ await awaitReconciliationReady();
2250
+ } catch {
2251
+ throw Object.assign(new Error("Prompt reconciliation state is unavailable; retry after restart."), {
2252
+ code: "unavailable",
2253
+ });
2254
+ }
2255
+ admitPrompt(trimmedClientRef);
2256
+ }
2096
2257
  try {
2097
2258
  if (forceFresh && isSessionBusy()) {
2098
2259
  throw Object.assign(
@@ -2140,17 +2301,31 @@ function sdkControlSurface(
2140
2301
  status: "rejected",
2141
2302
  error: Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
2142
2303
  });
2143
- pendingPreflightCancellations.add(cancelPreflight);
2304
+ pendingPreflightCancellations.set(preflightKey(requesterConnectionId, correlation), {
2305
+ connectionId: requesterConnectionId,
2306
+ cancel: cancelPreflight,
2307
+ });
2144
2308
  const settleAccepted = async () => {
2145
2309
  if (preflightSettled) return;
2146
2310
  accepted = true;
2147
- await onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
2311
+ try {
2312
+ await onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
2313
+ } catch (error) {
2314
+ // Durable acceptance failed, so the prompt was never accepted: reject the
2315
+ // control preflight and rethrow so the awaiting session does not execute it.
2316
+ accepted = false;
2317
+ onPromptAcceptFailed(correlation);
2318
+ settlePreflight({ status: "rejected", error });
2319
+ throw error;
2320
+ }
2148
2321
  settlePreflight({ status: "accepted" });
2149
2322
  };
2150
2323
  // Durable fence preferred; keep legacy onPreflightAccepted for hosts/tests that only fire the sync hook.
2151
2324
  const onPreflightAcceptCommit = settleAccepted;
2152
2325
  const onPreflightAccepted = () => {
2153
- void settleAccepted();
2326
+ // Legacy fire-and-forget hook: the rejection is already reported through the
2327
+ // preflight promise, so swallow it here instead of leaking an unhandled rejection.
2328
+ void settleAccepted().catch(() => {});
2154
2329
  };
2155
2330
  // Do not acknowledge the prompt until AgentSession's async preflight
2156
2331
  // succeeds. The terminal result records correlation before agent_start can fire.
@@ -2192,24 +2367,35 @@ function sdkControlSurface(
2192
2367
  if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
2193
2368
  throw error;
2194
2369
  } finally {
2195
- pendingPreflightCancellations.delete(cancelPreflight);
2370
+ pendingPreflightCancellations.delete(preflightKey(requesterConnectionId, correlation));
2196
2371
  }
2197
2372
  };
2198
- const surface: ControlSurface & { cancelPendingPreflights(): void } = {
2373
+ const surface: ControlSurface & {
2374
+ cancelPendingPreflights(): void;
2375
+ cancelPendingPreflightsForConnection(connectionId: string): void;
2376
+ } = {
2199
2377
  prompt: (text, images, clientRef) =>
2200
2378
  submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
2201
2379
  steer: text => sendSteer(text),
2202
2380
  followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
2203
- abort: () => {
2204
- cancelPendingPreflights();
2205
- ctx.abort();
2206
- return { aborted: true };
2381
+ abort: async () => {
2382
+ const requesterConnectionId = controlRequesterContext.getStore();
2383
+ const pendingPreflight = [...pendingPreflightCancellations.values()].some(
2384
+ entry => entry.connectionId === requesterConnectionId,
2385
+ );
2386
+ if (pendingPreflight) {
2387
+ if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId);
2388
+ else cancelPendingPreflights();
2389
+ return { aborted: true, disposition: "preflight_cancelled" };
2390
+ }
2391
+ return await abortOwnedPrompt(requesterConnectionId);
2207
2392
  },
2208
2393
  abortAndPrompt: async text => {
2209
2394
  await awaitAbortReady();
2210
2395
  return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
2211
2396
  },
2212
2397
  cancelPendingPreflights,
2398
+ cancelPendingPreflightsForConnection,
2213
2399
  answerAsk: (id, answer) => {
2214
2400
  const pending = pendingInteractive.get(id);
2215
2401
  if (!pending) throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
@@ -2385,7 +2571,16 @@ function sdkControlSurface(
2385
2571
  const commandId = crypto.randomUUID();
2386
2572
  const turnId = crypto.randomUUID();
2387
2573
  const correlation = { commandId, turnId };
2388
- if (skillRecon) skillRecon.admit(trimmedClientRef);
2574
+ if (skillRecon) {
2575
+ try {
2576
+ await awaitReconciliationReady();
2577
+ } catch {
2578
+ throw Object.assign(new Error("Skill reconciliation state is unavailable; retry after restart."), {
2579
+ code: "unavailable",
2580
+ });
2581
+ }
2582
+ skillRecon.admit(trimmedClientRef);
2583
+ }
2389
2584
  const { promise: acceptedP, resolve, reject } = Promise.withResolvers<Record<string, unknown>>();
2390
2585
  let phase: "pending" | "accepted" | "rejected" = "pending";
2391
2586
  const settleAccept = (value: Record<string, unknown>) => {
@@ -3315,7 +3510,6 @@ export function createNotificationsExtension(
3315
3510
  if (lifecycleRequired && !lifecycleAgentDir)
3316
3511
  return failLifecycleStartup("failed", "Lifecycle SDK startup requires an agent directory.");
3317
3512
 
3318
- const gateOptions = new Map<string, string[]>();
3319
3513
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
3320
3514
  const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
3321
3515
  const tag = sessionTag(id);
@@ -3353,32 +3547,86 @@ export function createNotificationsExtension(
3353
3547
 
3354
3548
  const revisions = new RevisionStore(id, Date.now, { storageDir: stateRoot });
3355
3549
  let host: SessionSdkHost | undefined;
3550
+ let disposeUiAnswerSource: (() => void) | undefined;
3356
3551
  const installProviderDefinitions = (capability: string, definitions: unknown) => {
3357
3552
  validateProviderDefinitions(capability, definitions);
3358
- if (capability !== "permission") return;
3359
- ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
3360
- const result = await host!.reverse.request("permission", "request", {
3361
- toolCall,
3362
- options: permissionOptions,
3363
- aborted: signal?.aborted === true,
3364
- });
3365
- if (!result || typeof result !== "object")
3553
+ if (capability === "permission") {
3554
+ ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
3555
+ const result = await host!.reverse.request(
3556
+ "permission",
3557
+ "request",
3558
+ {
3559
+ toolCall,
3560
+ options: permissionOptions,
3561
+ },
3562
+ signal,
3563
+ );
3564
+ if (!result || typeof result !== "object")
3565
+ throw new Error("permission provider returned an invalid response");
3566
+ const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
3567
+ if (response.outcome === "cancelled") return { outcome: "cancelled" };
3568
+ if (response.outcome === "selected" && typeof response.optionId === "string")
3569
+ return {
3570
+ outcome: "selected",
3571
+ optionId: response.optionId,
3572
+ ...(typeof response.kind === "string"
3573
+ ? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
3574
+ : {}),
3575
+ };
3366
3576
  throw new Error("permission provider returned an invalid response");
3367
- const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
3368
- if (response.outcome === "cancelled") return { outcome: "cancelled" };
3369
- if (response.outcome === "selected" && typeof response.optionId === "string")
3370
- return {
3371
- outcome: "selected",
3372
- optionId: response.optionId,
3373
- ...(typeof response.kind === "string"
3374
- ? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
3375
- : {}),
3376
- };
3377
- throw new Error("permission provider returned an invalid response");
3378
- });
3577
+ });
3578
+ return;
3579
+ }
3580
+ if (capability === "ui") {
3581
+ disposeUiAnswerSource?.();
3582
+ disposeUiAnswerSource = registerAskAnswerSource(
3583
+ id,
3584
+ createSdkUiAskAnswerSource(
3585
+ async (params, signal) => await host!.reverse.request("ui", "ui.elicit", params, signal),
3586
+ ),
3587
+ );
3588
+ return;
3589
+ }
3590
+ if (capability !== "fs") return;
3591
+ // Only advertise the methods the client actually declared; a read-only client
3592
+ // must keep using the local write path instead of failing against the bridge.
3593
+ const names = new Set(
3594
+ (Array.isArray(definitions) ? definitions : [])
3595
+ .map(definition =>
3596
+ definition && typeof definition === "object" ? (definition as { name?: unknown }).name : undefined,
3597
+ )
3598
+ .filter((name): name is string => typeof name === "string"),
3599
+ );
3600
+ const canRead = names.size === 0 || names.has("fs.readTextFile");
3601
+ const canWrite = names.size === 0 || names.has("fs.writeTextFile");
3602
+ const bridge: ClientBridge = {
3603
+ capabilities: { readTextFile: canRead, writeTextFile: canWrite },
3604
+ deferAgentInitiatedTurns: true,
3605
+ async readTextFile(params) {
3606
+ const result = await host!.reverse.request("fs", "fs.readTextFile", params);
3607
+ if (
3608
+ !result ||
3609
+ typeof result !== "object" ||
3610
+ typeof (result as { content?: unknown }).content !== "string"
3611
+ )
3612
+ throw new Error("fs provider returned an invalid read response");
3613
+ return (result as { content: string }).content;
3614
+ },
3615
+ async writeTextFile(params) {
3616
+ await host!.reverse.request("fs", "fs.writeTextFile", params);
3617
+ },
3618
+ };
3619
+ if (!canRead) delete bridge.readTextFile;
3620
+ if (!canWrite) delete bridge.writeTextFile;
3621
+ ctx.setSdkClientBridge?.(bridge);
3379
3622
  };
3380
3623
  const removeProviderDefinitions = (capability: string) => {
3381
3624
  if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
3625
+ if (capability === "fs") ctx.setSdkClientBridge?.(undefined);
3626
+ if (capability === "ui") {
3627
+ disposeUiAnswerSource?.();
3628
+ disposeUiAnswerSource = undefined;
3629
+ }
3382
3630
  };
3383
3631
 
3384
3632
  const hostCapCache = new Map<string, ReadonlySet<string>>();
@@ -3389,6 +3637,8 @@ export function createNotificationsExtension(
3389
3637
  const PROMPT_SUBMISSION_TTL_MS = 5 * 60_000;
3390
3638
  const PROMPT_TERMINAL_TOMBSTONE_CAPACITY = 256;
3391
3639
  const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
3640
+ // SDK-owned terminalization grace; injectable in tests, never a user setting.
3641
+ const PROMPT_TERMINALIZATION_GRACE_MS = 10_000;
3392
3642
  const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
3393
3643
  `${correlation.commandId}:${correlation.turnId}`;
3394
3644
  type PromptLifecycleFrame =
@@ -3398,6 +3648,7 @@ export function createNotificationsExtension(
3398
3648
  commandId?: string;
3399
3649
  turnId?: string;
3400
3650
  finalText?: string;
3651
+ outcome?: SdkPromptTerminalOutcome;
3401
3652
  }
3402
3653
  | {
3403
3654
  type: "agent_failed";
@@ -3405,6 +3656,7 @@ export function createNotificationsExtension(
3405
3656
  commandId: string;
3406
3657
  turnId: string;
3407
3658
  error: { code: string; message: string };
3659
+ outcome?: SdkPromptTerminalOutcome;
3408
3660
  };
3409
3661
  type PromptSubmission = {
3410
3662
  acknowledged: boolean;
@@ -3414,11 +3666,22 @@ export function createNotificationsExtension(
3414
3666
  error: unknown;
3415
3667
  terminal: boolean;
3416
3668
  retainCorrelation: boolean;
3669
+ /** Fatal/uncertain closure: transport-level only, never a semantic terminal. */
3670
+ fatal?: boolean;
3417
3671
  createdAt: number;
3672
+ deadlineMs: number;
3673
+ deadlineTimer?: Parameters<typeof clearTimeout>[0];
3674
+ phase: "active" | "outcome_claimed" | "terminalizing" | "publication_closed" | "delivered";
3675
+ outcome?: SdkPromptTerminalOutcome;
3676
+ /** Agent-owned resource run captured at acceptance; cleanup targets only this handle. */
3677
+ executionHandle?: string;
3418
3678
  bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
3419
3679
  };
3420
3680
  const promptSubmissions = new Map<string, PromptSubmission>();
3421
- const promptTerminalTombstones = new Map<string, number>();
3681
+ /** Connections fenced by a fatal prompt closure; their later frames are refused. */
3682
+ const fencedConnections = new Set<string>();
3683
+ let cancelPreflightsForConnection: ((connectionId: string) => void) | undefined;
3684
+ const promptTerminalTombstones = new Map<string, { connectionId: string; expiresAt: number }>();
3422
3685
  // Authoritative bounded reconciliation state for Q26 turn.prompt_status
3423
3686
  // (contract documented in ./prompt-reconciliation and ../prompt-status).
3424
3687
  // Active records never age into terminal; documented TTL/capacity
@@ -3432,7 +3695,14 @@ export function createNotificationsExtension(
3432
3695
  ? createReconciliationStore({ sessionFile, sessionId: String(reconciliationSessionId) })
3433
3696
  : null;
3434
3697
  const kindReconciliation = createKindAwareReconciliation({ store: durableStore });
3435
- if (durableStore) void kindReconciliation.hydrateFromStore();
3698
+ // Restart recovery must commit before any prompt is admitted; otherwise a new
3699
+ // admission can race the hydrated full-state replacement.
3700
+ const reconciliationReady: Promise<void> = durableStore
3701
+ ? kindReconciliation.hydrateFromStore()
3702
+ : Promise.resolve();
3703
+ // Never let a hydration rejection become an unhandled rejection; tracked prompts
3704
+ // re-await the same promise and fail closed below.
3705
+ void reconciliationReady.catch(() => {});
3436
3706
  // Backward-compatible process-local prompt reconciler kept for unit-test isolation;
3437
3707
  // production path uses kindReconciliation for prompt+skill.
3438
3708
  const reconciliation = createPromptReconciliation();
@@ -3452,32 +3722,39 @@ export function createNotificationsExtension(
3452
3722
  );
3453
3723
  if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
3454
3724
  };
3455
- const addTerminalTombstone = (key: string, now = Date.now()) => {
3725
+ const addTerminalTombstone = (key: string, connectionId: string, now = Date.now()) => {
3456
3726
  promptTerminalTombstones.delete(key);
3457
- promptTerminalTombstones.set(key, now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS);
3727
+ promptTerminalTombstones.set(key, { connectionId, expiresAt: now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS });
3458
3728
  while (promptTerminalTombstones.size > PROMPT_TERMINAL_TOMBSTONE_CAPACITY)
3459
3729
  promptTerminalTombstones.delete(promptTerminalTombstones.keys().next().value!);
3460
3730
  };
3461
3731
  const finalizePrompt = (key: string, correlation: { commandId: string; turnId: string }) => {
3732
+ const submission = promptSubmissions.get(key);
3733
+ if (submission?.deadlineTimer) clearTimeout(submission.deadlineTimer);
3462
3734
  promptSubmissions.delete(key);
3463
3735
  removePendingPromptCorrelation(correlation);
3464
- addTerminalTombstone(key);
3736
+ if (submission) addTerminalTombstone(key, submission.connectionId);
3465
3737
  };
3466
3738
  const expirePromptDelivery = (key: string, submission: PromptSubmission) => {
3739
+ if (!submission.terminal) return;
3740
+ if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
3467
3741
  promptSubmissions.delete(key);
3468
- if (!submission.terminal && submission.retainCorrelation) return;
3742
+ // A fatal closure is transport-level, not a committed semantic terminal: the
3743
+ // durable record stays authoritative, so it must never leave a tombstone.
3744
+ if (submission.fatal) return;
3469
3745
  const [commandId, turnId] = key.split(":", 2);
3470
3746
  if (!commandId || !turnId) return;
3471
3747
  removePendingPromptCorrelation({ commandId, turnId });
3472
- addTerminalTombstone(key);
3748
+ addTerminalTombstone(key, submission.connectionId);
3473
3749
  };
3474
3750
  const cleanupPromptRecords = (now = Date.now()) => {
3475
3751
  kindReconciliation.cleanup();
3476
3752
  reconciliation.cleanup();
3477
- for (const [key, expiresAt] of promptTerminalTombstones)
3478
- if (expiresAt <= now) promptTerminalTombstones.delete(key);
3753
+ for (const [key, tombstone] of promptTerminalTombstones)
3754
+ if (tombstone.expiresAt <= now) promptTerminalTombstones.delete(key);
3479
3755
  for (const [key, submission] of promptSubmissions)
3480
- if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now) expirePromptDelivery(key, submission);
3756
+ if (submission.terminal && submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now)
3757
+ expirePromptDelivery(key, submission);
3481
3758
  };
3482
3759
  const abandonPrompt = (submission: PromptSubmission) => {
3483
3760
  submission.abandoned = true;
@@ -3511,7 +3788,10 @@ export function createNotificationsExtension(
3511
3788
  logger.warn(`sdk: correlated lifecycle delivery failed: ${String(error)}`);
3512
3789
  abandonPrompt(submission);
3513
3790
  }
3514
- if (submission.terminal) finalizePrompt(key, correlation);
3791
+ if (submission.terminal) {
3792
+ submission.phase = "delivered";
3793
+ finalizePrompt(key, correlation);
3794
+ }
3515
3795
  };
3516
3796
  const emitPromptEvent = (event: AgentSessionEvent) => {
3517
3797
  if (!runtime?.activePromptCorrelation) return;
@@ -3547,6 +3827,7 @@ export function createNotificationsExtension(
3547
3827
  }
3548
3828
  }
3549
3829
  if (submission.terminal) {
3830
+ submission.phase = "delivered";
3550
3831
  const [commandId, turnId] = key.split(":", 2);
3551
3832
  if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
3552
3833
  }
@@ -3567,12 +3848,16 @@ export function createNotificationsExtension(
3567
3848
  // failures that race the durable write still emit correlated terminals.
3568
3849
  cleanupPromptRecords();
3569
3850
  while (promptSubmissions.size >= PROMPT_SUBMISSION_CAPACITY) {
3570
- const oldest = promptSubmissions.entries().next().value as [string, PromptSubmission] | undefined;
3571
- if (!oldest) break;
3572
- expirePromptDelivery(oldest[0], oldest[1]);
3851
+ const oldestTerminal = [...promptSubmissions.entries()].find(([, submission]) => submission.terminal);
3852
+ if (!oldestTerminal)
3853
+ throw Object.assign(
3854
+ new Error("Too many active prompt submissions; reconcile or await terminal state."),
3855
+ { code: "reconciliation_capacity" },
3856
+ );
3857
+ expirePromptDelivery(oldestTerminal[0], oldestTerminal[1]);
3573
3858
  }
3574
3859
  pendingPromptCorrelations.push(correlation);
3575
- promptSubmissions.set(promptSubmissionKey(correlation), {
3860
+ const submission: PromptSubmission = {
3576
3861
  acknowledged: false,
3577
3862
  connectionId: requesterConnectionId,
3578
3863
  abandoned: false,
@@ -3581,31 +3866,217 @@ export function createNotificationsExtension(
3581
3866
  terminal: false,
3582
3867
  retainCorrelation: trackReconciliation,
3583
3868
  createdAt: Date.now(),
3869
+ deadlineMs: settings?.get("sdk.promptDeadlineMs") ?? 1_800_000,
3870
+ phase: "active",
3871
+ // Bound to the Agent run at `agent_start`; acceptance precedes execution.
3872
+ executionHandle: undefined,
3584
3873
  bufferedFrames: [],
3585
- });
3874
+ };
3875
+ promptSubmissions.set(promptSubmissionKey(correlation), submission);
3586
3876
  if (trackReconciliation) await notePromptReconciliationAccepted(correlation, clientRef);
3877
+ submission.deadlineTimer = setTimeout(() => {
3878
+ void terminalizePrompt(
3879
+ correlation,
3880
+ {
3881
+ kind: "failed",
3882
+ code: "prompt_deadline_exceeded",
3883
+ message: "Prompt deadline exceeded.",
3884
+ provenance: "deadline",
3885
+ },
3886
+ { fence: true },
3887
+ );
3888
+ }, submission.deadlineMs);
3889
+ };
3890
+ /** Roll back process-local registration when durable acceptance failed. */
3891
+ const discardPromptAcceptance = (correlation: { commandId: string; turnId: string }) => {
3892
+ promptSubmissions.delete(promptSubmissionKey(correlation));
3893
+ removePendingPromptCorrelation(correlation);
3587
3894
  };
3588
3895
  const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
3589
3896
  if (!correlation) return false;
3590
- cleanupPromptRecords();
3591
- const key = promptSubmissionKey(correlation);
3592
- if (promptTerminalTombstones.has(key)) return false;
3593
- const submission = promptSubmissions.get(key);
3897
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3594
3898
  if (!submission || submission.terminal) return false;
3595
3899
  submission.terminal = true;
3900
+ submission.phase = "publication_closed";
3596
3901
  return true;
3597
3902
  };
3903
+ /**
3904
+ * Fail-closed teardown: the durable claim survives for restart recovery, but the
3905
+ * waiting client must still reject exactly once, so an infrastructure failure frame
3906
+ * is delivered before the connection is released.
3907
+ */
3908
+ const failPromptClosed = (
3909
+ correlation: { commandId: string; turnId: string },
3910
+ submission: PromptSubmission,
3911
+ code: string,
3912
+ message: string,
3913
+ ) => {
3914
+ if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
3915
+ if (!submission.terminal) {
3916
+ submission.terminal = true;
3917
+ submission.fatal = true;
3918
+ submission.phase = "publication_closed";
3919
+ // Deliver the transport failure directly to the accepted requester instead of
3920
+ // `emitPromptLifecycle`: this must never enter the resumable semantic event
3921
+ // ring, finalize reconciliation, or tombstone the correlation, because the
3922
+ // durable claim stays authoritative for Q26 and restart recovery.
3923
+ // Delivered regardless of acknowledgement: a pre-ack fatal failure must still
3924
+ // settle the waiting request instead of leaving it pending forever.
3925
+ if (runtime)
3926
+ try {
3927
+ runtime.server.sendTo(
3928
+ submission.connectionId,
3929
+ JSON.stringify({
3930
+ type: "agent_failed",
3931
+ sessionId: runtime.id,
3932
+ ...correlation,
3933
+ error: { code, message },
3934
+ }),
3935
+ );
3936
+ } catch (error) {
3937
+ logger.warn(`sdk: fatal prompt closure delivery failed: ${String(error)}`);
3938
+ }
3939
+ }
3940
+ // Fence the endpoint. The native transport exposes no close primitive, so the
3941
+ // connection is marked failed (every later inbound frame from it is refused),
3942
+ // its reverse leases are released, and its deliveries are abandoned. The durable
3943
+ // record stays active for Q26 and restart recovery.
3944
+ cancelPreflightsForConnection?.(submission.connectionId);
3945
+ fencedConnections.add(submission.connectionId);
3946
+ host?.handleDisconnect(submission.connectionId);
3947
+ for (const [key, owned] of promptSubmissions)
3948
+ if (owned.connectionId === submission.connectionId) {
3949
+ abandonPrompt(owned);
3950
+ const [commandId, turnId] = key.split(":", 2);
3951
+ if (commandId && turnId) removePendingPromptCorrelation({ commandId, turnId });
3952
+ }
3953
+ if (
3954
+ runtime?.activePromptCorrelation?.commandId === correlation.commandId &&
3955
+ runtime.activePromptCorrelation.turnId === correlation.turnId
3956
+ )
3957
+ runtime.activePromptCorrelation = undefined;
3958
+ };
3959
+ const bindPromptExecutionHandle = (
3960
+ correlation: { commandId: string; turnId: string },
3961
+ handle: string | undefined,
3962
+ ) => {
3963
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3964
+ if (submission) submission.executionHandle = handle;
3965
+ };
3966
+ const terminalizePrompt = async (
3967
+ correlation: { commandId: string; turnId: string },
3968
+ requestedOutcome: SdkPromptTerminalOutcome,
3969
+ // Cleanup-initiated claims (cancel, deadline, owner disconnect) must abort the
3970
+ // run and prove settlement. A natural `agent_end`/`agent_failed` already unwound,
3971
+ // so aborting there would cancel the next turn instead of fencing this one.
3972
+ options: { fence?: boolean } = {},
3973
+ extra?: { finalText?: string; error?: { code: string; message: string } },
3974
+ ) => {
3975
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3976
+ if (!submission || submission.terminal || submission.phase !== "active") return;
3977
+ submission.phase = "outcome_claimed";
3978
+ let winner: SdkPromptTerminalOutcome;
3979
+ try {
3980
+ winner = await kindReconciliation.claimPendingOutcome(correlation, requestedOutcome);
3981
+ } catch (error) {
3982
+ // The claim is the durability boundary: without it nothing may be published
3983
+ // and the endpoint must fail closed so the client rejects exactly once. The
3984
+ // last durable record stays active for restart recovery.
3985
+ logger.warn(`sdk: prompt claim persistence failed: ${String(error)}`);
3986
+ failPromptClosed(correlation, submission, "terminal_uncertain", "Prompt reconciliation is unavailable.");
3987
+ return;
3988
+ }
3989
+ submission.outcome = winner;
3990
+ submission.phase = "terminalizing";
3991
+ if (options.fence) {
3992
+ const seam = ctx as typeof ctx & {
3993
+ abortPromptAndWait?: (
3994
+ handle: string,
3995
+ options: { graceMs: number },
3996
+ ) => Promise<{ status: "settled" } | { status: "unfenced" }>;
3997
+ };
3998
+ // Only the handle captured for this correlation may be fenced; a later run
3999
+ // must never be aborted by an older prompt's cleanup.
4000
+ if (typeof seam.abortPromptAndWait !== "function" || !submission.executionHandle) {
4001
+ failPromptClosed(
4002
+ correlation,
4003
+ submission,
4004
+ "terminal_uncertain",
4005
+ "Prompt resources could not be fenced with an exact run handle.",
4006
+ );
4007
+ return;
4008
+ }
4009
+ let proof: { status: "settled" } | { status: "unfenced" };
4010
+ try {
4011
+ proof = await seam.abortPromptAndWait(submission.executionHandle, {
4012
+ graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
4013
+ });
4014
+ } catch (error) {
4015
+ logger.warn(`sdk: prompt resource fencing failed: ${String(error)}`);
4016
+ failPromptClosed(
4017
+ correlation,
4018
+ submission,
4019
+ "terminal_uncertain",
4020
+ "Prompt resources could not be settled before terminalization.",
4021
+ );
4022
+ return;
4023
+ }
4024
+ if (proof?.status !== "settled") {
4025
+ // No settlement proof: never publish a normal terminal. The durable
4026
+ // pending claim stays active so restart recovery finalizes it.
4027
+ failPromptClosed(
4028
+ correlation,
4029
+ submission,
4030
+ "terminal_uncertain",
4031
+ "Prompt resources did not settle before the terminalization grace expired.",
4032
+ );
4033
+ return;
4034
+ }
4035
+ }
4036
+ try {
4037
+ await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error);
4038
+ } catch (error) {
4039
+ // The durable pending claim survives; publishing an unpersisted terminal
4040
+ // would contradict it, so fail the endpoint closed instead.
4041
+ logger.warn(`sdk: prompt terminal persistence failed: ${String(error)}`);
4042
+ failPromptClosed(correlation, submission, "terminal_uncertain", "Prompt reconciliation is unavailable.");
4043
+ return;
4044
+ }
4045
+ if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
4046
+ if (!recordPromptTerminal(correlation) || !runtime) return;
4047
+ if (winner.kind === "failed") {
4048
+ emitPromptLifecycle(correlation, {
4049
+ type: "agent_failed",
4050
+ sessionId: runtime.id,
4051
+ ...correlation,
4052
+ error: extra?.error ?? { code: winner.code, message: winner.message },
4053
+ outcome: winner,
4054
+ });
4055
+ } else {
4056
+ emitPromptLifecycle(correlation, {
4057
+ type: "agent_end",
4058
+ sessionId: runtime.id,
4059
+ ...correlation,
4060
+ ...(extra?.finalText ? { finalText: extra.finalText } : {}),
4061
+ outcome: winner,
4062
+ });
4063
+ }
4064
+ };
3598
4065
  const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
3599
4066
  const sanitized = sanitizePromptFailure(error);
3600
- void kindReconciliation.noteTransition("prompt", correlation, { type: "agent_failed", error: sanitized });
3601
- const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3602
- if (!submission || !runtime || !recordPromptTerminal(correlation)) return;
3603
- emitPromptLifecycle(correlation, {
3604
- type: "agent_failed",
3605
- sessionId: runtime.id,
3606
- ...correlation,
3607
- error: sanitized,
3608
- });
4067
+ void terminalizePrompt(
4068
+ correlation,
4069
+ {
4070
+ kind: "failed",
4071
+ code: "prompt_failed",
4072
+ message: sanitized.message,
4073
+ provenance: "agent_failed",
4074
+ },
4075
+ {},
4076
+ // The normalized outcome is the ACP contract; the wire error keeps the
4077
+ // existing sanitized discriminator so SDK clients keep branching on it.
4078
+ { error: sanitized },
4079
+ );
3609
4080
  };
3610
4081
  const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
3611
4082
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
@@ -3662,13 +4133,29 @@ export function createNotificationsExtension(
3662
4133
  () => runtime?.busy === true || pendingPromptCorrelations.length > 0,
3663
4134
  recordPromptAccepted,
3664
4135
  recordPromptFailure,
4136
+ discardPromptAcceptance,
3665
4137
  () => runtime?.stopping !== true,
3666
4138
  trackGateResolution,
3667
4139
  admitPromptSubmission,
3668
4140
  releasePromptAdmission,
4141
+ () => reconciliationReady,
3669
4142
  settings,
3670
4143
  configOverrides,
3671
4144
  configRevision,
4145
+ async connectionId => {
4146
+ const active = [...promptSubmissions.entries()].find(
4147
+ ([, submission]) => submission.connectionId === connectionId && !submission.terminal,
4148
+ );
4149
+ if (!active) return { aborted: true, disposition: "idle" as const };
4150
+ const [commandId, turnId] = active[0].split(":", 2);
4151
+ if (commandId && turnId)
4152
+ await terminalizePrompt(
4153
+ { commandId, turnId },
4154
+ { kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
4155
+ { fence: true },
4156
+ );
4157
+ return { aborted: true, disposition: "cancelled" as const };
4158
+ },
3672
4159
  {
3673
4160
  admit: (clientRef?: string) => kindReconciliation.admit("skill", clientRef),
3674
4161
  release: (clientRef?: string) => kindReconciliation.releaseAdmission("skill", clientRef),
@@ -3678,6 +4165,7 @@ export function createNotificationsExtension(
3678
4165
  lookup: selector => kindReconciliation.lookup("skill", selector),
3679
4166
  },
3680
4167
  );
4168
+ cancelPreflightsForConnection = controlSurface.cancelPendingPreflightsForConnection;
3681
4169
  const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
3682
4170
  if (
3683
4171
  frame.type !== "control_response" ||
@@ -3951,6 +4439,9 @@ export function createNotificationsExtension(
3951
4439
  busy: false,
3952
4440
  pendingPromptCorrelations,
3953
4441
  activePromptCorrelation: undefined,
4442
+ bindPromptExecutionHandle,
4443
+ peekPromptPendingOutcome: correlation => kindReconciliation.peekPendingOutcome(correlation),
4444
+ terminalizePrompt: (correlation, outcome, extra) => terminalizePrompt(correlation, outcome, {}, extra),
3954
4445
  recordPromptTerminal,
3955
4446
  notePromptReconciliation: (correlation, frame) => {
3956
4447
  void kindReconciliation.noteTransition("prompt", correlation, frame);
@@ -4013,6 +4504,23 @@ export function createNotificationsExtension(
4013
4504
  });
4014
4505
  initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
4015
4506
  initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
4507
+ const sendEndpointStale = (connectionId: string, frame: Record<string, unknown>) => {
4508
+ const id = typeof frame.id === "string" ? frame.id : undefined;
4509
+ if (!id) return;
4510
+ const error = { code: "endpoint_stale", message: "session endpoint is stale." };
4511
+ const responseType =
4512
+ frame.type === "control_request"
4513
+ ? "control_response"
4514
+ : frame.type === "query_request"
4515
+ ? "query_response"
4516
+ : frame.type === "global_request"
4517
+ ? "global_response"
4518
+ : undefined;
4519
+ if (!responseType) return;
4520
+ try {
4521
+ server.sendTo(connectionId, JSON.stringify({ type: responseType, id, ok: false, error }));
4522
+ } catch {}
4523
+ };
4016
4524
  try {
4017
4525
  server.onSdkFrame((err, inbound) => {
4018
4526
  if (err || !inbound) return;
@@ -4020,20 +4528,12 @@ export function createNotificationsExtension(
4020
4528
  const frame = JSON.parse(inbound.json) as unknown;
4021
4529
  if (!frame || typeof frame !== "object") return;
4022
4530
  const typedFrame = frame as Record<string, unknown>;
4531
+ if (inbound.connectionId && fencedConnections.has(inbound.connectionId)) {
4532
+ sendEndpointStale(inbound.connectionId, typedFrame);
4533
+ return;
4534
+ }
4023
4535
  if (initializedRuntime.inboundFenced) {
4024
- if (typedFrame.type === "control_request" && typeof typedFrame.id === "string") {
4025
- try {
4026
- server.sendTo(
4027
- inbound.connectionId,
4028
- JSON.stringify({
4029
- type: "control_response",
4030
- id: typedFrame.id,
4031
- ok: false,
4032
- error: { code: "endpoint_stale", message: "session endpoint is draining." },
4033
- }),
4034
- );
4035
- } catch {}
4036
- }
4536
+ sendEndpointStale(inbound.connectionId, typedFrame);
4037
4537
  return;
4038
4538
  }
4039
4539
  if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return;
@@ -4062,8 +4562,19 @@ export function createNotificationsExtension(
4062
4562
  });
4063
4563
  server.onConnectionClose((_err, connectionId) => {
4064
4564
  if (!connectionId) return;
4565
+ controlSurface.cancelPendingPreflightsForConnection(connectionId);
4065
4566
  host.handleDisconnect(connectionId);
4066
4567
  hostCapCache.delete(connectionId);
4568
+ // The socket is gone, so its fence has nothing left to refuse. Dropping the
4569
+ // entry keeps the set bounded by live connections instead of growing forever.
4570
+ fencedConnections.delete(connectionId);
4571
+ // Deliberate deviation from the plan's "claim prompt_failed on old-owner
4572
+ // disconnect": that would break the shipped Q26 reconnect contract, where a
4573
+ // client may drop its socket and reconcile the still-running prompt without
4574
+ // duplicate execution (see test/sdk-host-wiring.test.ts "turn.prompt_status
4575
+ // reconciles an accepted prompt across client reconnect"). A closed socket
4576
+ // therefore only abandons delivery; ACP rejects its own local waiter once and
4577
+ // terminal authority stays with the eventual normalized SDK outcome.
4067
4578
  for (const submission of promptSubmissions.values())
4068
4579
  if (submission.connectionId === connectionId) abandonPrompt(submission);
4069
4580
  });
@@ -4232,7 +4743,7 @@ export function createNotificationsExtension(
4232
4743
  } else if (presentation?.multi && typeof rawAnswer === "string") {
4233
4744
  answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
4234
4745
  } else {
4235
- const mapped = mapAnswerToGate(reply.answerJson, gateOptions.get(gateId) ?? []);
4746
+ const mapped = mapAnswerToGate(reply.answerJson, presentation?.options ?? []);
4236
4747
  if (!mapped.ok) {
4237
4748
  // A numeric selector outside options is invalid (issue #2030): close the
4238
4749
  // exact claim/receipt and reissue the interaction — never a success ack.
@@ -4585,16 +5096,28 @@ export function createNotificationsExtension(
4585
5096
  if (!canDeliverAsync(runtime, generation)) {
4586
5097
  return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4587
5098
  }
4588
- pushFileAttachment(
4589
- runtime,
4590
- {
4591
- type: "file_attachment",
5099
+ if (file.mime?.startsWith("image/")) {
5100
+ pushSessionFrame(runtime, {
5101
+ type: "image_attachment",
4592
5102
  sessionId: runtime.id,
4593
- name: path.basename(file.path),
5103
+ source: "telegram_send",
5104
+ mime: file.mime,
4594
5105
  caption: file.caption,
4595
- },
4596
- data,
4597
- );
5106
+ data: data.toString("base64"),
5107
+ });
5108
+ } else {
5109
+ pushFileAttachment(
5110
+ runtime,
5111
+ {
5112
+ type: "file_attachment",
5113
+ sessionId: runtime.id,
5114
+ name: path.basename(file.path),
5115
+ mime: file.mime,
5116
+ caption: file.caption,
5117
+ },
5118
+ data,
5119
+ );
5120
+ }
4598
5121
  return { ok: true };
4599
5122
  } catch (e) {
4600
5123
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
@@ -4628,7 +5151,6 @@ export function createNotificationsExtension(
4628
5151
  activeRuntime.disposeGateTerminalController = () => {};
4629
5152
  activeRuntime.disposeAckRecoveryParticipant = () => {};
4630
5153
  activeRuntime.workflowGate = undefined;
4631
- gateOptions.clear();
4632
5154
  if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGateFromNotification !== "function") {
4633
5155
  return;
4634
5156
  }
@@ -4653,7 +5175,6 @@ export function createNotificationsExtension(
4653
5175
  const rawGateOptions = g.options ?? [];
4654
5176
  const options = rawGateOptions.map(o => String((o as { label?: unknown }).label ?? ""));
4655
5177
  const recommendedIndex = recommendedIndexFromGateOptions(rawGateOptions);
4656
- gateOptions.set(g.gate_id, options);
4657
5178
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
4658
5179
  const question =
4659
5180
  (typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
@@ -5169,6 +5690,7 @@ export function createNotificationsExtension(
5169
5690
  rt.busy = true;
5170
5691
  const correlation = rt.pendingPromptCorrelations.shift();
5171
5692
  rt.activePromptCorrelation = correlation;
5693
+ if (correlation) rt.bindPromptExecutionHandle(correlation, ctx.getActivePromptHandle());
5172
5694
  rt.notePromptReconciliation(correlation, { type: "agent_start" });
5173
5695
  rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
5174
5696
  try {
@@ -5214,15 +5736,45 @@ export function createNotificationsExtension(
5214
5736
  rt.busy = false;
5215
5737
  const correlation = rt.activePromptCorrelation;
5216
5738
  if (correlation) {
5217
- const terminalAssistant = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).find(
5739
+ const assistants = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).filter(
5740
+ message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant",
5741
+ ) as Array<{ stopReason?: unknown; errorKind?: unknown }>;
5742
+ const finalAssistant = assistants[0];
5743
+ const pendingOutcome = rt.peekPromptPendingOutcome(correlation);
5744
+ let outcome: SdkPromptTerminalOutcome;
5745
+ if (pendingOutcome) outcome = pendingOutcome;
5746
+ else if (finalAssistant?.stopReason === "length")
5747
+ outcome = { kind: "stopped", reason: "max_tokens", provenance: "agent" };
5748
+ else if (finalAssistant?.errorKind === "provider_safety_stop")
5749
+ outcome = { kind: "stopped", reason: "refusal", provenance: "agent" };
5750
+ // A missing/normal final assistant with a non-failing loop stop reason is a
5751
+ // normal turn end; only explicit error/aborted assistants or non-completed
5752
+ // loop stop reasons are failures. Text is never parsed.
5753
+ else if (
5754
+ (event.stopReason === undefined || event.stopReason === "completed") &&
5755
+ finalAssistant?.stopReason !== "error" &&
5756
+ finalAssistant?.stopReason !== "aborted"
5757
+ )
5758
+ outcome = { kind: "stopped", reason: "end_turn", provenance: "agent" };
5759
+ else
5760
+ outcome = {
5761
+ kind: "failed",
5762
+ code: "prompt_failed",
5763
+ message: "Prompt submission failed.",
5764
+ provenance: "agent_failed",
5765
+ };
5766
+ const successAssistant = assistants.find(
5767
+ message => (message as { stopReason?: unknown }).stopReason === "stop",
5768
+ );
5769
+ const finalText = successAssistant ? acpFinalTextFromMessage(successAssistant).text : "";
5770
+ // The normalized outcome is the ACP contract; existing SDK clients keep the
5771
+ // legacy failure discriminator on the wire and in the reconciliation record.
5772
+ const terminalAssistant = assistants.find(
5218
5773
  message =>
5219
- message &&
5220
- typeof message === "object" &&
5221
- (message as { role?: unknown }).role === "assistant" &&
5222
- ((message as { stopReason?: unknown }).stopReason === "error" ||
5223
- (message as { stopReason?: unknown }).stopReason === "aborted"),
5774
+ (message as { stopReason?: unknown }).stopReason === "error" ||
5775
+ (message as { stopReason?: unknown }).stopReason === "aborted",
5224
5776
  ) as { stopReason?: "error" | "aborted"; errorMessage?: unknown } | undefined;
5225
- const failureCode =
5777
+ const legacyCode =
5226
5778
  event.stopReason === "cancelled"
5227
5779
  ? "cancelled"
5228
5780
  : terminalAssistant?.stopReason === "error"
@@ -5230,33 +5782,16 @@ export function createNotificationsExtension(
5230
5782
  : terminalAssistant?.stopReason === "aborted"
5231
5783
  ? "aborted"
5232
5784
  : undefined;
5233
- if (failureCode)
5234
- rt.emitPromptFailure(correlation, {
5235
- code: failureCode,
5236
- message:
5237
- typeof terminalAssistant?.errorMessage === "string"
5238
- ? terminalAssistant.errorMessage
5239
- : "Prompt submission failed.",
5240
- });
5241
- else {
5242
- rt.notePromptReconciliation(correlation, { type: "agent_end" });
5243
- if (rt.recordPromptTerminal(correlation)) {
5244
- const finalAssistant = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).find(
5245
- message =>
5246
- message &&
5247
- typeof message === "object" &&
5248
- (message as { role?: unknown }).role === "assistant" &&
5249
- (message as { stopReason?: unknown }).stopReason === "stop",
5250
- );
5251
- const finalText = finalAssistant ? acpFinalTextFromMessage(finalAssistant).text : "";
5252
- rt.emitPromptLifecycle(correlation, {
5253
- type: "agent_end",
5254
- sessionId: id,
5255
- ...correlation,
5256
- ...(finalText ? { finalText } : {}),
5257
- });
5258
- }
5259
- }
5785
+ void rt.terminalizePrompt(correlation, outcome, {
5786
+ ...(finalText ? { finalText } : {}),
5787
+ ...(outcome.kind === "failed" && legacyCode
5788
+ ? {
5789
+ // Only the legacy discriminator is preserved; provider text is never
5790
+ // retained, matching `sanitizePromptFailure`.
5791
+ error: { code: legacyCode, message: "Prompt submission failed." },
5792
+ }
5793
+ : {}),
5794
+ });
5260
5795
  } else {
5261
5796
  rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
5262
5797
  }