@sayknow-cli/coding-agent 0.3.12 → 0.3.15

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 (248) hide show
  1. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  2. package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
  3. package/dist/types/cli/classify-install-target.d.ts +0 -10
  4. package/dist/types/cli/daemon-cli.d.ts +2 -0
  5. package/dist/types/cli/initial-message.d.ts +1 -1
  6. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  7. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  8. package/dist/types/cli/notify-cli.d.ts +3 -1
  9. package/dist/types/cli/session-picker.d.ts +4 -3
  10. package/dist/types/cli/update-cli.d.ts +22 -3
  11. package/dist/types/cli.d.ts +5 -0
  12. package/dist/types/commands/daemon.d.ts +6 -2
  13. package/dist/types/commands/launch.d.ts +1 -0
  14. package/dist/types/commands/notify.d.ts +6 -0
  15. package/dist/types/commands/web-search.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
  18. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
  19. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
  20. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  21. package/dist/types/commit/analysis/summary.d.ts +1 -1
  22. package/dist/types/commit/changelog/generate.d.ts +3 -3
  23. package/dist/types/commit/changelog/index.d.ts +2 -2
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  26. package/dist/types/commit/shared-llm.d.ts +9 -9
  27. package/dist/types/config/keybindings.d.ts +134 -129
  28. package/dist/types/config/model-profile-activation.d.ts +1 -0
  29. package/dist/types/config/model-registry.d.ts +35 -35
  30. package/dist/types/config/models-config-schema.d.ts +111 -111
  31. package/dist/types/config/settings-schema.d.ts +56 -16
  32. package/dist/types/config/settings.d.ts +2 -0
  33. package/dist/types/coordinator/contract.d.ts +1 -1
  34. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  35. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  36. package/dist/types/coordinator-mcp/server.d.ts +79 -6
  37. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  38. package/dist/types/daemon/control-types.d.ts +10 -0
  39. package/dist/types/daemon/operator-contract.d.ts +47 -0
  40. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  41. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
  42. package/dist/types/discovery/helpers.d.ts +1 -1
  43. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  44. package/dist/types/edit/modes/patch.d.ts +2 -2
  45. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  46. package/dist/types/goals/tools/goal-tool.d.ts +6 -6
  47. package/dist/types/i18n/messages/en.d.ts +8 -8
  48. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  49. package/dist/types/lsp/index.d.ts +9 -9
  50. package/dist/types/lsp/types.d.ts +9 -9
  51. package/dist/types/main.d.ts +66 -2
  52. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  53. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  54. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  55. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  56. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  57. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  58. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  59. package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
  60. package/dist/types/modes/components/session-selector.d.ts +22 -9
  61. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  62. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  63. package/dist/types/modes/components/status-line.d.ts +0 -10
  64. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  65. package/dist/types/modes/components/user-message.d.ts +2 -1
  66. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  67. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  68. package/dist/types/modes/interactive-mode.d.ts +24 -2
  69. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  70. package/dist/types/modes/print-mode.d.ts +3 -4
  71. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  72. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  73. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  74. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  75. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  76. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  77. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  78. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  79. package/dist/types/modes/types.d.ts +28 -2
  80. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  81. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  82. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  83. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  84. package/dist/types/notifications/config.d.ts +34 -0
  85. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  86. package/dist/types/notifications/index.d.ts +27 -0
  87. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  88. package/dist/types/notifications/notification-service.d.ts +144 -0
  89. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  90. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  91. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  92. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  93. package/dist/types/notifications/topic-registry.d.ts +24 -4
  94. package/dist/types/registry/agent-registry.d.ts +2 -0
  95. package/dist/types/rlm/index.d.ts +1 -1
  96. package/dist/types/sdk.d.ts +2 -0
  97. package/dist/types/secrets/obfuscator.d.ts +1 -1
  98. package/dist/types/session/agent-session.d.ts +40 -3
  99. package/dist/types/session/cache-economics.d.ts +79 -0
  100. package/dist/types/session/context-estimation.d.ts +31 -0
  101. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  102. package/dist/types/session/session-manager.d.ts +124 -4
  103. package/dist/types/session/session-storage.d.ts +155 -13
  104. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  105. package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
  106. package/dist/types/skc-runtime/state-schema.d.ts +19 -19
  107. package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
  108. package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  109. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
  110. package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
  111. package/dist/types/task/agents.d.ts +2 -2
  112. package/dist/types/task/executor.d.ts +3 -0
  113. package/dist/types/task/receipt.d.ts +3 -1
  114. package/dist/types/task/types.d.ts +35 -27
  115. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  116. package/dist/types/tools/bash.d.ts +1 -1
  117. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  118. package/dist/types/tools/browser.d.ts +28 -28
  119. package/dist/types/tools/computer.d.ts +20 -20
  120. package/dist/types/tools/cron.d.ts +2 -2
  121. package/dist/types/tools/debug.d.ts +40 -40
  122. package/dist/types/tools/gh.d.ts +10 -10
  123. package/dist/types/tools/image-gen.d.ts +2 -2
  124. package/dist/types/tools/index.d.ts +58 -6
  125. package/dist/types/tools/monitor.d.ts +6 -6
  126. package/dist/types/tools/skill-discovery.d.ts +4 -4
  127. package/dist/types/tools/subagent.d.ts +12 -12
  128. package/dist/types/tools/telegram-send.d.ts +7 -0
  129. package/dist/types/tools/todo-write.d.ts +8 -8
  130. package/dist/types/utils/external-editor.d.ts +2 -0
  131. package/dist/types/web/insane/bridge.d.ts +9 -9
  132. package/dist/types/web/search/index.d.ts +4 -4
  133. package/package.json +8 -8
  134. package/scripts/g004-tmux-smoke.ts +196 -36
  135. package/src/cli/daemon-cli.ts +19 -29
  136. package/src/cli/malloc-env-guard.test.ts +104 -0
  137. package/src/cli/malloc-env-guard.ts +149 -0
  138. package/src/cli/notify-cli.ts +78 -14
  139. package/src/cli/session-picker.ts +23 -40
  140. package/src/cli/update-cli.ts +56 -37
  141. package/src/cli.ts +51 -10
  142. package/src/commands/daemon.ts +19 -5
  143. package/src/commands/harness.ts +515 -47
  144. package/src/commands/launch.ts +11 -3
  145. package/src/commands/notify.ts +5 -1
  146. package/src/commands/session.ts +6 -1
  147. package/src/config/keybindings.ts +5 -0
  148. package/src/config/model-profile-activation.ts +1 -1
  149. package/src/config/model-profiles.ts +14 -14
  150. package/src/config/settings-schema.ts +41 -1
  151. package/src/config/settings.ts +96 -45
  152. package/src/coordinator/contract.ts +1 -0
  153. package/src/coordinator-mcp/model-preset.ts +100 -0
  154. package/src/coordinator-mcp/policy.ts +30 -0
  155. package/src/coordinator-mcp/server.ts +2958 -430
  156. package/src/coordinator-mcp/session-reaper.ts +129 -0
  157. package/src/daemon/control-types.ts +11 -0
  158. package/src/daemon/operator-contract.ts +121 -0
  159. package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  160. package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  161. package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  162. package/src/extensibility/extensions/runner.ts +1 -1
  163. package/src/extensibility/extensions/types.ts +3 -1
  164. package/src/internal-urls/docs-index.generated.ts +10 -9
  165. package/src/internal-urls/local-protocol.ts +29 -10
  166. package/src/main.ts +301 -80
  167. package/src/modes/acp/acp-agent.ts +730 -100
  168. package/src/modes/acp/acp-mode.ts +29 -2
  169. package/src/modes/components/assistant-message.ts +32 -3
  170. package/src/modes/components/bash-execution.ts +43 -13
  171. package/src/modes/components/custom-editor.ts +5 -0
  172. package/src/modes/components/footer.ts +3 -3
  173. package/src/modes/components/hook-selector.ts +12 -0
  174. package/src/modes/components/irc-sidebar.ts +206 -0
  175. package/src/modes/components/pet-selector.ts +55 -0
  176. package/src/modes/components/sayknow-pet-widget.ts +360 -0
  177. package/src/modes/components/session-selector.ts +222 -221
  178. package/src/modes/components/settings-selector.ts +10 -0
  179. package/src/modes/components/status-line/segments.ts +14 -7
  180. package/src/modes/components/status-line/types.ts +1 -1
  181. package/src/modes/components/status-line.ts +4 -171
  182. package/src/modes/components/tool-execution.ts +47 -6
  183. package/src/modes/components/user-message.ts +26 -9
  184. package/src/modes/controllers/command-controller.ts +28 -2
  185. package/src/modes/controllers/event-controller.ts +159 -36
  186. package/src/modes/controllers/extension-ui-controller.ts +37 -18
  187. package/src/modes/controllers/input-controller.ts +8 -2
  188. package/src/modes/controllers/selector-controller.ts +62 -15
  189. package/src/modes/interactive-mode.ts +199 -7
  190. package/src/modes/irc-observation-ledger.ts +127 -0
  191. package/src/modes/print-mode.ts +228 -92
  192. package/src/modes/rpc/rpc-client.ts +58 -1
  193. package/src/modes/rpc/rpc-mode.ts +286 -85
  194. package/src/modes/rpc/rpc-types.ts +15 -1
  195. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  196. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  197. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  198. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  199. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  200. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  201. package/src/modes/types.ts +34 -2
  202. package/src/modes/utils/context-usage.ts +56 -92
  203. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  204. package/src/modes/utils/irc-message.ts +138 -0
  205. package/src/modes/utils/ui-helpers.ts +216 -50
  206. package/src/notifications/config.ts +44 -0
  207. package/src/notifications/daemon-paths.ts +8 -0
  208. package/src/notifications/index.ts +535 -75
  209. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  210. package/src/notifications/notification-service.ts +710 -0
  211. package/src/notifications/rate-limit-pool.ts +46 -12
  212. package/src/notifications/telegram-daemon-control.ts +27 -2
  213. package/src/notifications/telegram-daemon.ts +635 -94
  214. package/src/notifications/telegram-reference.ts +73 -13
  215. package/src/notifications/topic-registry.ts +86 -9
  216. package/src/prompts/system/irc-peer-roster.md +1 -0
  217. package/src/prompts/system/subagent-system-prompt.md +1 -0
  218. package/src/prompts/system/system-prompt.md +1 -0
  219. package/src/prompts/tools/irc.md +1 -1
  220. package/src/registry/agent-registry.ts +3 -0
  221. package/src/sdk.ts +52 -1
  222. package/src/secrets/obfuscator.ts +4 -2
  223. package/src/session/agent-session.ts +670 -217
  224. package/src/session/cache-economics.ts +275 -0
  225. package/src/session/context-estimation.ts +91 -0
  226. package/src/session/provider-safety-stop.ts +9 -0
  227. package/src/session/session-dump-format.ts +6 -0
  228. package/src/session/session-manager.ts +809 -39
  229. package/src/session/session-storage.ts +582 -38
  230. package/src/skc-runtime/session-state-sidecar.ts +756 -135
  231. package/src/skc-runtime/tmux-common.ts +26 -7
  232. package/src/skc-runtime/tmux-gc.ts +52 -9
  233. package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
  234. package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
  235. package/src/skc-runtime/tmux-sessions.ts +871 -68
  236. package/src/slash-commands/builtin-registry.ts +153 -9
  237. package/src/slash-commands/helpers/context-report.ts +26 -4
  238. package/src/task/executor.ts +12 -3
  239. package/src/task/index.ts +8 -0
  240. package/src/task/receipt.ts +7 -2
  241. package/src/task/types.ts +26 -0
  242. package/src/tools/ask-answer-registry.ts +25 -0
  243. package/src/tools/browser/tab-supervisor.ts +63 -6
  244. package/src/tools/browser.ts +8 -3
  245. package/src/tools/index.ts +81 -6
  246. package/src/tools/sqlite-reader.ts +8 -4
  247. package/src/tui/output-block.ts +18 -6
  248. package/src/utils/external-editor.ts +6 -1
@@ -10,6 +10,8 @@ import {
10
10
  type CloseSessionRequest,
11
11
  type CloseSessionResponse,
12
12
  type CreateElicitationResponse,
13
+ type DeleteSessionRequest,
14
+ type DeleteSessionResponse,
13
15
  type ElicitationContentValue,
14
16
  type ElicitationPropertySchema,
15
17
  type ForkSessionRequest,
@@ -30,14 +32,11 @@ import {
30
32
  type ResumeSessionResponse,
31
33
  type SessionConfigOption,
32
34
  type SessionInfo,
33
- type SessionModelState,
34
35
  type SessionModeState,
35
36
  type SessionNotification,
36
37
  type SessionUpdate,
37
38
  type SetSessionConfigOptionRequest,
38
39
  type SetSessionConfigOptionResponse,
39
- type SetSessionModelRequest,
40
- type SetSessionModelResponse,
41
40
  type SetSessionModeRequest,
42
41
  type SetSessionModeResponse,
43
42
  type Usage,
@@ -66,11 +65,19 @@ import { MCPManager } from "../../runtime-mcp/manager";
66
65
  import type { MCPServerConfig } from "../../runtime-mcp/types";
67
66
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
68
67
  import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
68
+ import { isLegacyProviderSafetyStopMessage } from "../../session/provider-safety-stop";
69
69
  import {
70
70
  SessionManager,
71
71
  type SessionInfo as StoredSessionInfo,
72
+ type StrictInventoryCandidate,
72
73
  type UsageStatistics,
73
74
  } from "../../session/session-manager";
75
+ import {
76
+ FileSessionStorage,
77
+ type SessionStorageFileIdentity,
78
+ type VerifiedSessionDeleteResult,
79
+ type VerifiedSessionDeleteTarget,
80
+ } from "../../session/session-storage";
74
81
  import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
75
82
  import { parseThinkingLevel } from "../../thinking";
76
83
  import { toAgentWireEventPayload } from "../shared/agent-wire/event-envelope";
@@ -90,6 +97,36 @@ const MODEL_CONFIG_ID = "model";
90
97
  const THINKING_CONFIG_ID = "thinking";
91
98
  const THINKING_OFF = "off";
92
99
  const SESSION_PAGE_SIZE = 50;
100
+
101
+ /**
102
+ * One immutable authorization snapshot built from a complete strict scoped inventory.
103
+ * `entries` maps every discovered session id to either the single exact-identity
104
+ * candidate (authoritative) or a conflict tombstone (duplicate/unsafe). The snapshot
105
+ * is built once, before pagination, so a duplicate beyond page 1 is known before the
106
+ * first page response. Lifecycle changes bump {@link #authorityGeneration} and
107
+ * invalidate the snapshot.
108
+ */
109
+ type AcpAuthorityEntry =
110
+ | { kind: "candidate"; candidate: StrictInventoryCandidate }
111
+ | { kind: "conflict"; reason: string };
112
+ /**
113
+ * Phase-aware same-connection retry evidence for an incomplete verified delete.
114
+ * The transcript identity is always recorded and re-verified on retry; the
115
+ * artifacts identity is recorded only when the failure occurred at the
116
+ * artifacts phase (the artifact directory still existed at failure time).
117
+ */
118
+ type PendingDeleteEvidence = {
119
+ transcriptIdentity: SessionStorageFileIdentity;
120
+ artifactsIdentity?: SessionStorageFileIdentity;
121
+ };
122
+
123
+ type AcpAuthoritySnapshot = {
124
+ scope: string;
125
+ entries: Map<string, AcpAuthorityEntry>;
126
+ /** Ordered unique session ids (conflicts appear once at first sight) for paging. */
127
+ orderedIds: string[];
128
+ generation: number;
129
+ };
93
130
  /**
94
131
  * Delay between `session/new` (or `session/load` / `session/resume` /
95
132
  * `unstable_session/fork`) returning and the agent firing the first
@@ -143,6 +180,28 @@ function isPromptTurnInFlight(turn: PromptTurnState | undefined): turn is Prompt
143
180
  return turn !== undefined && (!turn.settled || turn.cleanup !== undefined);
144
181
  }
145
182
 
183
+ /**
184
+ * Per-record terminal state machine for session deletion.
185
+ * - `active`: normal operation; all lifecycle methods allowed.
186
+ * - `deleting`: a terminal delete has reserved the record; prompt/config/close/
187
+ * load/resume/fork reject, concurrent deletes join the terminal promise.
188
+ * - `pre_dispatch_retryable`: close_failed_retryable before verified-delete
189
+ * dispatch; normal ops rejected but a subsequent delete may retry.
190
+ * - `cleanup_pending`: verified delete returned cleanup_pending with identity
191
+ * evidence; normal ops rejected but a subsequent delete may retry.
192
+ * - `terminal_failure`: close_unknown or quiescence failure (or any unexpected
193
+ * failure); normal ops rejected and delete NEVER reopens — subsequent deletes
194
+ * share the settled terminal failure error.
195
+ * - `deleted`: verified delete succeeded; record removed from the active map.
196
+ */
197
+ type AcpSessionTerminalState =
198
+ | "active"
199
+ | "deleting"
200
+ | "pre_dispatch_retryable"
201
+ | "cleanup_pending"
202
+ | "terminal_failure"
203
+ | "deleted";
204
+
146
205
  type ManagedSessionRecord = {
147
206
  session: AgentSession;
148
207
  mcpManager: MCPManager | undefined;
@@ -155,6 +214,17 @@ type ManagedSessionRecord = {
155
214
  // Installed after the bootstrap race guard or eagerly when the client starts a prompt;
156
215
  // released in `#disposeSessionRecord`. Lives independent of any prompt turn.
157
216
  lifetimeUnsubscribe: (() => void) | undefined;
217
+ // Per-record terminal state machine (see AcpSessionTerminalState). After ANY
218
+ // delete failure the record stays non-`active` so prompt/config/close/load/
219
+ // resume/fork remain rejected. Only `pre_dispatch_retryable` and
220
+ // `cleanup_pending` permit a retry delete; `terminal_failure` never reopens.
221
+ terminalState: AcpSessionTerminalState;
222
+ // The settled terminal failure error, shared with subsequent delete callers
223
+ // so a terminal_failure is observable without re-running the terminal op.
224
+ terminalFailure: Error | undefined;
225
+ // Resolves when an in-progress terminal delete settles. Concurrent delete
226
+ // callers and abort/shutdown join this promise.
227
+ terminalPromise: Promise<DeleteSessionResponse> | undefined;
158
228
  };
159
229
 
160
230
  type ReplayableMessage = {
@@ -276,10 +346,24 @@ async function elicitFromAcpClient(
276
346
  finish(undefined);
277
347
  });
278
348
  const response = await promise;
279
- if (response?.action !== "accept" || !response.content) {
349
+ if (
350
+ response?.action !== "accept" ||
351
+ typeof response.content !== "object" ||
352
+ response.content === null ||
353
+ !("value" in response.content)
354
+ ) {
280
355
  return undefined;
281
356
  }
282
- return response.content.value;
357
+ const value = response.content.value;
358
+ if (
359
+ typeof value === "string" ||
360
+ typeof value === "number" ||
361
+ typeof value === "boolean" ||
362
+ (Array.isArray(value) && value.every(item => typeof item === "string"))
363
+ ) {
364
+ return value;
365
+ }
366
+ return undefined;
283
367
  }
284
368
 
285
369
  /**
@@ -381,6 +465,23 @@ export class AcpAgent implements Agent {
381
465
  #cleanupRegistered = false;
382
466
  #clientCapabilities: ClientCapabilities | undefined;
383
467
  #cancelCleanupTimeoutMs = ACP_CANCEL_CLEANUP_TIMEOUT_MS;
468
+ /** Immutable canonical cwd locked by the first successful explicit-cwd lifecycle/list call. */
469
+ #canonicalCwdScope: string | undefined;
470
+ /** Authority snapshot from the last complete strict scoped inventory. Invalidation bumps this. */
471
+ #authorityGeneration = 0;
472
+ #authoritySnapshot: AcpAuthoritySnapshot | undefined;
473
+ /**
474
+ * Same-connection retry evidence for an incomplete verified delete. Keyed by
475
+ * session id. Phase-aware: every cleanup_pending carries the transcript
476
+ * identity at failure time, which is re-verified against the fresh candidate
477
+ * on retry so a replaced transcript cannot authorize a replacement; the
478
+ * artifacts-phase result additionally carries the artifact directory
479
+ * identity to re-accept. Cleared once a delete reaches `kind: "deleted"`.
480
+ */
481
+ #pendingDeleteEvidence = new Map<string, PendingDeleteEvidence>();
482
+ #shutdownPromise: Promise<void> | undefined;
483
+ #lifecycleOperations = new Set<Promise<unknown>>();
484
+ #shuttingDown = false;
384
485
 
385
486
  constructor(connection: AgentSideConnection, createSession: CreateAcpSession, initialSession?: AgentSession) {
386
487
  this.#connection = connection;
@@ -434,6 +535,7 @@ export class AcpAgent implements Agent {
434
535
  fork: {},
435
536
  resume: {},
436
537
  close: {},
538
+ delete: {},
437
539
  },
438
540
  },
439
541
  };
@@ -452,12 +554,17 @@ export class AcpAgent implements Agent {
452
554
  }
453
555
 
454
556
  async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
455
- this.#assertAbsoluteCwd(params.cwd);
456
- const record = await this.#createNewSessionRecord(params.cwd, params.mcpServers);
557
+ this.#rejectIfShuttingDown();
558
+ // Stage scope before the lifecycle op; commit only after it succeeds so a
559
+ // failed first call does not pin the connection to a cwd it never served.
560
+ const canonical = this.#stageCanonicalScope(params.cwd);
561
+ const record = await this.#trackLifecycle(() => this.#createNewSessionRecord(params.cwd, params.mcpServers));
562
+ this.#rejectIfShuttingDown();
563
+ this.#commitCanonicalScope(canonical);
564
+ this.#invalidateAuthority();
457
565
  const response: NewSessionResponse = {
458
566
  sessionId: record.session.sessionId,
459
567
  configOptions: this.#buildConfigOptions(record.session),
460
- models: this.#buildModelState(record.session),
461
568
  modes: this.#buildModeState(record.session),
462
569
  };
463
570
  this.#scheduleBootstrapUpdates(record.session.sessionId);
@@ -465,41 +572,103 @@ export class AcpAgent implements Agent {
465
572
  }
466
573
 
467
574
  async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
468
- this.#assertAbsoluteCwd(params.cwd);
469
- const record = await this.#loadManagedSession(params.sessionId, params.cwd, params.mcpServers);
470
- await this.#replaySessionHistory(record);
471
- const response: LoadSessionResponse = {
472
- configOptions: this.#buildConfigOptions(record.session),
473
- models: this.#buildModelState(record.session),
474
- modes: this.#buildModeState(record.session),
475
- };
476
- this.#scheduleBootstrapUpdates(record.session.sessionId);
477
- return response;
575
+ this.#rejectIfShuttingDown();
576
+ const canonical = this.#stageCanonicalScope(params.cwd);
577
+ const existingRecord = this.#sessions.get(params.sessionId);
578
+ const record = await this.#trackLifecycle(() =>
579
+ this.#loadManagedSession(params.sessionId, params.cwd, params.mcpServers),
580
+ );
581
+ try {
582
+ this.#rejectIfShuttingDown();
583
+ await this.#replaySessionHistory(record);
584
+ this.#rejectIfShuttingDown();
585
+ // Construct every fallible response/bootstrap value inside the
586
+ // transaction so a build/schedule failure rolls back the prepared
587
+ // record and never pins the connection.
588
+ const response: LoadSessionResponse = {
589
+ configOptions: this.#buildConfigOptions(record.session),
590
+ modes: this.#buildModeState(record.session),
591
+ };
592
+ this.#scheduleBootstrapUpdates(record.session.sessionId);
593
+ // Commit the cwd scope only after replay, response construction, and
594
+ // bootstrap scheduling all succeed — a failed first load leaves no
595
+ // half-loaded session and does not pin the connection.
596
+ this.#commitCanonicalScope(canonical);
597
+ this.#invalidateAuthority();
598
+ return response;
599
+ } catch (error) {
600
+ // Any pre-return failure: roll back the prepared record so a failed
601
+ // first load leaves no half-loaded session and does not pin the
602
+ // connection, and commit no cwd scope.
603
+ if (record !== existingRecord) {
604
+ this.#sessions.delete(record.session.sessionId);
605
+ await this.#disposeSessionRecord(record);
606
+ }
607
+ throw error;
608
+ }
478
609
  }
479
610
 
480
611
  async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
481
- if (params.cwd) {
482
- this.#assertAbsoluteCwd(params.cwd);
483
- }
612
+ this.#rejectIfShuttingDown();
613
+ const scope = params.cwd ? this.#stageCanonicalScope(params.cwd) : undefined;
484
614
  for (const record of this.#sessions.values()) {
485
615
  await record.session.sessionManager.flush();
486
616
  }
487
- const sessions = await this.#listStoredSessions(params.cwd ?? undefined);
488
- const offset = this.#parseCursor(params.cursor ?? undefined);
489
- const paged = sessions.slice(offset, offset + SESSION_PAGE_SIZE);
490
- const nextOffset = offset + paged.length;
491
- return {
492
- sessions: paged.map(session => this.#toSessionInfo(session)),
493
- nextCursor: nextOffset < sessions.length ? String(nextOffset) : undefined,
617
+ this.#rejectIfShuttingDown();
618
+ // Cwd-less / global listing stays display-only and non-authorizing.
619
+ if (!scope) {
620
+ const sessions = await this.#listStoredSessions(undefined);
621
+ const offset = this.#parseCursor(params.cursor ?? undefined);
622
+ const paged = sessions.slice(offset, offset + SESSION_PAGE_SIZE);
623
+ const nextOffset = offset + paged.length;
624
+ return {
625
+ sessions: paged.map(session => this.#toSessionInfo(session)),
626
+ nextCursor: nextOffset < sessions.length ? String(nextOffset) : undefined,
627
+ };
628
+ }
629
+ // Explicit scoped list: stage scope, strict raw inventory, build the full
630
+ // authorization/conflict snapshot BEFORE page slicing so duplicate ids
631
+ // beyond page 1 are known before the first page response. Scope commits
632
+ // only after cursor validation, stored-session read, and full response
633
+ // construction succeed — a failed first list never pins the connection.
634
+ if (params.cursor === undefined) {
635
+ // A new first-page request is a new issuance event. Refresh the complete
636
+ // snapshot and invalidate cursors minted by the previous issuance.
637
+ this.#invalidateAuthority();
638
+ }
639
+ const snapshot = this.#buildAuthoritySnapshot(scope);
640
+ // Scoped cursors carry the authority generation at which they were minted
641
+ // (form `${generation}:${offset}`). A lifecycle change between page 1 and
642
+ // page 2 bumps the generation, so the old cursor is rejected instead of
643
+ // silently rebuilding at the new generation. The cwd-less display cursor
644
+ // stays a plain offset (see #parseCursor).
645
+ const offset = this.#parseScopedCursor(params.cursor ?? undefined, snapshot.generation);
646
+ const stored = await this.#listStoredSessions(scope);
647
+ const storedById = new Map(stored.map(s => [s.id, s] as const));
648
+ const pageIds = snapshot.orderedIds.slice(offset, offset + SESSION_PAGE_SIZE);
649
+ const nextOffset = offset + pageIds.length;
650
+ const pageSessions = pageIds.map(id => storedById.get(id)).filter((s): s is StoredSessionInfo => s !== undefined);
651
+ const response: ListSessionsResponse = {
652
+ sessions: pageSessions.map(session => this.#toSessionInfo(session)),
653
+ nextCursor: nextOffset < snapshot.orderedIds.length ? `${snapshot.generation}:${nextOffset}` : undefined,
494
654
  };
655
+ // Commit the scope only after cursor validation, stored-session read, and
656
+ // full response construction succeed.
657
+ this.#commitCanonicalScope(scope);
658
+ return response;
495
659
  }
496
660
 
497
661
  async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
498
- this.#assertAbsoluteCwd(params.cwd);
499
- const record = await this.#resumeManagedSession(params.sessionId, params.cwd, params.mcpServers ?? []);
662
+ this.#rejectIfShuttingDown();
663
+ const canonical = this.#stageCanonicalScope(params.cwd);
664
+ const record = await this.#trackLifecycle(() =>
665
+ this.#resumeManagedSession(params.sessionId, params.cwd, params.mcpServers ?? []),
666
+ );
667
+ this.#rejectIfShuttingDown();
668
+ this.#commitCanonicalScope(canonical);
669
+ this.#invalidateAuthority();
500
670
  const response: ResumeSessionResponse = {
501
671
  configOptions: this.#buildConfigOptions(record.session),
502
- models: this.#buildModelState(record.session),
503
672
  modes: this.#buildModeState(record.session),
504
673
  };
505
674
  this.#scheduleBootstrapUpdates(record.session.sessionId);
@@ -507,12 +676,15 @@ export class AcpAgent implements Agent {
507
676
  }
508
677
 
509
678
  async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
510
- this.#assertAbsoluteCwd(params.cwd);
511
- const record = await this.#forkManagedSession(params);
679
+ this.#rejectIfShuttingDown();
680
+ const canonical = this.#stageCanonicalScope(params.cwd);
681
+ const record = await this.#trackLifecycle(() => this.#forkManagedSession(params));
682
+ this.#rejectIfShuttingDown();
683
+ this.#commitCanonicalScope(canonical);
684
+ this.#invalidateAuthority();
512
685
  const response: ForkSessionResponse = {
513
686
  sessionId: record.session.sessionId,
514
687
  configOptions: this.#buildConfigOptions(record.session),
515
- models: this.#buildModelState(record.session),
516
688
  modes: this.#buildModeState(record.session),
517
689
  };
518
690
  this.#scheduleBootstrapUpdates(record.session.sessionId);
@@ -524,12 +696,42 @@ export class AcpAgent implements Agent {
524
696
  if (!record) {
525
697
  return {};
526
698
  }
699
+ this.#rejectIfTerminal(record);
527
700
  await this.#closeManagedSession(params.sessionId, record);
701
+ this.#invalidateAuthority();
528
702
  return {};
529
703
  }
530
704
 
705
+ /**
706
+ * Hard delete an ACP session. `DeleteSessionRequest` carries only `sessionId`;
707
+ * the server owns authorization through the immutable canonical cwd scope and
708
+ * the complete strict scoped inventory snapshot.
709
+ *
710
+ * - No scope / unknown / already-deleted id → lookup-free `{}`.
711
+ * - Duplicate/conflict id → visible error; neither transcript nor artifacts change.
712
+ * - Active session → reserve `deleting`, drain/cancel prompt, flush, strict
713
+ * dispose/close, recheck identity/state, then verified artifact-first deletion.
714
+ * - Concurrent delete callers join the terminal promise.
715
+ */
716
+ async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
717
+ // Before scope: return {} without scanning/listing storage.
718
+ if (this.#canonicalCwdScope === undefined) {
719
+ return {};
720
+ }
721
+ if (this.#shuttingDown) {
722
+ throw new Error("ACP session delete is unavailable during shutdown");
723
+ }
724
+ const record = this.#sessions.get(params.sessionId);
725
+ if (record) {
726
+ return await this.#deleteActiveSession(params.sessionId, record);
727
+ }
728
+ // Inactive: read authority from the strict inventory snapshot.
729
+ return await this.#deleteInactiveSession(params.sessionId);
730
+ }
731
+
531
732
  async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
532
733
  const record = this.#getSessionRecord(params.sessionId);
734
+ this.#rejectIfTerminal(record);
533
735
  this.#applyModeChange(record.session, params.modeId);
534
736
  await this.#connection.sessionUpdate({
535
737
  sessionId: record.session.sessionId,
@@ -541,6 +743,7 @@ export class AcpAgent implements Agent {
541
743
 
542
744
  async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
543
745
  const record = this.#getSessionRecord(params.sessionId);
746
+ this.#rejectIfTerminal(record);
544
747
  if (typeof params.value === "boolean") {
545
748
  throw new Error(`Unsupported boolean ACP config option: ${params.configId}`);
546
749
  }
@@ -580,15 +783,9 @@ export class AcpAgent implements Agent {
580
783
  return { configOptions: this.#buildConfigOptions(record.session) };
581
784
  }
582
785
 
583
- async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
584
- const record = this.#getSessionRecord(params.sessionId);
585
- await this.#setModelById(record.session, params.modelId);
586
- await this.#pushConfigOptionUpdate(record);
587
- return {};
588
- }
589
-
590
786
  async prompt(params: PromptRequest): Promise<PromptResponse> {
591
787
  const record = this.#getSessionRecord(params.sessionId);
788
+ this.#rejectIfTerminal(record);
592
789
  const activeTurn = record.promptTurn;
593
790
  if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
594
791
  throw new Error("ACP prompt already in progress for this session");
@@ -607,8 +804,9 @@ export class AcpAgent implements Agent {
607
804
 
608
805
  const converted = this.#convertPromptBlocks(params.prompt);
609
806
  const pendingPrompt = Promise.withResolvers<PromptResponse>();
807
+ const metaUserMessageId = params._meta?.userMessageId;
610
808
  record.promptTurn = {
611
- userMessageId: params.messageId ?? crypto.randomUUID(),
809
+ userMessageId: typeof metaUserMessageId === "string" ? metaUserMessageId : crypto.randomUUID(),
612
810
  cancelRequested: false,
613
811
  settled: false,
614
812
  cleanup: undefined,
@@ -694,7 +892,7 @@ export class AcpAgent implements Agent {
694
892
  this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
695
893
  record.session.sessionManager.getUsageStatistics(),
696
894
  ),
697
- userMessageId: promptTurn?.userMessageId,
895
+ _meta: promptTurn ? { userMessageId: promptTurn.userMessageId } : undefined,
698
896
  });
699
897
  return;
700
898
  }
@@ -814,6 +1012,11 @@ export class AcpAgent implements Agent {
814
1012
  try {
815
1013
  await cleanup;
816
1014
  } catch (error: unknown) {
1015
+ // A terminal delete owns the record; let the delete handle the failure
1016
+ // rather than racing it with an independent close.
1017
+ if (record.terminalState !== "active") {
1018
+ return;
1019
+ }
817
1020
  logger.warn("ACP cancel cleanup timed out; closing session", { sessionId: record.session.sessionId, error });
818
1021
  await this.#closeManagedSession(record.session.sessionId, record);
819
1022
  }
@@ -838,7 +1041,7 @@ export class AcpAgent implements Agent {
838
1041
  this.#finishPrompt(record, {
839
1042
  stopReason: "cancelled",
840
1043
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
841
- userMessageId: promptTurn.userMessageId,
1044
+ _meta: { userMessageId: promptTurn.userMessageId },
842
1045
  });
843
1046
  return cleanup;
844
1047
  }
@@ -957,12 +1160,49 @@ export class AcpAgent implements Agent {
957
1160
  this.#connection.signal.addEventListener(
958
1161
  "abort",
959
1162
  () => {
960
- void this.#disposeAllSessions();
1163
+ this.#shuttingDown = true;
1164
+ // Mark shutdown under mutex (no actual mutex needed — single-threaded),
1165
+ // reject new scope/inventory/lifecycle work, then await terminal work.
1166
+ this.#shutdownPromise = (async () => {
1167
+ // Join lifecycle preparation that has not registered yet. Each tracked
1168
+ // operation rechecks shutdown and disposes its prepared session before settling.
1169
+ await Promise.allSettled(Array.from(this.#lifecycleOperations));
1170
+ const terminalPromises = Array.from(this.#sessions.values())
1171
+ .map(r => r.terminalPromise)
1172
+ .filter((p): p is Promise<DeleteSessionResponse> => p !== undefined);
1173
+ await Promise.allSettled(terminalPromises);
1174
+ await this.#disposeAllSessions();
1175
+ // Clear connection-local authority.
1176
+ this.#authoritySnapshot = undefined;
1177
+ this.#canonicalCwdScope = undefined;
1178
+ })();
961
1179
  },
962
1180
  { once: true },
963
1181
  );
964
1182
  }
965
1183
 
1184
+ /**
1185
+ * Connection-local shutdown promise. Resolves once all terminal work (dispose,
1186
+ * active deletes, abort cleanup) has settled. `acp-mode.ts` awaits this after
1187
+ * transport closure so `process.exit` never kills in-flight deletion/disposal.
1188
+ */
1189
+ get shutdownPromise(): Promise<void> {
1190
+ return this.#shutdownPromise ?? Promise.resolve();
1191
+ }
1192
+
1193
+ async #trackLifecycle<T>(operation: () => Promise<T>): Promise<T> {
1194
+ this.#rejectIfShuttingDown();
1195
+ const start = Promise.withResolvers<void>();
1196
+ const promise = start.promise.then(operation);
1197
+ this.#lifecycleOperations.add(promise);
1198
+ start.resolve();
1199
+ try {
1200
+ return await promise;
1201
+ } finally {
1202
+ this.#lifecycleOperations.delete(promise);
1203
+ }
1204
+ }
1205
+
966
1206
  async #createNewSessionRecord(cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
967
1207
  const session = await this.#createSession(path.resolve(cwd));
968
1208
  try {
@@ -977,31 +1217,45 @@ export class AcpAgent implements Agent {
977
1217
  async #loadManagedSession(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
978
1218
  const existing = this.#sessions.get(sessionId);
979
1219
  if (existing) {
1220
+ this.#rejectIfTerminal(existing);
980
1221
  this.#assertMatchingCwd(existing.session, cwd);
981
1222
  await this.#configureMcpServers(existing, mcpServers);
982
1223
  return existing;
983
1224
  }
984
1225
 
985
- const storedSession = await this.#findStoredSession(sessionId, cwd);
986
- if (!storedSession) {
987
- throw new Error(`ACP session not found: ${sessionId}`);
988
- }
989
- return await this.#openStoredSession(storedSession.path, cwd, mcpServers, sessionId);
1226
+ const candidate = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
1227
+ return await this.#openStoredSession(candidate, cwd, mcpServers, sessionId);
990
1228
  }
991
1229
 
992
1230
  async #resumeManagedSession(sessionId: string, cwd: string, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
993
1231
  const existing = this.#sessions.get(sessionId);
994
1232
  if (existing) {
1233
+ this.#rejectIfTerminal(existing);
995
1234
  this.#assertMatchingCwd(existing.session, cwd);
996
1235
  await this.#configureMcpServers(existing, mcpServers);
997
1236
  return existing;
998
1237
  }
999
1238
 
1000
- const storedSession = await this.#findStoredSession(sessionId, cwd);
1001
- if (!storedSession) {
1239
+ const candidate = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
1240
+ return await this.#openStoredSession(candidate, cwd, mcpServers, sessionId);
1241
+ }
1242
+
1243
+ /**
1244
+ * Resolve a single exact-one scoped candidate via a fresh strict inventory.
1245
+ * Rejects duplicates and inventory failures — never falls back to a forgiving
1246
+ * first-match lookup. Used by load/resume so authoritative lifecycle paths bind
1247
+ * exact identity before opening a stored session.
1248
+ */
1249
+ #resolveStrictLifecycleCandidate(sessionId: string, scope: string): StrictInventoryCandidate {
1250
+ const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1251
+ const entry = snapshot.entries.get(sessionId);
1252
+ if (!entry) {
1002
1253
  throw new Error(`ACP session not found: ${sessionId}`);
1003
1254
  }
1004
- return await this.#openStoredSession(storedSession.path, cwd, mcpServers, sessionId);
1255
+ if (entry.kind !== "candidate") {
1256
+ throw new Error(`ACP session ${sessionId} load failed: ${entry.reason}`);
1257
+ }
1258
+ return entry.candidate;
1005
1259
  }
1006
1260
 
1007
1261
  async #forkManagedSession(params: ForkSessionRequest): Promise<ManagedSessionRecord> {
@@ -1024,14 +1278,23 @@ export class AcpAgent implements Agent {
1024
1278
  }
1025
1279
 
1026
1280
  async #openStoredSession(
1027
- sessionPath: string,
1281
+ issued: StrictInventoryCandidate,
1028
1282
  cwd: string,
1029
1283
  mcpServers: McpServer[],
1030
1284
  sessionId: string,
1031
1285
  ): Promise<ManagedSessionRecord> {
1032
1286
  const session = await this.#createSession(path.resolve(cwd));
1033
1287
  try {
1034
- const success = await session.switchSession(sessionPath);
1288
+ const current = this.#resolveStrictLifecycleCandidate(sessionId, cwd);
1289
+ if (
1290
+ current.path !== issued.path ||
1291
+ current.cwd !== issued.cwd ||
1292
+ current.identity.dev !== issued.identity.dev ||
1293
+ current.identity.ino !== issued.identity.ino
1294
+ ) {
1295
+ throw new Error(`ACP session ${sessionId} changed while opening`);
1296
+ }
1297
+ const success = await session.switchSession(current.path);
1035
1298
  if (!success) {
1036
1299
  throw new Error(`ACP session load was cancelled: ${sessionId}`);
1037
1300
  }
@@ -1050,6 +1313,10 @@ export class AcpAgent implements Agent {
1050
1313
  try {
1051
1314
  await this.#configureExtensions(record);
1052
1315
  await this.#configureMcpServers(record, mcpServers);
1316
+ if (this.#shuttingDown) {
1317
+ await this.#disposeSessionRecord(record);
1318
+ throw new Error("ACP session lifecycle is unavailable during shutdown");
1319
+ }
1053
1320
  this.#sessions.set(session.sessionId, record);
1054
1321
  return record;
1055
1322
  } catch (error) {
@@ -1069,6 +1336,9 @@ export class AcpAgent implements Agent {
1069
1336
  toolArgsById: new Map(),
1070
1337
  extensionsConfigured: false,
1071
1338
  lifetimeUnsubscribe: undefined,
1339
+ terminalState: "active",
1340
+ terminalFailure: undefined,
1341
+ terminalPromise: undefined,
1072
1342
  };
1073
1343
  }
1074
1344
 
@@ -1136,6 +1406,9 @@ export class AcpAgent implements Agent {
1136
1406
  async #resolveForkSourceSessionPath(sessionId: string): Promise<string> {
1137
1407
  const loaded = this.#sessions.get(sessionId);
1138
1408
  if (loaded) {
1409
+ if (loaded.terminalState !== "active") {
1410
+ throw new Error(`ACP session fork is unavailable while it is in terminal state: ${sessionId}`);
1411
+ }
1139
1412
  if (isPromptTurnInFlight(loaded.promptTurn)) {
1140
1413
  throw new Error(`ACP session fork is unavailable while a prompt is in progress: ${sessionId}`);
1141
1414
  }
@@ -1147,11 +1420,21 @@ export class AcpAgent implements Agent {
1147
1420
  return sessionPath;
1148
1421
  }
1149
1422
 
1150
- const storedSession = await this.#findStoredSessionById(sessionId);
1151
- if (!storedSession) {
1423
+ // Inactive fork source: exact-one scoped lookup only. Never use global
1424
+ // listAll/first-match — that was the P1 data-loss path.
1425
+ const scope = this.#canonicalCwdScope;
1426
+ if (!scope) {
1427
+ throw new Error(`ACP session not found (no scope established): ${sessionId}`);
1428
+ }
1429
+ const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1430
+ const entry = snapshot.entries.get(sessionId);
1431
+ if (!entry) {
1152
1432
  throw new Error(`ACP session not found: ${sessionId}`);
1153
1433
  }
1154
- return storedSession.path;
1434
+ if (entry.kind !== "candidate") {
1435
+ throw new Error(`ACP session fork failed: ${entry.reason}`);
1436
+ }
1437
+ return entry.candidate.path;
1155
1438
  }
1156
1439
 
1157
1440
  async #handlePromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
@@ -1189,7 +1472,7 @@ export class AcpAgent implements Agent {
1189
1472
  this.#finishPrompt(record, {
1190
1473
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
1191
1474
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
1192
- userMessageId: promptTurn.userMessageId,
1475
+ _meta: { userMessageId: promptTurn.userMessageId },
1193
1476
  });
1194
1477
  }
1195
1478
  }
@@ -1281,8 +1564,10 @@ export class AcpAgent implements Agent {
1281
1564
  case "length":
1282
1565
  return "max_tokens";
1283
1566
  case "error": {
1284
- const errorMessage = lastAssistant?.errorMessage ?? "";
1285
- if (/content[_ ]?filter|refus(al|ed)/i.test(errorMessage)) {
1567
+ if (lastAssistant?.errorKind === "provider_safety_stop") {
1568
+ return "refusal";
1569
+ }
1570
+ if (isLegacyProviderSafetyStopMessage(lastAssistant?.errorMessage ?? "")) {
1286
1571
  return "refusal";
1287
1572
  }
1288
1573
  return "end_turn";
@@ -1312,6 +1597,281 @@ export class AcpAgent implements Agent {
1312
1597
  }
1313
1598
  }
1314
1599
 
1600
+ /**
1601
+ * Validate the cwd and reject cross-cwd calls against an already-committed scope,
1602
+ * WITHOUT committing. Returns the canonical (resolved) cwd. Pair with
1603
+ * {@link #commitCanonicalScope} after the lifecycle operation succeeds so a failed
1604
+ * first new/load/resume/fork/list never pins the connection to a cwd it never served.
1605
+ */
1606
+ #stageCanonicalScope(cwd: string): string {
1607
+ this.#assertAbsoluteCwd(cwd);
1608
+ const canonical = path.resolve(cwd);
1609
+ if (this.#canonicalCwdScope !== undefined && this.#canonicalCwdScope !== canonical) {
1610
+ throw new Error(`ACP connection is scoped to ${this.#canonicalCwdScope}, not ${canonical}`);
1611
+ }
1612
+ return canonical;
1613
+ }
1614
+
1615
+ /** Commit the canonical cwd scope on the first successful explicit-cwd call. */
1616
+ #commitCanonicalScope(canonical: string): void {
1617
+ if (this.#canonicalCwdScope === undefined) {
1618
+ this.#canonicalCwdScope = canonical;
1619
+ }
1620
+ }
1621
+
1622
+ /** Bump the generation and discard the authority snapshot on lifecycle changes. */
1623
+ #invalidateAuthority(): void {
1624
+ this.#authorityGeneration += 1;
1625
+ this.#authoritySnapshot = undefined;
1626
+ }
1627
+
1628
+ #rejectIfTerminal(record: ManagedSessionRecord): void {
1629
+ if (record.terminalState !== "active") {
1630
+ throw new Error(`ACP session ${record.session.sessionId} is in terminal state: ${record.terminalState}`);
1631
+ }
1632
+ }
1633
+
1634
+ #rejectIfShuttingDown(): void {
1635
+ if (this.#shuttingDown) {
1636
+ throw new Error("ACP session lifecycle is unavailable during shutdown");
1637
+ }
1638
+ }
1639
+
1640
+ /**
1641
+ * Build (or reuse) the complete authorization snapshot from a strict raw scoped
1642
+ * inventory. The cached pagination snapshot is reused for display/list paging.
1643
+ * Any inventory failure throws a sanitized error — it grants zero authority.
1644
+ * The snapshot binds every discovered id to its exact-identity candidate or a
1645
+ * conflict tombstone BEFORE pagination, so duplicates beyond page 1 are known
1646
+ * before the first page response.
1647
+ */
1648
+ #buildAuthoritySnapshot(scope: string): AcpAuthoritySnapshot {
1649
+ if (this.#authoritySnapshot?.scope === scope) {
1650
+ return this.#authoritySnapshot;
1651
+ }
1652
+ return this.#rebuildAuthoritySnapshot(scope, true);
1653
+ }
1654
+
1655
+ /**
1656
+ * Build a FRESH authorization snapshot, ignoring the cached pagination snapshot.
1657
+ * Destructive authority (delete, inactive fork) must read storage at mutation
1658
+ * time so an external create/remove/replace/duplicate between list and delete
1659
+ * cannot yield a stale no-op or wrong mutation. The result is NOT cached: it
1660
+ * must not poison the generation-aware pagination cursor contract.
1661
+ */
1662
+ #buildFreshAuthoritySnapshot(scope: string): AcpAuthoritySnapshot {
1663
+ return this.#rebuildAuthoritySnapshot(scope, false);
1664
+ }
1665
+
1666
+ #rebuildAuthoritySnapshot(scope: string, cache: boolean): AcpAuthoritySnapshot {
1667
+ const inventory = SessionManager.inventorySessionsStrict(scope);
1668
+ if (inventory.kind === "failure") {
1669
+ const messages = inventory.failures.map(f => `${f.kind}: ${f.message}`);
1670
+ throw new Error(`ACP scoped session inventory is incomplete: ${messages.join("; ")}`);
1671
+ }
1672
+ const entries = new Map<string, AcpAuthorityEntry>();
1673
+ const orderedIds: string[] = [];
1674
+ for (const candidate of inventory.candidates) {
1675
+ const existing = entries.get(candidate.id);
1676
+ if (existing) {
1677
+ entries.set(candidate.id, { kind: "conflict", reason: "Duplicate session id in scoped inventory" });
1678
+ } else {
1679
+ entries.set(candidate.id, { kind: "candidate", candidate });
1680
+ orderedIds.push(candidate.id);
1681
+ }
1682
+ }
1683
+ const snapshot: AcpAuthoritySnapshot = {
1684
+ scope,
1685
+ entries,
1686
+ orderedIds,
1687
+ generation: this.#authorityGeneration,
1688
+ };
1689
+ if (cache) {
1690
+ this.#authoritySnapshot = snapshot;
1691
+ }
1692
+ return snapshot;
1693
+ }
1694
+
1695
+ /**
1696
+ * Delete an active (loaded) session via the per-record terminal state machine.
1697
+ * Reserves `deleting` before the first await; concurrent deletes join the
1698
+ * terminal promise. On failure the state transitions to `pre_dispatch_retryable`
1699
+ * (close_failed_retryable), `cleanup_pending` (verified-delete partial), or
1700
+ * `terminal_failure` (close_unknown / quiescence / unexpected) — all non-`active`
1701
+ * states reject prompt/config/close/load/resume/fork. Only `pre_dispatch_retryable`
1702
+ * and `cleanup_pending` permit a retry delete; `terminal_failure` never reopens.
1703
+ */
1704
+ async #deleteActiveSession(sessionId: string, record: ManagedSessionRecord): Promise<DeleteSessionResponse> {
1705
+ // Concurrent delete joins the in-progress terminal promise.
1706
+ if (record.terminalPromise) {
1707
+ return await record.terminalPromise;
1708
+ }
1709
+ // terminal_failure: never reopen — share the settled error.
1710
+ if (record.terminalState === "terminal_failure") {
1711
+ throw record.terminalFailure ?? new Error(`ACP session ${sessionId} has a settled terminal failure`);
1712
+ }
1713
+ // Only active / pre_dispatch_retryable / cleanup_pending may start a delete.
1714
+ if (record.terminalState === "deleting") {
1715
+ throw new Error(`ACP session ${sessionId} is being deleted`);
1716
+ }
1717
+ record.terminalState = "deleting";
1718
+ const scope = this.#canonicalCwdScope!;
1719
+ const terminalPromise = (async (): Promise<DeleteSessionResponse> => {
1720
+ try {
1721
+ // Quiesce — failure is terminal (never swallow).
1722
+ try {
1723
+ await this.#quiesceForDelete(sessionId, record);
1724
+ } catch (quiesceError) {
1725
+ record.terminalState = "terminal_failure";
1726
+ record.terminalFailure = quiesceError instanceof Error ? quiesceError : new Error(String(quiesceError));
1727
+ throw quiesceError;
1728
+ }
1729
+ const manager = record.session.sessionManager;
1730
+ // Strict close — outcome determines retryability.
1731
+ const closeOutcome = await record.session.closeWriterStrict();
1732
+ if (closeOutcome.kind !== "closed") {
1733
+ if (closeOutcome.kind === "close_failed_retryable") {
1734
+ record.terminalState = "pre_dispatch_retryable";
1735
+ } else {
1736
+ record.terminalState = "terminal_failure";
1737
+ record.terminalFailure = closeOutcome.error;
1738
+ }
1739
+ const reason =
1740
+ closeOutcome.kind === "close_unknown"
1741
+ ? "writer close outcome is unknown"
1742
+ : "writer close failed before dispatch";
1743
+ throw new Error(`ACP session ${sessionId} cannot be deleted: ${reason}`);
1744
+ }
1745
+ // Recheck: the record must still own this exact session.
1746
+ if (this.#sessions.get(sessionId) !== record || record.terminalState !== "deleting") {
1747
+ record.terminalState = "terminal_failure";
1748
+ record.terminalFailure = new Error(`ACP session ${sessionId} state changed during deletion`);
1749
+ throw new Error(`ACP session ${sessionId} state changed during deletion`);
1750
+ }
1751
+ const sessionFile = manager.getSessionFile();
1752
+ const sessionDir = manager.getSessionDir();
1753
+ if (!sessionFile) {
1754
+ record.terminalState = "terminal_failure";
1755
+ record.terminalFailure = new Error(`ACP session ${sessionId} is not persisted and cannot be deleted`);
1756
+ throw new Error(`ACP session ${sessionId} is not persisted and cannot be deleted`);
1757
+ }
1758
+ const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1759
+ const entry = snapshot.entries.get(sessionId);
1760
+ if (entry?.kind !== "candidate") {
1761
+ record.terminalState = "terminal_failure";
1762
+ record.terminalFailure = new Error(`ACP session ${sessionId} is not an exact-one scoped candidate`);
1763
+ throw new Error(`ACP session ${sessionId} is not an exact-one scoped candidate`);
1764
+ }
1765
+ if (entry.candidate.path !== sessionFile) {
1766
+ record.terminalState = "terminal_failure";
1767
+ record.terminalFailure = new Error(`ACP session ${sessionId} transcript path changed during deletion`);
1768
+ throw new Error(`ACP session ${sessionId} transcript path changed during deletion`);
1769
+ }
1770
+ this.#assertPendingTranscriptIdentity(sessionId, entry.candidate.identity);
1771
+ const sessionsRoot = path.dirname(sessionDir);
1772
+ const target: VerifiedSessionDeleteTarget = {
1773
+ sessionsRoot,
1774
+ transcriptPath: sessionFile,
1775
+ sessionId,
1776
+ cwd: manager.getCwd(),
1777
+ transcriptIdentity: {
1778
+ dev: entry.candidate.identity.dev,
1779
+ ino: entry.candidate.identity.ino,
1780
+ },
1781
+ ...this.#pendingArtifactsIdentity(sessionId),
1782
+ };
1783
+ const outcome = await manager.deleteSessionVerified(target);
1784
+ if (outcome.kind !== "deleted") {
1785
+ record.terminalState = "cleanup_pending";
1786
+ this.#recordPendingDeleteEvidence(sessionId, outcome);
1787
+ throw new Error(
1788
+ `ACP session ${sessionId} delete is incomplete: ${outcome.phase} cleanup pending (${outcome.error.message})`,
1789
+ );
1790
+ }
1791
+ // Fully deleted: clear retry evidence, remove from active map, dispose.
1792
+ record.terminalState = "deleted";
1793
+ this.#pendingDeleteEvidence.delete(sessionId);
1794
+ this.#sessions.delete(sessionId);
1795
+ this.#invalidateAuthority();
1796
+ await this.#disposeSessionRecord(record);
1797
+ return {};
1798
+ } catch (error) {
1799
+ // terminalState was set at the failure point above. For any
1800
+ // unexpected error that left the state as `deleting`, treat it
1801
+ // as terminal_failure so the record never silently reopens.
1802
+ if (record.terminalState === "deleting") {
1803
+ record.terminalState = "terminal_failure";
1804
+ record.terminalFailure = error instanceof Error ? error : new Error(String(error));
1805
+ }
1806
+ record.terminalPromise = undefined;
1807
+ this.#invalidateAuthority();
1808
+ throw error;
1809
+ }
1810
+ })();
1811
+ record.terminalPromise = terminalPromise;
1812
+ return await terminalPromise;
1813
+ }
1814
+
1815
+ /**
1816
+ * Delete an inactive (not currently loaded) session. The ID must have been
1817
+ * issued by the last complete scoped authority snapshot; an absent/unissued ID
1818
+ * returns lookup-free `{}` with NO inventory scan. Only after issuance may a
1819
+ * fresh strict inventory revalidate the same exact candidate before mutation.
1820
+ * A repeat-unknown (issued but gone from the fresh inventory) is also lookup-free `{}`.
1821
+ */
1822
+ async #deleteInactiveSession(sessionId: string): Promise<DeleteSessionResponse> {
1823
+ const scope = this.#canonicalCwdScope!;
1824
+ // Gate: only IDs issued by the last complete scoped authority snapshot
1825
+ // may trigger a fresh inventory scan. Absent/unissued → lookup-free {}.
1826
+ const issued = this.#authoritySnapshot?.entries.get(sessionId);
1827
+ if (!issued) {
1828
+ return {};
1829
+ }
1830
+ if (issued.kind === "conflict") {
1831
+ throw new Error(`ACP session delete failed: ${issued.reason}`);
1832
+ }
1833
+ // Fresh strict inventory to revalidate the same exact candidate.
1834
+ const snapshot = this.#buildFreshAuthoritySnapshot(scope);
1835
+ const entry = snapshot.entries.get(sessionId);
1836
+ if (!entry) {
1837
+ // Repeat unknown: issued but no longer present in the fresh inventory.
1838
+ return {};
1839
+ }
1840
+ if (entry.kind === "conflict") {
1841
+ throw new Error(`ACP session delete failed: ${entry.reason}`);
1842
+ }
1843
+ const candidate = entry.candidate;
1844
+ if (
1845
+ candidate.path !== issued.candidate.path ||
1846
+ candidate.cwd !== issued.candidate.cwd ||
1847
+ candidate.identity.dev !== issued.candidate.identity.dev ||
1848
+ candidate.identity.ino !== issued.candidate.identity.ino
1849
+ ) {
1850
+ throw new Error(`ACP session ${sessionId} changed since authority was issued`);
1851
+ }
1852
+ this.#assertPendingTranscriptIdentity(sessionId, candidate.identity);
1853
+ const sessionsRoot = path.dirname(candidate.path);
1854
+ const target: VerifiedSessionDeleteTarget = {
1855
+ sessionsRoot,
1856
+ transcriptPath: candidate.path,
1857
+ sessionId,
1858
+ cwd: candidate.cwd,
1859
+ transcriptIdentity: { dev: candidate.identity.dev, ino: candidate.identity.ino },
1860
+ ...this.#pendingArtifactsIdentity(sessionId),
1861
+ };
1862
+ const storage = new FileSessionStorage();
1863
+ const outcome = await storage.deleteSessionVerified(target);
1864
+ if (outcome.kind !== "deleted") {
1865
+ this.#recordPendingDeleteEvidence(sessionId, outcome);
1866
+ throw new Error(
1867
+ `ACP session ${sessionId} delete is incomplete: ${outcome.phase} cleanup pending (${outcome.error.message})`,
1868
+ );
1869
+ }
1870
+ this.#pendingDeleteEvidence.delete(sessionId);
1871
+ this.#invalidateAuthority();
1872
+ return {};
1873
+ }
1874
+
1315
1875
  #convertPromptBlocks(blocks: PromptRequest["prompt"]): { text: string; images: AgentImageContent[] } {
1316
1876
  const textParts: string[] = [];
1317
1877
  const images: AgentImageContent[] = [];
@@ -1406,28 +1966,6 @@ export class AcpAgent implements Agent {
1406
1966
  return configOptions;
1407
1967
  }
1408
1968
 
1409
- #buildModelState(session: AgentSession): SessionModelState | undefined {
1410
- const models = session.getAvailableModels();
1411
- if (models.length === 0) {
1412
- return undefined;
1413
- }
1414
-
1415
- const availableModels = models.map(model => ({
1416
- modelId: this.#toModelId(model),
1417
- name: model.name,
1418
- description: `${model.provider}/${model.id}`,
1419
- }));
1420
- const currentModelId = session.model ? this.#toModelId(session.model) : availableModels[0]?.modelId;
1421
- if (!currentModelId) {
1422
- return undefined;
1423
- }
1424
-
1425
- return {
1426
- availableModels,
1427
- currentModelId,
1428
- };
1429
- }
1430
-
1431
1969
  #buildThinkingOptions(session: AgentSession): Array<{ value: string; name: string; description?: string }> {
1432
1970
  return [
1433
1971
  { value: THINKING_OFF, name: "Off" },
@@ -1679,14 +2217,14 @@ export class AcpAgent implements Agent {
1679
2217
  const sessionId = record.session.sessionId;
1680
2218
 
1681
2219
  const contextUsage = record.session.getContextUsage();
1682
- if (contextUsage) {
2220
+ if (contextUsage?.tokens != null) {
1683
2221
  const usageStats = record.session.sessionManager.getUsageStatistics();
1684
2222
  await this.#connection.sessionUpdate({
1685
2223
  sessionId,
1686
2224
  update: {
1687
2225
  sessionUpdate: "usage_update",
1688
2226
  size: contextUsage.contextWindow,
1689
- used: contextUsage.tokens ?? 0,
2227
+ used: contextUsage.tokens,
1690
2228
  cost: usageStats.cost > 0 ? { amount: usageStats.cost, currency: "USD" } : undefined,
1691
2229
  },
1692
2230
  });
@@ -1748,16 +2286,6 @@ export class AcpAgent implements Agent {
1748
2286
  return sessions.sort((left, right) => right.modified.getTime() - left.modified.getTime());
1749
2287
  }
1750
2288
 
1751
- async #findStoredSession(sessionId: string, cwd: string): Promise<StoredSessionInfo | undefined> {
1752
- const sessions = await this.#listStoredSessions(cwd);
1753
- return sessions.find(session => session.id === sessionId);
1754
- }
1755
-
1756
- async #findStoredSessionById(sessionId: string): Promise<StoredSessionInfo | undefined> {
1757
- const sessions = await this.#listStoredSessions();
1758
- return sessions.find(session => session.id === sessionId);
1759
- }
1760
-
1761
2289
  #parseCursor(cursor: string | undefined): number {
1762
2290
  if (!cursor) {
1763
2291
  return 0;
@@ -1769,6 +2297,32 @@ export class AcpAgent implements Agent {
1769
2297
  return parsed;
1770
2298
  }
1771
2299
 
2300
+ /**
2301
+ * Parse a scoped cursor of the form `${generation}:${offset}` and reject it when
2302
+ * its embedded generation no longer matches the current authority generation.
2303
+ * Unlike the cwd-less display cursor, the scoped cursor binds the page offset to
2304
+ * the exact authority snapshot that produced it, so a lifecycle invalidation
2305
+ * between pages is observable instead of silently rebuilt.
2306
+ */
2307
+ #parseScopedCursor(cursor: string | undefined, currentGeneration: number): number {
2308
+ if (!cursor) {
2309
+ return 0;
2310
+ }
2311
+ const separator = cursor.indexOf(":");
2312
+ if (separator <= 0) {
2313
+ throw new Error(`Invalid ACP session cursor: ${cursor}`);
2314
+ }
2315
+ const generation = Number.parseInt(cursor.slice(0, separator), 10);
2316
+ const offset = Number.parseInt(cursor.slice(separator + 1), 10);
2317
+ if (!Number.isFinite(generation) || generation < 0 || !Number.isFinite(offset) || offset < 0) {
2318
+ throw new Error(`Invalid ACP session cursor: ${cursor}`);
2319
+ }
2320
+ if (generation !== currentGeneration) {
2321
+ throw new Error("ACP session list cursor is stale; the scoped session inventory changed");
2322
+ }
2323
+ return offset;
2324
+ }
2325
+
1772
2326
  async #replaySessionHistory(record: ManagedSessionRecord): Promise<void> {
1773
2327
  const cwd = record.session.sessionManager.getCwd();
1774
2328
  const replayedToolCallIds = new Set<string>();
@@ -2169,11 +2723,14 @@ export class AcpAgent implements Agent {
2169
2723
  headers: this.#toNameValueMap(server.headers),
2170
2724
  };
2171
2725
  }
2172
- return {
2173
- type: "sse",
2174
- url: server.url,
2175
- headers: this.#toNameValueMap(server.headers),
2176
- };
2726
+ if (server.type === "sse") {
2727
+ return {
2728
+ type: "sse",
2729
+ url: server.url,
2730
+ headers: this.#toNameValueMap(server.headers),
2731
+ };
2732
+ }
2733
+ throw new Error(`Unsupported ACP MCP transport: ${server.type}`);
2177
2734
  }
2178
2735
 
2179
2736
  #toNameValueMap(values: Array<{ name: string; value: string }>): { [name: string]: string } {
@@ -2202,6 +2759,79 @@ export class AcpAgent implements Agent {
2202
2759
  logger.warn("Failed to abort ACP prompt during session close", { error });
2203
2760
  }
2204
2761
  }
2762
+ /**
2763
+ * Delete-specific strict prompt quiescence barrier. Mirrors the cancel setup of
2764
+ * {@link #cancelPromptForClose} but NEVER swallows abort/timeout failures, then
2765
+ * waits for full idle + async-delivery drain. A quiescence failure blocks the
2766
+ * destructive mutation that follows — it is not catch-and-logged. The best-effort
2767
+ * close path is preserved separately for nondestructive shutdown.
2768
+ */
2769
+ async #quiesceForDelete(sessionId: string, record: ManagedSessionRecord): Promise<void> {
2770
+ const promptTurn = record.promptTurn;
2771
+ if (isPromptTurnInFlight(promptTurn)) {
2772
+ const cleanup = promptTurn.cleanup ?? this.#beginCancelCleanup(record, promptTurn);
2773
+ await cleanup;
2774
+ }
2775
+ await this.#waitForAcpPromptIdle(record);
2776
+ // Explicit post-drain status check: the drain loop's boolean return is
2777
+ // not trusted as proof. Treat a drain false/timeout/no-progress/max-pass
2778
+ // as unproven and verify the owner-scoped delivery state directly. Any
2779
+ // remaining queued/delivering work blocks the destructive mutation.
2780
+ const delivery = record.session.getAsyncDeliveryStateForAcp();
2781
+ if (delivery.queued > 0 || delivery.delivering) {
2782
+ throw new Error(`ACP session ${sessionId} cannot be deleted: async delivery is not quiesced`);
2783
+ }
2784
+ }
2785
+
2786
+ /**
2787
+ * Record phase-aware same-connection retry evidence for an incomplete verified
2788
+ * delete. The transcript identity (carried by every cleanup_pending) is always
2789
+ * recorded so a retry can reject a replaced transcript; the artifacts identity
2790
+ * is recorded only at the artifacts phase.
2791
+ */
2792
+ #recordPendingDeleteEvidence(sessionId: string, result: VerifiedSessionDeleteResult): void {
2793
+ if (result.kind !== "cleanup_pending") {
2794
+ return;
2795
+ }
2796
+ if (result.phase === "artifacts") {
2797
+ this.#pendingDeleteEvidence.set(sessionId, {
2798
+ transcriptIdentity: result.transcriptIdentity,
2799
+ artifactsIdentity: result.artifactsIdentity,
2800
+ });
2801
+ } else {
2802
+ // Transcript phase: artifacts were already removed successfully, so only
2803
+ // the transcript identity remains for retry binding.
2804
+ this.#pendingDeleteEvidence.set(sessionId, {
2805
+ transcriptIdentity: result.transcriptIdentity,
2806
+ });
2807
+ }
2808
+ }
2809
+
2810
+ /** Build the optional `expectedArtifactsIdentity` field for a retry target. */
2811
+ #pendingArtifactsIdentity(sessionId: string): { expectedArtifactsIdentity?: SessionStorageFileIdentity } {
2812
+ const pending = this.#pendingDeleteEvidence.get(sessionId);
2813
+ return pending?.artifactsIdentity ? { expectedArtifactsIdentity: pending.artifactsIdentity } : {};
2814
+ }
2815
+
2816
+ /**
2817
+ * Phase-aware retry gate: a preserved transcript identity must match the fresh
2818
+ * candidate's transcript identity before that candidate can authorize a
2819
+ * replacement. A mismatch (the transcript was externally replaced between the
2820
+ * cleanup_pending failure and the retry) fails closed — it never authorizes a
2821
+ * wrong mutation.
2822
+ */
2823
+ #assertPendingTranscriptIdentity(sessionId: string, candidateIdentity: { dev: bigint; ino: bigint }): void {
2824
+ const pending = this.#pendingDeleteEvidence.get(sessionId);
2825
+ if (
2826
+ pending &&
2827
+ (pending.transcriptIdentity.dev !== candidateIdentity.dev ||
2828
+ pending.transcriptIdentity.ino !== candidateIdentity.ino)
2829
+ ) {
2830
+ throw new Error(
2831
+ `ACP session ${sessionId} transcript identity changed since pending cleanup; replacement rejected`,
2832
+ );
2833
+ }
2834
+ }
2205
2835
 
2206
2836
  async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
2207
2837
  record.lifetimeUnsubscribe?.();