@gajae-code/coding-agent 0.11.4 → 0.11.5

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 (146) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +20 -0
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +88 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/task/executor.d.ts +9 -0
  50. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  51. package/dist/types/tools/ask.d.ts +48 -0
  52. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  53. package/dist/types/tools/index.d.ts +4 -0
  54. package/package.json +9 -9
  55. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  56. package/src/cli/notify-cli.ts +40 -18
  57. package/src/commands/sdk.ts +7 -2
  58. package/src/config/settings-schema.ts +17 -0
  59. package/src/daemon/runtime.ts +2 -2
  60. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  61. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  62. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  64. package/src/eval/py/index.ts +2 -2
  65. package/src/export/html/template.generated.ts +1 -1
  66. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  67. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  68. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  69. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  70. package/src/gjc-runtime/launch-tmux.ts +61 -6
  71. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  72. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  73. package/src/gjc-runtime/state-runtime.ts +38 -12
  74. package/src/gjc-runtime/team-runtime.ts +1 -1
  75. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  76. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  77. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  78. package/src/harness-control-plane/owner.ts +59 -3
  79. package/src/hindsight/client.ts +65 -2
  80. package/src/internal-urls/agent-protocol.ts +76 -5
  81. package/src/internal-urls/docs-index.generated.ts +7 -6
  82. package/src/internal-urls/local-protocol.ts +416 -18
  83. package/src/lsp/index.ts +4 -88
  84. package/src/main.ts +37 -13
  85. package/src/modes/acp/acp-agent.ts +8 -0
  86. package/src/modes/components/ansi-display-validator.ts +54 -0
  87. package/src/modes/components/notifications-settings-editor.ts +26 -2
  88. package/src/modes/components/session-observer-overlay.ts +40 -24
  89. package/src/modes/components/settings-selector.ts +3 -1
  90. package/src/modes/components/tool-transcript-format.ts +321 -0
  91. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  92. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  93. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  94. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  95. package/src/modes/controllers/selector-controller.ts +70 -8
  96. package/src/modes/interactive-mode.ts +1 -0
  97. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  98. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  99. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  100. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  101. package/src/plan-mode/approved-plan.ts +3 -1
  102. package/src/prompts/agents/architect.md +1 -0
  103. package/src/prompts/agents/critic.md +1 -0
  104. package/src/prompts/system/custom-system-prompt.md +1 -1
  105. package/src/prompts/system/system-prompt.md +2 -1
  106. package/src/prompts/tools/lsp.md +3 -3
  107. package/src/runtime-mcp/manager.ts +16 -1
  108. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  109. package/src/sdk/broker/session-index.ts +122 -64
  110. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  111. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  112. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  113. package/src/sdk/bus/config-commands.ts +22 -0
  114. package/src/sdk/bus/config.ts +37 -0
  115. package/src/sdk/bus/index.ts +384 -92
  116. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  117. package/src/sdk/bus/notification-service.ts +284 -62
  118. package/src/sdk/bus/recent-activity.ts +14 -5
  119. package/src/sdk/bus/session-control.ts +150 -44
  120. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  121. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  122. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  123. package/src/sdk/bus/telegram-daemon.ts +1788 -267
  124. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  125. package/src/sdk/session.ts +155 -27
  126. package/src/secrets/index.ts +53 -10
  127. package/src/secrets/obfuscator.ts +21 -18
  128. package/src/secrets/regex.ts +2 -0
  129. package/src/session/agent-session.ts +495 -253
  130. package/src/session/artifacts.ts +188 -23
  131. package/src/session/context-estimation.ts +7 -2
  132. package/src/session/internal/managed-session-scope.ts +200 -38
  133. package/src/session/internal/managed-session-storage.ts +1152 -46
  134. package/src/session/session-manager.ts +1055 -148
  135. package/src/session/session-storage.ts +109 -11
  136. package/src/session/streaming-output.ts +6 -0
  137. package/src/task/executor.ts +71 -26
  138. package/src/task/index.ts +63 -20
  139. package/src/tool-discovery/tool-index.ts +18 -0
  140. package/src/tools/ask.ts +129 -32
  141. package/src/tools/bash-skill-urls.ts +14 -3
  142. package/src/tools/bash.ts +2 -2
  143. package/src/tools/index.ts +4 -0
  144. package/src/tools/plan-mode-guard.ts +1 -0
  145. package/src/tools/sqlite-reader.ts +3 -2
  146. package/src/tools/subagent.ts +2 -0
@@ -33,6 +33,7 @@ import {
33
33
  } from "@gajae-code/utils";
34
34
  import type { TtsrInjectionRecord } from "../export/ttsr";
35
35
  import { writeTextAtomic } from "../gjc-runtime/state-writer";
36
+ import type { ManagedLegacyLocalMigrationSource } from "../internal-urls/local-protocol";
36
37
 
37
38
  import * as git from "../utils/git";
38
39
  import { ArtifactManager } from "./artifacts";
@@ -58,10 +59,19 @@ import {
58
59
  deleteManagedSessionCandidate,
59
60
  listManagedCandidates,
60
61
  type ManagedMigrationPolicy,
62
+ type ManagedScope,
63
+ managedDirectoryAuthorityForScope,
64
+ managedRootForScope,
61
65
  openManagedCandidateForWrite,
62
66
  prepareManagedSessionScopeForWriteSync,
63
67
  resolveManagedScope,
64
68
  } from "./internal/managed-session-scope";
69
+ import {
70
+ type ManagedDirectoryRoot,
71
+ type ManagedFileSnapshot,
72
+ ManagedSessionDescendantStore,
73
+ retainManagedDirectoryAuthority,
74
+ } from "./internal/managed-session-storage";
65
75
 
66
76
  import {
67
77
  type BashExecutionMessage,
@@ -77,6 +87,7 @@ import {
77
87
  } from "./messages";
78
88
  import { type SessionManagerReadAccess, sessionManagerReadCapability } from "./session-manager-internal";
79
89
  import type {
90
+ ManagedSessionSecurityContext,
80
91
  SessionStorage,
81
92
  SessionStorageSnapshot,
82
93
  SessionStorageStat,
@@ -85,14 +96,13 @@ import type {
85
96
  VerifiedSessionDeleteResult,
86
97
  VerifiedSessionDeleteTarget,
87
98
  } from "./session-storage";
88
- import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
99
+ import { createManagedSessionSecurityContext, FileSessionStorage, MemorySessionStorage } from "./session-storage";
89
100
 
90
- export const CURRENT_SESSION_VERSION = 4;
101
+ export const CURRENT_SESSION_VERSION = 5;
91
102
 
92
103
  /**
93
- * Version 4 adds append-only patch records. New writers emit v4 headers; v1–v3
94
- * transcripts remain readable. Older builds do not apply these records, so they
95
- * must not be used to edit a session after a v4 writer has updated it.
104
+ * Version 5 separates persisted MCP and discovered-built-in selection authority.
105
+ * Version 4 patch records remain readable; older writers must not edit v5 sessions.
96
106
  */
97
107
 
98
108
  function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
@@ -326,6 +336,18 @@ export interface MCPToolSelectionEntry extends SessionEntryBase {
326
336
  type: "mcp_tool_selection";
327
337
  /** MCP tool names selected for visibility in discovery mode. */
328
338
  selectedToolNames: string[];
339
+ /** Legacy v4 combined built-in authority, retained for read compatibility. */
340
+ selectedDiscoveredBuiltinToolNames?: string[];
341
+ /** Correlates the ordered MCP and built-in entries emitted by one combined activation. */
342
+ mutationCorrelationId?: string;
343
+ }
344
+
345
+ /** Persisted discovered-built-in selection state, independent of MCP authority. */
346
+ export interface DiscoveredBuiltinToolSelectionEntry extends SessionEntryBase {
347
+ type: "discovered_builtin_tool_selection";
348
+ selectedToolNames: string[];
349
+ /** Correlates the ordered MCP and built-in entries emitted by one combined activation. */
350
+ mutationCorrelationId?: string;
329
351
  }
330
352
 
331
353
  /** Session init entry - captures initial context for subagent sessions (debugging/replay). */
@@ -389,6 +411,7 @@ export type SessionEntry =
389
411
  | LabelEntry
390
412
  | TtsrInjectionEntry
391
413
  | MCPToolSelectionEntry
414
+ | DiscoveredBuiltinToolSelectionEntry
392
415
  | SessionInitEntry
393
416
  | ModeChangeEntry
394
417
  | ConfiguredModelChainEntry;
@@ -456,8 +479,12 @@ export interface SessionContext {
456
479
 
457
480
  /** MCP tool names selected through discovery for this session branch. */
458
481
  selectedMCPToolNames: string[];
482
+ /** Built-in discoverable tool names activated through discovery, when explicitly persisted. */
483
+ selectedDiscoveredBuiltinToolNames?: string[];
459
484
  /** Whether this branch contains an explicit persisted MCP selection entry. */
460
485
  hasPersistedMCPToolSelection: boolean;
486
+ /** Whether this branch contains an explicit persisted discovered-built-in selection entry. */
487
+ hasPersistedDiscoveredBuiltinToolSelection?: boolean;
461
488
  /** Active mode (e.g. "plan") or "none" if no special mode is active */
462
489
  mode: string;
463
490
  /** Mode-specific data from the last mode_change entry */
@@ -521,7 +548,7 @@ export interface StrictSessionOpenSuccess {
521
548
 
522
549
  export interface StrictSessionOpenFailure {
523
550
  kind: "error";
524
- reason: ResumeTailError["reason"] | "identity-mismatch";
551
+ reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
525
552
  }
526
553
 
527
554
  /** Exact transcript bytes and authority captured from one descriptor-bound read. */
@@ -541,6 +568,27 @@ export type StrictSessionForkResult = { kind: "forked"; manager: SessionManager
541
568
  /** Result of opening an inspected session without create-or-rewrite fallback. */
542
569
  export type StrictSessionOpenResult = StrictSessionOpenSuccess | StrictSessionOpenFailure;
543
570
 
571
+ /**
572
+ * Capability returned only by a strict recovery hydration open. It represents
573
+ * immutable transcript authority and is consumed by the promotion seam.
574
+ */
575
+ export interface RecoveryHydrationContext {
576
+ readonly identity: ResumeSessionIdentity;
577
+ }
578
+
579
+ export interface RecoveryHydrationOpenSuccess {
580
+ readonly kind: "hydrated";
581
+ readonly manager: SessionManager;
582
+ readonly context: RecoveryHydrationContext;
583
+ }
584
+
585
+ export type RecoveryHydrationOpenResult = RecoveryHydrationOpenSuccess | StrictSessionOpenFailure;
586
+
587
+ export interface RecoveryHydrationPromotionFence {
588
+ /** The caller has durably published ownership and acquired its writer lease. */
589
+ readonly ownershipReady: true;
590
+ }
591
+
544
592
  export interface SessionInfo {
545
593
  path: string;
546
594
  id: string;
@@ -805,24 +853,34 @@ function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | un
805
853
  return path.resolve(sessionDir) === path.resolve(resolved.scope.directoryPath) ? sessionsRoot : undefined;
806
854
  }
807
855
 
856
+ function isSupportedSessionVersion(version: unknown): boolean {
857
+ return (
858
+ version === undefined ||
859
+ (typeof version === "number" && Number.isInteger(version) && version <= CURRENT_SESSION_VERSION)
860
+ );
861
+ }
862
+
808
863
  /** Exported for compaction.test.ts */
809
864
  export function parseSessionEntries(content: string): FileEntry[] {
810
865
  const records = parseJsonlLenient<FileEntry | SessionPatchRecord>(content);
866
+ const rawHeader = records.find((record): record is SessionHeader => record.type === "session");
867
+ if (!isSupportedSessionVersion(rawHeader?.version)) {
868
+ throw new Error(`Unsupported session version: ${String(rawHeader?.version)}`);
869
+ }
811
870
  const entries: FileEntry[] = [];
812
871
  const entriesById = new Map<string, SessionEntry>();
813
872
  let header: SessionHeader | undefined;
814
873
 
815
874
  for (const record of records) {
816
- // Patch records are defined only by v4 transcripts. Ignore them before a
817
- // v4 header as well: a truncated or malicious prefix must not mutate a
818
- // later header/entry when replay catches up.
875
+ // Patch records are valid in v4 and v5 only. Ignore patches before a valid
876
+ // header so malformed prefixes cannot affect later records.
819
877
  if (record.type === "header_patch") {
820
- if (header?.version === CURRENT_SESSION_VERSION && isHeaderPatchRecord(record))
878
+ if (header?.version !== undefined && header.version >= 4 && isHeaderPatchRecord(record))
821
879
  applyHeaderPatch(header, record.patch);
822
880
  continue;
823
881
  }
824
882
  if (record.type === "entry_patch") {
825
- if (header?.version === CURRENT_SESSION_VERSION && isEntryPatchRecord(record)) {
883
+ if (header?.version !== undefined && header.version >= 4 && isEntryPatchRecord(record)) {
826
884
  const entry = entriesById.get(record.entryId);
827
885
  if (entry?.type === "message" && record.patch.message) entry.message = record.patch.message;
828
886
  }
@@ -939,6 +997,7 @@ export function buildSessionContext(
939
997
 
940
998
  selectedMCPToolNames: [],
941
999
  hasPersistedMCPToolSelection: false,
1000
+ hasPersistedDiscoveredBuiltinToolSelection: false,
942
1001
  mode: "none",
943
1002
  };
944
1003
  }
@@ -963,6 +1022,7 @@ export function buildSessionContext(
963
1022
  ttsrMessageCount: 0,
964
1023
  selectedMCPToolNames: [],
965
1024
  hasPersistedMCPToolSelection: false,
1025
+ hasPersistedDiscoveredBuiltinToolSelection: false,
966
1026
  mode: "none",
967
1027
  };
968
1028
  }
@@ -992,6 +1052,8 @@ export function buildSessionContext(
992
1052
 
993
1053
  let selectedMCPToolNames: string[] = [];
994
1054
  let hasPersistedMCPToolSelection = false;
1055
+ let selectedDiscoveredBuiltinToolNames: string[] | undefined;
1056
+ let hasPersistedDiscoveredBuiltinToolSelection = false;
995
1057
  let mode = "none";
996
1058
  let modeData: Record<string, unknown> | undefined;
997
1059
  // Track whether an explicit `model_change` with role="default" has been
@@ -1052,7 +1114,14 @@ export function buildSessionContext(
1052
1114
  }
1053
1115
  } else if (entry.type === "mcp_tool_selection") {
1054
1116
  selectedMCPToolNames = [...entry.selectedToolNames];
1117
+ if (entry.selectedDiscoveredBuiltinToolNames !== undefined) {
1118
+ selectedDiscoveredBuiltinToolNames = [...entry.selectedDiscoveredBuiltinToolNames];
1119
+ hasPersistedDiscoveredBuiltinToolSelection = true;
1120
+ }
1055
1121
  hasPersistedMCPToolSelection = true;
1122
+ } else if (entry.type === "discovered_builtin_tool_selection") {
1123
+ selectedDiscoveredBuiltinToolNames = [...entry.selectedToolNames];
1124
+ hasPersistedDiscoveredBuiltinToolSelection = true;
1056
1125
  } else if (entry.type === "mode_change") {
1057
1126
  mode = entry.mode;
1058
1127
  modeData = entry.data;
@@ -1176,7 +1245,9 @@ export function buildSessionContext(
1176
1245
  ttsrMessageCount,
1177
1246
 
1178
1247
  selectedMCPToolNames,
1248
+ selectedDiscoveredBuiltinToolNames,
1179
1249
  hasPersistedMCPToolSelection,
1250
+ hasPersistedDiscoveredBuiltinToolSelection,
1180
1251
  mode,
1181
1252
  modeData,
1182
1253
  };
@@ -1201,6 +1272,9 @@ function cloneSessionContext(context: SessionContext): SessionContext {
1201
1272
  ttsrMessageCount: context.ttsrMessageCount,
1202
1273
 
1203
1274
  selectedMCPToolNames: [...context.selectedMCPToolNames],
1275
+ selectedDiscoveredBuiltinToolNames: context.selectedDiscoveredBuiltinToolNames
1276
+ ? [...context.selectedDiscoveredBuiltinToolNames]
1277
+ : undefined,
1204
1278
  modeData: cloneJsonSemantic(context.modeData),
1205
1279
  };
1206
1280
  }
@@ -1231,6 +1305,140 @@ function computeDefaultSessionDir(
1231
1305
  return prepared.scope.directoryPath;
1232
1306
  }
1233
1307
 
1308
+ /** A session directory's authority is distinct from its string path. */
1309
+ export type SessionDestination =
1310
+ | { readonly kind: "managed"; readonly directory: string; readonly securityContext: ManagedSessionSecurityContext }
1311
+ | { readonly kind: "explicit"; readonly directory: string };
1312
+ export type SessionDestinationInput = string | SessionDestination | undefined;
1313
+
1314
+ interface ForkArtifactPublication {
1315
+ readonly snapshot: native.NativeDirectoryTreeSnapshot;
1316
+ readonly store: ManagedSessionDescendantStore;
1317
+ readonly cleanupStore: ManagedSessionDescendantStore;
1318
+ readonly cleanupRelativePath: string;
1319
+ }
1320
+
1321
+ function retainedTreeSnapshotEquals(
1322
+ left: native.NativeDirectoryTreeSnapshot,
1323
+ right: native.NativeDirectoryTreeSnapshot,
1324
+ ): boolean {
1325
+ return (
1326
+ left.rootDev === right.rootDev &&
1327
+ left.rootIno === right.rootIno &&
1328
+ left.entries.length === right.entries.length &&
1329
+ left.entries.every((entry, index) => {
1330
+ const other = right.entries[index];
1331
+ return (
1332
+ other !== undefined &&
1333
+ entry.relativePath === other.relativePath &&
1334
+ entry.kind === other.kind &&
1335
+ entry.dev === other.dev &&
1336
+ entry.ino === other.ino &&
1337
+ entry.size === other.size &&
1338
+ entry.mtimeNs === other.mtimeNs &&
1339
+ entry.ctimeNs === other.ctimeNs &&
1340
+ entry.sha256 === other.sha256
1341
+ );
1342
+ })
1343
+ );
1344
+ }
1345
+
1346
+ function retainedTreeSnapshotEqualsAfterRename(
1347
+ left: native.NativeDirectoryTreeSnapshot,
1348
+ right: native.NativeDirectoryTreeSnapshot,
1349
+ ): boolean {
1350
+ return (
1351
+ left.rootDev === right.rootDev &&
1352
+ left.rootIno === right.rootIno &&
1353
+ left.entries.length === right.entries.length &&
1354
+ left.entries.every((entry, index) => {
1355
+ const other = right.entries[index];
1356
+ return (
1357
+ other !== undefined &&
1358
+ entry.relativePath === other.relativePath &&
1359
+ entry.kind === other.kind &&
1360
+ entry.dev === other.dev &&
1361
+ entry.ino === other.ino &&
1362
+ entry.size === other.size &&
1363
+ entry.mtimeNs === other.mtimeNs &&
1364
+ (entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
1365
+ entry.sha256 === other.sha256
1366
+ );
1367
+ })
1368
+ );
1369
+ }
1370
+
1371
+ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
1372
+ return (
1373
+ left !== null &&
1374
+ right !== null &&
1375
+ left.identity.dev === right.identity.dev &&
1376
+ left.identity.ino === right.identity.ino &&
1377
+ left.identity.size === right.identity.size &&
1378
+ left.identity.mtimeNs === right.identity.mtimeNs &&
1379
+ left.identity.ctimeNs === right.identity.ctimeNs &&
1380
+ left.identity.sha256 === right.identity.sha256 &&
1381
+ left.bytes.equals(right.bytes)
1382
+ );
1383
+ }
1384
+ const trustedSessionDestinations = new WeakSet<SessionDestination>();
1385
+
1386
+ function managedStoreFromContext(
1387
+ securityContext: ManagedSessionSecurityContext,
1388
+ directory: string,
1389
+ ): ManagedSessionDescendantStore {
1390
+ return new ManagedSessionDescendantStore(
1391
+ securityContext.rootAuthority,
1392
+ directory,
1393
+ securityContext.retainedAuthority
1394
+ ? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
1395
+ : undefined,
1396
+ );
1397
+ }
1398
+
1399
+ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1400
+ const rootAuthority = managedRootForScope(scope);
1401
+ const securityContext = createManagedSessionSecurityContext({
1402
+ agentDir: scope.agentDir,
1403
+ sessionsRoot: scope.sessionsRoot,
1404
+ sessionDir: scope.directoryPath,
1405
+ rootAuthority,
1406
+ retainedAuthority: managedDirectoryAuthorityForScope(scope),
1407
+ });
1408
+ const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
1409
+ trustedSessionDestinations.add(destination);
1410
+ return destination;
1411
+ }
1412
+
1413
+ function explicitDestination(directory: string): SessionDestination {
1414
+ const destination = Object.freeze({ kind: "explicit" as const, directory });
1415
+ trustedSessionDestinations.add(destination);
1416
+ return destination;
1417
+ }
1418
+
1419
+ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
1420
+ if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1421
+ const sessionsRoot = getSessionsDir(agentDir);
1422
+ const resolved = resolveManagedScope({ cwd, agentDir: agentDir ?? path.resolve(sessionsRoot, ".."), sessionsRoot });
1423
+ if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1424
+ const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1425
+ if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1426
+ return freezeManagedDestination(prepared.scope);
1427
+ }
1428
+
1429
+ function destinationFor(
1430
+ cwd: string,
1431
+ input: SessionDestinationInput,
1432
+ storage: SessionStorage,
1433
+ agentDir?: string,
1434
+ ): SessionDestination {
1435
+ if (typeof input === "object") {
1436
+ if (!trustedSessionDestinations.has(input)) throw new Error("Untrusted session destination authority");
1437
+ return input;
1438
+ }
1439
+ return input === undefined ? managedDestination(cwd, storage, agentDir) : explicitDestination(input);
1440
+ }
1441
+
1234
1442
  // =============================================================================
1235
1443
  // Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
1236
1444
  // =============================================================================
@@ -1475,7 +1683,19 @@ function hasStrictSessionSchema(entries: FileEntry[]): boolean {
1475
1683
  case "mcp_tool_selection":
1476
1684
  if (
1477
1685
  !Array.isArray(value.selectedToolNames) ||
1478
- !value.selectedToolNames.every(name => typeof name === "string")
1686
+ !value.selectedToolNames.every(name => typeof name === "string") ||
1687
+ (value.selectedDiscoveredBuiltinToolNames !== undefined &&
1688
+ (!Array.isArray(value.selectedDiscoveredBuiltinToolNames) ||
1689
+ !value.selectedDiscoveredBuiltinToolNames.every(name => typeof name === "string"))) ||
1690
+ (value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
1691
+ )
1692
+ return false;
1693
+ break;
1694
+ case "discovered_builtin_tool_selection":
1695
+ if (
1696
+ !Array.isArray(value.selectedToolNames) ||
1697
+ !value.selectedToolNames.every(name => typeof name === "string") ||
1698
+ (value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
1479
1699
  )
1480
1700
  return false;
1481
1701
  break;
@@ -1806,8 +2026,13 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
1806
2026
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
1807
2027
  if (entries.length === 0) return;
1808
2028
  const header = entries[0] as Record<string, unknown>;
1809
- if (header.type !== "session" || typeof header.id !== "string") return;
1810
- if (header.version === CURRENT_SESSION_VERSION) {
2029
+ if (
2030
+ header.type !== "session" ||
2031
+ typeof header.id !== "string" ||
2032
+ !isSupportedSessionVersion(header.version)
2033
+ )
2034
+ return;
2035
+ if (typeof header.version === "number" && header.version >= 4) {
1811
2036
  for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
1812
2037
  applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
1813
2038
  }
@@ -3005,11 +3230,16 @@ class NdjsonFileWriter {
3005
3230
  #onError: ((err: Error) => void) | undefined;
3006
3231
  #closeDrained = false;
3007
3232
 
3008
- constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
3233
+ constructor(
3234
+ storage: SessionStorage,
3235
+ path: string,
3236
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void; securityContext?: ManagedSessionSecurityContext },
3237
+ ) {
3009
3238
  this.#onError = options?.onError;
3010
3239
  this.#writer = storage.openWriter(path, {
3011
3240
  flags: options?.flags ?? "a",
3012
3241
  onError: (err: Error) => this.#recordError(err),
3242
+ securityContext: options?.securityContext,
3013
3243
  });
3014
3244
  }
3015
3245
 
@@ -3489,7 +3719,7 @@ async function collectSessionFromFile(
3489
3719
  const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
3490
3720
  const header = parseSessionListHeader(content, entries);
3491
3721
  if (!header) return undefined;
3492
- if (header.version === CURRENT_SESSION_VERSION) {
3722
+ if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
3493
3723
  for (const patch of await readSessionListTrailingPatches(file, storage, buffer)) {
3494
3724
  applySessionListHeaderPatch(header, patch);
3495
3725
  }
@@ -3653,6 +3883,7 @@ export class SessionManager {
3653
3883
  #flushed: boolean = false;
3654
3884
  #needsFullRewriteOnNextPersist: boolean = false;
3655
3885
  #ensuredOnDisk: boolean = false;
3886
+ #recoveryHydrationContext: RecoveryHydrationContext | undefined;
3656
3887
  #fileEntries: FileEntry[] = [];
3657
3888
  #byId: Map<string, SessionEntry> = new Map();
3658
3889
  #labelsById: Map<string, string> = new Map();
@@ -3673,6 +3904,7 @@ export class SessionManager {
3673
3904
 
3674
3905
  #artifactManager: ArtifactManager | null = null;
3675
3906
  #artifactManagerSessionFile: string | null = null;
3907
+ #managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
3676
3908
  // When set, take precedence over the lazily-derived per-session manager.
3677
3909
  // Subagents adopt the parent's manager so artifact IDs are unique across the
3678
3910
  // whole agent tree and all files land in the parent's artifacts dir.
@@ -3718,6 +3950,7 @@ export class SessionManager {
3718
3950
  private sessionDir: string,
3719
3951
  private readonly persist: boolean,
3720
3952
  private readonly storage: SessionStorage,
3953
+ private destination: SessionDestination = explicitDestination(sessionDir),
3721
3954
  ) {
3722
3955
  this.#blobStore = persist ? new BlobStore(getBlobsDir()) : this.#residentTextBlobStore;
3723
3956
  this.#residentImageBlobStore = this.#blobStore;
@@ -3822,7 +4055,12 @@ export class SessionManager {
3822
4055
 
3823
4056
  #resetResidentTextBlobStore(): void {
3824
4057
  this.#disposeResidentTextBlobStore();
3825
- if (this.persist && this.#sessionFile && this.storage instanceof FileSessionStorage) {
4058
+ if (
4059
+ this.destination.kind === "explicit" &&
4060
+ this.persist &&
4061
+ this.#sessionFile &&
4062
+ this.storage instanceof FileSessionStorage
4063
+ ) {
3826
4064
  this.#residentTextBlobStore = new EphemeralBlobStore(this.#residentCacheDir(this.#sessionFile));
3827
4065
  }
3828
4066
  }
@@ -4014,6 +4252,9 @@ export class SessionManager {
4014
4252
  const oldSessionFile = this.#sessionFile;
4015
4253
  const oldSessionId = this.#sessionId;
4016
4254
  const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
4255
+ const oldFileEntries = this.#fileEntries;
4256
+ const oldSessionName = this.#sessionName;
4257
+ const oldTitleSource = this.#titleSource;
4017
4258
 
4018
4259
  // Close the current writer
4019
4260
  await this.#closePersistWriter();
@@ -4049,13 +4290,169 @@ export class SessionManager {
4049
4290
  this.#reexternalizeFileEntriesForResidentStore();
4050
4291
  this.#bumpAllRevisions();
4051
4292
 
4052
- // Write the new session file
4293
+ // Write the new session file, import artifacts, and adopt only after the
4294
+ // retained destination manifest is complete.
4053
4295
  this.#flushed = false;
4054
- await this.#rewriteFile();
4296
+ let forkArtifactPublication: ForkArtifactPublication | undefined;
4297
+ let forkTranscriptPublication: ManagedFileSnapshot | null = null;
4298
+ let forkTranscriptStore: ManagedSessionDescendantStore | undefined;
4299
+ try {
4300
+ forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, this.#sessionFile);
4301
+ await this.#rewriteFile();
4302
+ if (this.destination.kind === "managed") {
4303
+ forkTranscriptStore = this.#managedTranscriptStore();
4304
+ forkTranscriptPublication = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
4305
+ if (!forkTranscriptPublication) throw new Error("managed_fork_transcript_publish_missing");
4306
+ }
4307
+ forkArtifactPublication?.store.verifyRootSecurity();
4308
+ forkArtifactPublication?.store.assertBound();
4309
+ if (forkArtifactPublication) {
4310
+ const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
4311
+ if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot)) {
4312
+ throw new Error("artifact_destination_changed_during_transcript_publication");
4313
+ }
4314
+ }
4315
+ if (forkTranscriptStore && forkTranscriptPublication) {
4316
+ const terminalTranscript = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
4317
+ if (!managedFileSnapshotEquals(terminalTranscript, forkTranscriptPublication))
4318
+ throw new Error("managed_fork_transcript_changed");
4319
+ }
4320
+ } catch (error) {
4321
+ let failure = error;
4322
+ const failedSessionFile = this.#sessionFile;
4323
+ if (this.destination.kind === "managed") {
4324
+ try {
4325
+ if (forkTranscriptStore && forkTranscriptPublication)
4326
+ forkTranscriptStore.removeExpected(path.basename(failedSessionFile), forkTranscriptPublication);
4327
+ if (forkArtifactPublication)
4328
+ forkArtifactPublication.cleanupStore.removeTreeExpected(
4329
+ forkArtifactPublication.cleanupRelativePath,
4330
+ forkArtifactPublication.snapshot,
4331
+ );
4332
+ } catch (cleanupError) {
4333
+ failure = new Error(`Failed to clean up managed fork destination: ${toError(cleanupError).message}`, {
4334
+ cause: toError(error),
4335
+ });
4336
+ }
4337
+ } else {
4338
+ try {
4339
+ await this.storage.unlink(failedSessionFile);
4340
+ } catch {
4341
+ // Preserve ambiguous cleanup evidence while restoring the active manager.
4342
+ }
4343
+ }
4344
+ this.#sessionId = oldSessionId;
4345
+ this.#sessionFile = oldSessionFile;
4346
+ this.#fileEntries = oldFileEntries;
4347
+ this.#sessionName = oldSessionName;
4348
+ this.#titleSource = oldTitleSource;
4349
+ this.#persistChain = Promise.resolve();
4350
+ this.#persistError = undefined;
4351
+ this.#persistErrorReported = false;
4352
+ this.#resetResidentTextBlobStore();
4353
+ this.#reexternalizeFileEntriesForResidentStore();
4354
+ this.#bumpAllRevisions();
4355
+ throw failure;
4356
+ }
4055
4357
 
4056
4358
  return { oldSessionFile, newSessionFile: this.#sessionFile };
4057
4359
  }
4058
4360
 
4361
+ async copyArtifactsForFork(
4362
+ oldSessionFile: string,
4363
+ newSessionFile: string,
4364
+ ): Promise<ForkArtifactPublication | undefined> {
4365
+ const sourceDir = oldSessionFile.slice(0, -6);
4366
+ const finalDestinationDir = newSessionFile.slice(0, -6);
4367
+ if (this.destination.kind !== "managed") {
4368
+ try {
4369
+ const sourceStat = fs.lstatSync(sourceDir);
4370
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("unsafe_artifacts");
4371
+ } catch (error) {
4372
+ if (isEnoent(error)) return;
4373
+ throw error;
4374
+ }
4375
+ await fs.promises.cp(sourceDir, finalDestinationDir, { recursive: true });
4376
+ return undefined;
4377
+ }
4378
+
4379
+ const sourceName = path.basename(sourceDir);
4380
+ const destinationName = path.basename(finalDestinationDir);
4381
+ const stagingName = `.${destinationName}.${crypto.randomUUID()}.fork-staging`;
4382
+ const parentStore = this.#managedTranscriptStore(oldSessionFile);
4383
+ let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
4384
+ try {
4385
+ sourceSnapshot = parentStore.captureTree(sourceName);
4386
+ } catch (error) {
4387
+ if (error instanceof Error && error.message === "not_found") return;
4388
+ throw error;
4389
+ }
4390
+ let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
4391
+ let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
4392
+ try {
4393
+ parentStore.ensureDirectory(stagingName);
4394
+ stagingSnapshot = parentStore.captureTree(stagingName);
4395
+ for (const entry of sourceSnapshot.entries) {
4396
+ if (entry.relativePath === "") continue;
4397
+ const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
4398
+ if (entry.kind === "directory") {
4399
+ parentStore.ensureDirectory(destinationRelativePath);
4400
+ } else if (entry.kind === "file") {
4401
+ const sourceFile = parentStore.readExpected(path.posix.join(sourceName, entry.relativePath));
4402
+ if (
4403
+ !sourceFile ||
4404
+ sourceFile.identity.dev.toString() !== entry.dev ||
4405
+ sourceFile.identity.ino.toString() !== entry.ino ||
4406
+ sourceFile.identity.size.toString() !== entry.size ||
4407
+ sourceFile.identity.mtimeNs.toString() !== entry.mtimeNs ||
4408
+ crypto.createHash("sha256").update(sourceFile.bytes).digest("hex") !== entry.sha256
4409
+ ) {
4410
+ throw new Error("artifact_source_changed");
4411
+ }
4412
+ await parentStore.publishNoReplace(destinationRelativePath, sourceFile.bytes);
4413
+ } else {
4414
+ throw new Error("unsafe_artifacts");
4415
+ }
4416
+ stagingSnapshot = parentStore.captureTree(stagingName);
4417
+ }
4418
+ if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
4419
+ throw new Error("artifact_source_changed");
4420
+ parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4421
+ publishedSnapshot = parentStore.captureTree(destinationName);
4422
+ const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4423
+ tree.entries.map(entry => ({
4424
+ relativePath: entry.relativePath,
4425
+ kind: entry.kind,
4426
+ size: entry.kind === "file" ? entry.size : undefined,
4427
+ sha256: entry.sha256,
4428
+ }));
4429
+ if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(sourceSnapshot)))
4430
+ throw new Error("artifact_destination_terminal_mismatch");
4431
+ const publishedStore = parentStore.deriveSubtree(destinationName);
4432
+ publishedStore.verifyRootSecurity();
4433
+ const retainedPublishedSnapshot = publishedStore.captureTree("");
4434
+ if (!retainedTreeSnapshotEqualsAfterRename(retainedPublishedSnapshot, publishedSnapshot))
4435
+ throw new Error("artifact_destination_retained_identity_changed");
4436
+ publishedSnapshot = retainedPublishedSnapshot;
4437
+ return {
4438
+ snapshot: publishedSnapshot,
4439
+ store: publishedStore,
4440
+ cleanupStore: parentStore,
4441
+ cleanupRelativePath: destinationName,
4442
+ };
4443
+ } catch (error) {
4444
+ try {
4445
+ if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
4446
+ else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4447
+ } catch (cleanupError) {
4448
+ throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
4449
+ cause: toError(error),
4450
+ });
4451
+ }
4452
+ throw error;
4453
+ }
4454
+ }
4455
+
4059
4456
  /**
4060
4457
  * Move the session to a new working directory.
4061
4458
  * Moves session files and artifacts on disk, updates all internal references,
@@ -4067,17 +4464,26 @@ export class SessionManager {
4067
4464
  const previousCwd = this.cwd;
4068
4465
  const previousSessionDir = this.sessionDir;
4069
4466
  const previousSessionFile = this.#sessionFile;
4467
+ const previousDestination = this.destination;
4070
4468
  const previousArtifactDir = previousSessionFile?.slice(0, -6);
4071
4469
 
4072
- const managedSessionsRoot =
4073
- this.storage instanceof FileSessionStorage ? resolveManagedSessionRoot(this.sessionDir, this.cwd) : undefined;
4074
- const newSessionDir =
4075
- this.storage instanceof FileSessionStorage
4076
- ? managedSessionsRoot
4077
- ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
4078
- : computeDefaultSessionDir(resolvedCwd, this.storage)
4079
- : this.sessionDir;
4470
+ const nextDestination = (() => {
4471
+ if (this.destination.kind === "managed") {
4472
+ return managedDestination(resolvedCwd, this.storage, this.destination.securityContext.agentDir);
4473
+ }
4474
+ if (!(this.storage instanceof FileSessionStorage)) return this.destination;
4475
+ const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
4476
+ const directory = managedSessionsRoot
4477
+ ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
4478
+ : computeDefaultSessionDir(resolvedCwd, this.storage);
4479
+ return explicitDestination(directory);
4480
+ })();
4481
+ const newSessionDir = nextDestination.directory;
4080
4482
  let hadSessionFile = false;
4483
+ const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
4484
+ let managedSourceStore: ManagedSessionDescendantStore | undefined;
4485
+ let managedDestinationStore: ManagedSessionDescendantStore | undefined;
4486
+ let rollbackManagedMove: (() => Promise<void>) | undefined;
4081
4487
 
4082
4488
  if (this.persist && this.#sessionFile) {
4083
4489
  // Close the persist writer before moving files
@@ -4090,7 +4496,11 @@ export class SessionManager {
4090
4496
  const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
4091
4497
  const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
4092
4498
  const newArtifactDir = newSessionFile.slice(0, -6);
4093
- hadSessionFile = this.storage.existsSync(oldSessionFile);
4499
+ let managedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
4500
+ let managedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
4501
+ let managedPublishedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
4502
+ let managedPublishedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
4503
+ hadSessionFile = managedMove ? false : this.storage.existsSync(oldSessionFile);
4094
4504
  let movedSessionFile = false;
4095
4505
  let movedArtifactDir = false;
4096
4506
  const materializedEntries = materializeResidentEntriesForReadSync(
@@ -4113,56 +4523,184 @@ export class SessionManager {
4113
4523
  }
4114
4524
  throw error;
4115
4525
  };
4526
+ const copyManagedTree = async (
4527
+ source: ManagedSessionDescendantStore,
4528
+ sourceRelative: string,
4529
+ destination: ManagedSessionDescendantStore,
4530
+ destinationRelative: string,
4531
+ snapshot: native.NativeDirectoryTreeSnapshot,
4532
+ ): Promise<void> => {
4533
+ destination.ensureDirectory(destinationRelative);
4534
+ for (const entry of snapshot.entries) {
4535
+ if (entry.relativePath === "") continue;
4536
+ const target = path.posix.join(destinationRelative, entry.relativePath);
4537
+ if (entry.kind === "directory") destination.ensureDirectory(target);
4538
+ else {
4539
+ const file = source.readExpected(path.posix.join(sourceRelative, entry.relativePath));
4540
+ if (
4541
+ !file ||
4542
+ file.identity.dev.toString() !== entry.dev ||
4543
+ file.identity.ino.toString() !== entry.ino ||
4544
+ file.identity.size.toString() !== entry.size ||
4545
+ file.identity.mtimeNs.toString() !== entry.mtimeNs ||
4546
+ crypto.createHash("sha256").update(file.bytes).digest("hex") !== entry.sha256
4547
+ )
4548
+ throw new Error("artifact_source_changed");
4549
+ await destination.publishNoReplace(target, file.bytes);
4550
+ }
4551
+ }
4552
+ const imported = destination.captureTree(destinationRelative);
4553
+ const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4554
+ tree.entries.map(entry => ({
4555
+ relativePath: entry.relativePath,
4556
+ kind: entry.kind,
4557
+ size: entry.size,
4558
+ sha256: entry.sha256,
4559
+ }));
4560
+ if (JSON.stringify(comparable(imported)) !== JSON.stringify(comparable(snapshot)))
4561
+ throw new Error("artifact_destination_mismatch");
4562
+ destination.fsyncTree();
4563
+ };
4116
4564
  this.#disposeResidentTextBlobStore();
4117
-
4118
4565
  try {
4119
- // Guard: session file may not exist yet (no assistant messages persisted)
4120
- if (hadSessionFile) {
4566
+ if (managedMove) {
4567
+ if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
4568
+ throw new Error("managed_move_destination_unavailable");
4569
+ managedSourceStore = this.#managedTranscriptStore(oldSessionFile);
4570
+ managedDestinationStore = managedStoreFromContext(nextDestination.securityContext, newSessionDir);
4571
+ rollbackManagedMove = async () => {
4572
+ if (!managedSourceStore || !managedDestinationStore)
4573
+ throw new Error("managed_move_authority_unavailable");
4574
+ if (managedArtifacts && managedPublishedArtifacts) {
4575
+ await copyManagedTree(
4576
+ managedDestinationStore,
4577
+ path.basename(newArtifactDir),
4578
+ managedSourceStore,
4579
+ path.basename(oldArtifactDir),
4580
+ managedPublishedArtifacts,
4581
+ );
4582
+ }
4583
+ if (managedTranscript)
4584
+ await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4585
+ if (managedPublishedArtifacts)
4586
+ managedDestinationStore.removeTreeExpected(
4587
+ path.basename(newArtifactDir),
4588
+ managedPublishedArtifacts,
4589
+ );
4590
+ if (managedPublishedTranscript)
4591
+ managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4592
+ };
4593
+ managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
4594
+ hadSessionFile = managedTranscript !== null;
4595
+ if (managedTranscript) {
4596
+ await managedDestinationStore.publishNoReplace(
4597
+ path.basename(newSessionFile),
4598
+ managedTranscript.bytes,
4599
+ );
4600
+ managedPublishedTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
4601
+ if (!managedPublishedTranscript) throw new Error("managed_transcript_publish_missing");
4602
+ movedSessionFile = true;
4603
+ }
4121
4604
  try {
4605
+ managedArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
4606
+ await copyManagedTree(
4607
+ managedSourceStore,
4608
+ path.basename(oldArtifactDir),
4609
+ managedDestinationStore,
4610
+ path.basename(newArtifactDir),
4611
+ managedArtifacts,
4612
+ );
4613
+ managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
4614
+ movedArtifactDir = true;
4615
+ } catch (error) {
4616
+ if (!(error instanceof Error) || error.message !== "not_found") throw error;
4617
+ }
4618
+ if (managedPublishedTranscript) {
4619
+ const terminalTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
4620
+ if (!managedFileSnapshotEquals(terminalTranscript, managedPublishedTranscript))
4621
+ throw new Error("managed_move_destination_transcript_changed");
4622
+ }
4623
+ if (managedPublishedArtifacts) {
4624
+ const terminalArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
4625
+ if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
4626
+ throw new Error("managed_move_destination_artifacts_changed");
4627
+ }
4628
+ if (managedArtifacts)
4629
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4630
+ if (managedTranscript)
4631
+ managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
4632
+ } else {
4633
+ if (hadSessionFile && oldSessionFile !== newSessionFile) {
4122
4634
  await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
4123
4635
  movedSessionFile = true;
4124
- } catch (error) {
4125
- if (error instanceof PathMoveCompletedWithSyncFailure) movedSessionFile = true;
4126
- throw error;
4127
4636
  }
4128
- }
4129
-
4130
- try {
4131
- const stat = await fs.promises.stat(oldArtifactDir);
4132
- if (stat.isDirectory()) {
4133
- try {
4637
+ try {
4638
+ const stat = await fs.promises.stat(oldArtifactDir);
4639
+ if (stat.isDirectory() && oldArtifactDir !== newArtifactDir) {
4134
4640
  await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
4135
4641
  movedArtifactDir = true;
4136
- } catch (error) {
4137
- if (error instanceof PathMoveCompletedWithSyncFailure) movedArtifactDir = true;
4138
- throw error;
4139
4642
  }
4643
+ } catch (err) {
4644
+ if (!isEnoent(err)) throw err;
4140
4645
  }
4141
- } catch (err) {
4142
- if (!isEnoent(err)) throw err;
4143
4646
  }
4144
4647
  } catch (err) {
4145
- if (movedArtifactDir) {
4146
- try {
4147
- await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
4148
- } catch (rollbackErr) {
4149
- restoreResidentStateAndThrow(
4150
- new Error(
4151
- `Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
4152
- ),
4153
- );
4154
- }
4155
- }
4156
- if (movedSessionFile) {
4648
+ if (managedMove && managedDestinationStore) {
4157
4649
  try {
4158
- await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
4650
+ if (managedArtifacts && !managedPublishedArtifacts) {
4651
+ try {
4652
+ managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
4653
+ } catch (captureError) {
4654
+ if (!(captureError instanceof Error) || captureError.message !== "not_found")
4655
+ throw captureError;
4656
+ }
4657
+ }
4658
+ if (managedSourceStore && managedArtifacts && managedPublishedArtifacts) {
4659
+ let sourceArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
4660
+ try {
4661
+ sourceArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
4662
+ } catch (captureError) {
4663
+ if (!(captureError instanceof Error) || captureError.message !== "not_found")
4664
+ throw captureError;
4665
+ }
4666
+ if (!sourceArtifacts || !retainedTreeSnapshotEquals(sourceArtifacts, managedArtifacts)) {
4667
+ if (sourceArtifacts)
4668
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), sourceArtifacts);
4669
+ await copyManagedTree(
4670
+ managedDestinationStore,
4671
+ path.basename(newArtifactDir),
4672
+ managedSourceStore,
4673
+ path.basename(oldArtifactDir),
4674
+ managedPublishedArtifacts,
4675
+ );
4676
+ const restored = managedSourceStore.captureTree(path.basename(oldArtifactDir));
4677
+ if (!retainedTreeSnapshotEqualsAfterRename(restored, managedArtifacts))
4678
+ throw new Error("managed_move_source_restore_mismatch");
4679
+ }
4680
+ }
4681
+ if (
4682
+ managedSourceStore &&
4683
+ managedTranscript &&
4684
+ !managedSourceStore.readExpected(path.basename(oldSessionFile))
4685
+ )
4686
+ await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4687
+ if (managedPublishedArtifacts)
4688
+ managedDestinationStore.removeTreeExpected(
4689
+ path.basename(newArtifactDir),
4690
+ managedPublishedArtifacts,
4691
+ );
4692
+ if (managedPublishedTranscript)
4693
+ managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4159
4694
  } catch (rollbackErr) {
4160
4695
  restoreResidentStateAndThrow(
4161
- new Error(
4162
- `Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
4163
- ),
4696
+ new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
4697
+ cause: toError(err),
4698
+ }),
4164
4699
  );
4165
4700
  }
4701
+ } else {
4702
+ if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
4703
+ if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
4166
4704
  }
4167
4705
  restoreResidentStateAndThrow(err);
4168
4706
  }
@@ -4176,6 +4714,11 @@ export class SessionManager {
4176
4714
  // Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
4177
4715
  this.cwd = resolvedCwd;
4178
4716
  this.sessionDir = newSessionDir;
4717
+ this.destination = nextDestination;
4718
+ this.#managedTranscriptStoreCache =
4719
+ managedMove && managedDestinationStore
4720
+ ? { directory: path.resolve(newSessionDir), store: managedDestinationStore }
4721
+ : null;
4179
4722
 
4180
4723
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
4181
4724
  try {
@@ -4196,6 +4739,21 @@ export class SessionManager {
4196
4739
  this.#persistChain = Promise.resolve();
4197
4740
  this.#persistError = undefined;
4198
4741
  this.#persistErrorReported = false;
4742
+ if (rollbackManagedMove) {
4743
+ await rollbackManagedMove();
4744
+ this.#sessionFile = previousSessionFile;
4745
+ this.cwd = previousCwd;
4746
+ this.sessionDir = previousSessionDir;
4747
+ this.destination = previousDestination;
4748
+ this.#managedTranscriptStoreCache =
4749
+ managedMove && managedSourceStore
4750
+ ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
4751
+ : null;
4752
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4753
+ if (header) applyHeaderPatch(header, { cwd: previousCwd });
4754
+ this.#headerExportRevision++;
4755
+ throw error;
4756
+ }
4199
4757
  if (
4200
4758
  this.storage instanceof FileSessionStorage &&
4201
4759
  failedArtifactDir &&
@@ -4217,6 +4775,8 @@ export class SessionManager {
4217
4775
  this.#sessionFile = previousSessionFile;
4218
4776
  this.cwd = previousCwd;
4219
4777
  this.sessionDir = previousSessionDir;
4778
+ this.destination = previousDestination;
4779
+ this.#managedTranscriptStoreCache = null;
4220
4780
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4221
4781
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
4222
4782
  this.#headerExportRevision++;
@@ -4348,6 +4908,10 @@ export class SessionManager {
4348
4908
  #ensurePersistWriter(): NdjsonFileWriter | undefined {
4349
4909
  if (!this.persist || !this.#sessionFile) return undefined;
4350
4910
  if (this.#persistError) throw this.#persistError;
4911
+ if (this.destination.kind === "managed") {
4912
+ this.#managedTranscriptStore();
4913
+ return undefined;
4914
+ }
4351
4915
  if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
4352
4916
  if (this.#persistWriter.isOpen()) return this.#persistWriter;
4353
4917
  // Cached writer for the current file is mid-close (queued
@@ -4511,8 +5075,31 @@ export class SessionManager {
4511
5075
  }
4512
5076
  }
4513
5077
 
5078
+ #managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
5079
+ if (this.destination.kind !== "managed" || !sessionFile) {
5080
+ throw new Error("Managed transcript authority is unavailable");
5081
+ }
5082
+ const sessionDir = path.resolve(path.dirname(sessionFile));
5083
+ if (sessionDir !== path.resolve(this.destination.directory)) {
5084
+ throw new Error("Managed transcript escaped its session directory");
5085
+ }
5086
+ if (this.#managedTranscriptStoreCache) {
5087
+ if (this.#managedTranscriptStoreCache.directory !== sessionDir)
5088
+ throw new Error("Managed transcript store transition was not verified");
5089
+ return this.#managedTranscriptStoreCache.store;
5090
+ }
5091
+ const store = managedStoreFromContext(this.destination.securityContext, sessionDir);
5092
+ this.#managedTranscriptStoreCache = { directory: sessionDir, store };
5093
+ return store;
5094
+ }
5095
+
4514
5096
  #writeEntriesAtomicallySync(entries: FileEntry[]): void {
4515
5097
  if (!this.#sessionFile) return;
5098
+ if (this.destination.kind === "managed") {
5099
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
5100
+ this.#managedTranscriptStore().replaceSync(path.basename(this.#sessionFile), bytes);
5101
+ return;
5102
+ }
4516
5103
  const dir = path.resolve(this.#sessionFile, "..");
4517
5104
  const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
4518
5105
  const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
@@ -4539,6 +5126,11 @@ export class SessionManager {
4539
5126
  }
4540
5127
  async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
4541
5128
  if (!this.#sessionFile) return;
5129
+ if (this.destination.kind === "managed") {
5130
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
5131
+ await this.#managedTranscriptStore().replace(path.basename(this.#sessionFile), bytes);
5132
+ return;
5133
+ }
4542
5134
  const dir = path.resolve(this.#sessionFile, "..");
4543
5135
  const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
4544
5136
  const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
@@ -4569,10 +5161,13 @@ export class SessionManager {
4569
5161
  entries: readonly FileEntry[],
4570
5162
  sessionFile: string,
4571
5163
  ): Promise<string | undefined> {
5164
+ if (this.destination.kind === "managed") return undefined;
4572
5165
  if (!this.storage.existsSync(sessionFile)) return undefined;
4573
5166
  const dir = path.resolve(sessionFile, "..");
4574
5167
  const tempPath = path.join(dir, `.${path.basename(sessionFile)}.${Snowflake.next()}.default-selection.tmp`);
4575
- const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
5168
+ const writer = new NdjsonFileWriter(this.storage, tempPath, {
5169
+ flags: "w",
5170
+ });
4576
5171
  try {
4577
5172
  const persistedEntries = await Promise.all(
4578
5173
  materializeResidentEntriesForPersistenceSync([...entries], this.#residentBlobStores()).map(entry =>
@@ -4702,19 +5297,31 @@ export class SessionManager {
4702
5297
  return { kind: "not_promoted" };
4703
5298
  }
4704
5299
  if (stage.persistsToExistingFile) {
4705
- if (!stage.tempPath || !this.#sessionFile)
4706
- return { kind: "unknown", error: new Error("Missing staged session replacement") };
4707
- try {
4708
- this.#closePersistWriterInternalSync();
4709
- const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
4710
- if (replacement.kind === "restored_previous") {
4711
- return { kind: "not_promoted", error: replacement.error };
5300
+ if (!this.#sessionFile) return { kind: "unknown", error: new Error("Missing staged session replacement") };
5301
+ if (this.destination.kind === "managed") {
5302
+ try {
5303
+ const persistedEntries = materializeResidentEntriesForPersistenceSync(
5304
+ [...stage.entries],
5305
+ this.#residentBlobStores(),
5306
+ ).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
5307
+ this.#writeEntriesAtomicallySync(persistedEntries);
5308
+ } catch (error) {
5309
+ return { kind: "unknown", error: toError(error) };
4712
5310
  }
4713
- } catch (error) {
4714
- if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
4715
- return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
5311
+ } else {
5312
+ if (!stage.tempPath) return { kind: "unknown", error: new Error("Missing staged session replacement") };
5313
+ try {
5314
+ this.#closePersistWriterInternalSync();
5315
+ const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
5316
+ if (replacement.kind === "restored_previous") {
5317
+ return { kind: "not_promoted", error: replacement.error };
5318
+ }
5319
+ } catch (error) {
5320
+ if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
5321
+ return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
5322
+ }
5323
+ return { kind: "unknown", error: toError(error) };
4716
5324
  }
4717
- return { kind: "unknown", error: toError(error) };
4718
5325
  }
4719
5326
  }
4720
5327
  this.#fileEntries = [...stage.entries];
@@ -4862,15 +5469,62 @@ export class SessionManager {
4862
5469
  * Returns the session artifacts directory path (session file path without .jsonl).
4863
5470
  * Returns null when the session is not persisted to a file.
4864
5471
  * When this session has adopted an external ArtifactManager (subagent case),
4865
- * returns that manager's directory so reads/writes land in the shared parent
4866
- * dir instead of a private (non-existent) subdir.
5472
+ * never exposes that managed directory as a pathname. Reads and writes use the
5473
+ * adopted manager capability directly.
4867
5474
  */
4868
5475
  getArtifactsDir(): string | null {
4869
- if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
5476
+ if (this.#adoptedArtifactManager) return null;
4870
5477
  const sessionFile = this.#sessionFile;
4871
5478
  return sessionFile ? sessionFile.slice(0, -6) : null;
4872
5479
  }
4873
5480
 
5481
+ isManagedDestination(): boolean {
5482
+ return this.destination.kind === "managed";
5483
+ }
5484
+
5485
+ /** Supplies opaque retained authority for mandatory managed legacy local migration. */
5486
+ getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
5487
+ if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
5488
+ const store = this.#managedTranscriptStore();
5489
+ return {
5490
+ capture: async () => {
5491
+ let snapshot: native.NativeDirectoryTreeSnapshot;
5492
+ try {
5493
+ snapshot = store.captureTree("local");
5494
+ } catch (error) {
5495
+ if (error instanceof Error && error.message === "not_found") return null;
5496
+ throw error;
5497
+ }
5498
+ let totalBytes = 0;
5499
+ for (const entry of snapshot.entries) if (entry.kind === "file") totalBytes += Number(entry.size);
5500
+ if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
5501
+ const entries = snapshot.entries.map(entry => {
5502
+ if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
5503
+ const captured = store.readExpected(path.posix.join("local", entry.relativePath));
5504
+ if (
5505
+ !captured ||
5506
+ captured.identity.dev.toString() !== entry.dev ||
5507
+ captured.identity.ino.toString() !== entry.ino ||
5508
+ captured.identity.size.toString() !== entry.size ||
5509
+ crypto.createHash("sha256").update(captured.bytes).digest("hex") !== entry.sha256
5510
+ )
5511
+ throw new Error("Legacy local:// migration source changed during capture");
5512
+ return {
5513
+ relativePath: entry.relativePath,
5514
+ kind: "file" as const,
5515
+ bytes: captured.bytes,
5516
+ sha256: entry.sha256,
5517
+ };
5518
+ });
5519
+ const verified = store.captureTree("local");
5520
+ if (JSON.stringify(verified) !== JSON.stringify(snapshot))
5521
+ throw new Error("Legacy local:// migration source changed during capture");
5522
+ return { snapshot, entries };
5523
+ },
5524
+ retire: snapshot => store.removeTreeExpected("local", snapshot),
5525
+ };
5526
+ }
5527
+
4874
5528
  /**
4875
5529
  * Adopt an externally-owned ArtifactManager. Used by subagents to share
4876
5530
  * the parent session's artifact directory and ID counter.
@@ -4906,7 +5560,12 @@ export class SessionManager {
4906
5560
  return this.#artifactManager;
4907
5561
  }
4908
5562
 
4909
- const manager = new ArtifactManager(sessionFile.slice(0, -6));
5563
+ const artifactDir = sessionFile.slice(0, -6);
5564
+ let artifactStorage: string | ManagedSessionDescendantStore = artifactDir;
5565
+ if (this.destination.kind === "managed") {
5566
+ artifactStorage = this.#managedTranscriptStore(sessionFile).deriveSubtree(path.basename(artifactDir));
5567
+ }
5568
+ const manager = new ArtifactManager(artifactStorage);
4910
5569
  this.#artifactManager = manager;
4911
5570
  this.#artifactManagerSessionFile = sessionFile;
4912
5571
  return manager;
@@ -4947,21 +5606,43 @@ export class SessionManager {
4947
5606
  }
4948
5607
 
4949
5608
  /**
4950
- * Path to the unsent-input draft sidecar for the current session. Lives inside
4951
- * the artifacts directory so it is removed together with the session on
4952
- * `dropSession`. Returns null when the session has no on-disk identity.
5609
+ * Path to the unsent-input draft sidecar for an explicit session. It lives
5610
+ * inside the artifacts directory so `dropSession` removes it with its owner.
5611
+ * Managed drafts use a retained descendant-store capability instead.
4953
5612
  */
4954
5613
  #getDraftPath(): string | null {
5614
+ if (this.destination.kind === "managed") return null;
4955
5615
  const dir = this.getArtifactsDir();
4956
5616
  return dir ? path.join(dir, "draft.txt") : null;
4957
5617
  }
4958
5618
 
5619
+ /** Retained authority for this managed session's private draft sidecar. */
5620
+ #getManagedDraftStore(): ManagedSessionDescendantStore | null {
5621
+ if (this.destination.kind !== "managed" || !this.#sessionFile) return null;
5622
+ return this.#managedTranscriptStore().deriveSubtree(path.basename(this.#sessionFile.slice(0, -6)));
5623
+ }
5624
+
4959
5625
  /**
4960
5626
  * Persist (or clear) the current editor draft so the next resume of this
4961
5627
  * session can restore it. Empty text deletes any stale draft. No-op when the
4962
5628
  * session is not persisted.
4963
5629
  */
4964
5630
  async saveDraft(text: string): Promise<void> {
5631
+ if (this.destination.kind === "managed") {
5632
+ if (text.length === 0) {
5633
+ await this.#getManagedDraftStore()?.remove("draft.txt");
5634
+ return;
5635
+ }
5636
+ // Force the session header onto disk so resume can find the file we are
5637
+ // attaching this draft to. Without this, a session whose first message
5638
+ // never produced an assistant reply would persist a draft next to a
5639
+ // session file that does not exist on disk.
5640
+ await this.ensureOnDisk();
5641
+ const store = this.#getManagedDraftStore();
5642
+ if (!store) return;
5643
+ await store.replace("draft.txt", Buffer.from(text, "utf8"));
5644
+ return;
5645
+ }
4965
5646
  const draftPath = this.#getDraftPath();
4966
5647
  if (!draftPath || !this.persist) return;
4967
5648
  if (text.length === 0) {
@@ -4977,7 +5658,9 @@ export class SessionManager {
4977
5658
  // never produced an assistant reply would persist a draft next to a
4978
5659
  // session file that does not exist on disk.
4979
5660
  await this.ensureOnDisk();
4980
- await this.storage.writeText(draftPath, text);
5661
+ const artifactManager = this.#getOrCreateArtifactManager();
5662
+ if (!artifactManager) return;
5663
+ await artifactManager.replaceNamed("draft.txt", text);
4981
5664
  }
4982
5665
 
4983
5666
  /**
@@ -4986,6 +5669,10 @@ export class SessionManager {
4986
5669
  * sidecar so a subsequent resume does not re-restore the same text.
4987
5670
  */
4988
5671
  async consumeDraft(): Promise<string | null> {
5672
+ if (this.destination.kind === "managed") {
5673
+ const draft = await this.#getManagedDraftStore()?.consume("draft.txt");
5674
+ return draft ? Buffer.from(draft).toString("utf8") : null;
5675
+ }
4989
5676
  const draftPath = this.#getDraftPath();
4990
5677
  if (!draftPath) return null;
4991
5678
  let text: string;
@@ -5047,14 +5734,19 @@ export class SessionManager {
5047
5734
  await this.#persistPatch({ type: "header_patch", patch });
5048
5735
  }
5049
5736
 
5737
+ #appendManagedRecordSync(record: FileEntry | SessionPatchRecord): void {
5738
+ if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
5739
+ const store = this.#managedTranscriptStore();
5740
+ const relativePath = path.basename(this.#sessionFile);
5741
+ store.appendSync(relativePath, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"));
5742
+ }
5743
+
5050
5744
  async #persistPatch(record: SessionPatchRecord): Promise<void> {
5051
5745
  if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
5052
5746
  await this.#queuePersistTask(async () => {
5053
5747
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
5054
5748
  if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
5055
5749
  return this.#rewriteFileContents();
5056
- const writer = this.#ensurePersistWriter();
5057
- if (!writer) return this.#rewriteFileContents();
5058
5750
  const persistedRecord =
5059
5751
  record.type === "entry_patch"
5060
5752
  ? (prepareEntryForPersistenceSync(
@@ -5066,6 +5758,12 @@ export class SessionManager {
5066
5758
  this.#blobStore,
5067
5759
  ) as unknown as SessionPatchRecord)
5068
5760
  : record;
5761
+ if (this.destination.kind === "managed") {
5762
+ this.#appendManagedRecordSync(persistedRecord);
5763
+ return;
5764
+ }
5765
+ const writer = this.#ensurePersistWriter();
5766
+ if (!writer) return this.#rewriteFileContents();
5069
5767
  await writer.write(persistedRecord);
5070
5768
  });
5071
5769
  }
@@ -5108,6 +5806,16 @@ export class SessionManager {
5108
5806
  // the synchronous blob-store path so blob bytes are durable before the JSONL
5109
5807
  // line referencing them is written.
5110
5808
  try {
5809
+ if (this.destination.kind === "managed") {
5810
+ const materializedEntry = materializeResidentEntryForPersistenceSync(
5811
+ entry,
5812
+ this.#residentBlobStores(),
5813
+ new Map(),
5814
+ );
5815
+ const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
5816
+ this.#appendManagedRecordSync(persistedEntry);
5817
+ return;
5818
+ }
5111
5819
  const writer = this.#ensurePersistWriter();
5112
5820
  if (!writer) {
5113
5821
  // `#ensurePersistWriter` returns undefined here only when the cached
@@ -5446,18 +6154,29 @@ export class SessionManager {
5446
6154
  // TTSR (Time Traveling Stream Rules)
5447
6155
  // =========================================================================
5448
6156
 
5449
- /**
5450
- * Append an MCP tool selection entry recording the discovery-selected MCP tools.
5451
- * @param selectedToolNames MCP tool names selected for this branch
5452
- * @returns Entry id
5453
- */
5454
- appendMCPToolSelection(selectedToolNames: string[]): string {
6157
+ /** Append MCP discovery selection authority without altering discovered built-in authority. */
6158
+ appendMCPToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
5455
6159
  const entry: MCPToolSelectionEntry = {
5456
6160
  type: "mcp_tool_selection",
5457
6161
  id: generateId(this.#byId),
5458
6162
  parentId: this.#leafId,
5459
6163
  timestamp: new Date().toISOString(),
5460
6164
  selectedToolNames: [...selectedToolNames],
6165
+ mutationCorrelationId,
6166
+ };
6167
+ this.#appendEntry(entry);
6168
+ return entry.id;
6169
+ }
6170
+
6171
+ /** Append discovered built-in selection authority without altering MCP authority. */
6172
+ appendDiscoveredBuiltinToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
6173
+ const entry: DiscoveredBuiltinToolSelectionEntry = {
6174
+ type: "discovered_builtin_tool_selection",
6175
+ id: generateId(this.#byId),
6176
+ parentId: this.#leafId,
6177
+ timestamp: new Date().toISOString(),
6178
+ selectedToolNames: [...selectedToolNames],
6179
+ mutationCorrelationId,
5461
6180
  };
5462
6181
  this.#appendEntry(entry);
5463
6182
  return entry.id;
@@ -6204,7 +6923,14 @@ export class SessionManager {
6204
6923
  labelEntries.push(labelEntry);
6205
6924
  parentId = labelEntry.id;
6206
6925
  }
6207
- this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
6926
+ if (this.destination.kind === "managed") {
6927
+ this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
6928
+ path.basename(newSessionFile),
6929
+ Buffer.from(`${lines.join("\n")}\n`, "utf8"),
6930
+ );
6931
+ } else {
6932
+ this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
6933
+ }
6208
6934
  this.#sessionId = newSessionId;
6209
6935
  this.#sessionFile = newSessionFile;
6210
6936
  this.#resetResidentTextBlobStore();
@@ -6278,9 +7004,47 @@ export class SessionManager {
6278
7004
  * @param cwd Working directory (stored in session header)
6279
7005
  * @param sessionDir Optional session directory. If omitted, uses default (~/.gjc/agent/sessions/<encoded-cwd>/).
6280
7006
  */
6281
- static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
6282
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
6283
- const manager = new SessionManager(cwd, dir, true, storage);
7007
+ static nestedManagedDestination(
7008
+ authority: ManagedDirectoryRoot | ManagedSessionDescendantStore,
7009
+ directory: string,
7010
+ ): SessionDestination {
7011
+ const rootAuthority =
7012
+ authority instanceof ManagedSessionDescendantStore ? authority.subtreeRootAuthority : authority;
7013
+ const retainedAuthority =
7014
+ authority instanceof ManagedSessionDescendantStore
7015
+ ? authority.retainAuthority()
7016
+ : retainManagedDirectoryAuthority(rootAuthority, directory);
7017
+ const securityContext = createManagedSessionSecurityContext({
7018
+ agentDir: rootAuthority.canonicalPath,
7019
+ sessionsRoot: rootAuthority.canonicalPath,
7020
+ sessionDir: directory,
7021
+ rootAuthority,
7022
+ retainedAuthority,
7023
+ });
7024
+ const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
7025
+ trustedSessionDestinations.add(destination);
7026
+ return destination;
7027
+ }
7028
+
7029
+ static managedDestination(
7030
+ cwd: string,
7031
+ agentDir?: string,
7032
+ storage: SessionStorage = new FileSessionStorage(),
7033
+ ): SessionDestination {
7034
+ return managedDestination(cwd, storage, agentDir);
7035
+ }
7036
+
7037
+ static explicitDestination(directory: string): SessionDestination {
7038
+ return explicitDestination(directory);
7039
+ }
7040
+
7041
+ static create(
7042
+ cwd: string,
7043
+ destinationInput?: SessionDestinationInput,
7044
+ storage: SessionStorage = new FileSessionStorage(),
7045
+ ): SessionManager {
7046
+ const destination = destinationFor(cwd, destinationInput, storage);
7047
+ const manager = new SessionManager(cwd, destination.directory, true, storage, destination);
6284
7048
  manager.#initNewSession();
6285
7049
  return manager;
6286
7050
  }
@@ -6345,16 +7109,17 @@ export class SessionManager {
6345
7109
  static async forkFrom(
6346
7110
  sourcePath: string,
6347
7111
  cwd: string,
6348
- sessionDir?: string,
7112
+ destinationInput?: SessionDestinationInput,
6349
7113
  storage: SessionStorage = new FileSessionStorage(),
6350
7114
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6351
7115
  ): Promise<SessionManager> {
7116
+ const destination = destinationFor(cwd, destinationInput, storage);
6352
7117
  const managedSourcePath =
6353
- !sessionDir && storage instanceof FileSessionStorage
7118
+ destination.kind === "managed" && storage instanceof FileSessionStorage
6354
7119
  ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
6355
7120
  : sourcePath;
6356
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
6357
- const manager = new SessionManager(cwd, dir, true, storage);
7121
+ const dir = destination.directory;
7122
+ const manager = new SessionManager(cwd, dir, true, storage, destination);
6358
7123
  const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
6359
7124
  migrateToCurrentVersion(forkEntries);
6360
7125
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
@@ -6388,52 +7153,103 @@ export class SessionManager {
6388
7153
  */
6389
7154
  static async open(
6390
7155
  filePath: string,
6391
- sessionDir?: string,
7156
+ destinationInput?: SessionDestinationInput,
6392
7157
  storage: SessionStorage = new FileSessionStorage(),
6393
7158
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6394
7159
  ): Promise<SessionManager> {
7160
+ const destination =
7161
+ destinationInput === undefined
7162
+ ? explicitDestination(path.dirname(filePath))
7163
+ : destinationFor(getProjectDir(), destinationInput, storage);
6395
7164
  // Canonicalize benign ancestor symlinks in the caller-supplied path (e.g.
6396
7165
  // macOS `/var -> /private/var`, a symlinked `$HOME`/project) so explicit
6397
7166
  // session files land under a symlink-free trusted root that the strict
6398
7167
  // owner-only and reparse guards accept.
6399
7168
  if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
6400
- if (sessionDir || !(storage instanceof FileSessionStorage)) {
7169
+ if (destination.kind === "explicit" || !(storage instanceof FileSessionStorage)) {
6401
7170
  const entries = await loadEntriesFromFile(filePath, storage);
6402
7171
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
6403
7172
  const manager = new SessionManager(
6404
7173
  header?.cwd ?? getProjectDir(),
6405
- sessionDir ??
6406
- (storage instanceof FileSessionStorage ? path.resolve(filePath, "..") : path.dirname(filePath)),
7174
+ destination.directory,
6407
7175
  true,
6408
7176
  storage,
7177
+ destination,
6409
7178
  );
6410
7179
  await manager.#initSessionFile(filePath);
6411
7180
  return manager;
6412
7181
  }
6413
7182
 
6414
- const managedPath =
6415
- !sessionDir && storage instanceof FileSessionStorage
6416
- ? await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy)
6417
- : filePath;
7183
+ const managedPath = await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy);
6418
7184
  const inspected = inspectResumeSessionFile(managedPath, storage);
6419
7185
  if ("kind" in inspected) {
6420
7186
  if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
6421
- const manager = new SessionManager(getProjectDir(), path.resolve(filePath, ".."), true, storage);
7187
+ const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
6422
7188
  await manager.#initSessionFile(filePath);
6423
7189
  return manager;
6424
7190
  }
6425
7191
  throw new Error(`Could not open session: ${inspected.reason}`);
6426
7192
  }
6427
- const opened = await SessionManager.openExistingStrict(
6428
- inspected.identity,
6429
- sessionDir ?? path.resolve(managedPath, ".."),
6430
- storage,
6431
- migrationPolicy,
6432
- );
7193
+ const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy);
6433
7194
  if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
6434
7195
  return opened.manager;
6435
7196
  }
6436
7197
 
7198
+ static async openNestedManaged(
7199
+ filePath: string,
7200
+ destination: SessionDestination,
7201
+ store: ManagedSessionDescendantStore,
7202
+ storage: SessionStorage = new FileSessionStorage(),
7203
+ ): Promise<SessionManager> {
7204
+ if (destination.kind !== "managed" || !trustedSessionDestinations.has(destination))
7205
+ throw new Error("Nested managed session authority is unavailable");
7206
+ const resolved = path.resolve(filePath);
7207
+ if (
7208
+ path.dirname(resolved) !== path.resolve(store.dir) ||
7209
+ path.dirname(resolved) !== path.resolve(destination.directory)
7210
+ )
7211
+ throw new Error("Nested managed session escaped retained authority");
7212
+ store.assertBound();
7213
+ const captured = store.readExpected(path.basename(resolved));
7214
+ const entries = captured ? parseSessionEntries(captured.bytes.toString("utf8")) : [];
7215
+ const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
7216
+ const manager = new SessionManager(
7217
+ header?.cwd ?? getProjectDir(),
7218
+ destination.directory,
7219
+ true,
7220
+ storage,
7221
+ destination,
7222
+ );
7223
+ manager.#sessionFile = resolved;
7224
+ writeTerminalBreadcrumb(manager.cwd, resolved);
7225
+ manager.#fileEntries = entries;
7226
+ if (entries.length > 0) {
7227
+ manager.#sessionId = header?.id ?? createSessionId();
7228
+ manager.#sessionName = header?.title;
7229
+ manager.#titleSource = header?.titleSource;
7230
+ manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
7231
+ await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
7232
+ manager.#resetResidentTextBlobStore();
7233
+ manager.#fileEntries = manager.#fileEntries.map(entry =>
7234
+ prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
7235
+ );
7236
+ manager.#buildIndex();
7237
+ manager.#bumpAllRevisions();
7238
+ manager.#flushed = true;
7239
+ manager.#ensuredOnDisk = true;
7240
+ await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7241
+ } else {
7242
+ manager.#newSessionSync();
7243
+ manager.#sessionFile = resolved;
7244
+ manager.#resetResidentTextBlobStore();
7245
+ await manager.#rewriteFile();
7246
+ manager.#flushed = true;
7247
+ manager.#ensuredOnDisk = true;
7248
+ manager.#bumpAllRevisions();
7249
+ }
7250
+ store.assertBound();
7251
+ return manager;
7252
+ }
6437
7253
  /**
6438
7254
  * List sessions for the resume picker without recovery or other maintenance writes.
6439
7255
  */
@@ -6524,9 +7340,10 @@ export class SessionManager {
6524
7340
  static async forkFromCaptured(
6525
7341
  snapshot: CapturedSessionTranscriptSnapshot,
6526
7342
  cwd: string,
6527
- sessionDir?: string,
7343
+ destinationInput?: SessionDestinationInput,
6528
7344
  _migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6529
7345
  ): Promise<StrictSessionForkResult> {
7346
+ const destination = destinationFor(cwd, destinationInput, snapshot.storage);
6530
7347
  if (crypto.createHash("sha256").update(snapshot.content).digest("hex") !== snapshot.identity.sha256) {
6531
7348
  return { kind: "error", reason: "identity-mismatch" };
6532
7349
  }
@@ -6553,13 +7370,17 @@ export class SessionManager {
6553
7370
  return { kind: "error", reason: "identity-mismatch" };
6554
7371
  }
6555
7372
 
6556
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, snapshot.storage);
7373
+ const dir = destination.directory;
6557
7374
  const removeSessionDirOnFailure =
6558
- snapshot.storage instanceof FileSessionStorage && !snapshot.storage.existsSync(dir);
7375
+ destination.kind !== "managed" &&
7376
+ snapshot.storage instanceof FileSessionStorage &&
7377
+ !snapshot.storage.existsSync(dir);
7378
+ let managedForkStore: ManagedSessionDescendantStore | undefined;
7379
+ let managedForkTranscript: ManagedFileSnapshot | null = null;
6559
7380
  let manager: SessionManager | undefined;
6560
7381
  let authorityFailure: StrictSessionOpenFailure | undefined;
6561
7382
  try {
6562
- manager = new SessionManager(cwd, dir, true, snapshot.storage);
7383
+ manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
6563
7384
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
6564
7385
  manager.#fileEntries = forkEntries;
6565
7386
  const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -6589,6 +7410,11 @@ export class SessionManager {
6589
7410
  throw new Error("Captured fork source authority changed before destination write.");
6590
7411
  }
6591
7412
  await manager.#rewriteFile();
7413
+ if (destination.kind === "managed" && manager.#sessionFile) {
7414
+ managedForkStore = manager.#managedTranscriptStore();
7415
+ managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
7416
+ if (!managedForkTranscript) throw new Error("managed_fork_transcript_publish_missing");
7417
+ }
6592
7418
  const afterWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
6593
7419
  if ("kind" in afterWrite) {
6594
7420
  authorityFailure = afterWrite;
@@ -6598,6 +7424,11 @@ export class SessionManager {
6598
7424
  authorityFailure = { kind: "error", reason: "identity-mismatch" };
6599
7425
  throw new Error("Captured fork source authority changed during destination write.");
6600
7426
  }
7427
+ if (managedForkStore && managedForkTranscript && manager.#sessionFile) {
7428
+ const terminalTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
7429
+ if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
7430
+ throw new Error("managed_fork_transcript_changed");
7431
+ }
6601
7432
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
6602
7433
  return { kind: "forked", manager };
6603
7434
  } catch (error) {
@@ -6610,35 +7441,44 @@ export class SessionManager {
6610
7441
  cleanupErrors.push(toError(cleanupError));
6611
7442
  }
6612
7443
  if (sessionFile) {
6613
- try {
6614
- await manager.storage.unlink(sessionFile);
6615
- } catch (cleanupError) {
6616
- if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
6617
- }
6618
- if (manager.storage instanceof FileSessionStorage) {
7444
+ if (destination.kind === "managed") {
6619
7445
  try {
6620
- await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
7446
+ if (managedForkStore && managedForkTranscript)
7447
+ managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
6621
7448
  } catch (cleanupError) {
6622
7449
  cleanupErrors.push(toError(cleanupError));
6623
7450
  }
7451
+ } else {
6624
7452
  try {
6625
- const sessionFileName = path.basename(sessionFile);
6626
- const transientPrefix = `.${sessionFileName}.`;
6627
- const backupPrefix = `${sessionFileName}.`;
6628
- for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
6629
- if (
6630
- (name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
6631
- (name.startsWith(backupPrefix) && name.endsWith(".bak"))
6632
- ) {
6633
- await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
6634
- recursive: true,
6635
- force: true,
6636
- });
6637
- }
6638
- }
7453
+ await manager.storage.unlink(sessionFile);
6639
7454
  } catch (cleanupError) {
6640
7455
  if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
6641
7456
  }
7457
+ if (manager.storage instanceof FileSessionStorage) {
7458
+ try {
7459
+ await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
7460
+ } catch (cleanupError) {
7461
+ cleanupErrors.push(toError(cleanupError));
7462
+ }
7463
+ try {
7464
+ const sessionFileName = path.basename(sessionFile);
7465
+ const transientPrefix = `.${sessionFileName}.`;
7466
+ const backupPrefix = `${sessionFileName}.`;
7467
+ for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
7468
+ if (
7469
+ (name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
7470
+ (name.startsWith(backupPrefix) && name.endsWith(".bak"))
7471
+ ) {
7472
+ await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
7473
+ recursive: true,
7474
+ force: true,
7475
+ });
7476
+ }
7477
+ }
7478
+ } catch (cleanupError) {
7479
+ if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
7480
+ }
7481
+ }
6642
7482
  }
6643
7483
  }
6644
7484
  if (removeSessionDirOnFailure) {
@@ -6673,6 +7513,67 @@ export class SessionManager {
6673
7513
  : { kind: "terminal", identity: inspected.identity };
6674
7514
  }
6675
7515
 
7516
+ /**
7517
+ * Hydrate an existing predecessor transcript for recovery without taking any
7518
+ * write-capable action. The caller must retain the returned context and use
7519
+ * the explicit promotion seam only after its ownership-ready fence and writer
7520
+ * lease are durable.
7521
+ */
7522
+ static async openExistingForRecoveryHydrationStrict(
7523
+ identity: ResumeSessionIdentity,
7524
+ destinationInput?: SessionDestinationInput,
7525
+ storage: SessionStorage = new FileSessionStorage(),
7526
+ ): Promise<RecoveryHydrationOpenResult> {
7527
+ const destination =
7528
+ destinationInput === undefined
7529
+ ? explicitDestination(path.dirname(identity.canonicalPath))
7530
+ : destinationFor(getProjectDir(), destinationInput, storage);
7531
+ const inspected = inspectResumeSessionFile(identity.canonicalPath, storage);
7532
+ if ("kind" in inspected) return inspected;
7533
+ if (!sameResumeIdentity(identity, inspected.identity)) return { kind: "error", reason: "identity-mismatch" };
7534
+ if (inspected.migrationApplied) return { kind: "error", reason: "migration-required" };
7535
+
7536
+ const header = inspected.entries[0] as SessionHeader;
7537
+ const manager = new SessionManager(
7538
+ header.cwd || getProjectDir(),
7539
+ destination.directory,
7540
+ true,
7541
+ storage,
7542
+ destination,
7543
+ );
7544
+ await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
7545
+ const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
7546
+ if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
7547
+ await manager.close();
7548
+ return "kind" in revalidated ? revalidated : { kind: "error", reason: "identity-mismatch" };
7549
+ }
7550
+ const context: RecoveryHydrationContext = Object.freeze({
7551
+ identity: Object.freeze({ ...identity }),
7552
+ });
7553
+ manager.#recoveryHydrationContext = context;
7554
+ return { kind: "hydrated", manager, context };
7555
+ }
7556
+
7557
+ /**
7558
+ * Allows the normal post-open metadata sanitation only after the caller has
7559
+ * fsynced its external ownership-ready fence and acquired the writer lease.
7560
+ */
7561
+ async promoteRecoveryHydrationAfterOwnershipReadyFence(
7562
+ context: RecoveryHydrationContext,
7563
+ fence: RecoveryHydrationPromotionFence,
7564
+ ): Promise<void> {
7565
+ if (fence.ownershipReady !== true || this.#recoveryHydrationContext !== context) {
7566
+ throw new Error("Recovery hydration promotion requires the original ownership-ready context.");
7567
+ }
7568
+ const inspected = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
7569
+ if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
7570
+ throw new Error("Recovery transcript authority changed before promotion.");
7571
+ }
7572
+ await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7573
+ writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
7574
+ this.#recoveryHydrationContext = undefined;
7575
+ }
7576
+
6676
7577
  /**
6677
7578
  * Main startup code MUST retain the consented inspection identity and branch on
6678
7579
  * the returned discriminant; an error result never creates, rewrites, or adopts
@@ -6680,12 +7581,16 @@ export class SessionManager {
6680
7581
  */
6681
7582
  static async openExistingStrict(
6682
7583
  identity: ResumeSessionIdentity,
6683
- sessionDir?: string,
7584
+ destinationInput?: SessionDestinationInput,
6684
7585
  storage: SessionStorage = new FileSessionStorage(),
6685
7586
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6686
7587
  ): Promise<StrictSessionOpenResult> {
7588
+ const destination =
7589
+ destinationInput === undefined
7590
+ ? explicitDestination(path.dirname(identity.canonicalPath))
7591
+ : destinationFor(getProjectDir(), destinationInput, storage);
6687
7592
  let sessionPath = identity.canonicalPath;
6688
- if (!sessionDir && storage instanceof FileSessionStorage) {
7593
+ if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
6689
7594
  try {
6690
7595
  sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
6691
7596
  } catch (error) {
@@ -6705,8 +7610,8 @@ export class SessionManager {
6705
7610
  }
6706
7611
  const entries = inspected.entries;
6707
7612
  const header = entries[0] as SessionHeader;
6708
- const dir = sessionDir ?? path.resolve(sessionPath, "..");
6709
- const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
7613
+ const dir = destination.directory;
7614
+ const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
6710
7615
  await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
6711
7616
  const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
6712
7617
  if ("kind" in ownershipInspection) {
@@ -6734,17 +7639,18 @@ export class SessionManager {
6734
7639
  */
6735
7640
  static async continueRecent(
6736
7641
  cwd: string,
6737
- sessionDir?: string,
7642
+ destinationInput?: SessionDestinationInput,
6738
7643
  storage: SessionStorage = new FileSessionStorage(),
6739
7644
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6740
7645
  ): Promise<SessionManager> {
6741
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
7646
+ const destination = destinationFor(cwd, destinationInput, storage);
7647
+ const dir = destination.directory;
6742
7648
  const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
6743
7649
  const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
6744
7650
  if (inspected.kind === "error") return undefined;
6745
7651
  const opened = await SessionManager.openExistingStrict(
6746
7652
  inspected.identity,
6747
- sessionDir,
7653
+ destination,
6748
7654
  storage,
6749
7655
  migrationPolicy,
6750
7656
  );
@@ -6756,9 +7662,10 @@ export class SessionManager {
6756
7662
  };
6757
7663
  // Legacy paths are candidates only: the managed scan validates each transcript
6758
7664
  // header cwd before it can affect default resume authority.
6759
- const managedCandidates = sessionDir
6760
- ? []
6761
- : await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
7665
+ const managedCandidates =
7666
+ destination.kind === "explicit"
7667
+ ? []
7668
+ : await SessionManager.listForResumePickerReadOnly(cwd, destination.directory, storage);
6762
7669
  const terminalSession = await readTerminalBreadcrumb(cwd);
6763
7670
  if (terminalSession) {
6764
7671
  const opened = await openSelectedStrictly(terminalSession);
@@ -6773,7 +7680,7 @@ export class SessionManager {
6773
7680
  const opened = await openSelectedStrictly(mostRecent);
6774
7681
  if (opened) return opened;
6775
7682
  }
6776
- const manager = new SessionManager(cwd, dir, true, storage);
7683
+ const manager = new SessionManager(cwd, dir, true, storage, destination);
6777
7684
  manager.#initNewSession();
6778
7685
  return manager;
6779
7686
  }