@gajae-code/coding-agent 0.11.4 → 0.11.6

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 (154) 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 +31 -1
  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 +42 -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 +11 -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 +98 -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/session/yield-queue.d.ts +8 -0
  50. package/dist/types/task/executor.d.ts +9 -0
  51. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  52. package/dist/types/tools/ask.d.ts +48 -0
  53. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  54. package/dist/types/tools/index.d.ts +4 -0
  55. package/package.json +9 -9
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  57. package/src/cli/notify-cli.ts +40 -18
  58. package/src/commands/sdk.ts +7 -2
  59. package/src/config/settings-schema.ts +31 -1
  60. package/src/daemon/runtime.ts +2 -2
  61. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  63. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  64. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  65. package/src/eval/py/index.ts +2 -2
  66. package/src/export/html/template.generated.ts +1 -1
  67. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  68. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  69. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  70. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  71. package/src/gjc-runtime/launch-tmux.ts +61 -6
  72. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  73. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  74. package/src/gjc-runtime/state-runtime.ts +38 -12
  75. package/src/gjc-runtime/team-runtime.ts +1 -1
  76. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  77. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  78. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  79. package/src/harness-control-plane/owner.ts +59 -3
  80. package/src/hindsight/client.ts +65 -2
  81. package/src/internal-urls/agent-protocol.ts +76 -5
  82. package/src/internal-urls/docs-index.generated.ts +8 -7
  83. package/src/internal-urls/local-protocol.ts +416 -18
  84. package/src/lsp/index.ts +4 -88
  85. package/src/main.ts +36 -12
  86. package/src/modes/acp/acp-agent.ts +8 -0
  87. package/src/modes/components/ansi-display-validator.ts +54 -0
  88. package/src/modes/components/notifications-settings-editor.ts +26 -2
  89. package/src/modes/components/session-observer-overlay.ts +40 -24
  90. package/src/modes/components/settings-selector.ts +3 -1
  91. package/src/modes/components/tool-transcript-format.ts +321 -0
  92. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  93. package/src/modes/controllers/command-controller.ts +15 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  95. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  96. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  97. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  98. package/src/modes/controllers/selector-controller.ts +70 -8
  99. package/src/modes/interactive-mode.ts +1 -0
  100. package/src/modes/runtime-init.ts +16 -2
  101. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  102. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  103. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  104. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  105. package/src/plan-mode/approved-plan.ts +3 -1
  106. package/src/prompts/agents/architect.md +1 -0
  107. package/src/prompts/agents/critic.md +1 -0
  108. package/src/prompts/system/custom-system-prompt.md +1 -1
  109. package/src/prompts/system/system-prompt.md +2 -1
  110. package/src/prompts/tools/lsp.md +3 -3
  111. package/src/runtime-mcp/manager.ts +16 -1
  112. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  113. package/src/sdk/broker/session-index.ts +122 -64
  114. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  115. package/src/sdk/bus/chat-daemon-control.ts +538 -130
  116. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  117. package/src/sdk/bus/config-commands.ts +22 -0
  118. package/src/sdk/bus/config.ts +37 -0
  119. package/src/sdk/bus/index.ts +388 -94
  120. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  121. package/src/sdk/bus/notification-service.ts +284 -62
  122. package/src/sdk/bus/recent-activity.ts +14 -5
  123. package/src/sdk/bus/session-control.ts +150 -44
  124. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  125. package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
  126. package/src/sdk/bus/telegram-daemon-control.ts +312 -76
  127. package/src/sdk/bus/telegram-daemon.ts +1916 -285
  128. package/src/sdk/host/control/dispatch.ts +14 -5
  129. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  130. package/src/sdk/session.ts +155 -27
  131. package/src/secrets/index.ts +53 -10
  132. package/src/secrets/obfuscator.ts +21 -18
  133. package/src/secrets/regex.ts +2 -0
  134. package/src/session/agent-session.ts +1447 -852
  135. package/src/session/artifacts.ts +188 -23
  136. package/src/session/context-estimation.ts +7 -2
  137. package/src/session/internal/managed-session-scope.ts +200 -38
  138. package/src/session/internal/managed-session-storage.ts +1152 -46
  139. package/src/session/session-manager.ts +1121 -148
  140. package/src/session/session-storage.ts +109 -11
  141. package/src/session/streaming-output.ts +6 -0
  142. package/src/session/yield-queue.ts +20 -0
  143. package/src/slash-commands/builtin-registry.ts +36 -0
  144. package/src/task/executor.ts +71 -26
  145. package/src/task/index.ts +63 -20
  146. package/src/tool-discovery/tool-index.ts +18 -0
  147. package/src/tools/ask-answer-registry.ts +15 -1
  148. package/src/tools/ask.ts +129 -32
  149. package/src/tools/bash-skill-urls.ts +14 -3
  150. package/src/tools/bash.ts +2 -2
  151. package/src/tools/index.ts +4 -0
  152. package/src/tools/plan-mode-guard.ts +1 -0
  153. package/src/tools/sqlite-reader.ts +3 -2
  154. 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,60 @@ 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
+ /**
857
+ * Resolve `target` through the real path of its nearest existing ancestor,
858
+ * re-appending any not-yet-created trailing segments. Symlink/reparse components
859
+ * in the existing prefix are canonicalized (so aliases cannot escape a containment
860
+ * check or alias another file), while a missing leaf (e.g. an uncommitted session
861
+ * transcript) is still resolved deterministically. Non-ENOENT errors (ELOOP,
862
+ * EACCES, ...) surface rather than silently falling back to the lexical path.
863
+ */
864
+ function canonicalizeThroughExistingAncestor(target: string): string {
865
+ const resolved = path.resolve(target);
866
+ const tail: string[] = [];
867
+ let current = resolved;
868
+ while (true) {
869
+ try {
870
+ const real = fs.realpathSync(current);
871
+ return tail.length === 0 ? real : path.join(real, ...tail.reverse());
872
+ } catch (err) {
873
+ if (!isEnoent(err)) throw err;
874
+ const parent = path.dirname(current);
875
+ if (parent === current) return resolved;
876
+ tail.push(path.basename(current));
877
+ current = parent;
878
+ }
879
+ }
880
+ }
881
+
882
+ function isSupportedSessionVersion(version: unknown): boolean {
883
+ return (
884
+ version === undefined ||
885
+ (typeof version === "number" && Number.isInteger(version) && version <= CURRENT_SESSION_VERSION)
886
+ );
887
+ }
888
+
808
889
  /** Exported for compaction.test.ts */
809
890
  export function parseSessionEntries(content: string): FileEntry[] {
810
891
  const records = parseJsonlLenient<FileEntry | SessionPatchRecord>(content);
892
+ const rawHeader = records.find((record): record is SessionHeader => record.type === "session");
893
+ if (!isSupportedSessionVersion(rawHeader?.version)) {
894
+ throw new Error(`Unsupported session version: ${String(rawHeader?.version)}`);
895
+ }
811
896
  const entries: FileEntry[] = [];
812
897
  const entriesById = new Map<string, SessionEntry>();
813
898
  let header: SessionHeader | undefined;
814
899
 
815
900
  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.
901
+ // Patch records are valid in v4 and v5 only. Ignore patches before a valid
902
+ // header so malformed prefixes cannot affect later records.
819
903
  if (record.type === "header_patch") {
820
- if (header?.version === CURRENT_SESSION_VERSION && isHeaderPatchRecord(record))
904
+ if (header?.version !== undefined && header.version >= 4 && isHeaderPatchRecord(record))
821
905
  applyHeaderPatch(header, record.patch);
822
906
  continue;
823
907
  }
824
908
  if (record.type === "entry_patch") {
825
- if (header?.version === CURRENT_SESSION_VERSION && isEntryPatchRecord(record)) {
909
+ if (header?.version !== undefined && header.version >= 4 && isEntryPatchRecord(record)) {
826
910
  const entry = entriesById.get(record.entryId);
827
911
  if (entry?.type === "message" && record.patch.message) entry.message = record.patch.message;
828
912
  }
@@ -939,6 +1023,7 @@ export function buildSessionContext(
939
1023
 
940
1024
  selectedMCPToolNames: [],
941
1025
  hasPersistedMCPToolSelection: false,
1026
+ hasPersistedDiscoveredBuiltinToolSelection: false,
942
1027
  mode: "none",
943
1028
  };
944
1029
  }
@@ -963,6 +1048,7 @@ export function buildSessionContext(
963
1048
  ttsrMessageCount: 0,
964
1049
  selectedMCPToolNames: [],
965
1050
  hasPersistedMCPToolSelection: false,
1051
+ hasPersistedDiscoveredBuiltinToolSelection: false,
966
1052
  mode: "none",
967
1053
  };
968
1054
  }
@@ -992,6 +1078,8 @@ export function buildSessionContext(
992
1078
 
993
1079
  let selectedMCPToolNames: string[] = [];
994
1080
  let hasPersistedMCPToolSelection = false;
1081
+ let selectedDiscoveredBuiltinToolNames: string[] | undefined;
1082
+ let hasPersistedDiscoveredBuiltinToolSelection = false;
995
1083
  let mode = "none";
996
1084
  let modeData: Record<string, unknown> | undefined;
997
1085
  // Track whether an explicit `model_change` with role="default" has been
@@ -1052,7 +1140,14 @@ export function buildSessionContext(
1052
1140
  }
1053
1141
  } else if (entry.type === "mcp_tool_selection") {
1054
1142
  selectedMCPToolNames = [...entry.selectedToolNames];
1143
+ if (entry.selectedDiscoveredBuiltinToolNames !== undefined) {
1144
+ selectedDiscoveredBuiltinToolNames = [...entry.selectedDiscoveredBuiltinToolNames];
1145
+ hasPersistedDiscoveredBuiltinToolSelection = true;
1146
+ }
1055
1147
  hasPersistedMCPToolSelection = true;
1148
+ } else if (entry.type === "discovered_builtin_tool_selection") {
1149
+ selectedDiscoveredBuiltinToolNames = [...entry.selectedToolNames];
1150
+ hasPersistedDiscoveredBuiltinToolSelection = true;
1056
1151
  } else if (entry.type === "mode_change") {
1057
1152
  mode = entry.mode;
1058
1153
  modeData = entry.data;
@@ -1176,7 +1271,9 @@ export function buildSessionContext(
1176
1271
  ttsrMessageCount,
1177
1272
 
1178
1273
  selectedMCPToolNames,
1274
+ selectedDiscoveredBuiltinToolNames,
1179
1275
  hasPersistedMCPToolSelection,
1276
+ hasPersistedDiscoveredBuiltinToolSelection,
1180
1277
  mode,
1181
1278
  modeData,
1182
1279
  };
@@ -1201,6 +1298,9 @@ function cloneSessionContext(context: SessionContext): SessionContext {
1201
1298
  ttsrMessageCount: context.ttsrMessageCount,
1202
1299
 
1203
1300
  selectedMCPToolNames: [...context.selectedMCPToolNames],
1301
+ selectedDiscoveredBuiltinToolNames: context.selectedDiscoveredBuiltinToolNames
1302
+ ? [...context.selectedDiscoveredBuiltinToolNames]
1303
+ : undefined,
1204
1304
  modeData: cloneJsonSemantic(context.modeData),
1205
1305
  };
1206
1306
  }
@@ -1231,6 +1331,140 @@ function computeDefaultSessionDir(
1231
1331
  return prepared.scope.directoryPath;
1232
1332
  }
1233
1333
 
1334
+ /** A session directory's authority is distinct from its string path. */
1335
+ export type SessionDestination =
1336
+ | { readonly kind: "managed"; readonly directory: string; readonly securityContext: ManagedSessionSecurityContext }
1337
+ | { readonly kind: "explicit"; readonly directory: string };
1338
+ export type SessionDestinationInput = string | SessionDestination | undefined;
1339
+
1340
+ interface ForkArtifactPublication {
1341
+ readonly snapshot: native.NativeDirectoryTreeSnapshot;
1342
+ readonly store: ManagedSessionDescendantStore;
1343
+ readonly cleanupStore: ManagedSessionDescendantStore;
1344
+ readonly cleanupRelativePath: string;
1345
+ }
1346
+
1347
+ function retainedTreeSnapshotEquals(
1348
+ left: native.NativeDirectoryTreeSnapshot,
1349
+ right: native.NativeDirectoryTreeSnapshot,
1350
+ ): boolean {
1351
+ return (
1352
+ left.rootDev === right.rootDev &&
1353
+ left.rootIno === right.rootIno &&
1354
+ left.entries.length === right.entries.length &&
1355
+ left.entries.every((entry, index) => {
1356
+ const other = right.entries[index];
1357
+ return (
1358
+ other !== undefined &&
1359
+ entry.relativePath === other.relativePath &&
1360
+ entry.kind === other.kind &&
1361
+ entry.dev === other.dev &&
1362
+ entry.ino === other.ino &&
1363
+ entry.size === other.size &&
1364
+ entry.mtimeNs === other.mtimeNs &&
1365
+ entry.ctimeNs === other.ctimeNs &&
1366
+ entry.sha256 === other.sha256
1367
+ );
1368
+ })
1369
+ );
1370
+ }
1371
+
1372
+ function retainedTreeSnapshotEqualsAfterRename(
1373
+ left: native.NativeDirectoryTreeSnapshot,
1374
+ right: native.NativeDirectoryTreeSnapshot,
1375
+ ): boolean {
1376
+ return (
1377
+ left.rootDev === right.rootDev &&
1378
+ left.rootIno === right.rootIno &&
1379
+ left.entries.length === right.entries.length &&
1380
+ left.entries.every((entry, index) => {
1381
+ const other = right.entries[index];
1382
+ return (
1383
+ other !== undefined &&
1384
+ entry.relativePath === other.relativePath &&
1385
+ entry.kind === other.kind &&
1386
+ entry.dev === other.dev &&
1387
+ entry.ino === other.ino &&
1388
+ entry.size === other.size &&
1389
+ entry.mtimeNs === other.mtimeNs &&
1390
+ (entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
1391
+ entry.sha256 === other.sha256
1392
+ );
1393
+ })
1394
+ );
1395
+ }
1396
+
1397
+ function managedFileSnapshotEquals(left: ManagedFileSnapshot | null, right: ManagedFileSnapshot | null): boolean {
1398
+ return (
1399
+ left !== null &&
1400
+ right !== null &&
1401
+ left.identity.dev === right.identity.dev &&
1402
+ left.identity.ino === right.identity.ino &&
1403
+ left.identity.size === right.identity.size &&
1404
+ left.identity.mtimeNs === right.identity.mtimeNs &&
1405
+ left.identity.ctimeNs === right.identity.ctimeNs &&
1406
+ left.identity.sha256 === right.identity.sha256 &&
1407
+ left.bytes.equals(right.bytes)
1408
+ );
1409
+ }
1410
+ const trustedSessionDestinations = new WeakSet<SessionDestination>();
1411
+
1412
+ function managedStoreFromContext(
1413
+ securityContext: ManagedSessionSecurityContext,
1414
+ directory: string,
1415
+ ): ManagedSessionDescendantStore {
1416
+ return new ManagedSessionDescendantStore(
1417
+ securityContext.rootAuthority,
1418
+ directory,
1419
+ securityContext.retainedAuthority
1420
+ ? { authority: securityContext.retainedAuthority, authorityBaseDir: directory }
1421
+ : undefined,
1422
+ );
1423
+ }
1424
+
1425
+ function freezeManagedDestination(scope: ManagedScope): SessionDestination {
1426
+ const rootAuthority = managedRootForScope(scope);
1427
+ const securityContext = createManagedSessionSecurityContext({
1428
+ agentDir: scope.agentDir,
1429
+ sessionsRoot: scope.sessionsRoot,
1430
+ sessionDir: scope.directoryPath,
1431
+ rootAuthority,
1432
+ retainedAuthority: managedDirectoryAuthorityForScope(scope),
1433
+ });
1434
+ const destination = Object.freeze({ kind: "managed" as const, directory: scope.directoryPath, securityContext });
1435
+ trustedSessionDestinations.add(destination);
1436
+ return destination;
1437
+ }
1438
+
1439
+ function explicitDestination(directory: string): SessionDestination {
1440
+ const destination = Object.freeze({ kind: "explicit" as const, directory });
1441
+ trustedSessionDestinations.add(destination);
1442
+ return destination;
1443
+ }
1444
+
1445
+ function managedDestination(cwd: string, storage: SessionStorage, agentDir?: string): SessionDestination {
1446
+ if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1447
+ const sessionsRoot = getSessionsDir(agentDir);
1448
+ const resolved = resolveManagedScope({ cwd, agentDir: agentDir ?? path.resolve(sessionsRoot, ".."), sessionsRoot });
1449
+ if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1450
+ const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1451
+ if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1452
+ return freezeManagedDestination(prepared.scope);
1453
+ }
1454
+
1455
+ function destinationFor(
1456
+ cwd: string,
1457
+ input: SessionDestinationInput,
1458
+ storage: SessionStorage,
1459
+ agentDir?: string,
1460
+ ): SessionDestination {
1461
+ if (typeof input === "object") {
1462
+ if (!trustedSessionDestinations.has(input)) throw new Error("Untrusted session destination authority");
1463
+ return input;
1464
+ }
1465
+ return input === undefined ? managedDestination(cwd, storage, agentDir) : explicitDestination(input);
1466
+ }
1467
+
1234
1468
  // =============================================================================
1235
1469
  // Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
1236
1470
  // =============================================================================
@@ -1475,7 +1709,19 @@ function hasStrictSessionSchema(entries: FileEntry[]): boolean {
1475
1709
  case "mcp_tool_selection":
1476
1710
  if (
1477
1711
  !Array.isArray(value.selectedToolNames) ||
1478
- !value.selectedToolNames.every(name => typeof name === "string")
1712
+ !value.selectedToolNames.every(name => typeof name === "string") ||
1713
+ (value.selectedDiscoveredBuiltinToolNames !== undefined &&
1714
+ (!Array.isArray(value.selectedDiscoveredBuiltinToolNames) ||
1715
+ !value.selectedDiscoveredBuiltinToolNames.every(name => typeof name === "string"))) ||
1716
+ (value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
1717
+ )
1718
+ return false;
1719
+ break;
1720
+ case "discovered_builtin_tool_selection":
1721
+ if (
1722
+ !Array.isArray(value.selectedToolNames) ||
1723
+ !value.selectedToolNames.every(name => typeof name === "string") ||
1724
+ (value.mutationCorrelationId !== undefined && typeof value.mutationCorrelationId !== "string")
1479
1725
  )
1480
1726
  return false;
1481
1727
  break;
@@ -1806,8 +2052,13 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
1806
2052
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
1807
2053
  if (entries.length === 0) return;
1808
2054
  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) {
2055
+ if (
2056
+ header.type !== "session" ||
2057
+ typeof header.id !== "string" ||
2058
+ !isSupportedSessionVersion(header.version)
2059
+ )
2060
+ return;
2061
+ if (typeof header.version === "number" && header.version >= 4) {
1811
2062
  for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
1812
2063
  applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
1813
2064
  }
@@ -3005,11 +3256,16 @@ class NdjsonFileWriter {
3005
3256
  #onError: ((err: Error) => void) | undefined;
3006
3257
  #closeDrained = false;
3007
3258
 
3008
- constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
3259
+ constructor(
3260
+ storage: SessionStorage,
3261
+ path: string,
3262
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void; securityContext?: ManagedSessionSecurityContext },
3263
+ ) {
3009
3264
  this.#onError = options?.onError;
3010
3265
  this.#writer = storage.openWriter(path, {
3011
3266
  flags: options?.flags ?? "a",
3012
3267
  onError: (err: Error) => this.#recordError(err),
3268
+ securityContext: options?.securityContext,
3013
3269
  });
3014
3270
  }
3015
3271
 
@@ -3489,7 +3745,7 @@ async function collectSessionFromFile(
3489
3745
  const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
3490
3746
  const header = parseSessionListHeader(content, entries);
3491
3747
  if (!header) return undefined;
3492
- if (header.version === CURRENT_SESSION_VERSION) {
3748
+ if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
3493
3749
  for (const patch of await readSessionListTrailingPatches(file, storage, buffer)) {
3494
3750
  applySessionListHeaderPatch(header, patch);
3495
3751
  }
@@ -3653,6 +3909,7 @@ export class SessionManager {
3653
3909
  #flushed: boolean = false;
3654
3910
  #needsFullRewriteOnNextPersist: boolean = false;
3655
3911
  #ensuredOnDisk: boolean = false;
3912
+ #recoveryHydrationContext: RecoveryHydrationContext | undefined;
3656
3913
  #fileEntries: FileEntry[] = [];
3657
3914
  #byId: Map<string, SessionEntry> = new Map();
3658
3915
  #labelsById: Map<string, string> = new Map();
@@ -3673,6 +3930,7 @@ export class SessionManager {
3673
3930
 
3674
3931
  #artifactManager: ArtifactManager | null = null;
3675
3932
  #artifactManagerSessionFile: string | null = null;
3933
+ #managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
3676
3934
  // When set, take precedence over the lazily-derived per-session manager.
3677
3935
  // Subagents adopt the parent's manager so artifact IDs are unique across the
3678
3936
  // whole agent tree and all files land in the parent's artifacts dir.
@@ -3718,6 +3976,7 @@ export class SessionManager {
3718
3976
  private sessionDir: string,
3719
3977
  private readonly persist: boolean,
3720
3978
  private readonly storage: SessionStorage,
3979
+ private destination: SessionDestination = explicitDestination(sessionDir),
3721
3980
  ) {
3722
3981
  this.#blobStore = persist ? new BlobStore(getBlobsDir()) : this.#residentTextBlobStore;
3723
3982
  this.#residentImageBlobStore = this.#blobStore;
@@ -3822,7 +4081,12 @@ export class SessionManager {
3822
4081
 
3823
4082
  #resetResidentTextBlobStore(): void {
3824
4083
  this.#disposeResidentTextBlobStore();
3825
- if (this.persist && this.#sessionFile && this.storage instanceof FileSessionStorage) {
4084
+ if (
4085
+ this.destination.kind === "explicit" &&
4086
+ this.persist &&
4087
+ this.#sessionFile &&
4088
+ this.storage instanceof FileSessionStorage
4089
+ ) {
3826
4090
  this.#residentTextBlobStore = new EphemeralBlobStore(this.#residentCacheDir(this.#sessionFile));
3827
4091
  }
3828
4092
  }
@@ -4001,6 +4265,46 @@ export class SessionManager {
4001
4265
  }
4002
4266
  }
4003
4267
 
4268
+ /**
4269
+ * Exact-delete a session transcript and its artifacts by path WITHOUT requiring
4270
+ * managed logical authorization. This is only for discarding an UNCOMMITTED
4271
+ * successor that a transaction (e.g. handoff) created via `newSession()` but
4272
+ * never durably authorized — such a session has no managed candidate listing
4273
+ * yet, so `dropSession` would refuse it and leak its resident-cache/artifact
4274
+ * root. Bounded to this manager's configured session root and refuses to touch
4275
+ * the active session; tolerates missing files.
4276
+ */
4277
+ async discardUncommittedSession(sessionPath: string): Promise<void> {
4278
+ // Close the writer FIRST so the canonical checks and the delete run with no
4279
+ // intervening await, eliminating the check/use window in which a parent
4280
+ // directory could be swapped for a symlink between authorization and deletion.
4281
+ await this.#closePersistWriter();
4282
+ // Canonicalize the candidate (and the active file / configured dir) through
4283
+ // their nearest existing ancestor so symlink/reparse components cannot alias
4284
+ // the active session or escape the configured session directory, even when
4285
+ // the successor transcript itself does not yet exist.
4286
+ const canonicalCandidate = canonicalizeThroughExistingAncestor(path.resolve(sessionPath));
4287
+ if (this.#sessionFile) {
4288
+ const canonicalActive = canonicalizeThroughExistingAncestor(path.resolve(this.#sessionFile));
4289
+ if (canonicalActive === canonicalCandidate) {
4290
+ throw new Error("Refusing to discard the active session as uncommitted.");
4291
+ }
4292
+ }
4293
+ // Least-authority containment: only within this manager's configured session
4294
+ // directory (canonical), where newSession() allocates the successor. The
4295
+ // broader managed root (parent of sessionDir) is deliberately NOT accepted.
4296
+ const canonicalSessionDir = canonicalizeThroughExistingAncestor(path.resolve(this.sessionDir));
4297
+ if (!pathIsWithin(canonicalSessionDir, canonicalCandidate)) {
4298
+ throw new Error("Uncommitted session discard is limited to this manager's configured session directory.");
4299
+ }
4300
+ try {
4301
+ await this.storage.deleteSessionWithArtifacts(canonicalCandidate);
4302
+ } catch (err) {
4303
+ if (isEnoent(err)) return;
4304
+ throw err;
4305
+ }
4306
+ }
4307
+
4004
4308
  /**
4005
4309
  * Fork the current session, creating a new session file with the same entries.
4006
4310
  * Returns both the old and new session file paths for artifact copying.
@@ -4014,6 +4318,9 @@ export class SessionManager {
4014
4318
  const oldSessionFile = this.#sessionFile;
4015
4319
  const oldSessionId = this.#sessionId;
4016
4320
  const materializedEntries = materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores());
4321
+ const oldFileEntries = this.#fileEntries;
4322
+ const oldSessionName = this.#sessionName;
4323
+ const oldTitleSource = this.#titleSource;
4017
4324
 
4018
4325
  // Close the current writer
4019
4326
  await this.#closePersistWriter();
@@ -4049,13 +4356,169 @@ export class SessionManager {
4049
4356
  this.#reexternalizeFileEntriesForResidentStore();
4050
4357
  this.#bumpAllRevisions();
4051
4358
 
4052
- // Write the new session file
4359
+ // Write the new session file, import artifacts, and adopt only after the
4360
+ // retained destination manifest is complete.
4053
4361
  this.#flushed = false;
4054
- await this.#rewriteFile();
4362
+ let forkArtifactPublication: ForkArtifactPublication | undefined;
4363
+ let forkTranscriptPublication: ManagedFileSnapshot | null = null;
4364
+ let forkTranscriptStore: ManagedSessionDescendantStore | undefined;
4365
+ try {
4366
+ forkArtifactPublication = await this.copyArtifactsForFork(oldSessionFile, this.#sessionFile);
4367
+ await this.#rewriteFile();
4368
+ if (this.destination.kind === "managed") {
4369
+ forkTranscriptStore = this.#managedTranscriptStore();
4370
+ forkTranscriptPublication = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
4371
+ if (!forkTranscriptPublication) throw new Error("managed_fork_transcript_publish_missing");
4372
+ }
4373
+ forkArtifactPublication?.store.verifyRootSecurity();
4374
+ forkArtifactPublication?.store.assertBound();
4375
+ if (forkArtifactPublication) {
4376
+ const adoptedSnapshot = forkArtifactPublication.store.fsyncTree();
4377
+ if (!retainedTreeSnapshotEquals(adoptedSnapshot, forkArtifactPublication.snapshot)) {
4378
+ throw new Error("artifact_destination_changed_during_transcript_publication");
4379
+ }
4380
+ }
4381
+ if (forkTranscriptStore && forkTranscriptPublication) {
4382
+ const terminalTranscript = forkTranscriptStore.readExpected(path.basename(this.#sessionFile));
4383
+ if (!managedFileSnapshotEquals(terminalTranscript, forkTranscriptPublication))
4384
+ throw new Error("managed_fork_transcript_changed");
4385
+ }
4386
+ } catch (error) {
4387
+ let failure = error;
4388
+ const failedSessionFile = this.#sessionFile;
4389
+ if (this.destination.kind === "managed") {
4390
+ try {
4391
+ if (forkTranscriptStore && forkTranscriptPublication)
4392
+ forkTranscriptStore.removeExpected(path.basename(failedSessionFile), forkTranscriptPublication);
4393
+ if (forkArtifactPublication)
4394
+ forkArtifactPublication.cleanupStore.removeTreeExpected(
4395
+ forkArtifactPublication.cleanupRelativePath,
4396
+ forkArtifactPublication.snapshot,
4397
+ );
4398
+ } catch (cleanupError) {
4399
+ failure = new Error(`Failed to clean up managed fork destination: ${toError(cleanupError).message}`, {
4400
+ cause: toError(error),
4401
+ });
4402
+ }
4403
+ } else {
4404
+ try {
4405
+ await this.storage.unlink(failedSessionFile);
4406
+ } catch {
4407
+ // Preserve ambiguous cleanup evidence while restoring the active manager.
4408
+ }
4409
+ }
4410
+ this.#sessionId = oldSessionId;
4411
+ this.#sessionFile = oldSessionFile;
4412
+ this.#fileEntries = oldFileEntries;
4413
+ this.#sessionName = oldSessionName;
4414
+ this.#titleSource = oldTitleSource;
4415
+ this.#persistChain = Promise.resolve();
4416
+ this.#persistError = undefined;
4417
+ this.#persistErrorReported = false;
4418
+ this.#resetResidentTextBlobStore();
4419
+ this.#reexternalizeFileEntriesForResidentStore();
4420
+ this.#bumpAllRevisions();
4421
+ throw failure;
4422
+ }
4055
4423
 
4056
4424
  return { oldSessionFile, newSessionFile: this.#sessionFile };
4057
4425
  }
4058
4426
 
4427
+ async copyArtifactsForFork(
4428
+ oldSessionFile: string,
4429
+ newSessionFile: string,
4430
+ ): Promise<ForkArtifactPublication | undefined> {
4431
+ const sourceDir = oldSessionFile.slice(0, -6);
4432
+ const finalDestinationDir = newSessionFile.slice(0, -6);
4433
+ if (this.destination.kind !== "managed") {
4434
+ try {
4435
+ const sourceStat = fs.lstatSync(sourceDir);
4436
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw new Error("unsafe_artifacts");
4437
+ } catch (error) {
4438
+ if (isEnoent(error)) return;
4439
+ throw error;
4440
+ }
4441
+ await fs.promises.cp(sourceDir, finalDestinationDir, { recursive: true });
4442
+ return undefined;
4443
+ }
4444
+
4445
+ const sourceName = path.basename(sourceDir);
4446
+ const destinationName = path.basename(finalDestinationDir);
4447
+ const stagingName = `.${destinationName}.${crypto.randomUUID()}.fork-staging`;
4448
+ const parentStore = this.#managedTranscriptStore(oldSessionFile);
4449
+ let sourceSnapshot: native.NativeDirectoryTreeSnapshot;
4450
+ try {
4451
+ sourceSnapshot = parentStore.captureTree(sourceName);
4452
+ } catch (error) {
4453
+ if (error instanceof Error && error.message === "not_found") return;
4454
+ throw error;
4455
+ }
4456
+ let stagingSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
4457
+ let publishedSnapshot: native.NativeDirectoryTreeSnapshot | undefined;
4458
+ try {
4459
+ parentStore.ensureDirectory(stagingName);
4460
+ stagingSnapshot = parentStore.captureTree(stagingName);
4461
+ for (const entry of sourceSnapshot.entries) {
4462
+ if (entry.relativePath === "") continue;
4463
+ const destinationRelativePath = path.posix.join(stagingName, entry.relativePath);
4464
+ if (entry.kind === "directory") {
4465
+ parentStore.ensureDirectory(destinationRelativePath);
4466
+ } else if (entry.kind === "file") {
4467
+ const sourceFile = parentStore.readExpected(path.posix.join(sourceName, entry.relativePath));
4468
+ if (
4469
+ !sourceFile ||
4470
+ sourceFile.identity.dev.toString() !== entry.dev ||
4471
+ sourceFile.identity.ino.toString() !== entry.ino ||
4472
+ sourceFile.identity.size.toString() !== entry.size ||
4473
+ sourceFile.identity.mtimeNs.toString() !== entry.mtimeNs ||
4474
+ crypto.createHash("sha256").update(sourceFile.bytes).digest("hex") !== entry.sha256
4475
+ ) {
4476
+ throw new Error("artifact_source_changed");
4477
+ }
4478
+ await parentStore.publishNoReplace(destinationRelativePath, sourceFile.bytes);
4479
+ } else {
4480
+ throw new Error("unsafe_artifacts");
4481
+ }
4482
+ stagingSnapshot = parentStore.captureTree(stagingName);
4483
+ }
4484
+ if (JSON.stringify(parentStore.captureTree(sourceName)) !== JSON.stringify(sourceSnapshot))
4485
+ throw new Error("artifact_source_changed");
4486
+ parentStore.moveTreeNoReplace(stagingName, destinationName, stagingSnapshot);
4487
+ publishedSnapshot = parentStore.captureTree(destinationName);
4488
+ const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4489
+ tree.entries.map(entry => ({
4490
+ relativePath: entry.relativePath,
4491
+ kind: entry.kind,
4492
+ size: entry.kind === "file" ? entry.size : undefined,
4493
+ sha256: entry.sha256,
4494
+ }));
4495
+ if (JSON.stringify(comparable(publishedSnapshot)) !== JSON.stringify(comparable(sourceSnapshot)))
4496
+ throw new Error("artifact_destination_terminal_mismatch");
4497
+ const publishedStore = parentStore.deriveSubtree(destinationName);
4498
+ publishedStore.verifyRootSecurity();
4499
+ const retainedPublishedSnapshot = publishedStore.captureTree("");
4500
+ if (!retainedTreeSnapshotEqualsAfterRename(retainedPublishedSnapshot, publishedSnapshot))
4501
+ throw new Error("artifact_destination_retained_identity_changed");
4502
+ publishedSnapshot = retainedPublishedSnapshot;
4503
+ return {
4504
+ snapshot: publishedSnapshot,
4505
+ store: publishedStore,
4506
+ cleanupStore: parentStore,
4507
+ cleanupRelativePath: destinationName,
4508
+ };
4509
+ } catch (error) {
4510
+ try {
4511
+ if (publishedSnapshot) parentStore.removeTreeExpected(destinationName, publishedSnapshot);
4512
+ else if (stagingSnapshot) parentStore.removeTreeExpected(stagingName, stagingSnapshot);
4513
+ } catch (cleanupError) {
4514
+ throw new Error(`Failed to clean up managed fork artifacts: ${toError(cleanupError).message}`, {
4515
+ cause: toError(error),
4516
+ });
4517
+ }
4518
+ throw error;
4519
+ }
4520
+ }
4521
+
4059
4522
  /**
4060
4523
  * Move the session to a new working directory.
4061
4524
  * Moves session files and artifacts on disk, updates all internal references,
@@ -4067,17 +4530,26 @@ export class SessionManager {
4067
4530
  const previousCwd = this.cwd;
4068
4531
  const previousSessionDir = this.sessionDir;
4069
4532
  const previousSessionFile = this.#sessionFile;
4533
+ const previousDestination = this.destination;
4070
4534
  const previousArtifactDir = previousSessionFile?.slice(0, -6);
4071
4535
 
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;
4536
+ const nextDestination = (() => {
4537
+ if (this.destination.kind === "managed") {
4538
+ return managedDestination(resolvedCwd, this.storage, this.destination.securityContext.agentDir);
4539
+ }
4540
+ if (!(this.storage instanceof FileSessionStorage)) return this.destination;
4541
+ const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
4542
+ const directory = managedSessionsRoot
4543
+ ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
4544
+ : computeDefaultSessionDir(resolvedCwd, this.storage);
4545
+ return explicitDestination(directory);
4546
+ })();
4547
+ const newSessionDir = nextDestination.directory;
4080
4548
  let hadSessionFile = false;
4549
+ const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
4550
+ let managedSourceStore: ManagedSessionDescendantStore | undefined;
4551
+ let managedDestinationStore: ManagedSessionDescendantStore | undefined;
4552
+ let rollbackManagedMove: (() => Promise<void>) | undefined;
4081
4553
 
4082
4554
  if (this.persist && this.#sessionFile) {
4083
4555
  // Close the persist writer before moving files
@@ -4090,7 +4562,11 @@ export class SessionManager {
4090
4562
  const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
4091
4563
  const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
4092
4564
  const newArtifactDir = newSessionFile.slice(0, -6);
4093
- hadSessionFile = this.storage.existsSync(oldSessionFile);
4565
+ let managedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
4566
+ let managedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
4567
+ let managedPublishedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
4568
+ let managedPublishedArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
4569
+ hadSessionFile = managedMove ? false : this.storage.existsSync(oldSessionFile);
4094
4570
  let movedSessionFile = false;
4095
4571
  let movedArtifactDir = false;
4096
4572
  const materializedEntries = materializeResidentEntriesForReadSync(
@@ -4113,56 +4589,184 @@ export class SessionManager {
4113
4589
  }
4114
4590
  throw error;
4115
4591
  };
4592
+ const copyManagedTree = async (
4593
+ source: ManagedSessionDescendantStore,
4594
+ sourceRelative: string,
4595
+ destination: ManagedSessionDescendantStore,
4596
+ destinationRelative: string,
4597
+ snapshot: native.NativeDirectoryTreeSnapshot,
4598
+ ): Promise<void> => {
4599
+ destination.ensureDirectory(destinationRelative);
4600
+ for (const entry of snapshot.entries) {
4601
+ if (entry.relativePath === "") continue;
4602
+ const target = path.posix.join(destinationRelative, entry.relativePath);
4603
+ if (entry.kind === "directory") destination.ensureDirectory(target);
4604
+ else {
4605
+ const file = source.readExpected(path.posix.join(sourceRelative, entry.relativePath));
4606
+ if (
4607
+ !file ||
4608
+ file.identity.dev.toString() !== entry.dev ||
4609
+ file.identity.ino.toString() !== entry.ino ||
4610
+ file.identity.size.toString() !== entry.size ||
4611
+ file.identity.mtimeNs.toString() !== entry.mtimeNs ||
4612
+ crypto.createHash("sha256").update(file.bytes).digest("hex") !== entry.sha256
4613
+ )
4614
+ throw new Error("artifact_source_changed");
4615
+ await destination.publishNoReplace(target, file.bytes);
4616
+ }
4617
+ }
4618
+ const imported = destination.captureTree(destinationRelative);
4619
+ const comparable = (tree: native.NativeDirectoryTreeSnapshot) =>
4620
+ tree.entries.map(entry => ({
4621
+ relativePath: entry.relativePath,
4622
+ kind: entry.kind,
4623
+ size: entry.size,
4624
+ sha256: entry.sha256,
4625
+ }));
4626
+ if (JSON.stringify(comparable(imported)) !== JSON.stringify(comparable(snapshot)))
4627
+ throw new Error("artifact_destination_mismatch");
4628
+ destination.fsyncTree();
4629
+ };
4116
4630
  this.#disposeResidentTextBlobStore();
4117
-
4118
4631
  try {
4119
- // Guard: session file may not exist yet (no assistant messages persisted)
4120
- if (hadSessionFile) {
4632
+ if (managedMove) {
4633
+ if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
4634
+ throw new Error("managed_move_destination_unavailable");
4635
+ managedSourceStore = this.#managedTranscriptStore(oldSessionFile);
4636
+ managedDestinationStore = managedStoreFromContext(nextDestination.securityContext, newSessionDir);
4637
+ rollbackManagedMove = async () => {
4638
+ if (!managedSourceStore || !managedDestinationStore)
4639
+ throw new Error("managed_move_authority_unavailable");
4640
+ if (managedArtifacts && managedPublishedArtifacts) {
4641
+ await copyManagedTree(
4642
+ managedDestinationStore,
4643
+ path.basename(newArtifactDir),
4644
+ managedSourceStore,
4645
+ path.basename(oldArtifactDir),
4646
+ managedPublishedArtifacts,
4647
+ );
4648
+ }
4649
+ if (managedTranscript)
4650
+ await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4651
+ if (managedPublishedArtifacts)
4652
+ managedDestinationStore.removeTreeExpected(
4653
+ path.basename(newArtifactDir),
4654
+ managedPublishedArtifacts,
4655
+ );
4656
+ if (managedPublishedTranscript)
4657
+ managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4658
+ };
4659
+ managedTranscript = managedSourceStore.readExpected(path.basename(oldSessionFile));
4660
+ hadSessionFile = managedTranscript !== null;
4661
+ if (managedTranscript) {
4662
+ await managedDestinationStore.publishNoReplace(
4663
+ path.basename(newSessionFile),
4664
+ managedTranscript.bytes,
4665
+ );
4666
+ managedPublishedTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
4667
+ if (!managedPublishedTranscript) throw new Error("managed_transcript_publish_missing");
4668
+ movedSessionFile = true;
4669
+ }
4121
4670
  try {
4671
+ managedArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
4672
+ await copyManagedTree(
4673
+ managedSourceStore,
4674
+ path.basename(oldArtifactDir),
4675
+ managedDestinationStore,
4676
+ path.basename(newArtifactDir),
4677
+ managedArtifacts,
4678
+ );
4679
+ managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
4680
+ movedArtifactDir = true;
4681
+ } catch (error) {
4682
+ if (!(error instanceof Error) || error.message !== "not_found") throw error;
4683
+ }
4684
+ if (managedPublishedTranscript) {
4685
+ const terminalTranscript = managedDestinationStore.readExpected(path.basename(newSessionFile));
4686
+ if (!managedFileSnapshotEquals(terminalTranscript, managedPublishedTranscript))
4687
+ throw new Error("managed_move_destination_transcript_changed");
4688
+ }
4689
+ if (managedPublishedArtifacts) {
4690
+ const terminalArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
4691
+ if (!retainedTreeSnapshotEquals(terminalArtifacts, managedPublishedArtifacts))
4692
+ throw new Error("managed_move_destination_artifacts_changed");
4693
+ }
4694
+ if (managedArtifacts)
4695
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), managedArtifacts);
4696
+ if (managedTranscript)
4697
+ managedSourceStore.removeExpected(path.basename(oldSessionFile), managedTranscript);
4698
+ } else {
4699
+ if (hadSessionFile && oldSessionFile !== newSessionFile) {
4122
4700
  await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
4123
4701
  movedSessionFile = true;
4124
- } catch (error) {
4125
- if (error instanceof PathMoveCompletedWithSyncFailure) movedSessionFile = true;
4126
- throw error;
4127
4702
  }
4128
- }
4129
-
4130
- try {
4131
- const stat = await fs.promises.stat(oldArtifactDir);
4132
- if (stat.isDirectory()) {
4133
- try {
4703
+ try {
4704
+ const stat = await fs.promises.stat(oldArtifactDir);
4705
+ if (stat.isDirectory() && oldArtifactDir !== newArtifactDir) {
4134
4706
  await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
4135
4707
  movedArtifactDir = true;
4136
- } catch (error) {
4137
- if (error instanceof PathMoveCompletedWithSyncFailure) movedArtifactDir = true;
4138
- throw error;
4139
4708
  }
4709
+ } catch (err) {
4710
+ if (!isEnoent(err)) throw err;
4140
4711
  }
4141
- } catch (err) {
4142
- if (!isEnoent(err)) throw err;
4143
4712
  }
4144
4713
  } catch (err) {
4145
- if (movedArtifactDir) {
4714
+ if (managedMove && managedDestinationStore) {
4146
4715
  try {
4147
- await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
4716
+ if (managedArtifacts && !managedPublishedArtifacts) {
4717
+ try {
4718
+ managedPublishedArtifacts = managedDestinationStore.captureTree(path.basename(newArtifactDir));
4719
+ } catch (captureError) {
4720
+ if (!(captureError instanceof Error) || captureError.message !== "not_found")
4721
+ throw captureError;
4722
+ }
4723
+ }
4724
+ if (managedSourceStore && managedArtifacts && managedPublishedArtifacts) {
4725
+ let sourceArtifacts: native.NativeDirectoryTreeSnapshot | undefined;
4726
+ try {
4727
+ sourceArtifacts = managedSourceStore.captureTree(path.basename(oldArtifactDir));
4728
+ } catch (captureError) {
4729
+ if (!(captureError instanceof Error) || captureError.message !== "not_found")
4730
+ throw captureError;
4731
+ }
4732
+ if (!sourceArtifacts || !retainedTreeSnapshotEquals(sourceArtifacts, managedArtifacts)) {
4733
+ if (sourceArtifacts)
4734
+ managedSourceStore.removeTreeExpected(path.basename(oldArtifactDir), sourceArtifacts);
4735
+ await copyManagedTree(
4736
+ managedDestinationStore,
4737
+ path.basename(newArtifactDir),
4738
+ managedSourceStore,
4739
+ path.basename(oldArtifactDir),
4740
+ managedPublishedArtifacts,
4741
+ );
4742
+ const restored = managedSourceStore.captureTree(path.basename(oldArtifactDir));
4743
+ if (!retainedTreeSnapshotEqualsAfterRename(restored, managedArtifacts))
4744
+ throw new Error("managed_move_source_restore_mismatch");
4745
+ }
4746
+ }
4747
+ if (
4748
+ managedSourceStore &&
4749
+ managedTranscript &&
4750
+ !managedSourceStore.readExpected(path.basename(oldSessionFile))
4751
+ )
4752
+ await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
4753
+ if (managedPublishedArtifacts)
4754
+ managedDestinationStore.removeTreeExpected(
4755
+ path.basename(newArtifactDir),
4756
+ managedPublishedArtifacts,
4757
+ );
4758
+ if (managedPublishedTranscript)
4759
+ managedDestinationStore.removeExpected(path.basename(newSessionFile), managedPublishedTranscript);
4148
4760
  } catch (rollbackErr) {
4149
4761
  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) {
4157
- try {
4158
- await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
4159
- } catch (rollbackErr) {
4160
- restoreResidentStateAndThrow(
4161
- new Error(
4162
- `Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
4163
- ),
4762
+ new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
4763
+ cause: toError(err),
4764
+ }),
4164
4765
  );
4165
4766
  }
4767
+ } else {
4768
+ if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
4769
+ if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
4166
4770
  }
4167
4771
  restoreResidentStateAndThrow(err);
4168
4772
  }
@@ -4176,6 +4780,11 @@ export class SessionManager {
4176
4780
  // Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
4177
4781
  this.cwd = resolvedCwd;
4178
4782
  this.sessionDir = newSessionDir;
4783
+ this.destination = nextDestination;
4784
+ this.#managedTranscriptStoreCache =
4785
+ managedMove && managedDestinationStore
4786
+ ? { directory: path.resolve(newSessionDir), store: managedDestinationStore }
4787
+ : null;
4179
4788
 
4180
4789
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
4181
4790
  try {
@@ -4196,6 +4805,21 @@ export class SessionManager {
4196
4805
  this.#persistChain = Promise.resolve();
4197
4806
  this.#persistError = undefined;
4198
4807
  this.#persistErrorReported = false;
4808
+ if (rollbackManagedMove) {
4809
+ await rollbackManagedMove();
4810
+ this.#sessionFile = previousSessionFile;
4811
+ this.cwd = previousCwd;
4812
+ this.sessionDir = previousSessionDir;
4813
+ this.destination = previousDestination;
4814
+ this.#managedTranscriptStoreCache =
4815
+ managedMove && managedSourceStore
4816
+ ? { directory: path.resolve(previousSessionDir), store: managedSourceStore }
4817
+ : null;
4818
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4819
+ if (header) applyHeaderPatch(header, { cwd: previousCwd });
4820
+ this.#headerExportRevision++;
4821
+ throw error;
4822
+ }
4199
4823
  if (
4200
4824
  this.storage instanceof FileSessionStorage &&
4201
4825
  failedArtifactDir &&
@@ -4217,6 +4841,8 @@ export class SessionManager {
4217
4841
  this.#sessionFile = previousSessionFile;
4218
4842
  this.cwd = previousCwd;
4219
4843
  this.sessionDir = previousSessionDir;
4844
+ this.destination = previousDestination;
4845
+ this.#managedTranscriptStoreCache = null;
4220
4846
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4221
4847
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
4222
4848
  this.#headerExportRevision++;
@@ -4348,6 +4974,10 @@ export class SessionManager {
4348
4974
  #ensurePersistWriter(): NdjsonFileWriter | undefined {
4349
4975
  if (!this.persist || !this.#sessionFile) return undefined;
4350
4976
  if (this.#persistError) throw this.#persistError;
4977
+ if (this.destination.kind === "managed") {
4978
+ this.#managedTranscriptStore();
4979
+ return undefined;
4980
+ }
4351
4981
  if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
4352
4982
  if (this.#persistWriter.isOpen()) return this.#persistWriter;
4353
4983
  // Cached writer for the current file is mid-close (queued
@@ -4511,8 +5141,31 @@ export class SessionManager {
4511
5141
  }
4512
5142
  }
4513
5143
 
5144
+ #managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
5145
+ if (this.destination.kind !== "managed" || !sessionFile) {
5146
+ throw new Error("Managed transcript authority is unavailable");
5147
+ }
5148
+ const sessionDir = path.resolve(path.dirname(sessionFile));
5149
+ if (sessionDir !== path.resolve(this.destination.directory)) {
5150
+ throw new Error("Managed transcript escaped its session directory");
5151
+ }
5152
+ if (this.#managedTranscriptStoreCache) {
5153
+ if (this.#managedTranscriptStoreCache.directory !== sessionDir)
5154
+ throw new Error("Managed transcript store transition was not verified");
5155
+ return this.#managedTranscriptStoreCache.store;
5156
+ }
5157
+ const store = managedStoreFromContext(this.destination.securityContext, sessionDir);
5158
+ this.#managedTranscriptStoreCache = { directory: sessionDir, store };
5159
+ return store;
5160
+ }
5161
+
4514
5162
  #writeEntriesAtomicallySync(entries: FileEntry[]): void {
4515
5163
  if (!this.#sessionFile) return;
5164
+ if (this.destination.kind === "managed") {
5165
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
5166
+ this.#managedTranscriptStore().replaceSync(path.basename(this.#sessionFile), bytes);
5167
+ return;
5168
+ }
4516
5169
  const dir = path.resolve(this.#sessionFile, "..");
4517
5170
  const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
4518
5171
  const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
@@ -4539,6 +5192,11 @@ export class SessionManager {
4539
5192
  }
4540
5193
  async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
4541
5194
  if (!this.#sessionFile) return;
5195
+ if (this.destination.kind === "managed") {
5196
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
5197
+ await this.#managedTranscriptStore().replace(path.basename(this.#sessionFile), bytes);
5198
+ return;
5199
+ }
4542
5200
  const dir = path.resolve(this.#sessionFile, "..");
4543
5201
  const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
4544
5202
  const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
@@ -4569,10 +5227,13 @@ export class SessionManager {
4569
5227
  entries: readonly FileEntry[],
4570
5228
  sessionFile: string,
4571
5229
  ): Promise<string | undefined> {
5230
+ if (this.destination.kind === "managed") return undefined;
4572
5231
  if (!this.storage.existsSync(sessionFile)) return undefined;
4573
5232
  const dir = path.resolve(sessionFile, "..");
4574
5233
  const tempPath = path.join(dir, `.${path.basename(sessionFile)}.${Snowflake.next()}.default-selection.tmp`);
4575
- const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
5234
+ const writer = new NdjsonFileWriter(this.storage, tempPath, {
5235
+ flags: "w",
5236
+ });
4576
5237
  try {
4577
5238
  const persistedEntries = await Promise.all(
4578
5239
  materializeResidentEntriesForPersistenceSync([...entries], this.#residentBlobStores()).map(entry =>
@@ -4702,19 +5363,31 @@ export class SessionManager {
4702
5363
  return { kind: "not_promoted" };
4703
5364
  }
4704
5365
  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 };
5366
+ if (!this.#sessionFile) return { kind: "unknown", error: new Error("Missing staged session replacement") };
5367
+ if (this.destination.kind === "managed") {
5368
+ try {
5369
+ const persistedEntries = materializeResidentEntriesForPersistenceSync(
5370
+ [...stage.entries],
5371
+ this.#residentBlobStores(),
5372
+ ).map(entry => prepareEntryForPersistenceSync(entry, this.#blobStore));
5373
+ this.#writeEntriesAtomicallySync(persistedEntries);
5374
+ } catch (error) {
5375
+ return { kind: "unknown", error: toError(error) };
4712
5376
  }
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.") };
5377
+ } else {
5378
+ if (!stage.tempPath) return { kind: "unknown", error: new Error("Missing staged session replacement") };
5379
+ try {
5380
+ this.#closePersistWriterInternalSync();
5381
+ const replacement = this.#replaceSessionFileSync(stage.tempPath, this.#sessionFile);
5382
+ if (replacement.kind === "restored_previous") {
5383
+ return { kind: "not_promoted", error: replacement.error };
5384
+ }
5385
+ } catch (error) {
5386
+ if (this.#persistWriter?.getCloseState() === "close_failed_retryable") {
5387
+ return { kind: "not_promoted", error: new Error("Session replacement could not be completed.") };
5388
+ }
5389
+ return { kind: "unknown", error: toError(error) };
4716
5390
  }
4717
- return { kind: "unknown", error: toError(error) };
4718
5391
  }
4719
5392
  }
4720
5393
  this.#fileEntries = [...stage.entries];
@@ -4862,15 +5535,62 @@ export class SessionManager {
4862
5535
  * Returns the session artifacts directory path (session file path without .jsonl).
4863
5536
  * Returns null when the session is not persisted to a file.
4864
5537
  * 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.
5538
+ * never exposes that managed directory as a pathname. Reads and writes use the
5539
+ * adopted manager capability directly.
4867
5540
  */
4868
5541
  getArtifactsDir(): string | null {
4869
- if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
5542
+ if (this.#adoptedArtifactManager) return null;
4870
5543
  const sessionFile = this.#sessionFile;
4871
5544
  return sessionFile ? sessionFile.slice(0, -6) : null;
4872
5545
  }
4873
5546
 
5547
+ isManagedDestination(): boolean {
5548
+ return this.destination.kind === "managed";
5549
+ }
5550
+
5551
+ /** Supplies opaque retained authority for mandatory managed legacy local migration. */
5552
+ getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
5553
+ if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
5554
+ const store = this.#managedTranscriptStore();
5555
+ return {
5556
+ capture: async () => {
5557
+ let snapshot: native.NativeDirectoryTreeSnapshot;
5558
+ try {
5559
+ snapshot = store.captureTree("local");
5560
+ } catch (error) {
5561
+ if (error instanceof Error && error.message === "not_found") return null;
5562
+ throw error;
5563
+ }
5564
+ let totalBytes = 0;
5565
+ for (const entry of snapshot.entries) if (entry.kind === "file") totalBytes += Number(entry.size);
5566
+ if (totalBytes > 64 * 1024 * 1024) throw new Error("Legacy local:// migration exceeds the safe size limit");
5567
+ const entries = snapshot.entries.map(entry => {
5568
+ if (entry.kind === "directory") return { relativePath: entry.relativePath, kind: "directory" as const };
5569
+ const captured = store.readExpected(path.posix.join("local", entry.relativePath));
5570
+ if (
5571
+ !captured ||
5572
+ captured.identity.dev.toString() !== entry.dev ||
5573
+ captured.identity.ino.toString() !== entry.ino ||
5574
+ captured.identity.size.toString() !== entry.size ||
5575
+ crypto.createHash("sha256").update(captured.bytes).digest("hex") !== entry.sha256
5576
+ )
5577
+ throw new Error("Legacy local:// migration source changed during capture");
5578
+ return {
5579
+ relativePath: entry.relativePath,
5580
+ kind: "file" as const,
5581
+ bytes: captured.bytes,
5582
+ sha256: entry.sha256,
5583
+ };
5584
+ });
5585
+ const verified = store.captureTree("local");
5586
+ if (JSON.stringify(verified) !== JSON.stringify(snapshot))
5587
+ throw new Error("Legacy local:// migration source changed during capture");
5588
+ return { snapshot, entries };
5589
+ },
5590
+ retire: snapshot => store.removeTreeExpected("local", snapshot),
5591
+ };
5592
+ }
5593
+
4874
5594
  /**
4875
5595
  * Adopt an externally-owned ArtifactManager. Used by subagents to share
4876
5596
  * the parent session's artifact directory and ID counter.
@@ -4906,7 +5626,12 @@ export class SessionManager {
4906
5626
  return this.#artifactManager;
4907
5627
  }
4908
5628
 
4909
- const manager = new ArtifactManager(sessionFile.slice(0, -6));
5629
+ const artifactDir = sessionFile.slice(0, -6);
5630
+ let artifactStorage: string | ManagedSessionDescendantStore = artifactDir;
5631
+ if (this.destination.kind === "managed") {
5632
+ artifactStorage = this.#managedTranscriptStore(sessionFile).deriveSubtree(path.basename(artifactDir));
5633
+ }
5634
+ const manager = new ArtifactManager(artifactStorage);
4910
5635
  this.#artifactManager = manager;
4911
5636
  this.#artifactManagerSessionFile = sessionFile;
4912
5637
  return manager;
@@ -4947,21 +5672,43 @@ export class SessionManager {
4947
5672
  }
4948
5673
 
4949
5674
  /**
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.
5675
+ * Path to the unsent-input draft sidecar for an explicit session. It lives
5676
+ * inside the artifacts directory so `dropSession` removes it with its owner.
5677
+ * Managed drafts use a retained descendant-store capability instead.
4953
5678
  */
4954
5679
  #getDraftPath(): string | null {
5680
+ if (this.destination.kind === "managed") return null;
4955
5681
  const dir = this.getArtifactsDir();
4956
5682
  return dir ? path.join(dir, "draft.txt") : null;
4957
5683
  }
4958
5684
 
5685
+ /** Retained authority for this managed session's private draft sidecar. */
5686
+ #getManagedDraftStore(): ManagedSessionDescendantStore | null {
5687
+ if (this.destination.kind !== "managed" || !this.#sessionFile) return null;
5688
+ return this.#managedTranscriptStore().deriveSubtree(path.basename(this.#sessionFile.slice(0, -6)));
5689
+ }
5690
+
4959
5691
  /**
4960
5692
  * Persist (or clear) the current editor draft so the next resume of this
4961
5693
  * session can restore it. Empty text deletes any stale draft. No-op when the
4962
5694
  * session is not persisted.
4963
5695
  */
4964
5696
  async saveDraft(text: string): Promise<void> {
5697
+ if (this.destination.kind === "managed") {
5698
+ if (text.length === 0) {
5699
+ await this.#getManagedDraftStore()?.remove("draft.txt");
5700
+ return;
5701
+ }
5702
+ // Force the session header onto disk so resume can find the file we are
5703
+ // attaching this draft to. Without this, a session whose first message
5704
+ // never produced an assistant reply would persist a draft next to a
5705
+ // session file that does not exist on disk.
5706
+ await this.ensureOnDisk();
5707
+ const store = this.#getManagedDraftStore();
5708
+ if (!store) return;
5709
+ await store.replace("draft.txt", Buffer.from(text, "utf8"));
5710
+ return;
5711
+ }
4965
5712
  const draftPath = this.#getDraftPath();
4966
5713
  if (!draftPath || !this.persist) return;
4967
5714
  if (text.length === 0) {
@@ -4977,7 +5724,9 @@ export class SessionManager {
4977
5724
  // never produced an assistant reply would persist a draft next to a
4978
5725
  // session file that does not exist on disk.
4979
5726
  await this.ensureOnDisk();
4980
- await this.storage.writeText(draftPath, text);
5727
+ const artifactManager = this.#getOrCreateArtifactManager();
5728
+ if (!artifactManager) return;
5729
+ await artifactManager.replaceNamed("draft.txt", text);
4981
5730
  }
4982
5731
 
4983
5732
  /**
@@ -4986,6 +5735,10 @@ export class SessionManager {
4986
5735
  * sidecar so a subsequent resume does not re-restore the same text.
4987
5736
  */
4988
5737
  async consumeDraft(): Promise<string | null> {
5738
+ if (this.destination.kind === "managed") {
5739
+ const draft = await this.#getManagedDraftStore()?.consume("draft.txt");
5740
+ return draft ? Buffer.from(draft).toString("utf8") : null;
5741
+ }
4989
5742
  const draftPath = this.#getDraftPath();
4990
5743
  if (!draftPath) return null;
4991
5744
  let text: string;
@@ -5047,14 +5800,19 @@ export class SessionManager {
5047
5800
  await this.#persistPatch({ type: "header_patch", patch });
5048
5801
  }
5049
5802
 
5803
+ #appendManagedRecordSync(record: FileEntry | SessionPatchRecord): void {
5804
+ if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
5805
+ const store = this.#managedTranscriptStore();
5806
+ const relativePath = path.basename(this.#sessionFile);
5807
+ store.appendSync(relativePath, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"));
5808
+ }
5809
+
5050
5810
  async #persistPatch(record: SessionPatchRecord): Promise<void> {
5051
5811
  if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
5052
5812
  await this.#queuePersistTask(async () => {
5053
5813
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
5054
5814
  if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
5055
5815
  return this.#rewriteFileContents();
5056
- const writer = this.#ensurePersistWriter();
5057
- if (!writer) return this.#rewriteFileContents();
5058
5816
  const persistedRecord =
5059
5817
  record.type === "entry_patch"
5060
5818
  ? (prepareEntryForPersistenceSync(
@@ -5066,6 +5824,12 @@ export class SessionManager {
5066
5824
  this.#blobStore,
5067
5825
  ) as unknown as SessionPatchRecord)
5068
5826
  : record;
5827
+ if (this.destination.kind === "managed") {
5828
+ this.#appendManagedRecordSync(persistedRecord);
5829
+ return;
5830
+ }
5831
+ const writer = this.#ensurePersistWriter();
5832
+ if (!writer) return this.#rewriteFileContents();
5069
5833
  await writer.write(persistedRecord);
5070
5834
  });
5071
5835
  }
@@ -5108,6 +5872,16 @@ export class SessionManager {
5108
5872
  // the synchronous blob-store path so blob bytes are durable before the JSONL
5109
5873
  // line referencing them is written.
5110
5874
  try {
5875
+ if (this.destination.kind === "managed") {
5876
+ const materializedEntry = materializeResidentEntryForPersistenceSync(
5877
+ entry,
5878
+ this.#residentBlobStores(),
5879
+ new Map(),
5880
+ );
5881
+ const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
5882
+ this.#appendManagedRecordSync(persistedEntry);
5883
+ return;
5884
+ }
5111
5885
  const writer = this.#ensurePersistWriter();
5112
5886
  if (!writer) {
5113
5887
  // `#ensurePersistWriter` returns undefined here only when the cached
@@ -5446,18 +6220,29 @@ export class SessionManager {
5446
6220
  // TTSR (Time Traveling Stream Rules)
5447
6221
  // =========================================================================
5448
6222
 
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 {
6223
+ /** Append MCP discovery selection authority without altering discovered built-in authority. */
6224
+ appendMCPToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
5455
6225
  const entry: MCPToolSelectionEntry = {
5456
6226
  type: "mcp_tool_selection",
5457
6227
  id: generateId(this.#byId),
5458
6228
  parentId: this.#leafId,
5459
6229
  timestamp: new Date().toISOString(),
5460
6230
  selectedToolNames: [...selectedToolNames],
6231
+ mutationCorrelationId,
6232
+ };
6233
+ this.#appendEntry(entry);
6234
+ return entry.id;
6235
+ }
6236
+
6237
+ /** Append discovered built-in selection authority without altering MCP authority. */
6238
+ appendDiscoveredBuiltinToolSelection(selectedToolNames: string[], mutationCorrelationId?: string): string {
6239
+ const entry: DiscoveredBuiltinToolSelectionEntry = {
6240
+ type: "discovered_builtin_tool_selection",
6241
+ id: generateId(this.#byId),
6242
+ parentId: this.#leafId,
6243
+ timestamp: new Date().toISOString(),
6244
+ selectedToolNames: [...selectedToolNames],
6245
+ mutationCorrelationId,
5461
6246
  };
5462
6247
  this.#appendEntry(entry);
5463
6248
  return entry.id;
@@ -6204,7 +6989,14 @@ export class SessionManager {
6204
6989
  labelEntries.push(labelEntry);
6205
6990
  parentId = labelEntry.id;
6206
6991
  }
6207
- this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
6992
+ if (this.destination.kind === "managed") {
6993
+ this.#managedTranscriptStore(newSessionFile).publishNoReplaceSync(
6994
+ path.basename(newSessionFile),
6995
+ Buffer.from(`${lines.join("\n")}\n`, "utf8"),
6996
+ );
6997
+ } else {
6998
+ this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
6999
+ }
6208
7000
  this.#sessionId = newSessionId;
6209
7001
  this.#sessionFile = newSessionFile;
6210
7002
  this.#resetResidentTextBlobStore();
@@ -6278,9 +7070,47 @@ export class SessionManager {
6278
7070
  * @param cwd Working directory (stored in session header)
6279
7071
  * @param sessionDir Optional session directory. If omitted, uses default (~/.gjc/agent/sessions/<encoded-cwd>/).
6280
7072
  */
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);
7073
+ static nestedManagedDestination(
7074
+ authority: ManagedDirectoryRoot | ManagedSessionDescendantStore,
7075
+ directory: string,
7076
+ ): SessionDestination {
7077
+ const rootAuthority =
7078
+ authority instanceof ManagedSessionDescendantStore ? authority.subtreeRootAuthority : authority;
7079
+ const retainedAuthority =
7080
+ authority instanceof ManagedSessionDescendantStore
7081
+ ? authority.retainAuthority()
7082
+ : retainManagedDirectoryAuthority(rootAuthority, directory);
7083
+ const securityContext = createManagedSessionSecurityContext({
7084
+ agentDir: rootAuthority.canonicalPath,
7085
+ sessionsRoot: rootAuthority.canonicalPath,
7086
+ sessionDir: directory,
7087
+ rootAuthority,
7088
+ retainedAuthority,
7089
+ });
7090
+ const destination = Object.freeze({ kind: "managed" as const, directory, securityContext });
7091
+ trustedSessionDestinations.add(destination);
7092
+ return destination;
7093
+ }
7094
+
7095
+ static managedDestination(
7096
+ cwd: string,
7097
+ agentDir?: string,
7098
+ storage: SessionStorage = new FileSessionStorage(),
7099
+ ): SessionDestination {
7100
+ return managedDestination(cwd, storage, agentDir);
7101
+ }
7102
+
7103
+ static explicitDestination(directory: string): SessionDestination {
7104
+ return explicitDestination(directory);
7105
+ }
7106
+
7107
+ static create(
7108
+ cwd: string,
7109
+ destinationInput?: SessionDestinationInput,
7110
+ storage: SessionStorage = new FileSessionStorage(),
7111
+ ): SessionManager {
7112
+ const destination = destinationFor(cwd, destinationInput, storage);
7113
+ const manager = new SessionManager(cwd, destination.directory, true, storage, destination);
6284
7114
  manager.#initNewSession();
6285
7115
  return manager;
6286
7116
  }
@@ -6345,16 +7175,17 @@ export class SessionManager {
6345
7175
  static async forkFrom(
6346
7176
  sourcePath: string,
6347
7177
  cwd: string,
6348
- sessionDir?: string,
7178
+ destinationInput?: SessionDestinationInput,
6349
7179
  storage: SessionStorage = new FileSessionStorage(),
6350
7180
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6351
7181
  ): Promise<SessionManager> {
7182
+ const destination = destinationFor(cwd, destinationInput, storage);
6352
7183
  const managedSourcePath =
6353
- !sessionDir && storage instanceof FileSessionStorage
7184
+ destination.kind === "managed" && storage instanceof FileSessionStorage
6354
7185
  ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
6355
7186
  : sourcePath;
6356
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
6357
- const manager = new SessionManager(cwd, dir, true, storage);
7187
+ const dir = destination.directory;
7188
+ const manager = new SessionManager(cwd, dir, true, storage, destination);
6358
7189
  const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
6359
7190
  migrateToCurrentVersion(forkEntries);
6360
7191
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
@@ -6388,52 +7219,103 @@ export class SessionManager {
6388
7219
  */
6389
7220
  static async open(
6390
7221
  filePath: string,
6391
- sessionDir?: string,
7222
+ destinationInput?: SessionDestinationInput,
6392
7223
  storage: SessionStorage = new FileSessionStorage(),
6393
7224
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6394
7225
  ): Promise<SessionManager> {
7226
+ const destination =
7227
+ destinationInput === undefined
7228
+ ? explicitDestination(path.dirname(filePath))
7229
+ : destinationFor(getProjectDir(), destinationInput, storage);
6395
7230
  // Canonicalize benign ancestor symlinks in the caller-supplied path (e.g.
6396
7231
  // macOS `/var -> /private/var`, a symlinked `$HOME`/project) so explicit
6397
7232
  // session files land under a symlink-free trusted root that the strict
6398
7233
  // owner-only and reparse guards accept.
6399
7234
  if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
6400
- if (sessionDir || !(storage instanceof FileSessionStorage)) {
7235
+ if (destination.kind === "explicit" || !(storage instanceof FileSessionStorage)) {
6401
7236
  const entries = await loadEntriesFromFile(filePath, storage);
6402
7237
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
6403
7238
  const manager = new SessionManager(
6404
7239
  header?.cwd ?? getProjectDir(),
6405
- sessionDir ??
6406
- (storage instanceof FileSessionStorage ? path.resolve(filePath, "..") : path.dirname(filePath)),
7240
+ destination.directory,
6407
7241
  true,
6408
7242
  storage,
7243
+ destination,
6409
7244
  );
6410
7245
  await manager.#initSessionFile(filePath);
6411
7246
  return manager;
6412
7247
  }
6413
7248
 
6414
- const managedPath =
6415
- !sessionDir && storage instanceof FileSessionStorage
6416
- ? await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy)
6417
- : filePath;
7249
+ const managedPath = await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy);
6418
7250
  const inspected = inspectResumeSessionFile(managedPath, storage);
6419
7251
  if ("kind" in inspected) {
6420
7252
  if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
6421
- const manager = new SessionManager(getProjectDir(), path.resolve(filePath, ".."), true, storage);
7253
+ const manager = new SessionManager(getProjectDir(), destination.directory, true, storage, destination);
6422
7254
  await manager.#initSessionFile(filePath);
6423
7255
  return manager;
6424
7256
  }
6425
7257
  throw new Error(`Could not open session: ${inspected.reason}`);
6426
7258
  }
6427
- const opened = await SessionManager.openExistingStrict(
6428
- inspected.identity,
6429
- sessionDir ?? path.resolve(managedPath, ".."),
6430
- storage,
6431
- migrationPolicy,
6432
- );
7259
+ const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy);
6433
7260
  if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
6434
7261
  return opened.manager;
6435
7262
  }
6436
7263
 
7264
+ static async openNestedManaged(
7265
+ filePath: string,
7266
+ destination: SessionDestination,
7267
+ store: ManagedSessionDescendantStore,
7268
+ storage: SessionStorage = new FileSessionStorage(),
7269
+ ): Promise<SessionManager> {
7270
+ if (destination.kind !== "managed" || !trustedSessionDestinations.has(destination))
7271
+ throw new Error("Nested managed session authority is unavailable");
7272
+ const resolved = path.resolve(filePath);
7273
+ if (
7274
+ path.dirname(resolved) !== path.resolve(store.dir) ||
7275
+ path.dirname(resolved) !== path.resolve(destination.directory)
7276
+ )
7277
+ throw new Error("Nested managed session escaped retained authority");
7278
+ store.assertBound();
7279
+ const captured = store.readExpected(path.basename(resolved));
7280
+ const entries = captured ? parseSessionEntries(captured.bytes.toString("utf8")) : [];
7281
+ const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
7282
+ const manager = new SessionManager(
7283
+ header?.cwd ?? getProjectDir(),
7284
+ destination.directory,
7285
+ true,
7286
+ storage,
7287
+ destination,
7288
+ );
7289
+ manager.#sessionFile = resolved;
7290
+ writeTerminalBreadcrumb(manager.cwd, resolved);
7291
+ manager.#fileEntries = entries;
7292
+ if (entries.length > 0) {
7293
+ manager.#sessionId = header?.id ?? createSessionId();
7294
+ manager.#sessionName = header?.title;
7295
+ manager.#titleSource = header?.titleSource;
7296
+ manager.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(manager.#fileEntries);
7297
+ await resolveBlobRefsInEntries(manager.#fileEntries, manager.#blobStore);
7298
+ manager.#resetResidentTextBlobStore();
7299
+ manager.#fileEntries = manager.#fileEntries.map(entry =>
7300
+ prepareEntryForResidentSync(entry, manager.#residentBlobStores()),
7301
+ );
7302
+ manager.#buildIndex();
7303
+ manager.#bumpAllRevisions();
7304
+ manager.#flushed = true;
7305
+ manager.#ensuredOnDisk = true;
7306
+ await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7307
+ } else {
7308
+ manager.#newSessionSync();
7309
+ manager.#sessionFile = resolved;
7310
+ manager.#resetResidentTextBlobStore();
7311
+ await manager.#rewriteFile();
7312
+ manager.#flushed = true;
7313
+ manager.#ensuredOnDisk = true;
7314
+ manager.#bumpAllRevisions();
7315
+ }
7316
+ store.assertBound();
7317
+ return manager;
7318
+ }
6437
7319
  /**
6438
7320
  * List sessions for the resume picker without recovery or other maintenance writes.
6439
7321
  */
@@ -6524,9 +7406,10 @@ export class SessionManager {
6524
7406
  static async forkFromCaptured(
6525
7407
  snapshot: CapturedSessionTranscriptSnapshot,
6526
7408
  cwd: string,
6527
- sessionDir?: string,
7409
+ destinationInput?: SessionDestinationInput,
6528
7410
  _migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6529
7411
  ): Promise<StrictSessionForkResult> {
7412
+ const destination = destinationFor(cwd, destinationInput, snapshot.storage);
6530
7413
  if (crypto.createHash("sha256").update(snapshot.content).digest("hex") !== snapshot.identity.sha256) {
6531
7414
  return { kind: "error", reason: "identity-mismatch" };
6532
7415
  }
@@ -6553,13 +7436,17 @@ export class SessionManager {
6553
7436
  return { kind: "error", reason: "identity-mismatch" };
6554
7437
  }
6555
7438
 
6556
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, snapshot.storage);
7439
+ const dir = destination.directory;
6557
7440
  const removeSessionDirOnFailure =
6558
- snapshot.storage instanceof FileSessionStorage && !snapshot.storage.existsSync(dir);
7441
+ destination.kind !== "managed" &&
7442
+ snapshot.storage instanceof FileSessionStorage &&
7443
+ !snapshot.storage.existsSync(dir);
7444
+ let managedForkStore: ManagedSessionDescendantStore | undefined;
7445
+ let managedForkTranscript: ManagedFileSnapshot | null = null;
6559
7446
  let manager: SessionManager | undefined;
6560
7447
  let authorityFailure: StrictSessionOpenFailure | undefined;
6561
7448
  try {
6562
- manager = new SessionManager(cwd, dir, true, snapshot.storage);
7449
+ manager = new SessionManager(cwd, dir, true, snapshot.storage, destination);
6563
7450
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
6564
7451
  manager.#fileEntries = forkEntries;
6565
7452
  const sourceHeader = manager.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -6589,6 +7476,11 @@ export class SessionManager {
6589
7476
  throw new Error("Captured fork source authority changed before destination write.");
6590
7477
  }
6591
7478
  await manager.#rewriteFile();
7479
+ if (destination.kind === "managed" && manager.#sessionFile) {
7480
+ managedForkStore = manager.#managedTranscriptStore();
7481
+ managedForkTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
7482
+ if (!managedForkTranscript) throw new Error("managed_fork_transcript_publish_missing");
7483
+ }
6592
7484
  const afterWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
6593
7485
  if ("kind" in afterWrite) {
6594
7486
  authorityFailure = afterWrite;
@@ -6598,6 +7490,11 @@ export class SessionManager {
6598
7490
  authorityFailure = { kind: "error", reason: "identity-mismatch" };
6599
7491
  throw new Error("Captured fork source authority changed during destination write.");
6600
7492
  }
7493
+ if (managedForkStore && managedForkTranscript && manager.#sessionFile) {
7494
+ const terminalTranscript = managedForkStore.readExpected(path.basename(manager.#sessionFile));
7495
+ if (!managedFileSnapshotEquals(terminalTranscript, managedForkTranscript))
7496
+ throw new Error("managed_fork_transcript_changed");
7497
+ }
6601
7498
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
6602
7499
  return { kind: "forked", manager };
6603
7500
  } catch (error) {
@@ -6610,35 +7507,44 @@ export class SessionManager {
6610
7507
  cleanupErrors.push(toError(cleanupError));
6611
7508
  }
6612
7509
  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) {
7510
+ if (destination.kind === "managed") {
6619
7511
  try {
6620
- await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
7512
+ if (managedForkStore && managedForkTranscript)
7513
+ managedForkStore.removeExpected(path.basename(sessionFile), managedForkTranscript);
6621
7514
  } catch (cleanupError) {
6622
7515
  cleanupErrors.push(toError(cleanupError));
6623
7516
  }
7517
+ } else {
6624
7518
  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
- }
7519
+ await manager.storage.unlink(sessionFile);
6639
7520
  } catch (cleanupError) {
6640
7521
  if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
6641
7522
  }
7523
+ if (manager.storage instanceof FileSessionStorage) {
7524
+ try {
7525
+ await fs.promises.rm(sessionFile.slice(0, -6), { recursive: true, force: true });
7526
+ } catch (cleanupError) {
7527
+ cleanupErrors.push(toError(cleanupError));
7528
+ }
7529
+ try {
7530
+ const sessionFileName = path.basename(sessionFile);
7531
+ const transientPrefix = `.${sessionFileName}.`;
7532
+ const backupPrefix = `${sessionFileName}.`;
7533
+ for (const name of await fs.promises.readdir(path.dirname(sessionFile))) {
7534
+ if (
7535
+ (name.startsWith(transientPrefix) && name.endsWith(".tmp")) ||
7536
+ (name.startsWith(backupPrefix) && name.endsWith(".bak"))
7537
+ ) {
7538
+ await fs.promises.rm(path.join(path.dirname(sessionFile), name), {
7539
+ recursive: true,
7540
+ force: true,
7541
+ });
7542
+ }
7543
+ }
7544
+ } catch (cleanupError) {
7545
+ if (!isEnoent(cleanupError)) cleanupErrors.push(toError(cleanupError));
7546
+ }
7547
+ }
6642
7548
  }
6643
7549
  }
6644
7550
  if (removeSessionDirOnFailure) {
@@ -6673,6 +7579,67 @@ export class SessionManager {
6673
7579
  : { kind: "terminal", identity: inspected.identity };
6674
7580
  }
6675
7581
 
7582
+ /**
7583
+ * Hydrate an existing predecessor transcript for recovery without taking any
7584
+ * write-capable action. The caller must retain the returned context and use
7585
+ * the explicit promotion seam only after its ownership-ready fence and writer
7586
+ * lease are durable.
7587
+ */
7588
+ static async openExistingForRecoveryHydrationStrict(
7589
+ identity: ResumeSessionIdentity,
7590
+ destinationInput?: SessionDestinationInput,
7591
+ storage: SessionStorage = new FileSessionStorage(),
7592
+ ): Promise<RecoveryHydrationOpenResult> {
7593
+ const destination =
7594
+ destinationInput === undefined
7595
+ ? explicitDestination(path.dirname(identity.canonicalPath))
7596
+ : destinationFor(getProjectDir(), destinationInput, storage);
7597
+ const inspected = inspectResumeSessionFile(identity.canonicalPath, storage);
7598
+ if ("kind" in inspected) return inspected;
7599
+ if (!sameResumeIdentity(identity, inspected.identity)) return { kind: "error", reason: "identity-mismatch" };
7600
+ if (inspected.migrationApplied) return { kind: "error", reason: "migration-required" };
7601
+
7602
+ const header = inspected.entries[0] as SessionHeader;
7603
+ const manager = new SessionManager(
7604
+ header.cwd || getProjectDir(),
7605
+ destination.directory,
7606
+ true,
7607
+ storage,
7608
+ destination,
7609
+ );
7610
+ await manager.#hydrateExistingSession(identity.canonicalPath, inspected.entries, false);
7611
+ const revalidated = inspectResumeSessionFile(identity.canonicalPath, storage);
7612
+ if ("kind" in revalidated || !sameResumeIdentity(identity, revalidated.identity)) {
7613
+ await manager.close();
7614
+ return "kind" in revalidated ? revalidated : { kind: "error", reason: "identity-mismatch" };
7615
+ }
7616
+ const context: RecoveryHydrationContext = Object.freeze({
7617
+ identity: Object.freeze({ ...identity }),
7618
+ });
7619
+ manager.#recoveryHydrationContext = context;
7620
+ return { kind: "hydrated", manager, context };
7621
+ }
7622
+
7623
+ /**
7624
+ * Allows the normal post-open metadata sanitation only after the caller has
7625
+ * fsynced its external ownership-ready fence and acquired the writer lease.
7626
+ */
7627
+ async promoteRecoveryHydrationAfterOwnershipReadyFence(
7628
+ context: RecoveryHydrationContext,
7629
+ fence: RecoveryHydrationPromotionFence,
7630
+ ): Promise<void> {
7631
+ if (fence.ownershipReady !== true || this.#recoveryHydrationContext !== context) {
7632
+ throw new Error("Recovery hydration promotion requires the original ownership-ready context.");
7633
+ }
7634
+ const inspected = inspectResumeSessionFile(context.identity.canonicalPath, this.storage);
7635
+ if ("kind" in inspected || !sameResumeIdentity(context.identity, inspected.identity)) {
7636
+ throw new Error("Recovery transcript authority changed before promotion.");
7637
+ }
7638
+ await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
7639
+ writeTerminalBreadcrumb(this.cwd, context.identity.canonicalPath);
7640
+ this.#recoveryHydrationContext = undefined;
7641
+ }
7642
+
6676
7643
  /**
6677
7644
  * Main startup code MUST retain the consented inspection identity and branch on
6678
7645
  * the returned discriminant; an error result never creates, rewrites, or adopts
@@ -6680,12 +7647,16 @@ export class SessionManager {
6680
7647
  */
6681
7648
  static async openExistingStrict(
6682
7649
  identity: ResumeSessionIdentity,
6683
- sessionDir?: string,
7650
+ destinationInput?: SessionDestinationInput,
6684
7651
  storage: SessionStorage = new FileSessionStorage(),
6685
7652
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6686
7653
  ): Promise<StrictSessionOpenResult> {
7654
+ const destination =
7655
+ destinationInput === undefined
7656
+ ? explicitDestination(path.dirname(identity.canonicalPath))
7657
+ : destinationFor(getProjectDir(), destinationInput, storage);
6687
7658
  let sessionPath = identity.canonicalPath;
6688
- if (!sessionDir && storage instanceof FileSessionStorage) {
7659
+ if (destination.kind === "managed" && storage instanceof FileSessionStorage) {
6689
7660
  try {
6690
7661
  sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
6691
7662
  } catch (error) {
@@ -6705,8 +7676,8 @@ export class SessionManager {
6705
7676
  }
6706
7677
  const entries = inspected.entries;
6707
7678
  const header = entries[0] as SessionHeader;
6708
- const dir = sessionDir ?? path.resolve(sessionPath, "..");
6709
- const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
7679
+ const dir = destination.directory;
7680
+ const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
6710
7681
  await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
6711
7682
  const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
6712
7683
  if ("kind" in ownershipInspection) {
@@ -6734,17 +7705,18 @@ export class SessionManager {
6734
7705
  */
6735
7706
  static async continueRecent(
6736
7707
  cwd: string,
6737
- sessionDir?: string,
7708
+ destinationInput?: SessionDestinationInput,
6738
7709
  storage: SessionStorage = new FileSessionStorage(),
6739
7710
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6740
7711
  ): Promise<SessionManager> {
6741
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
7712
+ const destination = destinationFor(cwd, destinationInput, storage);
7713
+ const dir = destination.directory;
6742
7714
  const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
6743
7715
  const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
6744
7716
  if (inspected.kind === "error") return undefined;
6745
7717
  const opened = await SessionManager.openExistingStrict(
6746
7718
  inspected.identity,
6747
- sessionDir,
7719
+ destination,
6748
7720
  storage,
6749
7721
  migrationPolicy,
6750
7722
  );
@@ -6756,9 +7728,10 @@ export class SessionManager {
6756
7728
  };
6757
7729
  // Legacy paths are candidates only: the managed scan validates each transcript
6758
7730
  // header cwd before it can affect default resume authority.
6759
- const managedCandidates = sessionDir
6760
- ? []
6761
- : await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
7731
+ const managedCandidates =
7732
+ destination.kind === "explicit"
7733
+ ? []
7734
+ : await SessionManager.listForResumePickerReadOnly(cwd, destination.directory, storage);
6762
7735
  const terminalSession = await readTerminalBreadcrumb(cwd);
6763
7736
  if (terminalSession) {
6764
7737
  const opened = await openSelectedStrictly(terminalSession);
@@ -6773,7 +7746,7 @@ export class SessionManager {
6773
7746
  const opened = await openSelectedStrictly(mostRecent);
6774
7747
  if (opened) return opened;
6775
7748
  }
6776
- const manager = new SessionManager(cwd, dir, true, storage);
7749
+ const manager = new SessionManager(cwd, dir, true, storage, destination);
6777
7750
  manager.#initNewSession();
6778
7751
  return manager;
6779
7752
  }