@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
@@ -1,7 +1,9 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
3
+
2
4
  import * as os from "node:os";
3
5
  import * as path from "node:path";
4
- import type { AgentMessage } from "@sayknow-cli/agent-core";
6
+ import { type AgentMessage, canContinuePersistedHistory } from "@sayknow-cli/agent-core";
5
7
  import type {
6
8
  ImageContent,
7
9
  Message,
@@ -61,7 +63,15 @@ import {
61
63
  sanitizeRehydratedOpenAIResponsesAssistantMessage,
62
64
  stripInternalDetailsFields,
63
65
  } from "./messages";
64
- import type { SessionStorage, SessionStorageWriter } from "./session-storage";
66
+ import type {
67
+ SessionStorage,
68
+ SessionStorageSnapshot,
69
+ SessionStorageStat,
70
+ SessionStorageWriter,
71
+ SessionStorageWriterCloseState,
72
+ VerifiedSessionDeleteResult,
73
+ VerifiedSessionDeleteTarget,
74
+ } from "./session-storage";
65
75
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
66
76
 
67
77
  export const CURRENT_SESSION_VERSION = 3;
@@ -145,6 +155,53 @@ export interface SessionMessageEntry extends SessionEntryBase {
145
155
  evictedContent?: EvictedContentMarker;
146
156
  }
147
157
 
158
+ const sessionMessageEntryIds = new WeakMap<object, string>();
159
+ const sessionMessageViewportAnchorIds = new WeakMap<object, string>();
160
+ const sessionMessageObservationIds = new WeakMap<object, string>();
161
+
162
+ export function associateSessionMessageEntryId(message: AgentMessage, entryId: string): void {
163
+ sessionMessageEntryIds.set(message, entryId);
164
+ }
165
+
166
+ export function getSessionMessageEntryId(message: AgentMessage): string | undefined {
167
+ return sessionMessageEntryIds.get(message);
168
+ }
169
+
170
+ export function associateSessionMessageObservationId(message: AgentMessage, observationId: string): string {
171
+ const existing = sessionMessageObservationIds.get(message);
172
+ if (existing) return existing;
173
+ sessionMessageObservationIds.set(message, observationId);
174
+ return observationId;
175
+ }
176
+
177
+ export function getSessionMessageObservationId(message: AgentMessage): string | undefined {
178
+ return sessionMessageObservationIds.get(message);
179
+ }
180
+
181
+ export function associateSessionMessageViewportAnchorId(message: AgentMessage, anchorId: string): void {
182
+ sessionMessageViewportAnchorIds.set(message, anchorId);
183
+ }
184
+
185
+ export function getSessionMessageViewportAnchorId(message: AgentMessage): string | undefined {
186
+ return sessionMessageViewportAnchorIds.get(message);
187
+ }
188
+
189
+ export function transferSessionMessageIdentity(source: AgentMessage[], target: AgentMessage[]): void {
190
+ if (source.length !== target.length) {
191
+ throw new Error(
192
+ `Cannot transfer session message identity across ${source.length} source and ${target.length} target messages`,
193
+ );
194
+ }
195
+ for (let index = 0; index < source.length; index++) {
196
+ const entryId = getSessionMessageEntryId(source[index]);
197
+ if (entryId) associateSessionMessageEntryId(target[index], entryId);
198
+ const observationId = getSessionMessageObservationId(source[index]);
199
+ if (observationId) associateSessionMessageObservationId(target[index], observationId);
200
+ const anchorId = getSessionMessageViewportAnchorId(source[index]);
201
+ if (anchorId) associateSessionMessageViewportAnchorId(target[index], anchorId);
202
+ }
203
+ }
204
+
148
205
  export interface ThinkingLevelChangeEntry extends SessionEntryBase {
149
206
  type: "thinking_level_change";
150
207
  thinkingLevel?: string | null;
@@ -332,6 +389,48 @@ export interface SessionContext {
332
389
  modeData?: Record<string, unknown>;
333
390
  }
334
391
 
392
+ /** Immutable fingerprint captured during read-only resume inspection. */
393
+ export interface ResumeSessionIdentity {
394
+ canonicalPath: string;
395
+ sessionId: string;
396
+ dev: bigint;
397
+ ino: bigint;
398
+ size: number;
399
+ mtimeMs: number;
400
+ mtimeNs: bigint;
401
+ sha256: string;
402
+ }
403
+
404
+ export interface ResumeTailResumable {
405
+ kind: "resumable";
406
+ identity: ResumeSessionIdentity;
407
+ }
408
+
409
+ export interface ResumeTailTerminal {
410
+ kind: "terminal";
411
+ identity: ResumeSessionIdentity;
412
+ }
413
+
414
+ export interface ResumeTailError {
415
+ kind: "error";
416
+ reason: "missing" | "malformed" | "unstable" | "read-failed";
417
+ }
418
+
419
+ export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
420
+
421
+ export interface StrictSessionOpenSuccess {
422
+ kind: "opened";
423
+ manager: SessionManager;
424
+ }
425
+
426
+ export interface StrictSessionOpenFailure {
427
+ kind: "error";
428
+ reason: ResumeTailError["reason"] | "identity-mismatch";
429
+ }
430
+
431
+ /** Result of opening an inspected session without create-or-rewrite fallback. */
432
+ export type StrictSessionOpenResult = StrictSessionOpenSuccess | StrictSessionOpenFailure;
433
+
335
434
  export interface SessionInfo {
336
435
  path: string;
337
436
  id: string;
@@ -348,6 +447,56 @@ export interface SessionInfo {
348
447
  firstMessage: string;
349
448
  allMessagesText: string;
350
449
  }
450
+ // =============================================================================
451
+ // Strict ACP authorization inventory (fail-closed, never partial authority)
452
+ // =============================================================================
453
+
454
+ /** Kind of failure surfaced by strict scoped inventory. Any failure grants zero authority. */
455
+ export type StrictInventoryFailureKind =
456
+ | "root"
457
+ | "scan"
458
+ | "lstat"
459
+ | "read"
460
+ | "parse"
461
+ | "stat"
462
+ | "header"
463
+ | "cwd"
464
+ | "containment"
465
+ | "identity";
466
+
467
+ /** Sanitized strict-inventory failure. Never carries raw file content. */
468
+ export interface StrictInventoryFailure {
469
+ kind: StrictInventoryFailureKind;
470
+ message: string;
471
+ path?: string;
472
+ }
473
+
474
+ /** One exact-identity candidate suitable for ACP authorization binding. */
475
+ export interface StrictInventoryCandidate {
476
+ /** Canonical absolute transcript path. */
477
+ path: string;
478
+ /** Session id parsed from the header. */
479
+ id: string;
480
+ /** Canonical cwd parsed from the header. */
481
+ cwd: string;
482
+ /** Descriptor-bound transcript identity (dev, ino, ...). */
483
+ identity: SessionStorageStat;
484
+ }
485
+
486
+ /**
487
+ * Strict inventory result. `complete` carries the full validated candidate set;
488
+ * `failure` carries every sanitized failure and grants zero page/cursor/authority.
489
+ * A failure result is never reduced to a partial candidate set.
490
+ */
491
+ export type StrictInventoryResult =
492
+ | { kind: "complete"; candidates: StrictInventoryCandidate[] }
493
+ | { kind: "failure"; failures: StrictInventoryFailure[] };
494
+
495
+ /** Certainty-aware close outcome for strict ACP disposal. */
496
+ export type SessionManagerCloseOutcome =
497
+ | { kind: "closed" }
498
+ | { kind: "close_failed_retryable"; error: Error }
499
+ | { kind: "close_unknown"; error: Error };
351
500
 
352
501
  export type ReadonlySessionManager = Pick<
353
502
  SessionManager,
@@ -517,6 +666,17 @@ function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolv
517
666
  return { encodedDirName, resolvedCwd };
518
667
  }
519
668
 
669
+ /** Default and legacy directory candidates for maintenance-free resume discovery. */
670
+ function getReadOnlyDefaultSessionDirs(cwd: string, sessionsRoot: string = getSessionsDir()): string[] {
671
+ const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
672
+ const canonicalCwd = resolveEquivalentPath(resolvedCwd);
673
+ return [
674
+ path.join(sessionsRoot, encodedDirName),
675
+ path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(canonicalCwd)),
676
+ path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(resolvedCwd)),
677
+ ].filter((dir, index, dirs) => dirs.indexOf(dir) === index);
678
+ }
679
+
520
680
  /**
521
681
  * Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
522
682
  * Runs once per sessions root on first access, best-effort.
@@ -602,6 +762,7 @@ export function buildSessionContext(
602
762
  entries: SessionEntry[],
603
763
  leafId?: string | null,
604
764
  byId?: Map<string, SessionEntry>,
765
+ sessionIdentityNamespace = "legacy-session",
605
766
  ): SessionContext {
606
767
  // Build uuid index if not available
607
768
  if (!byId) {
@@ -747,18 +908,29 @@ export function buildSessionContext(
747
908
 
748
909
  const appendMessage = (entry: SessionEntry) => {
749
910
  if (entry.type === "message") {
911
+ associateSessionMessageEntryId(entry.message, entry.id);
750
912
  messages.push(entry.message);
751
913
  } else if (entry.type === "custom_message") {
752
- messages.push(
753
- createCustomMessage(
754
- entry.customType,
755
- entry.content,
756
- entry.display,
757
- entry.details,
758
- entry.timestamp,
759
- entry.attribution,
760
- ),
914
+ const message = createCustomMessage(
915
+ entry.customType,
916
+ entry.content,
917
+ entry.display,
918
+ entry.details,
919
+ entry.timestamp,
920
+ entry.attribution,
761
921
  );
922
+ associateSessionMessageEntryId(message, entry.id);
923
+ const persistedObservationId =
924
+ entry.details && typeof entry.details === "object" && !Array.isArray(entry.details)
925
+ ? (entry.details as Record<string, unknown>).observationId
926
+ : undefined;
927
+ associateSessionMessageObservationId(
928
+ message,
929
+ typeof persistedObservationId === "string"
930
+ ? persistedObservationId
931
+ : `session:${sessionIdentityNamespace}:entry:${entry.id}`,
932
+ );
933
+ messages.push(message);
762
934
  } else if (entry.type === "branch_summary" && entry.summary) {
763
935
  messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
764
936
  }
@@ -836,9 +1008,11 @@ export function buildSessionContext(
836
1008
  }
837
1009
 
838
1010
  function cloneSessionContext(context: SessionContext): SessionContext {
1011
+ const messages = cloneJsonSemantic(context.messages);
1012
+ transferSessionMessageIdentity(context.messages, messages);
839
1013
  return {
840
1014
  ...context,
841
- messages: cloneJsonSemantic(context.messages),
1015
+ messages,
842
1016
  models: { ...context.models },
843
1017
  injectedTtsrRules: [...context.injectedTtsrRules],
844
1018
  injectedTtsrRuleRecords: context.injectedTtsrRuleRecords?.map(record => ({ ...record })),
@@ -970,6 +1144,212 @@ export async function loadEntriesFromFile(
970
1144
  return entries;
971
1145
  }
972
1146
 
1147
+ function sameResumeIdentity(left: ResumeSessionIdentity, right: ResumeSessionIdentity): boolean {
1148
+ return (
1149
+ left.canonicalPath === right.canonicalPath &&
1150
+ left.sessionId === right.sessionId &&
1151
+ left.dev === right.dev &&
1152
+ left.ino === right.ino &&
1153
+ left.size === right.size &&
1154
+ left.mtimeMs === right.mtimeMs &&
1155
+ left.mtimeNs === right.mtimeNs &&
1156
+ left.sha256 === right.sha256
1157
+ );
1158
+ }
1159
+
1160
+ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): boolean {
1161
+ return (
1162
+ left.isFile &&
1163
+ right.isFile &&
1164
+ left.dev === right.dev &&
1165
+ left.ino === right.ino &&
1166
+ left.size === right.size &&
1167
+ left.mtimeMs === right.mtimeMs &&
1168
+ left.mtimeNs === right.mtimeNs
1169
+ );
1170
+ }
1171
+
1172
+ interface ResumeInspectionSnapshot {
1173
+ identity: ResumeSessionIdentity;
1174
+ entries: FileEntry[];
1175
+ context: SessionContext;
1176
+ migrationApplied: boolean;
1177
+ }
1178
+
1179
+ function resumeReadFailure(error: unknown, storage: SessionStorage, path: string): ResumeTailError {
1180
+ let missing = isEnoent(error);
1181
+ if (!missing) {
1182
+ try {
1183
+ missing = !storage.existsSync(path);
1184
+ } catch {
1185
+ // Preserve the primary read/stat failure when existence cannot be checked.
1186
+ }
1187
+ }
1188
+ return { kind: "error", reason: missing ? "missing" : "read-failed" };
1189
+ }
1190
+
1191
+ function hasStrictSessionSchema(entries: FileEntry[]): boolean {
1192
+ for (const entry of entries) {
1193
+ const value = entry as unknown as Record<string, unknown>;
1194
+ if (typeof value.type !== "string") return false;
1195
+ if (value.type === "session") {
1196
+ if (typeof value.id !== "string" || typeof value.cwd !== "string" || typeof value.timestamp !== "string")
1197
+ return false;
1198
+ continue;
1199
+ }
1200
+ if (
1201
+ typeof value.id !== "string" ||
1202
+ (value.parentId !== null && typeof value.parentId !== "string") ||
1203
+ typeof value.timestamp !== "string"
1204
+ ) {
1205
+ return false;
1206
+ }
1207
+ switch (value.type) {
1208
+ case "message": {
1209
+ if (typeof value.message !== "object" || value.message === null) return false;
1210
+ const message = value.message as Record<string, unknown>;
1211
+ if (typeof message.role !== "string") return false;
1212
+ break;
1213
+ }
1214
+ case "model_change":
1215
+ if (
1216
+ typeof value.model !== "string" &&
1217
+ !(typeof value.provider === "string" && typeof value.modelId === "string")
1218
+ )
1219
+ return false;
1220
+ break;
1221
+ case "compaction":
1222
+ if (
1223
+ typeof value.summary !== "string" ||
1224
+ typeof value.firstKeptEntryId !== "string" ||
1225
+ typeof value.tokensBefore !== "number"
1226
+ )
1227
+ return false;
1228
+ break;
1229
+ case "branch_summary":
1230
+ if (typeof value.fromId !== "string" || typeof value.summary !== "string") return false;
1231
+ break;
1232
+ case "custom":
1233
+ if (typeof value.customType !== "string") return false;
1234
+ break;
1235
+ case "custom_message":
1236
+ if (
1237
+ typeof value.customType !== "string" ||
1238
+ (typeof value.content !== "string" && !Array.isArray(value.content)) ||
1239
+ typeof value.display !== "boolean"
1240
+ )
1241
+ return false;
1242
+ break;
1243
+ case "label":
1244
+ if (typeof value.targetId !== "string" || (value.label !== undefined && typeof value.label !== "string"))
1245
+ return false;
1246
+ break;
1247
+ case "ttsr_injection":
1248
+ if (!Array.isArray(value.injectedRules) || !value.injectedRules.every(rule => typeof rule === "string"))
1249
+ return false;
1250
+ break;
1251
+ case "mcp_tool_selection":
1252
+ if (
1253
+ !Array.isArray(value.selectedToolNames) ||
1254
+ !value.selectedToolNames.every(name => typeof name === "string")
1255
+ )
1256
+ return false;
1257
+ break;
1258
+ case "session_init":
1259
+ if (
1260
+ typeof value.systemPrompt !== "string" ||
1261
+ typeof value.task !== "string" ||
1262
+ !Array.isArray(value.tools) ||
1263
+ !value.tools.every(tool => typeof tool === "string")
1264
+ )
1265
+ return false;
1266
+ break;
1267
+ case "mode_change":
1268
+ if (typeof value.mode !== "string") return false;
1269
+ break;
1270
+ case "thinking_level_change":
1271
+ if (
1272
+ value.thinkingLevel !== undefined &&
1273
+ value.thinkingLevel !== null &&
1274
+ typeof value.thinkingLevel !== "string"
1275
+ )
1276
+ return false;
1277
+ break;
1278
+ case "service_tier_change":
1279
+ if (value.serviceTier !== null && typeof value.serviceTier !== "string") return false;
1280
+ break;
1281
+ default:
1282
+ return false;
1283
+ }
1284
+ }
1285
+ return true;
1286
+ }
1287
+
1288
+ function inspectResumeSessionFile(
1289
+ filePath: string,
1290
+ storage: SessionStorage,
1291
+ ): ResumeInspectionSnapshot | ResumeTailError {
1292
+ const canonicalPath = resolveEquivalentPath(path.resolve(filePath));
1293
+ let before: SessionStorageStat;
1294
+ try {
1295
+ before = storage.statSync(canonicalPath);
1296
+ } catch (error) {
1297
+ return resumeReadFailure(error, storage, canonicalPath);
1298
+ }
1299
+ if (!before.isFile || !storage.readSnapshotSync) {
1300
+ return { kind: "error", reason: "read-failed" };
1301
+ }
1302
+
1303
+ let bytes: Uint8Array;
1304
+ let snapshot: SessionStorageStat;
1305
+ let after: SessionStorageStat;
1306
+ try {
1307
+ const readSnapshot = storage.readSnapshotSync(canonicalPath);
1308
+ bytes = readSnapshot.bytes;
1309
+ snapshot = readSnapshot.stat;
1310
+ after = storage.statSync(canonicalPath);
1311
+ } catch (error) {
1312
+ return resumeReadFailure(error, storage, canonicalPath);
1313
+ }
1314
+ if (!sameResumeStat(before, snapshot) || !sameResumeStat(snapshot, after)) {
1315
+ return { kind: "error", reason: "unstable" };
1316
+ }
1317
+
1318
+ try {
1319
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1320
+ for (const line of content.split(/\r?\n/)) {
1321
+ if (line.length === 0) continue;
1322
+ JSON.parse(line);
1323
+ }
1324
+ const entries = parseSessionEntries(content);
1325
+ const header = entries[0] as SessionHeader | undefined;
1326
+ if (header?.type !== "session" || typeof header.id !== "string") {
1327
+ return { kind: "error", reason: "malformed" };
1328
+ }
1329
+ const migrationApplied = migrateToCurrentVersion(entries);
1330
+ if (!hasStrictSessionSchema(entries)) return { kind: "error", reason: "malformed" };
1331
+ const context = buildSessionContext(
1332
+ entries.filter((entry): entry is SessionEntry => entry.type !== "session"),
1333
+ undefined,
1334
+ undefined,
1335
+ header.id,
1336
+ );
1337
+ const identity: ResumeSessionIdentity = {
1338
+ canonicalPath,
1339
+ sessionId: header.id,
1340
+ dev: snapshot.dev,
1341
+ ino: snapshot.ino,
1342
+ size: snapshot.size,
1343
+ mtimeMs: snapshot.mtimeMs,
1344
+ mtimeNs: snapshot.mtimeNs,
1345
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
1346
+ };
1347
+ return { identity, entries, context, migrationApplied };
1348
+ } catch {
1349
+ return { kind: "error", reason: "malformed" };
1350
+ }
1351
+ }
1352
+
973
1353
  /**
974
1354
  * Convert legacy persisted blob references in loaded entries into resident sentinels.
975
1355
  * Images then materialize lazily at provider/display/export chokepoints instead of
@@ -1605,11 +1985,13 @@ function cloneJsonSemantic<T>(value: T): T {
1605
1985
  }
1606
1986
 
1607
1987
  function cloneAgentMessage<T extends AgentMessage>(message: T): T {
1608
- return {
1988
+ const cloned = {
1609
1989
  ...message,
1610
1990
  ...("content" in message ? { content: cloneJsonSemantic(message.content) } : {}),
1611
1991
  ...("providerPayload" in message ? { providerPayload: cloneJsonSemantic(message.providerPayload) } : {}),
1612
- };
1992
+ } as T;
1993
+ transferSessionMessageIdentity([message], [cloned]);
1994
+ return cloned;
1613
1995
  }
1614
1996
 
1615
1997
  function cloneSessionEntry(entry: SessionEntry): SessionEntry {
@@ -2196,6 +2578,7 @@ class NdjsonFileWriter {
2196
2578
  #error: Error | undefined;
2197
2579
  #pendingWrites: Promise<void> = Promise.resolve();
2198
2580
  #onError: ((err: Error) => void) | undefined;
2581
+ #closeDrained = false;
2199
2582
 
2200
2583
  constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
2201
2584
  this.#onError = options?.onError;
@@ -2289,35 +2672,61 @@ class NdjsonFileWriter {
2289
2672
  }
2290
2673
  }
2291
2674
 
2292
- /** Close the writer, flushing all data. */
2675
+ /** Close the writer, flushing all data. Retryable across certified pre-dispatch failures. */
2293
2676
  async close(): Promise<void> {
2294
- if (this.#closed || this.#closing) return;
2295
- this.#closing = true;
2677
+ // Terminal (confirmed closed or quarantined close_unknown): no further attempts.
2678
+ if (this.#closed) return;
2679
+ // Re-entry guard: once a fresh attempt has started draining, block concurrent
2680
+ // close() calls. A retry after a certified pre-dispatch failure has #closing
2681
+ // reset and #closeDrained set, so it re-enters to actually re-dispatch the
2682
+ // underlying OS close — the wrapper must NOT mark itself closed on a retryable
2683
+ // failure, and must allow a genuine later retry.
2684
+ if (this.#closing) return;
2296
2685
 
2297
- let closeError: Error | undefined;
2298
- try {
2299
- await this.flush();
2300
- } catch (err) {
2301
- closeError = toError(err);
2302
- }
2686
+ this.#closing = true;
2303
2687
 
2304
- try {
2305
- await this.#pendingWrites;
2306
- } catch (err) {
2307
- if (!closeError) closeError = toError(err);
2688
+ let drainError: Error | undefined;
2689
+ // Only drain once: a retry after a pre-dispatch failure has already flushed
2690
+ // pending writes, and the underlying writer rejects further flushes while in
2691
+ // the retryable state. Re-draining would mask the close retry with that error.
2692
+ if (!this.#closeDrained) {
2693
+ try {
2694
+ await this.flush();
2695
+ } catch (err) {
2696
+ drainError = toError(err);
2697
+ }
2698
+ try {
2699
+ await this.#pendingWrites;
2700
+ } catch (err) {
2701
+ if (!drainError) drainError = toError(err);
2702
+ }
2703
+ this.#closeDrained = true;
2308
2704
  }
2309
2705
 
2310
2706
  try {
2311
2707
  await this.#writer.close();
2312
2708
  } catch (err) {
2313
- const endErr = this.#recordError(err);
2314
- if (!closeError) closeError = endErr;
2709
+ if (this.#writer.getCloseState() === "close_failed_retryable") {
2710
+ // Certified pre-dispatch failure: the numeric fd is still owned and a
2711
+ // later retry is safe. Do NOT mark the wrapper closed, do NOT poison
2712
+ // #error (the retry must still be able to flush/close), and reset
2713
+ // #closing so the retry can actually re-dispatch. Surface the failure —
2714
+ // a partial close is never reported as success.
2715
+ this.#closing = false;
2716
+ throw toError(err);
2717
+ }
2718
+ // close_unknown (quarantined) or other dispatched failure: terminal. Record
2719
+ // the error and mark the wrapper closed so no retry/finalizer touches the
2720
+ // uncertain fd again.
2721
+ this.#closed = true;
2722
+ throw this.#recordError(err);
2315
2723
  }
2316
2724
 
2725
+ // Confirmed closed (underlying close succeeded).
2317
2726
  this.#closed = true;
2318
2727
 
2319
- if (!closeError && this.#error) closeError = this.#error;
2320
- if (closeError) throw closeError;
2728
+ if (drainError) throw drainError;
2729
+ if (this.#error) throw this.#error;
2321
2730
  }
2322
2731
 
2323
2732
  /** Check if there's a stored error. */
@@ -2325,16 +2734,49 @@ class NdjsonFileWriter {
2325
2734
  return this.#error;
2326
2735
  }
2327
2736
 
2328
- /** True while the writer accepts new writes (not closing or closed). */
2737
+ /**
2738
+ * True only while the writer accepts new writes. A retryable close failure
2739
+ * leaves the wrapper non-terminal but the underlying fd rejects writes, so
2740
+ * callers route appends around it rather than through it.
2741
+ */
2329
2742
  isOpen(): boolean {
2330
- return !this.#closed && !this.#closing;
2743
+ if (this.#closed || this.#closing) return false;
2744
+ return this.#writer.getCloseState() === "open";
2331
2745
  }
2332
2746
 
2747
+ /**
2748
+ * Truthful synchronous close used by the atomic-rewrite path: dispatches the
2749
+ * underlying close synchronously and throws on failure so a close failure is
2750
+ * observable BEFORE the rename step. No fire-and-forget: the old suppression
2751
+ * (`close().catch(() => {})`) let a rename proceed on an unclosed file.
2752
+ */
2333
2753
  closeSync(): void {
2334
2754
  if (this.#closed) return;
2755
+ if (this.#closing) return;
2756
+ // The sync path's writeSync bypasses the async queue, so #pendingWrites is
2757
+ // not applicable; the caller has already issued every writeSync before this.
2758
+ try {
2759
+ this.#writer.closeSync();
2760
+ } catch (err) {
2761
+ if (this.#writer.getCloseState() === "close_failed_retryable") {
2762
+ // Retryable: keep the wrapper open for the cleanup retry in the caller's
2763
+ // catch block; surface the failure so the rename is skipped.
2764
+ throw toError(err);
2765
+ }
2766
+ this.#closed = true;
2767
+ throw this.#recordError(err);
2768
+ }
2335
2769
  this.#closed = true;
2336
- this.#closing = true;
2337
- this.#writer.close().catch(() => {});
2770
+ }
2771
+
2772
+ /** Certainty-aware close state of the underlying storage writer. */
2773
+ getCloseState(): SessionStorageWriterCloseState {
2774
+ return this.#writer.getCloseState();
2775
+ }
2776
+
2777
+ /** Stored error for a non-success underlying close state. */
2778
+ getCloseError(): Error | undefined {
2779
+ return this.#writer.getCloseError();
2338
2780
  }
2339
2781
  }
2340
2782
 
@@ -2921,6 +3363,26 @@ export class SessionManager {
2921
3363
  await this.setSessionFile(sessionFile);
2922
3364
  }
2923
3365
 
3366
+ async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
3367
+ const header = entries[0] as SessionHeader;
3368
+ this.#sessionFile = path.resolve(sessionFile);
3369
+ this.#sessionId = header.id;
3370
+ this.#sessionName = header.title;
3371
+ this.#titleSource = header.titleSource;
3372
+ this.#needsFullRewriteOnNextPersist = migrationApplied;
3373
+ await resolveBlobRefsInEntries(entries, this.#blobStore);
3374
+ this.#fileEntries = entries;
3375
+ this.#resetResidentTextBlobStore();
3376
+ this.#fileEntries = this.#fileEntries.map(entry =>
3377
+ prepareEntryForResidentSync(entry, this.#residentBlobStores()),
3378
+ );
3379
+ this.sanitizeLoadedOpenAIResponsesReplayMetadata();
3380
+ this.#buildIndex();
3381
+ this.#bumpAllRevisions();
3382
+ this.#flushed = true;
3383
+ this.#ensuredOnDisk = true;
3384
+ }
3385
+
2924
3386
  /** Initialize with a new session (used by factory methods) */
2925
3387
  #initNewSession(): void {
2926
3388
  this.#newSessionSync();
@@ -3446,7 +3908,15 @@ export class SessionManager {
3446
3908
  writer.closeSync();
3447
3909
  this.#replaceSessionFileSync(tempPath, this.#sessionFile);
3448
3910
  } catch (err) {
3449
- writer.closeSync();
3911
+ // closeSync is now truthful and may throw; wrap the best-effort cleanup so
3912
+ // the original error (write/close failure) is the one surfaced, not the
3913
+ // cleanup failure. The rename above was already skipped because closeSync
3914
+ // threw before it.
3915
+ try {
3916
+ writer.closeSync();
3917
+ } catch {
3918
+ // Best-effort cleanup of the temp writer's descriptor.
3919
+ }
3450
3920
  void this.storage.unlink(tempPath).catch(() => {});
3451
3921
  throw toError(err);
3452
3922
  }
@@ -3544,6 +4014,73 @@ export class SessionManager {
3544
4014
  this.#disposeResidentTextBlobStore();
3545
4015
  if (this.#persistError) throw this.#persistError;
3546
4016
  }
4017
+ /** Flush while open, then strictly close; retryable close skips the invalid second flush. */
4018
+ async flushAndCloseStrict(): Promise<SessionManagerCloseOutcome> {
4019
+ if (this.#persistWriter?.getCloseState() !== "close_failed_retryable") {
4020
+ await this.flush();
4021
+ }
4022
+ return this.closeStrict();
4023
+ }
4024
+
4025
+ /**
4026
+ * Strictly flush and close the persist writer, returning the certainty-aware close
4027
+ * outcome without manufacturing success. The existing {@link close} path is
4028
+ * preserved for best-effort callers; this seam lets strict ACP disposal prove
4029
+ * writer closure before any destructive operation.
4030
+ */
4031
+ async closeStrict(): Promise<SessionManagerCloseOutcome> {
4032
+ let outcome: SessionManagerCloseOutcome = { kind: "closed" };
4033
+ await this.#queuePersistTask(async () => {
4034
+ const writer = this.#persistWriter;
4035
+ if (!writer) {
4036
+ this.#flushed = true;
4037
+ return;
4038
+ }
4039
+ try {
4040
+ await writer.close();
4041
+ } catch {
4042
+ // Outcome is captured from the underlying writer's close state below.
4043
+ }
4044
+ outcome = this.#closeOutcomeFromWriter(writer);
4045
+ if (outcome.kind === "closed") {
4046
+ this.#flushed = true;
4047
+ // Confirmed closed: release writer ownership.
4048
+ this.#persistWriter = undefined;
4049
+ this.#persistWriterPath = undefined;
4050
+ } else if (outcome.kind === "close_unknown") {
4051
+ // Quarantined (terminal): release ownership so no retry/finalizer
4052
+ // touches the uncertain fd again.
4053
+ this.#persistWriter = undefined;
4054
+ this.#persistWriterPath = undefined;
4055
+ }
4056
+ // close_failed_retryable: RETAIN the writer so a later closeStrict() call
4057
+ // can actually re-dispatch the OS close (ownership stays proven). The
4058
+ // wrapper must not manufacture success or surrender a retryable fd.
4059
+ });
4060
+ // Only tear down the resident blob store on a terminal outcome; a retryable
4061
+ // close leaves the session live for a genuine retry.
4062
+ if (!this.#persistWriter) {
4063
+ this.#disposeResidentTextBlobStore();
4064
+ }
4065
+ return outcome;
4066
+ }
4067
+
4068
+ #closeOutcomeFromWriter(writer: NdjsonFileWriter): SessionManagerCloseOutcome {
4069
+ const state = writer.getCloseState();
4070
+ const error = writer.getCloseError();
4071
+ switch (state) {
4072
+ case "closed":
4073
+ return { kind: "closed" };
4074
+ case "close_failed_retryable":
4075
+ return { kind: "close_failed_retryable", error: error ?? new Error("Writer close failed before dispatch") };
4076
+ case "close_unknown":
4077
+ return { kind: "close_unknown", error: error ?? new Error("Writer close outcome is unknown") };
4078
+ default:
4079
+ // "open" should not occur after close() returned, but treat defensively as
4080
+ // a non-quiescent terminal state rather than confirmed closed.
4081
+ return { kind: "close_unknown", error: error ?? new Error("Writer close did not dispatch") };
4082
+ }
4083
+ }
3547
4084
 
3548
4085
  getCwd(): string {
3549
4086
  return this.cwd;
@@ -3874,7 +4411,10 @@ export class SessionManager {
3874
4411
  timestamp: new Date().toISOString(),
3875
4412
  message,
3876
4413
  };
4414
+ associateSessionMessageEntryId(message, entry.id);
3877
4415
  this.#appendEntry(entry);
4416
+ const residentEntry = this.#byId.get(entry.id);
4417
+ if (residentEntry?.type === "message") transferSessionMessageIdentity([message], [residentEntry.message]);
3878
4418
  return entry.id;
3879
4419
  }
3880
4420
 
@@ -4067,7 +4607,15 @@ export class SessionManager {
4067
4607
  display: boolean,
4068
4608
  details?: T,
4069
4609
  attribution: MessageAttribution = "agent",
4610
+ observationId?: string,
4070
4611
  ): string {
4612
+ const entryId = generateId(this.#byId);
4613
+ const stableObservationId =
4614
+ observationId ?? (customType.startsWith("irc:") ? `session:${this.#sessionId}:entry:${entryId}` : undefined);
4615
+ const persistedDetails =
4616
+ stableObservationId && details && typeof details === "object" && !Array.isArray(details)
4617
+ ? ({ ...details, observationId: stableObservationId } as T)
4618
+ : details;
4071
4619
  const entry: CustomMessageEntry<T> = {
4072
4620
  type: "custom_message",
4073
4621
  customType,
@@ -4076,9 +4624,9 @@ export class SessionManager {
4076
4624
  // Drop AgentSession-internal transient fields (allowlist in
4077
4625
  // `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
4078
4626
  // chokepoint covers every CustomMessage write path.
4079
- details: stripInternalDetailsFields(details),
4627
+ details: stripInternalDetailsFields(persistedDetails),
4080
4628
  attribution,
4081
- id: generateId(this.#byId),
4629
+ id: entryId,
4082
4630
  parentId: this.#leafId,
4083
4631
  timestamp: new Date().toISOString(),
4084
4632
  };
@@ -4496,7 +5044,12 @@ export class SessionManager {
4496
5044
  return cloneSessionContext(cached);
4497
5045
  }
4498
5046
  this.#pathOnlyContextBuildCount++;
4499
- const context = buildSessionContext(this.#getActivePathEntriesForProviderContext(), this.#leafId);
5047
+ const context = buildSessionContext(
5048
+ this.#getActivePathEntriesForProviderContext(),
5049
+ this.#leafId,
5050
+ undefined,
5051
+ this.#sessionId,
5052
+ );
4500
5053
  this.#sessionContextCache = new WeakRef(context);
4501
5054
  this.#sessionContextEntryRevision = this.#entryRevision;
4502
5055
  this.#sessionContextLeafRevision = this.#leafRevision;
@@ -4546,6 +5099,9 @@ export class SessionManager {
4546
5099
  this.#residentBlobStoresForColdRehydrate(),
4547
5100
  );
4548
5101
  if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
5102
+ if (entry.type === "message" && rehydrated.type === "message") {
5103
+ transferSessionMessageIdentity([entry.message], [rehydrated.message]);
5104
+ }
4549
5105
  return rehydrated;
4550
5106
  }
4551
5107
 
@@ -4937,6 +5493,71 @@ export class SessionManager {
4937
5493
  return manager;
4938
5494
  }
4939
5495
 
5496
+ /**
5497
+ * List sessions for the resume picker without recovery or other maintenance writes.
5498
+ */
5499
+ static async listForResumePickerReadOnly(
5500
+ cwd: string,
5501
+ sessionDir?: string,
5502
+ storage: SessionStorage = new FileSessionStorage(),
5503
+ ): Promise<SessionInfo[]> {
5504
+ const dirs = sessionDir ? [sessionDir] : getReadOnlyDefaultSessionDirs(cwd);
5505
+ try {
5506
+ const files = new Set<string>();
5507
+ for (const dir of dirs) {
5508
+ for (const filePath of storage.listFilesSync(dir, "*.jsonl")) files.add(filePath);
5509
+ }
5510
+ return await collectSessionsFromFiles([...files], storage);
5511
+ } catch {
5512
+ return [];
5513
+ }
5514
+ }
5515
+
5516
+ /** Inspect a selected session without acquiring write-capable ownership. */
5517
+ static async inspectSessionTailReadOnly(
5518
+ filePath: string,
5519
+ storage: SessionStorage = new FileSessionStorage(),
5520
+ ): Promise<ResumeTailInspection> {
5521
+ const inspected = inspectResumeSessionFile(filePath, storage);
5522
+ if ("kind" in inspected) return inspected;
5523
+ return canContinuePersistedHistory(inspected.context.messages)
5524
+ ? { kind: "resumable", identity: inspected.identity }
5525
+ : { kind: "terminal", identity: inspected.identity };
5526
+ }
5527
+
5528
+ /**
5529
+ * Main startup code MUST retain the consented inspection identity and branch on
5530
+ * the returned discriminant; an error result never creates, rewrites, or adopts
5531
+ * the selected path. Breadcrumb ownership begins only after `kind: "opened"`.
5532
+ */
5533
+ static async openExistingStrict(
5534
+ identity: ResumeSessionIdentity,
5535
+ sessionDir?: string,
5536
+ storage: SessionStorage = new FileSessionStorage(),
5537
+ ): Promise<StrictSessionOpenResult> {
5538
+ const inspected = inspectResumeSessionFile(identity.canonicalPath, storage);
5539
+ if ("kind" in inspected) return inspected;
5540
+ if (!sameResumeIdentity(identity, inspected.identity)) {
5541
+ return { kind: "error", reason: "identity-mismatch" };
5542
+ }
5543
+ const entries = structuredClone(inspected.entries) as FileEntry[];
5544
+ const header = entries[0] as SessionHeader;
5545
+ const dir = sessionDir ?? path.resolve(identity.canonicalPath, "..");
5546
+ const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
5547
+ await manager.#hydrateExistingSession(identity.canonicalPath, entries, inspected.migrationApplied);
5548
+ const ownershipInspection = inspectResumeSessionFile(identity.canonicalPath, storage);
5549
+ if ("kind" in ownershipInspection) {
5550
+ await manager.close();
5551
+ return ownershipInspection;
5552
+ }
5553
+ if (!sameResumeIdentity(identity, ownershipInspection.identity)) {
5554
+ await manager.close();
5555
+ return { kind: "error", reason: "identity-mismatch" };
5556
+ }
5557
+ writeTerminalBreadcrumb(manager.cwd, identity.canonicalPath);
5558
+ return { kind: "opened", manager };
5559
+ }
5560
+
4940
5561
  /**
4941
5562
  * Continue the most recent session, or create new if none.
4942
5563
  * @param cwd Working directory
@@ -5014,4 +5635,153 @@ export class SessionManager {
5014
5635
  return [];
5015
5636
  }
5016
5637
  }
5638
+ /**
5639
+ * Strict raw scoped inventory for ACP authorization. Enumerates the scoped
5640
+ * session directory without suppressing any root/scan/lstat/read/parse/stat/
5641
+ * header/cwd/containment/identity failure. A failure result carries every
5642
+ * sanitized failure and grants zero authority — it is never reduced to a
5643
+ * partial candidate set. Display/global {@link list} behavior is unaffected.
5644
+ */
5645
+ static inventorySessionsStrict(
5646
+ cwd: string,
5647
+ options?: { sessionDir?: string; storage?: SessionStorage },
5648
+ ): StrictInventoryResult {
5649
+ const storage = options?.storage ?? new FileSessionStorage();
5650
+ const dir = options?.sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
5651
+ if (!storage.listFilesStrictSync) {
5652
+ return {
5653
+ kind: "failure",
5654
+ failures: [{ kind: "scan", message: "Strict scoped session scan is unavailable", path: dir }],
5655
+ };
5656
+ }
5657
+ const strictScan = storage.listFilesStrictSync.bind(storage);
5658
+
5659
+ let files: string[];
5660
+ try {
5661
+ files = strictScan(dir, "*.jsonl");
5662
+ } catch (err) {
5663
+ const code = (err as NodeJS.ErrnoException)?.code;
5664
+ // Only a confirmed ENOENT proves the scoped root is genuinely absent,
5665
+ // which is a complete (zero-candidate) inventory. Any other root/scan
5666
+ // error (EACCES/EIO/EPERM/ENOTDIR/...) is fail-closed: it must never
5667
+ // reduce to authoritative absence and grants zero authority. The
5668
+ // forgiving existsSync preflight is intentionally absent — fs.existsSync
5669
+ // collapses EACCES/EIO/EPERM onto a false return and would let a
5670
+ // permission-denied root masquerade as a confirmed-empty directory.
5671
+ if (code === "ENOENT") {
5672
+ return { kind: "complete", candidates: [] };
5673
+ }
5674
+ void err;
5675
+ const failureKind: StrictInventoryFailureKind =
5676
+ code === "EACCES" || code === "EPERM" || code === "ENOTDIR" ? "root" : "scan";
5677
+ return {
5678
+ kind: "failure",
5679
+ failures: [
5680
+ {
5681
+ kind: failureKind,
5682
+ message:
5683
+ failureKind === "root"
5684
+ ? "Scoped session root could not be inspected"
5685
+ : "Scoped session directory scan failed",
5686
+ path: dir,
5687
+ },
5688
+ ],
5689
+ };
5690
+ }
5691
+
5692
+ const failures: StrictInventoryFailure[] = [];
5693
+ const candidates: StrictInventoryCandidate[] = [];
5694
+ for (const file of files) {
5695
+ const candidate = inventoryReadCandidate(storage, file, cwd, dir);
5696
+ if ("failures" in candidate) {
5697
+ failures.push(...candidate.failures);
5698
+ continue;
5699
+ }
5700
+ candidates.push(candidate.candidate);
5701
+ }
5702
+ if (failures.length > 0) {
5703
+ return { kind: "failure", failures };
5704
+ }
5705
+ return { kind: "complete", candidates };
5706
+ }
5707
+
5708
+ /**
5709
+ * Propagate the storage-layer verified hard delete bound to exact identity evidence.
5710
+ * Never performs ID lookup or first-match selection; the caller supplies the exact
5711
+ * authorization target captured from a complete strict inventory.
5712
+ */
5713
+ async deleteSessionVerified(target: VerifiedSessionDeleteTarget): Promise<VerifiedSessionDeleteResult> {
5714
+ if (!this.storage.deleteSessionVerified) {
5715
+ throw new Error("Storage backend does not support verified session deletion");
5716
+ }
5717
+ return this.storage.deleteSessionVerified(target);
5718
+ }
5719
+ }
5720
+ const strictInventoryDecoder = new TextDecoder("utf-8", { fatal: false });
5721
+
5722
+ /** Strictly read + validate one scoped session candidate. Never suppresses a failure. */
5723
+ function inventoryReadCandidate(
5724
+ storage: SessionStorage,
5725
+ file: string,
5726
+ expectedCwd: string,
5727
+ sessionDir: string,
5728
+ ): { candidate: StrictInventoryCandidate } | { failures: StrictInventoryFailure[] } {
5729
+ const failures: StrictInventoryFailure[] = [];
5730
+ const resolvedFile = path.resolve(file);
5731
+ if (!pathIsWithin(path.resolve(sessionDir), resolvedFile)) {
5732
+ return {
5733
+ failures: [{ kind: "containment", message: "Candidate is outside the scoped session directory", path: file }],
5734
+ };
5735
+ }
5736
+ if (!storage.readSnapshotSync) {
5737
+ return { failures: [{ kind: "read", message: "Storage backend cannot read exact bytes", path: file }] };
5738
+ }
5739
+ let snapshot: SessionStorageSnapshot;
5740
+ try {
5741
+ snapshot = storage.readSnapshotSync(file);
5742
+ } catch (err) {
5743
+ const code = (err as NodeJS.ErrnoException)?.code;
5744
+ if (code === "ELOOP" || code === "SYMLINK") {
5745
+ failures.push({ kind: "lstat", message: "Candidate is a symlink", path: file });
5746
+ } else {
5747
+ failures.push({ kind: "read", message: "Candidate could not be read", path: file });
5748
+ }
5749
+ return { failures };
5750
+ }
5751
+ if (!snapshot.stat.isFile) {
5752
+ failures.push({ kind: "lstat", message: "Candidate is not a regular file", path: file });
5753
+ return { failures };
5754
+ }
5755
+ const newline = snapshot.bytes.indexOf(0x0a);
5756
+ const headerBytes = newline === -1 ? snapshot.bytes : snapshot.bytes.subarray(0, newline);
5757
+ let header: Record<string, unknown> | undefined;
5758
+ try {
5759
+ const text = strictInventoryDecoder.decode(headerBytes).trim();
5760
+ const value: unknown = text ? JSON.parse(text) : undefined;
5761
+ header = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
5762
+ } catch {
5763
+ header = undefined;
5764
+ }
5765
+ if (!header) {
5766
+ failures.push({ kind: "parse", message: "Candidate header is not valid JSON", path: file });
5767
+ return { failures };
5768
+ }
5769
+ if (header.type !== "session") {
5770
+ failures.push({ kind: "header", message: "Candidate header is not a session header", path: file });
5771
+ return { failures };
5772
+ }
5773
+ if (typeof header.id !== "string" || header.id.length === 0) {
5774
+ failures.push({ kind: "identity", message: "Candidate header is missing a session id", path: file });
5775
+ return { failures };
5776
+ }
5777
+ if (typeof header.cwd !== "string") {
5778
+ failures.push({ kind: "cwd", message: "Candidate header is missing a cwd", path: file });
5779
+ return { failures };
5780
+ }
5781
+ const canonicalHeaderCwd = resolveEquivalentPath(header.cwd);
5782
+ if (canonicalHeaderCwd !== resolveEquivalentPath(expectedCwd)) {
5783
+ failures.push({ kind: "cwd", message: "Candidate cwd does not match the scoped workspace", path: file });
5784
+ return { failures };
5785
+ }
5786
+ return { candidate: { path: resolvedFile, id: header.id, cwd: header.cwd, identity: snapshot.stat } };
5017
5787
  }