@gajae-code/coding-agent 0.15.6 → 0.16.1

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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. package/src/utils/external-editor.ts +72 -1
@@ -23,7 +23,19 @@ function nativeSessionManager(): typeof import("@gajae-code/natives") {
23
23
  return require("@gajae-code/natives") as typeof import("@gajae-code/natives");
24
24
  }
25
25
  const cwdTransitionAls = new AsyncLocalStorage<symbol>();
26
- type CwdReadLeaseContext = { active: boolean; owner: symbol };
26
+ type CwdReadLeaseContext = {
27
+ active: boolean;
28
+ released: boolean;
29
+ owner: symbol;
30
+ suspendedMoveCount: number;
31
+ committedMoveGeneration?: number;
32
+ /** Execution-local lineage; sibling tool executions never share this context. */
33
+ parent?: CwdReadLeaseContext;
34
+ root?: CwdReadLeaseContext;
35
+ executionId: symbol;
36
+ activeBranches: number;
37
+ suspendedMoveOwner?: symbol;
38
+ };
27
39
  const cwdReadLeaseAls = new AsyncLocalStorage<CwdReadLeaseContext>();
28
40
  const CWD_NOFOLLOW_OPEN_FLAGS =
29
41
  fs.constants.O_RDONLY |
@@ -246,6 +258,8 @@ export interface SessionHeader {
246
258
  id: string;
247
259
  title?: string; // Auto-generated title from first message
248
260
  titleSource?: "auto" | "user";
261
+ starredPatchVersion?: 1;
262
+ starred?: boolean;
249
263
  timestamp: string;
250
264
  cwd: string;
251
265
  parentSession?: string;
@@ -931,7 +945,7 @@ export type SessionEntry =
931
945
  /** Append-only replacement for mutable fields on the session header. */
932
946
  export interface HeaderPatchRecord {
933
947
  type: "header_patch";
934
- patch: Partial<Pick<SessionHeader, "title" | "titleSource" | "cwd">>;
948
+ patch: Partial<Pick<SessionHeader, "title" | "titleSource" | "cwd" | "starred">>;
935
949
  }
936
950
 
937
951
  /** Append-only replacement for replay metadata on one existing session entry. */
@@ -2390,6 +2404,7 @@ export interface SessionInfo {
2390
2404
  /** Working directory where the session was started. Empty string for old sessions. */
2391
2405
  cwd: string;
2392
2406
  title?: string;
2407
+ starred: boolean;
2393
2408
  /** Path to the parent session (if this session was forked). */
2394
2409
  parentSessionPath?: string;
2395
2410
  created: Date;
@@ -2699,12 +2714,13 @@ function isHeaderPatchRecord(record: SessionPatchRecord): record is HeaderPatchR
2699
2714
  )
2700
2715
  return false;
2701
2716
  const keys = Object.keys(record.patch);
2702
- if (!keys.every(key => key === "cwd" || key === "title" || key === "titleSource")) return false;
2703
- const { cwd, title, titleSource } = record.patch;
2717
+ if (!keys.every(key => key === "cwd" || key === "title" || key === "titleSource" || key === "starred")) return false;
2718
+ const { cwd, title, titleSource, starred } = record.patch;
2704
2719
  return (
2705
2720
  (cwd === undefined || typeof cwd === "string") &&
2706
2721
  (title === undefined || typeof title === "string") &&
2707
- (titleSource === undefined || titleSource === "auto" || titleSource === "user")
2722
+ (titleSource === undefined || titleSource === "auto" || titleSource === "user") &&
2723
+ (starred === undefined || typeof starred === "boolean")
2708
2724
  );
2709
2725
  }
2710
2726
 
@@ -2712,6 +2728,7 @@ function applyHeaderPatch(header: SessionHeader, patch: HeaderPatchRecord["patch
2712
2728
  if (patch.cwd !== undefined) header.cwd = patch.cwd;
2713
2729
  if (patch.title !== undefined) header.title = patch.title;
2714
2730
  if (patch.titleSource !== undefined) header.titleSource = patch.titleSource;
2731
+ if (header.starredPatchVersion === 1 && patch.starred !== undefined) header.starred = patch.starred;
2715
2732
  }
2716
2733
 
2717
2734
  function isEntryPatchRecord(record: SessionPatchRecord): record is EntryPatchRecord {
@@ -4654,7 +4671,7 @@ async function getSortedSessions(
4654
4671
  )
4655
4672
  return undefined;
4656
4673
  if (typeof header.version === "number" && header.version >= 4) {
4657
- for (const patch of await readSessionListTrailingPatches(candidate.path, storage)) {
4674
+ for (const patch of await readSessionListTrailingPatches(candidate.path, storage, false)) {
4658
4675
  applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
4659
4676
  }
4660
4677
  }
@@ -6413,6 +6430,10 @@ async function collectProjectSessions(cwd: string, storage: FileSessionStorage):
6413
6430
  return await collectSessionsFromFiles(listProjectSessionTranscriptFiles(cwd), storage);
6414
6431
  }
6415
6432
 
6433
+ export function prioritizeStarredSessions(sessions: readonly SessionInfo[]): SessionInfo[] {
6434
+ return [...sessions.filter(session => session.starred), ...sessions.filter(session => !session.starred)];
6435
+ }
6436
+
6416
6437
  function mergeSessionInventories(...inventories: SessionInfo[][]): SessionInfo[] {
6417
6438
  const sessions = new Map<string, SessionInfo>();
6418
6439
  for (const inventory of inventories) {
@@ -6574,36 +6595,41 @@ async function readSessionListPrefix(file: string, storage: SessionStorage, buff
6574
6595
  async function readSessionListTrailingPatches(
6575
6596
  file: string,
6576
6597
  storage: SessionStorage,
6598
+ includeStarred: boolean,
6577
6599
  ): Promise<HeaderPatchRecord["patch"][]> {
6578
6600
  if (!(storage instanceof FileSessionStorage)) {
6579
6601
  const content = await storage.readText(file);
6580
6602
  const entries = parseSessionEntries(content);
6581
6603
  const header = entries[0] as SessionHeader | undefined;
6582
- return header?.type === "session" ? [{ cwd: header.cwd, title: header.title }] : [];
6604
+ return header?.type === "session"
6605
+ ? [{ cwd: header.cwd, title: header.title, ...(includeStarred ? { starred: header.starred } : {}) }]
6606
+ : [];
6583
6607
  }
6584
6608
 
6585
6609
  const size = storage.statSync(file).size;
6586
6610
  if (size <= SESSION_LIST_PREFIX_BYTES) return [];
6587
6611
  const latest: HeaderPatchRecord["patch"] = {};
6588
6612
  let position = size;
6589
- // Reverse-scan from EOF in fixed chunks. Stop once both cwd and title
6590
- // resolve so recent patches stay cheap. Continue past the historical 16 KiB
6613
+ // Reverse-scan from EOF in fixed chunks. Stop once every requested mutable list field
6614
+ // resolves so recent patches stay cheap. Continue past the historical 16 KiB
6591
6615
  // window when a field is still missing so a buried but canonically valid
6592
6616
  // header_patch remains listable without a full sequential JSONL parse (#3633).
6593
6617
  // Chunks that cannot contain a header_patch marker skip JSON parsing.
6594
6618
  //
6595
- // The scan owns its buffer instead of borrowing the caller's 4 KiB prefix
6596
- // buffer. A transcript with no header_patch at all — the common case, since
6597
- // only /rename and workspace moves emit one — cannot be recognized without
6598
- // reaching BOF, so the chunk size sets how many read syscalls a resume costs
6599
- // per transcript byte.
6619
+ // The scan owns its buffer instead of borrowing the caller's 4 KiB prefix.
6620
+ // A transcript with no header_patch at all — the common case — cannot be
6621
+ // recognized as such without reaching BOF, so the chunk size sets how many
6622
+ // read syscalls a resume costs per transcript byte.
6600
6623
  let trailingFragment = Buffer.alloc(0);
6601
6624
  const chunkSize = Math.min(size, SESSION_LIST_TRAILING_PATCH_BYTES);
6602
6625
  const buffer = Buffer.allocUnsafe(chunkSize);
6603
6626
  const headerPatchMarker = Buffer.from("header_patch");
6604
6627
  const handle = await fs.promises.open(file, "r");
6605
6628
  try {
6606
- while (position > 0 && (latest.cwd === undefined || latest.title === undefined)) {
6629
+ while (
6630
+ position > 0 &&
6631
+ (latest.cwd === undefined || latest.title === undefined || (includeStarred && latest.starred === undefined))
6632
+ ) {
6607
6633
  const start = Math.max(0, position - chunkSize);
6608
6634
  const length = position - start;
6609
6635
  const { bytesRead } = await handle.read(buffer, 0, length, start);
@@ -6636,6 +6662,8 @@ async function readSessionListTrailingPatches(
6636
6662
  if (latest.cwd === undefined && typeof record.patch.cwd === "string") latest.cwd = record.patch.cwd;
6637
6663
  if (latest.title === undefined && typeof record.patch.title === "string")
6638
6664
  latest.title = record.patch.title;
6665
+ if (includeStarred && latest.starred === undefined && typeof record.patch.starred === "boolean")
6666
+ latest.starred = record.patch.starred;
6639
6667
  } catch {
6640
6668
  // Ignore malformed or partial records exactly as the canonical loader does.
6641
6669
  }
@@ -6643,7 +6671,7 @@ async function readSessionListTrailingPatches(
6643
6671
  }
6644
6672
  position = start;
6645
6673
  }
6646
- return latest.cwd === undefined && latest.title === undefined ? [] : [latest];
6674
+ return latest.cwd === undefined && latest.title === undefined && latest.starred === undefined ? [] : [latest];
6647
6675
  } finally {
6648
6676
  await handle.close();
6649
6677
  }
@@ -6652,6 +6680,7 @@ async function readSessionListTrailingPatches(
6652
6680
  function applySessionListHeaderPatch(header: SessionListHeader, patch: HeaderPatchRecord["patch"]): void {
6653
6681
  if (typeof patch.cwd === "string") header.cwd = patch.cwd;
6654
6682
  if (typeof patch.title === "string") header.title = patch.title;
6683
+ if (typeof patch.starred === "boolean") header.starred = patch.starred;
6655
6684
  }
6656
6685
  function decodeJsonStringFragment(value: string): string {
6657
6686
  const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
@@ -6702,6 +6731,79 @@ function extractStringProperty(source: string, name: string, startIndex = 0): st
6702
6731
  return decodeJsonStringFragment(source.slice(valueStart));
6703
6732
  }
6704
6733
 
6734
+ function extractTopLevelPrimitiveProperty(source: string, name: string): string | undefined {
6735
+ let depth = 0;
6736
+ let latest: string | undefined;
6737
+ for (let index = 0; index < source.length; index++) {
6738
+ const char = source.charCodeAt(index);
6739
+ if (char === 0x7b || char === 0x5b) {
6740
+ depth++;
6741
+ continue;
6742
+ }
6743
+ if (char === 0x7d || char === 0x5d) {
6744
+ depth--;
6745
+ continue;
6746
+ }
6747
+ if (char !== 0x22) continue;
6748
+
6749
+ const keyStart = index;
6750
+ let escaped = false;
6751
+ for (index++; index < source.length; index++) {
6752
+ const stringChar = source.charCodeAt(index);
6753
+ if (escaped) {
6754
+ escaped = false;
6755
+ continue;
6756
+ }
6757
+ if (stringChar === 0x5c) {
6758
+ escaped = true;
6759
+ continue;
6760
+ }
6761
+ if (stringChar === 0x22) break;
6762
+ }
6763
+ if (index >= source.length || depth !== 1) continue;
6764
+
6765
+ let key: unknown;
6766
+ try {
6767
+ key = JSON.parse(source.slice(keyStart, index + 1));
6768
+ } catch {
6769
+ continue;
6770
+ }
6771
+ if (key !== name) continue;
6772
+
6773
+ let valueStart = index + 1;
6774
+ while (valueStart < source.length && /\s/.test(source[valueStart]!)) valueStart++;
6775
+ if (source.charCodeAt(valueStart) !== 0x3a) continue;
6776
+ valueStart++;
6777
+ while (valueStart < source.length && /\s/.test(source[valueStart]!)) valueStart++;
6778
+
6779
+ let valueEnd = valueStart;
6780
+ while (
6781
+ valueEnd < source.length &&
6782
+ source[valueEnd] !== "," &&
6783
+ source[valueEnd] !== "}" &&
6784
+ !/\s/.test(source[valueEnd]!)
6785
+ )
6786
+ valueEnd++;
6787
+ latest = source.slice(valueStart, valueEnd);
6788
+ index = valueEnd - 1;
6789
+ }
6790
+ return latest;
6791
+ }
6792
+
6793
+ function extractTopLevelIntegerProperty(source: string, name: string): number | undefined {
6794
+ const token = extractTopLevelPrimitiveProperty(source, name);
6795
+ if (token === undefined || !/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(token)) return undefined;
6796
+ const value = Number(token);
6797
+ return Number.isSafeInteger(value) ? value : undefined;
6798
+ }
6799
+
6800
+ function extractTopLevelBooleanProperty(source: string, name: string): boolean | undefined {
6801
+ const token = extractTopLevelPrimitiveProperty(source, name);
6802
+ if (token === "true") return true;
6803
+ if (token === "false") return false;
6804
+ return undefined;
6805
+ }
6806
+
6705
6807
  function countMessageMarkers(content: string): number {
6706
6808
  let count = 0;
6707
6809
  let index = 0;
@@ -6740,6 +6842,8 @@ interface SessionListHeader {
6740
6842
 
6741
6843
  cwd?: string;
6742
6844
  title?: string;
6845
+ starredPatchVersion?: 1;
6846
+ starred?: boolean;
6743
6847
  parentSession?: string;
6744
6848
  timestamp?: string;
6745
6849
  }
@@ -6748,6 +6852,10 @@ function parseSessionListHeader(
6748
6852
  content: string,
6749
6853
  entries: Array<Record<string, unknown>>,
6750
6854
  ): SessionListHeader | undefined {
6855
+ const firstLineEnd = content.indexOf("\n");
6856
+ const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
6857
+ const inlineStarred = extractTopLevelBooleanProperty(firstLine, "starred");
6858
+ const starredPatchVersion = extractTopLevelIntegerProperty(firstLine, "starredPatchVersion") === 1 ? 1 : undefined;
6751
6859
  const parsedHeader = entries[0];
6752
6860
  if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
6753
6861
  return {
@@ -6756,13 +6864,13 @@ function parseSessionListHeader(
6756
6864
  version: typeof parsedHeader.version === "number" ? parsedHeader.version : undefined,
6757
6865
  cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
6758
6866
  title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
6867
+ starredPatchVersion: parsedHeader.starredPatchVersion === 1 ? 1 : starredPatchVersion,
6868
+ starred: typeof parsedHeader.starred === "boolean" ? parsedHeader.starred : inlineStarred,
6759
6869
  parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
6760
6870
  timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
6761
6871
  };
6762
6872
  }
6763
6873
 
6764
- const firstLineEnd = content.indexOf("\n");
6765
- const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
6766
6874
  if (extractStringProperty(firstLine, "type") !== "session") return undefined;
6767
6875
 
6768
6876
  const id = extractStringProperty(firstLine, "id");
@@ -6771,9 +6879,11 @@ function parseSessionListHeader(
6771
6879
  return {
6772
6880
  type: "session",
6773
6881
  id,
6774
- version: Number(extractStringProperty(firstLine, "version")) || undefined,
6882
+ version: extractTopLevelIntegerProperty(firstLine, "version"),
6775
6883
  cwd: extractStringProperty(firstLine, "cwd"),
6776
6884
  title: extractStringProperty(firstLine, "title"),
6885
+ starred: inlineStarred,
6886
+ starredPatchVersion,
6777
6887
  parentSession: extractStringProperty(firstLine, "parentSession"),
6778
6888
  timestamp: extractStringProperty(firstLine, "timestamp"),
6779
6889
  };
@@ -6798,8 +6908,9 @@ async function collectSessionFromFile(
6798
6908
  const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
6799
6909
  const header = parseSessionListHeader(content, entries);
6800
6910
  if (!header) return undefined;
6911
+ if (header.starredPatchVersion !== 1) header.starred = undefined;
6801
6912
  if (typeof header.version === "number" && header.version >= 4 && header.version <= CURRENT_SESSION_VERSION) {
6802
- for (const patch of await readSessionListTrailingPatches(file, storage)) {
6913
+ for (const patch of await readSessionListTrailingPatches(file, storage, header.starredPatchVersion === 1)) {
6803
6914
  applySessionListHeaderPatch(header, patch);
6804
6915
  }
6805
6916
  }
@@ -6841,6 +6952,7 @@ async function collectSessionFromFile(
6841
6952
  id: header.id,
6842
6953
  cwd: header.cwd ?? "",
6843
6954
  title: header.title ?? shortSummary,
6955
+ starred: header.starred === true,
6844
6956
  parentSessionPath: header.parentSession,
6845
6957
  created: new Date(header.timestamp ?? ""),
6846
6958
  modified: stats.mtime,
@@ -7268,6 +7380,7 @@ export class SessionManager {
7268
7380
  #flushed: boolean = false;
7269
7381
  #needsFullRewriteOnNextPersist: boolean = false;
7270
7382
  #readOnlyResume = false;
7383
+ #strictResumeMutationPending = false;
7271
7384
  #resumedDraftConsumed = false;
7272
7385
  #ensuredOnDisk: boolean = false;
7273
7386
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
@@ -7296,11 +7409,58 @@ export class SessionManager {
7296
7409
  /** Defense-in-depth (#4443): one-shot warn for adjacent private thinking blocks in persisted assistant transcripts. */
7297
7410
  #warnedAdjacentThinkingPersist = false;
7298
7411
  #closeRetryPending = false;
7412
+ #strictClosePending = false;
7299
7413
  /** Serializes model, SDK, and ACP cwd transitions; dispose joins this tail. */
7300
7414
  #cwdTransitionTail: Promise<void> = Promise.resolve();
7301
7415
  #cwdTransitionOwner: symbol | undefined;
7416
+ #cwdMoveAdmissionClosing = false;
7417
+ #cwdMoveAdmissionClosed = false;
7418
+ #pendingCwdMoveAdmissions = new Set<AbortController>();
7419
+ #cwdMoveAdmittedAdmissions = new Set<AbortController>();
7420
+ #cwdMoveAdmittedOwners = new Set<symbol>();
7421
+ #cwdMoveAdmittedOwnerCounts = new Map<symbol, number>();
7302
7422
  #cwdReadLeaseOwner = Symbol("cwd-read-lease-owner");
7303
7423
  #cwdGeneration = 0;
7424
+ /**
7425
+ * Listeners invoked after every committed `moveTo`, for every move surface
7426
+ * (`move_session`, TUI `/move`, SDK/ACP `session.cwd.move`). They receive the previous
7427
+ * and new cwd and the previous session file, and run best-effort: a listener throw is
7428
+ * logged and never turns an already-durable move into a rejection.
7429
+ */
7430
+ #afterMoveListeners = new Set<
7431
+ (move: { previousCwd: string; newCwd: string; previousSessionFile: string | undefined }) => void | Promise<void>
7432
+ >();
7433
+ #beforeMoveListeners = new Set<
7434
+ (move: {
7435
+ previousCwd: string;
7436
+ newCwd: string;
7437
+ previousSessionFile: string | undefined;
7438
+ newSessionFile: string | undefined;
7439
+ previousCwdIdentity: { dev: bigint; ino: bigint };
7440
+ newCwdIdentity: { dev: bigint; ino: bigint };
7441
+ }) => void | Promise<void>
7442
+ >();
7443
+ #moveAbortListeners = new Set<
7444
+ (move: {
7445
+ previousCwd: string;
7446
+ newCwd: string;
7447
+ previousSessionFile: string | undefined;
7448
+ newSessionFile: string | undefined;
7449
+ previousCwdIdentity: { dev: bigint; ino: bigint };
7450
+ newCwdIdentity: { dev: bigint; ino: bigint };
7451
+ preserveRecoveryJournal?: boolean;
7452
+ }) => void | Promise<void>
7453
+ >();
7454
+ #movePublicationListeners = new Set<
7455
+ (move: {
7456
+ previousCwd: string;
7457
+ newCwd: string;
7458
+ previousSessionFile: string | undefined;
7459
+ newSessionFile: string | undefined;
7460
+ previousCwdIdentity: { dev: bigint; ino: bigint };
7461
+ newCwdIdentity: { dev: bigint; ino: bigint };
7462
+ }) => void | Promise<void>
7463
+ >();
7304
7464
  /** Number of tool executions currently holding a shared read lease on `cwd`. */
7305
7465
  #cwdReaderCount = 0;
7306
7466
  /** Resolved when the last outstanding read lease is released. */
@@ -8124,6 +8284,7 @@ export class SessionManager {
8124
8284
  header: {
8125
8285
  type: "session",
8126
8286
  version: CURRENT_SESSION_VERSION,
8287
+ starredPatchVersion: 1,
8127
8288
  id: sessionId,
8128
8289
  timestamp,
8129
8290
  cwd: this.cwd,
@@ -9680,6 +9841,7 @@ export class SessionManager {
9680
9841
  const header: SessionHeader = {
9681
9842
  type: "session",
9682
9843
  version: CURRENT_SESSION_VERSION,
9844
+ starredPatchVersion: 1,
9683
9845
  id: sessionId,
9684
9846
  timestamp,
9685
9847
  cwd: this.cwd,
@@ -9888,6 +10050,7 @@ export class SessionManager {
9888
10050
  const header: SessionHeader = {
9889
10051
  type: "session",
9890
10052
  version: CURRENT_SESSION_VERSION,
10053
+ starredPatchVersion: 1,
9891
10054
  id: sessionId,
9892
10055
  title: this.#sessionName,
9893
10056
  titleSource: this.#titleSource,
@@ -9956,6 +10119,7 @@ export class SessionManager {
9956
10119
  const header: SessionHeader = {
9957
10120
  type: "session",
9958
10121
  version: CURRENT_SESSION_VERSION,
10122
+ starredPatchVersion: 1,
9959
10123
  id: sessionId,
9960
10124
  timestamp,
9961
10125
  cwd: this.cwd,
@@ -10278,6 +10442,7 @@ export class SessionManager {
10278
10442
  const newHeader: SessionHeader = {
10279
10443
  type: "session",
10280
10444
  version: CURRENT_SESSION_VERSION,
10445
+ starredPatchVersion: 1,
10281
10446
  id: newSessionId,
10282
10447
  title: oldHeader?.title ?? this.#sessionName,
10283
10448
  titleSource: oldHeader?.titleSource ?? this.#titleSource,
@@ -10650,11 +10815,32 @@ export class SessionManager {
10650
10815
  * only for the async context that already owns the lock — unrelated callers
10651
10816
  * queue on the tail instead of skipping it.
10652
10817
  */
10653
- async runExclusiveCwdTransition<T>(fn: () => Promise<T>): Promise<T> {
10818
+ async runExclusiveCwdTransition<T>(fn: () => Promise<T>, options?: { signal?: AbortSignal }): Promise<T> {
10654
10819
  const owner = this.#cwdTransitionOwner;
10655
10820
  if (owner !== undefined && cwdTransitionAls.getStore() === owner) {
10656
10821
  return fn();
10657
10822
  }
10823
+ const signal = options?.signal;
10824
+ const throwIfAborted = (): void => {
10825
+ if (!signal?.aborted) return;
10826
+ throw signal.reason instanceof Error ? signal.reason : new Error("Session cwd move admission is closed.");
10827
+ };
10828
+ const waitAbortably = async (promise: Promise<void>): Promise<void> => {
10829
+ throwIfAborted();
10830
+ if (!signal) {
10831
+ await promise;
10832
+ return;
10833
+ }
10834
+ const aborted = Promise.withResolvers<void>();
10835
+ const onAbort = () => aborted.resolve();
10836
+ signal.addEventListener("abort", onAbort, { once: true });
10837
+ try {
10838
+ await Promise.race([promise, aborted.promise]);
10839
+ } finally {
10840
+ signal.removeEventListener("abort", onAbort);
10841
+ }
10842
+ throwIfAborted();
10843
+ };
10658
10844
  const previous = this.#cwdTransitionTail;
10659
10845
  const { promise, resolve } = Promise.withResolvers<void>();
10660
10846
  this.#cwdTransitionTail = previous.then(
@@ -10666,11 +10852,11 @@ export class SessionManager {
10666
10852
  this.#cwdWriterPending += 1;
10667
10853
  const token = Symbol("cwd-transition");
10668
10854
  try {
10669
- await previous.catch(() => {});
10855
+ await waitAbortably(previous.catch(() => {}));
10670
10856
  // A reader that entered before this writer was announced still holds the
10671
10857
  // old cwd; the transition may not commit until every such lease is
10672
10858
  // released, otherwise an in-flight tool resolves paths across the move.
10673
- while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
10859
+ while (this.#cwdReaderCount > 0) await waitAbortably(this.#cwdReadersIdle);
10674
10860
  this.#cwdTransitionOwner = token;
10675
10861
  return await cwdTransitionAls.run(token, fn);
10676
10862
  } finally {
@@ -10680,6 +10866,90 @@ export class SessionManager {
10680
10866
  }
10681
10867
  }
10682
10868
 
10869
+ async runExclusiveCwdMoveTransition<T>(fn: () => Promise<T>): Promise<T> {
10870
+ if (this.#cwdMoveAdmissionClosing || this.#cwdMoveAdmissionClosed)
10871
+ throw new Error("Session cwd move admission is closed.");
10872
+ const transitionOwner = this.#cwdTransitionOwner;
10873
+ if (
10874
+ this.#strictClosePending &&
10875
+ (transitionOwner === undefined || cwdTransitionAls.getStore() !== transitionOwner)
10876
+ )
10877
+ throw new Error("Session manager is closing.");
10878
+ const readLease = cwdReadLeaseAls.getStore();
10879
+ const rootLease =
10880
+ readLease?.owner === this.#cwdReadLeaseOwner && !readLease.released
10881
+ ? (readLease.root ?? readLease)
10882
+ : undefined;
10883
+ const suspendedOwnReadLease = rootLease !== undefined;
10884
+ if (rootLease) {
10885
+ // A move may suspend the shared root lease only when this execution is
10886
+ // the sole active descendant. If a sibling tool is still fenced by the
10887
+ // lease, returning a retry error is safer than restoring the lease while
10888
+ // that sibling continues against the old cwd.
10889
+ const activeBranchLimit = readLease === rootLease ? 1 : 2;
10890
+ if (
10891
+ rootLease.activeBranches > activeBranchLimit ||
10892
+ (rootLease.suspendedMoveOwner !== undefined && rootLease.suspendedMoveOwner !== readLease?.executionId)
10893
+ )
10894
+ throw new Error("Session working directory changed while this tool executed; retry against the new cwd.");
10895
+ rootLease.suspendedMoveCount += 1;
10896
+ rootLease.suspendedMoveOwner ??= readLease?.executionId;
10897
+ if (rootLease.active) {
10898
+ rootLease.active = false;
10899
+ this.#cwdReaderCount -= 1;
10900
+ if (this.#cwdReaderCount === 0) {
10901
+ const drained = this.#cwdReadersDrained;
10902
+ this.#cwdReadersDrained = undefined;
10903
+ drained?.();
10904
+ }
10905
+ }
10906
+ }
10907
+ const admittedBeforeClose = this.#cwdWriterPending === 0 && this.#cwdReaderCount === 0;
10908
+ const admission = new AbortController();
10909
+ this.#pendingCwdMoveAdmissions.add(admission);
10910
+ if (admittedBeforeClose) this.#cwdMoveAdmittedAdmissions.add(admission);
10911
+ try {
10912
+ return await this.runExclusiveCwdTransition(
10913
+ async () => {
10914
+ const owner = this.#cwdTransitionOwner;
10915
+ if (owner === undefined) throw new Error("Session cwd move transition owner is unavailable.");
10916
+ this.#cwdMoveAdmittedAdmissions.add(admission);
10917
+ this.#cwdMoveAdmittedOwners.add(owner);
10918
+ this.#cwdMoveAdmittedOwnerCounts.set(owner, (this.#cwdMoveAdmittedOwnerCounts.get(owner) ?? 0) + 1);
10919
+ try {
10920
+ return await fn();
10921
+ } finally {
10922
+ this.#cwdMoveAdmittedAdmissions.delete(admission);
10923
+ const count = this.#cwdMoveAdmittedOwnerCounts.get(owner) ?? 0;
10924
+ if (count <= 1) {
10925
+ this.#cwdMoveAdmittedOwnerCounts.delete(owner);
10926
+ this.#cwdMoveAdmittedOwners.delete(owner);
10927
+ } else {
10928
+ this.#cwdMoveAdmittedOwnerCounts.set(owner, count - 1);
10929
+ }
10930
+ }
10931
+ },
10932
+ { signal: admission.signal },
10933
+ );
10934
+ } finally {
10935
+ this.#pendingCwdMoveAdmissions.delete(admission);
10936
+ this.#cwdMoveAdmittedAdmissions.delete(admission);
10937
+ if (suspendedOwnReadLease && rootLease) {
10938
+ rootLease.suspendedMoveCount -= 1;
10939
+ if (rootLease.suspendedMoveCount === 0 && !rootLease.released) {
10940
+ if (this.#cwdReaderCount === 0) {
10941
+ const { promise, resolve } = Promise.withResolvers<void>();
10942
+ this.#cwdReadersIdle = promise;
10943
+ this.#cwdReadersDrained = resolve;
10944
+ }
10945
+ this.#cwdReaderCount += 1;
10946
+ rootLease.active = true;
10947
+ rootLease.suspendedMoveOwner = undefined;
10948
+ }
10949
+ }
10950
+ }
10951
+ }
10952
+
10683
10953
  /**
10684
10954
  * Run `fn` under a shared read lease on `cwd`.
10685
10955
  *
@@ -10693,14 +10963,39 @@ export class SessionManager {
10693
10963
  async runWithCwdReadLease<T>(fn: () => Promise<T>): Promise<T> {
10694
10964
  const activeReadLease = cwdReadLeaseAls.getStore();
10695
10965
  // Nested tool dispatch (for example eval -> tool bridge) inherits the
10696
- // outer lease's async context. Re-entering that lease must not wait behind
10697
- // a writer that is already queued: the writer is waiting for the outer
10698
- // lease, and waiting here would deadlock the session permanently.
10699
- if (activeReadLease?.active && activeReadLease.owner === this.#cwdReadLeaseOwner) return fn();
10966
+ // outer lease's cwd authority, but receives an execution-local context so
10967
+ // sibling descendants cannot attribute one another's nested move.
10968
+ if (activeReadLease?.owner === this.#cwdReadLeaseOwner && !activeReadLease.released) {
10969
+ const rootLease = activeReadLease.root ?? activeReadLease;
10970
+ if (rootLease.suspendedMoveOwner !== undefined)
10971
+ throw new Error("Session working directory changed while this tool executed; retry against the new cwd.");
10972
+ rootLease.activeBranches += 1;
10973
+ const nestedReadLease: CwdReadLeaseContext = {
10974
+ active: true,
10975
+ released: false,
10976
+ owner: this.#cwdReadLeaseOwner,
10977
+ parent: activeReadLease,
10978
+ root: rootLease,
10979
+ executionId: Symbol("cwd-read-execution"),
10980
+ activeBranches: 0,
10981
+ suspendedMoveCount: 0,
10982
+ };
10983
+ return cwdReadLeaseAls.run(nestedReadLease, async () => {
10984
+ try {
10985
+ return await fn();
10986
+ } finally {
10987
+ if (nestedReadLease.committedMoveGeneration !== undefined)
10988
+ activeReadLease.committedMoveGeneration = nestedReadLease.committedMoveGeneration;
10989
+ nestedReadLease.released = true;
10990
+ rootLease.activeBranches -= 1;
10991
+ }
10992
+ });
10993
+ }
10700
10994
  const owner = this.#cwdTransitionOwner;
10701
10995
  // The writer's own async context already holds exclusive access; taking a
10702
10996
  // read lease there would wait on itself.
10703
10997
  if (owner !== undefined && cwdTransitionAls.getStore() === owner) return fn();
10998
+ if (this.#strictClosePending || this.#cwdMoveAdmissionClosed) throw new Error("Session manager is closing.");
10704
10999
  while (this.#cwdWriterPending > 0) await this.#cwdTransitionTail.catch(() => {});
10705
11000
  if (this.#cwdReaderCount === 0) {
10706
11001
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -10708,18 +11003,29 @@ export class SessionManager {
10708
11003
  this.#cwdReadersDrained = resolve;
10709
11004
  }
10710
11005
  this.#cwdReaderCount += 1;
10711
- const readLease: CwdReadLeaseContext = { active: true, owner: this.#cwdReadLeaseOwner };
11006
+ const readLease: CwdReadLeaseContext = {
11007
+ active: true,
11008
+ released: false,
11009
+ owner: this.#cwdReadLeaseOwner,
11010
+ suspendedMoveCount: 0,
11011
+ executionId: Symbol("cwd-read-execution"),
11012
+ activeBranches: 1,
11013
+ };
11014
+ readLease.root = readLease;
10712
11015
  return cwdReadLeaseAls.run(readLease, async () => {
10713
11016
  try {
10714
11017
  return await fn();
10715
11018
  } finally {
10716
- readLease.active = false;
10717
- this.#cwdReaderCount -= 1;
10718
- if (this.#cwdReaderCount === 0) {
10719
- const drained = this.#cwdReadersDrained;
10720
- this.#cwdReadersDrained = undefined;
10721
- drained?.();
11019
+ if (readLease.active) {
11020
+ readLease.active = false;
11021
+ this.#cwdReaderCount -= 1;
11022
+ if (this.#cwdReaderCount === 0) {
11023
+ const drained = this.#cwdReadersDrained;
11024
+ this.#cwdReadersDrained = undefined;
11025
+ drained?.();
11026
+ }
10722
11027
  }
11028
+ readLease.released = true;
10723
11029
  }
10724
11030
  });
10725
11031
  }
@@ -10728,10 +11034,160 @@ export class SessionManager {
10728
11034
  async joinCwdTransition(): Promise<void> {
10729
11035
  await this.#cwdTransitionTail;
10730
11036
  }
11037
+
11038
+ async joinCwdReaders(): Promise<void> {
11039
+ while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
11040
+ }
11041
+
11042
+ async closeCwdMoveAdmission(): Promise<void> {
11043
+ if (this.#cwdMoveAdmissionClosed) return;
11044
+ if (this.#cwdMoveAdmissionClosing) return;
11045
+ this.#cwdMoveAdmissionClosing = true;
11046
+ this.#cwdMoveAdmissionClosed = true;
11047
+ // Preserve moves that already acquired exclusive ownership, but cancel every
11048
+ // queued admission. A queued writer can otherwise pass the initial admission
11049
+ // check after an earlier writer finishes and mutate the closed session.
11050
+ for (const admission of this.#pendingCwdMoveAdmissions) {
11051
+ if (!this.#cwdMoveAdmittedAdmissions.has(admission)) {
11052
+ admission.abort(new Error("Session cwd move admission is closed."));
11053
+ }
11054
+ }
11055
+ }
10731
11056
  getCwdGeneration(): number {
10732
11057
  return this.#cwdGeneration;
10733
11058
  }
10734
11059
 
11060
+ /** Consume a move committed by a nested move_session call in this read lease. */
11061
+ consumeNestedCwdMove(admittedGeneration: number): boolean {
11062
+ const readLease = cwdReadLeaseAls.getStore();
11063
+ const committed = readLease?.committedMoveGeneration;
11064
+ if (readLease) readLease.committedMoveGeneration = undefined;
11065
+ return committed !== undefined && committed !== admittedGeneration && committed === this.#cwdGeneration;
11066
+ }
11067
+
11068
+ /**
11069
+ * Register a listener invoked after every committed `moveTo`. Returns an idempotent
11070
+ * unregister handle. Every move surface funnels through `moveTo`, so a single
11071
+ * registration covers `move_session`, `/move`, and SDK/ACP `session.cwd.move`.
11072
+ */
11073
+ registerAfterMoveListener(
11074
+ listener: (move: {
11075
+ previousCwd: string;
11076
+ newCwd: string;
11077
+ previousSessionFile: string | undefined;
11078
+ }) => void | Promise<void>,
11079
+ ): () => void {
11080
+ this.#afterMoveListeners.add(listener);
11081
+ return () => {
11082
+ this.#afterMoveListeners.delete(listener);
11083
+ };
11084
+ }
11085
+
11086
+ registerBeforeMoveListener(
11087
+ listener: (move: {
11088
+ previousCwd: string;
11089
+ newCwd: string;
11090
+ previousSessionFile: string | undefined;
11091
+ newSessionFile: string | undefined;
11092
+ previousCwdIdentity: { dev: bigint; ino: bigint };
11093
+ newCwdIdentity: { dev: bigint; ino: bigint };
11094
+ preserveRecoveryJournal?: boolean;
11095
+ }) => void | Promise<void>,
11096
+ ): () => void {
11097
+ this.#beforeMoveListeners.add(listener);
11098
+ return () => {
11099
+ this.#beforeMoveListeners.delete(listener);
11100
+ };
11101
+ }
11102
+
11103
+ async #runBeforeMoveListeners(move: {
11104
+ previousCwd: string;
11105
+ newCwd: string;
11106
+ previousSessionFile: string | undefined;
11107
+ newSessionFile: string | undefined;
11108
+ previousCwdIdentity: { dev: bigint; ino: bigint };
11109
+ newCwdIdentity: { dev: bigint; ino: bigint };
11110
+ }): Promise<void> {
11111
+ for (const listener of [...this.#beforeMoveListeners]) await listener(move);
11112
+ }
11113
+
11114
+ registerMovePublicationListener(
11115
+ listener: (move: {
11116
+ previousCwd: string;
11117
+ newCwd: string;
11118
+ previousSessionFile: string | undefined;
11119
+ newSessionFile: string | undefined;
11120
+ previousCwdIdentity: { dev: bigint; ino: bigint };
11121
+ newCwdIdentity: { dev: bigint; ino: bigint };
11122
+ preserveRecoveryJournal?: boolean;
11123
+ }) => void | Promise<void>,
11124
+ ): () => void {
11125
+ this.#movePublicationListeners.add(listener);
11126
+ return () => {
11127
+ this.#movePublicationListeners.delete(listener);
11128
+ };
11129
+ }
11130
+
11131
+ async #runMovePublicationListeners(move: {
11132
+ previousCwd: string;
11133
+ newCwd: string;
11134
+ previousSessionFile: string | undefined;
11135
+ newSessionFile: string | undefined;
11136
+ previousCwdIdentity: { dev: bigint; ino: bigint };
11137
+ newCwdIdentity: { dev: bigint; ino: bigint };
11138
+ }): Promise<void> {
11139
+ for (const listener of [...this.#movePublicationListeners]) await listener(move);
11140
+ }
11141
+
11142
+ registerMoveAbortListener(
11143
+ listener: (move: {
11144
+ previousCwd: string;
11145
+ newCwd: string;
11146
+ previousSessionFile: string | undefined;
11147
+ newSessionFile: string | undefined;
11148
+ previousCwdIdentity: { dev: bigint; ino: bigint };
11149
+ newCwdIdentity: { dev: bigint; ino: bigint };
11150
+ preserveRecoveryJournal?: boolean;
11151
+ }) => void | Promise<void>,
11152
+ ): () => void {
11153
+ this.#moveAbortListeners.add(listener);
11154
+ return () => {
11155
+ this.#moveAbortListeners.delete(listener);
11156
+ };
11157
+ }
11158
+
11159
+ async #runMoveAbortListeners(move: {
11160
+ previousCwd: string;
11161
+ newCwd: string;
11162
+ previousSessionFile: string | undefined;
11163
+ newSessionFile: string | undefined;
11164
+ previousCwdIdentity: { dev: bigint; ino: bigint };
11165
+ newCwdIdentity: { dev: bigint; ino: bigint };
11166
+ preserveRecoveryJournal?: boolean;
11167
+ }): Promise<void> {
11168
+ for (const listener of [...this.#moveAbortListeners]) {
11169
+ try {
11170
+ await listener(move);
11171
+ } catch (error) {
11172
+ logger.warn("move-abort listener failed", { error: String(error), cwd: move.newCwd });
11173
+ }
11174
+ }
11175
+ }
11176
+
11177
+ async #runAfterMoveListeners(move: {
11178
+ previousCwd: string;
11179
+ newCwd: string;
11180
+ previousSessionFile: string | undefined;
11181
+ }): Promise<void> {
11182
+ for (const listener of [...this.#afterMoveListeners]) {
11183
+ try {
11184
+ await listener(move);
11185
+ } catch (error) {
11186
+ logger.warn("after-move listener failed", { error: String(error), cwd: move.newCwd });
11187
+ }
11188
+ }
11189
+ }
11190
+
10735
11191
  #ownsCwdTransition(): boolean {
10736
11192
  const owner = this.#cwdTransitionOwner;
10737
11193
  return owner !== undefined && cwdTransitionAls.getStore() === owner;
@@ -10802,15 +11258,76 @@ export class SessionManager {
10802
11258
  options?: {
10803
11259
  expectedIdentity?: { dev: bigint; ino: bigint };
10804
11260
  targetHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
11261
+ expectedSourceIdentity?: { dev: bigint; ino: bigint };
11262
+ sourceHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
10805
11263
  },
10806
11264
  ): Promise<void> {
11265
+ if (this.#strictClosePending && !this.#ownsCwdTransition()) throw new Error("Session manager is closing.");
10807
11266
  if (!this.#ownsCwdTransition()) {
10808
- return this.runExclusiveCwdTransition(() => this.moveTo(newCwd, options));
11267
+ return this.runExclusiveCwdMoveTransition(() => this.moveTo(newCwd, options));
11268
+ }
11269
+ const owner = this.#cwdTransitionOwner;
11270
+ if (this.#cwdMoveAdmissionClosed && (owner === undefined || !this.#cwdMoveAdmittedOwners.has(owner)))
11271
+ throw new Error("Session cwd move admission is closed.");
11272
+ if (!options?.sourceHandle) {
11273
+ const sourceHandle = await SessionManager.openNoFollowDirectory(this.cwd);
11274
+ try {
11275
+ const sourceIdentity = await sourceHandle.stat({ bigint: true });
11276
+ if (!sourceIdentity.isDirectory()) throw new Error(`Current cwd is not a directory: ${this.cwd}`);
11277
+ return await this.moveTo(newCwd, {
11278
+ ...options,
11279
+ expectedSourceIdentity: options?.expectedSourceIdentity ?? {
11280
+ dev: sourceIdentity.dev,
11281
+ ino: sourceIdentity.ino,
11282
+ },
11283
+ sourceHandle,
11284
+ });
11285
+ } finally {
11286
+ await sourceHandle.close();
11287
+ }
11288
+ }
11289
+ const resolvedCwd = await fs.promises.realpath(path.resolve(newCwd));
11290
+ const resolvedIdentity = await fs.promises.lstat(resolvedCwd, { bigint: true });
11291
+ if (!resolvedIdentity.isDirectory() || resolvedIdentity.isSymbolicLink()) {
11292
+ throw new Error(`Refusing to rescope: target is not a directory: ${resolvedCwd}`);
11293
+ }
11294
+ if (!options?.targetHandle) {
11295
+ const targetHandle = await SessionManager.openNoFollowDirectory(resolvedCwd);
11296
+ try {
11297
+ const targetIdentity = await targetHandle.stat({ bigint: true });
11298
+ const revalidatedCwd = await fs.promises.realpath(path.resolve(newCwd));
11299
+ const namedIdentity = await fs.promises.lstat(revalidatedCwd, { bigint: true });
11300
+ if (
11301
+ revalidatedCwd !== resolvedCwd ||
11302
+ !namedIdentity.isDirectory() ||
11303
+ namedIdentity.isSymbolicLink() ||
11304
+ namedIdentity.dev !== resolvedIdentity.dev ||
11305
+ namedIdentity.ino !== resolvedIdentity.ino ||
11306
+ targetIdentity.dev !== resolvedIdentity.dev ||
11307
+ targetIdentity.ino !== resolvedIdentity.ino
11308
+ )
11309
+ throw new Error(`Refusing to rescope: target identity changed: ${resolvedCwd}`);
11310
+ return await this.moveTo(resolvedCwd, {
11311
+ ...options,
11312
+ expectedIdentity: options?.expectedIdentity ?? {
11313
+ dev: targetIdentity.dev,
11314
+ ino: targetIdentity.ino,
11315
+ },
11316
+ targetHandle,
11317
+ });
11318
+ } finally {
11319
+ await targetHandle.close();
11320
+ }
10809
11321
  }
10810
- const resolvedCwd = path.resolve(newCwd);
10811
11322
  if (options?.expectedIdentity || options?.targetHandle) {
10812
11323
  await this.#assertCwdTargetIdentity(resolvedCwd, options);
10813
11324
  }
11325
+ if (options?.expectedSourceIdentity || options?.sourceHandle) {
11326
+ await this.#assertCwdTargetIdentity(this.cwd, {
11327
+ expectedIdentity: options.expectedSourceIdentity,
11328
+ targetHandle: options.sourceHandle,
11329
+ });
11330
+ }
10814
11331
  if (resolvedCwd === this.cwd) return;
10815
11332
  const previousCwd = this.cwd;
10816
11333
  const previousSessionDir = this.sessionDir;
@@ -10826,6 +11343,19 @@ export class SessionManager {
10826
11343
  )
10827
11344
  : this.destination;
10828
11345
  const newSessionDir = nextDestination.directory;
11346
+ const nextSessionFile = this.#sessionFile
11347
+ ? path.join(newSessionDir, path.basename(this.#sessionFile))
11348
+ : undefined;
11349
+ const pinnedSourceIdentity = await options.sourceHandle.stat({ bigint: true });
11350
+ const pinnedTargetIdentity = await options.targetHandle.stat({ bigint: true });
11351
+ const moveDetails = {
11352
+ previousCwd,
11353
+ newCwd: resolvedCwd,
11354
+ previousSessionFile,
11355
+ newSessionFile: nextSessionFile,
11356
+ previousCwdIdentity: { dev: pinnedSourceIdentity.dev, ino: pinnedSourceIdentity.ino },
11357
+ newCwdIdentity: { dev: pinnedTargetIdentity.dev, ino: pinnedTargetIdentity.ino },
11358
+ };
10829
11359
  let hadSessionFile = false;
10830
11360
  const managedMove = this.destination.kind === "managed" && nextDestination.kind === "managed";
10831
11361
  let managedSourceStore: ManagedSessionDescendantStore | undefined;
@@ -10833,16 +11363,29 @@ export class SessionManager {
10833
11363
  let rollbackManagedMove: (() => Promise<void>) | undefined;
10834
11364
  let residentTransition: PreparedResidentStoreTransition | undefined;
10835
11365
  const hadPersistedSession = this.#ensuredOnDisk;
11366
+ try {
11367
+ await this.#runBeforeMoveListeners(moveDetails);
11368
+ } catch (error) {
11369
+ await this.#runMoveAbortListeners(moveDetails);
11370
+ throw error;
11371
+ }
10836
11372
 
11373
+ let targetIdentityRevalidated = false;
11374
+ let publicationStarted = false;
10837
11375
  if (this.persist && this.#sessionFile) {
10838
11376
  // Close the persist writer before moving files
10839
- await this.#closePersistWriter();
11377
+ try {
11378
+ await this.#closePersistWriter();
11379
+ } catch (error) {
11380
+ await this.#runMoveAbortListeners(moveDetails);
11381
+ throw error;
11382
+ }
10840
11383
  this.#persistChain = Promise.resolve();
10841
11384
  this.#persistError = undefined;
10842
11385
  this.#persistErrorReported = false;
10843
11386
 
10844
11387
  const oldSessionFile = this.#sessionFile;
10845
- const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
11388
+ const newSessionFile = nextSessionFile!;
10846
11389
  const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
10847
11390
  const newArtifactDir = newSessionFile.slice(0, -6);
10848
11391
  let managedTranscript!: ReturnType<ManagedSessionDescendantStore["readExpected"]>;
@@ -10852,24 +11395,30 @@ export class SessionManager {
10852
11395
  hadSessionFile = managedMove ? false : this.#storage.existsSync(oldSessionFile);
10853
11396
  let movedSessionFile = false;
10854
11397
  let movedArtifactDir = false;
10855
- const materializedEntries = materializeResidentEntriesForReadSync(
10856
- this.#fileEntries,
10857
- this.#residentBlobStores(),
10858
- );
10859
- const transitionEntries: FileEntry[] = materializedEntries.map(entry =>
10860
- entry.type === "session" ? { ...entry, cwd: resolvedCwd } : entry,
10861
- );
10862
- residentTransition = this.#prepareResidentTextStoreTransition(
10863
- {
10864
- target: { sessionId: this.#sessionId, sessionFile: newSessionFile },
10865
- primary: {
10866
- mode: "materialize",
10867
- sourceEntries: transitionEntries,
10868
- sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
11398
+ try {
11399
+ const materializedEntries = materializeResidentEntriesForReadSync(
11400
+ this.#fileEntries,
11401
+ this.#residentBlobStores(),
11402
+ );
11403
+ const transitionEntries: FileEntry[] = materializedEntries.map(entry =>
11404
+ entry.type === "session" ? { ...entry, cwd: resolvedCwd } : entry,
11405
+ );
11406
+ residentTransition = this.#prepareResidentTextStoreTransition(
11407
+ {
11408
+ target: { sessionId: this.#sessionId, sessionFile: newSessionFile },
11409
+ primary: {
11410
+ mode: "materialize",
11411
+ sourceEntries: transitionEntries,
11412
+ sourceStores: { textStore: null, imageStore: this.#residentImageBlobStore },
11413
+ },
10869
11414
  },
10870
- },
10871
- "retain-and-throw",
10872
- );
11415
+ "retain-and-throw",
11416
+ );
11417
+ } catch (error) {
11418
+ residentTransition?.dispose();
11419
+ await this.#runMoveAbortListeners(moveDetails);
11420
+ throw error;
11421
+ }
10873
11422
  const discardResidentTransitionAndThrow = (error: unknown): never => {
10874
11423
  residentTransition?.dispose();
10875
11424
  throw error;
@@ -10912,6 +11461,14 @@ export class SessionManager {
10912
11461
  throw new Error("artifact_destination_mismatch");
10913
11462
  destination.fsyncTree();
10914
11463
  };
11464
+ try {
11465
+ await this.#runMovePublicationListeners(moveDetails);
11466
+ publicationStarted = true;
11467
+ } catch (error) {
11468
+ residentTransition?.dispose();
11469
+ await this.#runMoveAbortListeners(moveDetails);
11470
+ throw error;
11471
+ }
10915
11472
  try {
10916
11473
  if (managedMove) {
10917
11474
  if (previousDestination.kind !== "managed" || nextDestination.kind !== "managed")
@@ -10997,6 +11554,16 @@ export class SessionManager {
10997
11554
  if (!isEnoent(err)) throw err;
10998
11555
  }
10999
11556
  }
11557
+ if (options?.expectedIdentity || options?.targetHandle) {
11558
+ if (options.expectedSourceIdentity || options.sourceHandle) {
11559
+ await this.#assertCwdTargetIdentity(previousCwd, {
11560
+ expectedIdentity: options.expectedSourceIdentity,
11561
+ targetHandle: options.sourceHandle,
11562
+ });
11563
+ }
11564
+ await this.#assertCwdTargetIdentity(resolvedCwd, options);
11565
+ targetIdentityRevalidated = true;
11566
+ }
11000
11567
  } catch (err) {
11001
11568
  if (managedMove && managedDestinationStore) {
11002
11569
  try {
@@ -11038,6 +11605,7 @@ export class SessionManager {
11038
11605
  )
11039
11606
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
11040
11607
  } catch (rollbackErr) {
11608
+ await this.#runMoveAbortListeners({ ...moveDetails, preserveRecoveryJournal: true });
11041
11609
  discardResidentTransitionAndThrow(
11042
11610
  new Error(`Failed to rollback managed move: ${toError(rollbackErr).message}`, {
11043
11611
  cause: toError(err),
@@ -11045,20 +11613,54 @@ export class SessionManager {
11045
11613
  );
11046
11614
  }
11047
11615
  } else {
11048
- if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
11049
- if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
11616
+ try {
11617
+ if (movedArtifactDir) await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
11618
+ if (movedSessionFile) await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
11619
+ } catch (rollbackError) {
11620
+ await this.#runMoveAbortListeners({ ...moveDetails, preserveRecoveryJournal: true });
11621
+ discardResidentTransitionAndThrow(
11622
+ new Error(`Failed to rollback move: ${toError(rollbackError).message}`, { cause: toError(err) }),
11623
+ );
11624
+ }
11050
11625
  }
11626
+ await this.#runMoveAbortListeners(moveDetails);
11051
11627
  discardResidentTransitionAndThrow(err);
11052
11628
  }
11053
11629
  this.#sessionFile = newSessionFile;
11054
11630
  }
11631
+ if (!publicationStarted) {
11632
+ try {
11633
+ await this.#runMovePublicationListeners(moveDetails);
11634
+ publicationStarted = true;
11635
+ } catch (error) {
11636
+ await this.#runMoveAbortListeners(moveDetails);
11637
+ throw error;
11638
+ }
11639
+ }
11055
11640
 
11056
11641
  // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
11057
11642
  // authority but deliberately retain any destination publication evidence rather than deleting it.
11058
- if (options?.expectedIdentity || options?.targetHandle) {
11059
- await this.#assertCwdTargetIdentity(resolvedCwd, options);
11643
+ if (!targetIdentityRevalidated && (options?.expectedIdentity || options?.targetHandle)) {
11644
+ try {
11645
+ if (options.expectedSourceIdentity || options.sourceHandle) {
11646
+ await this.#assertCwdTargetIdentity(previousCwd, {
11647
+ expectedIdentity: options.expectedSourceIdentity,
11648
+ targetHandle: options.sourceHandle,
11649
+ });
11650
+ }
11651
+ await this.#assertCwdTargetIdentity(resolvedCwd, options);
11652
+ } catch (error) {
11653
+ await this.#runMoveAbortListeners(moveDetails);
11654
+ throw error;
11655
+ }
11060
11656
  }
11061
11657
  this.#cwdGeneration += 1;
11658
+ const readLease = cwdReadLeaseAls.getStore();
11659
+ if (readLease?.owner === this.#cwdReadLeaseOwner) {
11660
+ readLease.committedMoveGeneration = this.#cwdGeneration;
11661
+ for (let parent = readLease.parent; parent; parent = parent.parent)
11662
+ parent.committedMoveGeneration = this.#cwdGeneration;
11663
+ }
11062
11664
  this.cwd = resolvedCwd;
11063
11665
  this.sessionDir = newSessionDir;
11064
11666
  this.destination = nextDestination;
@@ -11077,13 +11679,13 @@ export class SessionManager {
11077
11679
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
11078
11680
  try {
11079
11681
  if (this.persist && this.#sessionFile && hadSessionFile) {
11080
- await this.#appendHeaderPatch({ cwd: resolvedCwd });
11682
+ await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
11081
11683
  await this.#rewriteFile();
11082
11684
  } else if (this.persist && this.#sessionFile && (hasAssistant || (!hadSessionFile && hadPersistedSession))) {
11083
- await this.#appendHeaderPatch({ cwd: resolvedCwd });
11685
+ await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
11084
11686
  await this.#rewriteFile();
11085
11687
  } else {
11086
- await this.#appendHeaderPatch({ cwd: resolvedCwd });
11688
+ await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
11087
11689
  }
11088
11690
  } catch (error) {
11089
11691
  residentTransition?.dispose();
@@ -11096,6 +11698,7 @@ export class SessionManager {
11096
11698
  await rollbackManagedMove();
11097
11699
  } catch (rollbackError) {
11098
11700
  if (toError(rollbackError).message !== "cleanup_pending") {
11701
+ await this.#runMoveAbortListeners({ ...moveDetails, preserveRecoveryJournal: true });
11099
11702
  throw new Error(`Failed to rollback managed move: ${toError(rollbackError).message}`, {
11100
11703
  cause: toError(error),
11101
11704
  });
@@ -11116,12 +11719,14 @@ export class SessionManager {
11116
11719
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
11117
11720
  if (header) applyHeaderPatch(header, { cwd: previousCwd });
11118
11721
  this.#headerExportRevision++;
11722
+ await this.#runMoveAbortListeners(moveDetails);
11119
11723
  throw error;
11120
11724
  }
11121
11725
  // The destination has already been published. Retain it rather than moving it
11122
11726
  // back over the source after a metadata failure.
11123
11727
  this.#managedTranscriptStoreCache = null;
11124
11728
  this.#headerExportRevision++;
11729
+ await this.#runAfterMoveListeners(moveDetails);
11125
11730
  throw error;
11126
11731
  }
11127
11732
  if (residentTransition) this.#commitResidentTextStoreTransition(residentTransition);
@@ -11130,6 +11735,9 @@ export class SessionManager {
11130
11735
  if (this.#sessionFile) {
11131
11736
  writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
11132
11737
  }
11738
+ // The move is fully committed here; notify every surface's after-move listeners
11739
+ // (coordinator runtime-state relocation, postmortem finalizer rebinding, ...).
11740
+ await this.#runAfterMoveListeners(moveDetails);
11133
11741
  }
11134
11742
 
11135
11743
  /** Sync version for initial creation (no existing writer to close). */
@@ -15484,6 +16092,7 @@ export class SessionManager {
15484
16092
  * Used by ACP mode where session/new must create a discoverable session immediately.
15485
16093
  */
15486
16094
  async ensureOnDisk(): Promise<void> {
16095
+ this.#assertRecoveryHydrationWritable();
15487
16096
  if (!this.persist || !this.#sessionFile) return;
15488
16097
  if (this.#readOnlyResume) return;
15489
16098
  if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
@@ -15593,7 +16202,9 @@ export class SessionManager {
15593
16202
 
15594
16203
  /** Close the persistent writer after flushing all pending data. */
15595
16204
  async close(): Promise<void> {
16205
+ await this.closeCwdMoveAdmission();
15596
16206
  await this.joinCwdTransition();
16207
+ await this.joinCwdReaders();
15597
16208
  SessionManager.releaseProcessCwdOwnership(this);
15598
16209
  // Drain any uncommitted prepared successors before releasing resources so
15599
16210
  // dispose/shutdown retains exact cleanup authority (#3138).
@@ -15635,10 +16246,16 @@ export class SessionManager {
15635
16246
  }
15636
16247
  /** Flush while open, then strictly close; retryable close skips the invalid second flush. */
15637
16248
  async flushAndCloseStrict(): Promise<SessionManagerCloseOutcome> {
16249
+ let priorFlushError: Error | undefined;
15638
16250
  if (this.#persistWriter?.getCloseState() !== "close_failed_retryable") {
15639
- await this.flush();
16251
+ try {
16252
+ await this.flush();
16253
+ } catch (error) {
16254
+ priorFlushError = toError(error);
16255
+ }
15640
16256
  }
15641
- return this.closeStrict();
16257
+ const outcome = await this.closeStrict();
16258
+ return priorFlushError && outcome.kind === "closed" ? { kind: "close_unknown", error: priorFlushError } : outcome;
15642
16259
  }
15643
16260
 
15644
16261
  /**
@@ -15648,45 +16265,114 @@ export class SessionManager {
15648
16265
  * writer closure before any destructive operation.
15649
16266
  */
15650
16267
  async closeStrict(): Promise<SessionManagerCloseOutcome> {
16268
+ this.#strictClosePending = true;
16269
+ await this.closeCwdMoveAdmission();
16270
+ await this.joinCwdTransition();
16271
+ await this.joinCwdReaders();
15651
16272
  // Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
16273
+ let preparedCleanupError: Error | undefined;
15652
16274
  try {
15653
16275
  await this.#retryPreparedNewSessionCleanups();
15654
16276
  } catch (error) {
16277
+ preparedCleanupError = toError(error);
15655
16278
  logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
15656
- error: toError(error).message,
16279
+ error: preparedCleanupError.message,
15657
16280
  });
15658
16281
  }
15659
- let outcome: SessionManagerCloseOutcome = { kind: "closed" };
15660
- await this.#queuePersistTask(async () => {
15661
- const writer = this.#persistWriter;
15662
- if (!writer) {
15663
- this.#flushed = true;
15664
- return;
16282
+ if (preparedCleanupError) return { kind: "close_failed_retryable", error: preparedCleanupError };
16283
+ let priorPersistError = this.#persistError;
16284
+ if (this.#needsFullRewriteOnNextPersist && (!this.#readOnlyResume || this.#strictResumeMutationPending)) {
16285
+ await this.#persistChain.catch(() => {});
16286
+ try {
16287
+ await this.#rewriteFileContents();
16288
+ if (this.#strictResumeMutationPending && this.#readOnlyResume && this.#sessionFile) {
16289
+ writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
16290
+ this.#readOnlyResume = false;
16291
+ }
16292
+ this.#strictResumeMutationPending = false;
16293
+ } catch (error) {
16294
+ priorPersistError ??= toError(error);
16295
+ return { kind: "close_failed_retryable", error: priorPersistError };
15665
16296
  }
16297
+ }
16298
+ let outcome: SessionManagerCloseOutcome = { kind: "closed" };
16299
+ const closePublicationRevision = {
16300
+ entry: this.#entryRevision,
16301
+ header: this.#headerExportRevision,
16302
+ residentBlob: this.#residentBlobRevision,
16303
+ };
16304
+ await this.#queuePersistTask(
16305
+ async () => {
16306
+ const writer = this.#persistWriter;
16307
+ if (!writer) {
16308
+ this.#flushed = true;
16309
+ return;
16310
+ }
16311
+ try {
16312
+ await writer.close();
16313
+ } catch {
16314
+ // Outcome is captured from the underlying writer's close state below.
16315
+ }
16316
+ outcome = this.#closeOutcomeFromWriter(writer);
16317
+ if (outcome.kind === "closed") {
16318
+ this.#flushed = true;
16319
+ // Confirmed closed: release writer ownership.
16320
+ this.#persistWriter = undefined;
16321
+ this.#persistWriterPath = undefined;
16322
+ } else if (outcome.kind === "close_unknown") {
16323
+ // Quarantined (terminal): release ownership so no retry/finalizer
16324
+ // touches the uncertain fd again.
16325
+ this.#persistWriter = undefined;
16326
+ this.#persistWriterPath = undefined;
16327
+ }
16328
+ // close_failed_retryable: RETAIN the writer so a later closeStrict() call
16329
+ // can actually re-dispatch the OS close (ownership stays proven). The
16330
+ // wrapper must not manufacture success or surrender a retryable fd.
16331
+ },
16332
+ { ignoreError: true },
16333
+ );
16334
+ // A mutation that entered before strict-close admission can finish its
16335
+ // asynchronous preparation after the initial rewrite check. Re-check the
16336
+ // publication debt after writer closure and repair it before releasing any
16337
+ // resident state; strict admission prevents new callers from creating a
16338
+ // second race while this repair is in flight.
16339
+ let observedPersistError = priorPersistError ?? this.#persistError;
16340
+ const mutationDuringWriterClose =
16341
+ this.#entryRevision !== closePublicationRevision.entry ||
16342
+ this.#headerExportRevision !== closePublicationRevision.header ||
16343
+ this.#residentBlobRevision !== closePublicationRevision.residentBlob;
16344
+ if (mutationDuringWriterClose) {
16345
+ this.#needsFullRewriteOnNextPersist = true;
16346
+ if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
16347
+ }
16348
+ const lateRewriteDebt =
16349
+ (this.#needsFullRewriteOnNextPersist || this.#strictResumeMutationPending) &&
16350
+ (!this.#readOnlyResume || this.#strictResumeMutationPending);
16351
+ if (!observedPersistError && outcome.kind === "closed" && !this.#persistWriter && lateRewriteDebt) {
15666
16352
  try {
15667
- await writer.close();
15668
- } catch {
15669
- // Outcome is captured from the underlying writer's close state below.
16353
+ await this.#rewriteFileContents();
16354
+ if (this.#strictResumeMutationPending && this.#readOnlyResume && this.#sessionFile) {
16355
+ writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
16356
+ this.#readOnlyResume = false;
16357
+ }
16358
+ this.#strictResumeMutationPending = false;
16359
+ } catch (error) {
16360
+ observedPersistError = toError(error);
16361
+ outcome = { kind: "close_failed_retryable", error: observedPersistError };
15670
16362
  }
15671
- outcome = this.#closeOutcomeFromWriter(writer);
15672
- if (outcome.kind === "closed") {
15673
- this.#flushed = true;
15674
- // Confirmed closed: release writer ownership.
15675
- this.#persistWriter = undefined;
15676
- this.#persistWriterPath = undefined;
15677
- } else if (outcome.kind === "close_unknown") {
15678
- // Quarantined (terminal): release ownership so no retry/finalizer
15679
- // touches the uncertain fd again.
15680
- this.#persistWriter = undefined;
15681
- this.#persistWriterPath = undefined;
15682
- }
15683
- // close_failed_retryable: RETAIN the writer so a later closeStrict() call
15684
- // can actually re-dispatch the OS close (ownership stays proven). The
15685
- // wrapper must not manufacture success or surrender a retryable fd.
15686
- });
16363
+ }
16364
+ const unpublishedMutation =
16365
+ this.persist &&
16366
+ (this.#strictResumeMutationPending || (this.#needsFullRewriteOnNextPersist && !this.#readOnlyResume));
15687
16367
  // Only tear down the resident blob store on a terminal outcome; a retryable
15688
16368
  // close leaves the session live for a genuine retry.
15689
- if (!this.#persistWriter) {
16369
+ if (
16370
+ !observedPersistError &&
16371
+ !this.#persistWriter &&
16372
+ (!this.persist ||
16373
+ (this.#readOnlyResume && !this.#strictResumeMutationPending) ||
16374
+ (!this.#needsFullRewriteOnNextPersist && !this.#strictResumeMutationPending))
16375
+ ) {
15690
16376
  this.#releaseResidentTextStore();
15691
16377
  this.#retireEphemeralArtifacts();
15692
16378
  try {
@@ -15697,6 +16383,15 @@ export class SessionManager {
15697
16383
  if (this.#preparedNewSessions.size === 0) this.#releaseOwnedManagedAuthority();
15698
16384
  this.#releaseClosedSessionState();
15699
16385
  }
16386
+ if (!observedPersistError && !this.#persistWriter && unpublishedMutation && outcome.kind === "closed") {
16387
+ outcome = {
16388
+ kind: "close_failed_retryable",
16389
+ error: new Error("Session manager has unpublished state."),
16390
+ };
16391
+ }
16392
+ if (observedPersistError && outcome.kind === "closed") {
16393
+ return { kind: "close_unknown", error: observedPersistError };
16394
+ }
15700
16395
  return outcome;
15701
16396
  }
15702
16397
 
@@ -16575,6 +17270,49 @@ export class SessionManager {
16575
17270
  return this.#sessionName;
16576
17271
  }
16577
17272
 
17273
+ isSessionStarred(): boolean {
17274
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
17275
+ return header?.starredPatchVersion === 1 && header.starred === true;
17276
+ }
17277
+
17278
+ async setSessionStarred(starred: boolean): Promise<boolean> {
17279
+ this.#assertRecoveryHydrationWritable();
17280
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
17281
+ if (!header || this.isSessionStarred() === starred) return false;
17282
+ await this.#ensureStarPatchCapability(header);
17283
+ const previous = header.starred;
17284
+ try {
17285
+ await this.#appendHeaderPatch({ starred });
17286
+ return true;
17287
+ } catch (error) {
17288
+ if (previous === undefined) delete header.starred;
17289
+ else header.starred = previous;
17290
+ this.#headerExportRevision++;
17291
+ throw error;
17292
+ }
17293
+ }
17294
+
17295
+ async #ensureStarPatchCapability(header: SessionHeader): Promise<void> {
17296
+ if (header.starredPatchVersion === 1) return;
17297
+ const previousStarred = header.starred;
17298
+ const previousRewriteRequired = this.#needsFullRewriteOnNextPersist;
17299
+ header.starredPatchVersion = 1;
17300
+ delete header.starred;
17301
+ this.#headerExportRevision++;
17302
+ if (!this.persist || !this.#sessionFile || !this.#storage.existsSync(this.#sessionFile)) return;
17303
+ this.#needsFullRewriteOnNextPersist = true;
17304
+ try {
17305
+ await this.#persistPatches([{ type: "header_patch", patch: {} }]);
17306
+ } catch (error) {
17307
+ delete header.starredPatchVersion;
17308
+ if (previousStarred === undefined) delete header.starred;
17309
+ else header.starred = previousStarred;
17310
+ this.#needsFullRewriteOnNextPersist = previousRewriteRequired;
17311
+ this.#headerExportRevision++;
17312
+ throw error;
17313
+ }
17314
+ }
17315
+
16578
17316
  /** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
16579
17317
  static #sanitizeName(name: string): string {
16580
17318
  return name
@@ -16591,6 +17329,7 @@ export class SessionManager {
16591
17329
  */
16592
17330
  #assertRecoveryHydrationWritable(): void {
16593
17331
  if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
17332
+ if (this.#strictClosePending) throw new Error("Session manager is closing.");
16594
17333
  }
16595
17334
  async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
16596
17335
  this.#assertRecoveryHydrationWritable();
@@ -16606,12 +17345,25 @@ export class SessionManager {
16606
17345
  return true;
16607
17346
  }
16608
17347
 
16609
- async #appendHeaderPatch(patch: HeaderPatchRecord["patch"]): Promise<void> {
17348
+ async #appendHeaderPatch(
17349
+ patch: HeaderPatchRecord["patch"],
17350
+ options?: { allowStrictClose?: boolean },
17351
+ ): Promise<void> {
17352
+ if (options?.allowStrictClose) {
17353
+ if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
17354
+ } else this.#assertRecoveryHydrationWritable();
16610
17355
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
16611
17356
  if (!header) return;
16612
17357
  applyHeaderPatch(header, patch);
16613
17358
  this.#headerExportRevision++;
16614
- await this.#persistPatch({ type: "header_patch", patch });
17359
+ const records: HeaderPatchRecord[] =
17360
+ header.starredPatchVersion === 1 && patch.starred === undefined
17361
+ ? [
17362
+ { type: "header_patch", patch: { starred: header.starred === true } },
17363
+ { type: "header_patch", patch },
17364
+ ]
17365
+ : [{ type: "header_patch", patch }];
17366
+ await this.#persistPatches(records);
16615
17367
  }
16616
17368
 
16617
17369
  #appendManagedRecordsSync(records: readonly (FileEntry | SessionPatchRecord)[]): void {
@@ -16644,10 +17396,6 @@ export class SessionManager {
16644
17396
  });
16645
17397
  }
16646
17398
 
16647
- async #persistPatch(record: SessionPatchRecord): Promise<void> {
16648
- await this.#persistPatches([record]);
16649
- }
16650
-
16651
17399
  async #persistPatches(records: readonly SessionPatchRecord[]): Promise<void> {
16652
17400
  if (records.length === 0) return;
16653
17401
  if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
@@ -16714,6 +17462,7 @@ export class SessionManager {
16714
17462
  }
16715
17463
  _persist(entry: SessionEntry): void {
16716
17464
  if (!this.persist || !this.#sessionFile) return;
17465
+ if (this.#strictClosePending) throw new Error("Session manager is closing.");
16717
17466
  const publishResumeBreadcrumb = this.#readOnlyResume;
16718
17467
  if (this.#persistError) throw this.#persistError;
16719
17468
 
@@ -17302,6 +18051,7 @@ export class SessionManager {
17302
18051
  */
17303
18052
  applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
17304
18053
  this.#assertRecoveryHydrationWritable();
18054
+ if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
17305
18055
  this.#deactivateColdForBranchMutation();
17306
18056
  for (const updated of entries) {
17307
18057
  const canonical = this.#byId.get(updated.id);
@@ -17323,6 +18073,7 @@ export class SessionManager {
17323
18073
  options: { preserveEvictedContent?: boolean } = {},
17324
18074
  ): void {
17325
18075
  this.#assertRecoveryHydrationWritable();
18076
+ if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
17326
18077
  this.#deactivateColdForBranchMutation();
17327
18078
  for (const updated of entries) {
17328
18079
  const canonical = this.#byId.get(updated.id);
@@ -17350,6 +18101,7 @@ export class SessionManager {
17350
18101
  * boundary for both managed and explicit persistent destinations.
17351
18102
  */
17352
18103
  async recoverPersistenceFailure(): Promise<void> {
18104
+ this.#assertRecoveryHydrationWritable();
17353
18105
  const persistenceError = this.#persistError;
17354
18106
  if (!persistenceError) return;
17355
18107
  if (!this.#sessionFile) throw persistenceError;
@@ -18501,6 +19253,7 @@ export class SessionManager {
18501
19253
  const header: SessionHeader = {
18502
19254
  type: "session",
18503
19255
  version: CURRENT_SESSION_VERSION,
19256
+ starredPatchVersion: 1,
18504
19257
  id: newSessionId,
18505
19258
  timestamp,
18506
19259
  cwd: this.cwd,