@gajae-code/coding-agent 0.16.0 → 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 (163) hide show
  1. package/CHANGELOG.md +41 -1
  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/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. 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,10 +7409,16 @@ 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;
7302
7417
  #cwdMoveAdmissionClosed = false;
7418
+ #pendingCwdMoveAdmissions = new Set<AbortController>();
7419
+ #cwdMoveAdmittedAdmissions = new Set<AbortController>();
7420
+ #cwdMoveAdmittedOwners = new Set<symbol>();
7421
+ #cwdMoveAdmittedOwnerCounts = new Map<symbol, number>();
7303
7422
  #cwdReadLeaseOwner = Symbol("cwd-read-lease-owner");
7304
7423
  #cwdGeneration = 0;
7305
7424
  /**
@@ -8165,6 +8284,7 @@ export class SessionManager {
8165
8284
  header: {
8166
8285
  type: "session",
8167
8286
  version: CURRENT_SESSION_VERSION,
8287
+ starredPatchVersion: 1,
8168
8288
  id: sessionId,
8169
8289
  timestamp,
8170
8290
  cwd: this.cwd,
@@ -9721,6 +9841,7 @@ export class SessionManager {
9721
9841
  const header: SessionHeader = {
9722
9842
  type: "session",
9723
9843
  version: CURRENT_SESSION_VERSION,
9844
+ starredPatchVersion: 1,
9724
9845
  id: sessionId,
9725
9846
  timestamp,
9726
9847
  cwd: this.cwd,
@@ -9929,6 +10050,7 @@ export class SessionManager {
9929
10050
  const header: SessionHeader = {
9930
10051
  type: "session",
9931
10052
  version: CURRENT_SESSION_VERSION,
10053
+ starredPatchVersion: 1,
9932
10054
  id: sessionId,
9933
10055
  title: this.#sessionName,
9934
10056
  titleSource: this.#titleSource,
@@ -9997,6 +10119,7 @@ export class SessionManager {
9997
10119
  const header: SessionHeader = {
9998
10120
  type: "session",
9999
10121
  version: CURRENT_SESSION_VERSION,
10122
+ starredPatchVersion: 1,
10000
10123
  id: sessionId,
10001
10124
  timestamp,
10002
10125
  cwd: this.cwd,
@@ -10319,6 +10442,7 @@ export class SessionManager {
10319
10442
  const newHeader: SessionHeader = {
10320
10443
  type: "session",
10321
10444
  version: CURRENT_SESSION_VERSION,
10445
+ starredPatchVersion: 1,
10322
10446
  id: newSessionId,
10323
10447
  title: oldHeader?.title ?? this.#sessionName,
10324
10448
  titleSource: oldHeader?.titleSource ?? this.#titleSource,
@@ -10691,11 +10815,32 @@ export class SessionManager {
10691
10815
  * only for the async context that already owns the lock — unrelated callers
10692
10816
  * queue on the tail instead of skipping it.
10693
10817
  */
10694
- async runExclusiveCwdTransition<T>(fn: () => Promise<T>): Promise<T> {
10818
+ async runExclusiveCwdTransition<T>(fn: () => Promise<T>, options?: { signal?: AbortSignal }): Promise<T> {
10695
10819
  const owner = this.#cwdTransitionOwner;
10696
10820
  if (owner !== undefined && cwdTransitionAls.getStore() === owner) {
10697
10821
  return fn();
10698
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
+ };
10699
10844
  const previous = this.#cwdTransitionTail;
10700
10845
  const { promise, resolve } = Promise.withResolvers<void>();
10701
10846
  this.#cwdTransitionTail = previous.then(
@@ -10707,11 +10852,11 @@ export class SessionManager {
10707
10852
  this.#cwdWriterPending += 1;
10708
10853
  const token = Symbol("cwd-transition");
10709
10854
  try {
10710
- await previous.catch(() => {});
10855
+ await waitAbortably(previous.catch(() => {}));
10711
10856
  // A reader that entered before this writer was announced still holds the
10712
10857
  // old cwd; the transition may not commit until every such lease is
10713
10858
  // released, otherwise an in-flight tool resolves paths across the move.
10714
- while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
10859
+ while (this.#cwdReaderCount > 0) await waitAbortably(this.#cwdReadersIdle);
10715
10860
  this.#cwdTransitionOwner = token;
10716
10861
  return await cwdTransitionAls.run(token, fn);
10717
10862
  } finally {
@@ -10721,6 +10866,90 @@ export class SessionManager {
10721
10866
  }
10722
10867
  }
10723
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
+
10724
10953
  /**
10725
10954
  * Run `fn` under a shared read lease on `cwd`.
10726
10955
  *
@@ -10734,14 +10963,39 @@ export class SessionManager {
10734
10963
  async runWithCwdReadLease<T>(fn: () => Promise<T>): Promise<T> {
10735
10964
  const activeReadLease = cwdReadLeaseAls.getStore();
10736
10965
  // Nested tool dispatch (for example eval -> tool bridge) inherits the
10737
- // outer lease's async context. Re-entering that lease must not wait behind
10738
- // a writer that is already queued: the writer is waiting for the outer
10739
- // lease, and waiting here would deadlock the session permanently.
10740
- 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
+ }
10741
10994
  const owner = this.#cwdTransitionOwner;
10742
10995
  // The writer's own async context already holds exclusive access; taking a
10743
10996
  // read lease there would wait on itself.
10744
10997
  if (owner !== undefined && cwdTransitionAls.getStore() === owner) return fn();
10998
+ if (this.#strictClosePending || this.#cwdMoveAdmissionClosed) throw new Error("Session manager is closing.");
10745
10999
  while (this.#cwdWriterPending > 0) await this.#cwdTransitionTail.catch(() => {});
10746
11000
  if (this.#cwdReaderCount === 0) {
10747
11001
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -10749,18 +11003,29 @@ export class SessionManager {
10749
11003
  this.#cwdReadersDrained = resolve;
10750
11004
  }
10751
11005
  this.#cwdReaderCount += 1;
10752
- 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;
10753
11015
  return cwdReadLeaseAls.run(readLease, async () => {
10754
11016
  try {
10755
11017
  return await fn();
10756
11018
  } finally {
10757
- readLease.active = false;
10758
- this.#cwdReaderCount -= 1;
10759
- if (this.#cwdReaderCount === 0) {
10760
- const drained = this.#cwdReadersDrained;
10761
- this.#cwdReadersDrained = undefined;
10762
- 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
+ }
10763
11027
  }
11028
+ readLease.released = true;
10764
11029
  }
10765
11030
  });
10766
11031
  }
@@ -10770,15 +11035,36 @@ export class SessionManager {
10770
11035
  await this.#cwdTransitionTail;
10771
11036
  }
10772
11037
 
11038
+ async joinCwdReaders(): Promise<void> {
11039
+ while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
11040
+ }
11041
+
10773
11042
  async closeCwdMoveAdmission(): Promise<void> {
10774
- await this.runExclusiveCwdTransition(async () => {
10775
- this.#cwdMoveAdmissionClosed = true;
10776
- });
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
+ }
10777
11055
  }
10778
11056
  getCwdGeneration(): number {
10779
11057
  return this.#cwdGeneration;
10780
11058
  }
10781
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
+
10782
11068
  /**
10783
11069
  * Register a listener invoked after every committed `moveTo`. Returns an idempotent
10784
11070
  * unregister handle. Every move surface funnels through `moveTo`, so a single
@@ -10976,10 +11262,13 @@ export class SessionManager {
10976
11262
  sourceHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
10977
11263
  },
10978
11264
  ): Promise<void> {
11265
+ if (this.#strictClosePending && !this.#ownsCwdTransition()) throw new Error("Session manager is closing.");
10979
11266
  if (!this.#ownsCwdTransition()) {
10980
- return this.runExclusiveCwdTransition(() => this.moveTo(newCwd, options));
11267
+ return this.runExclusiveCwdMoveTransition(() => this.moveTo(newCwd, options));
10981
11268
  }
10982
- if (this.#cwdMoveAdmissionClosed) throw new Error("Session cwd move admission is closed.");
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.");
10983
11272
  if (!options?.sourceHandle) {
10984
11273
  const sourceHandle = await SessionManager.openNoFollowDirectory(this.cwd);
10985
11274
  try {
@@ -11366,6 +11655,12 @@ export class SessionManager {
11366
11655
  }
11367
11656
  }
11368
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
+ }
11369
11664
  this.cwd = resolvedCwd;
11370
11665
  this.sessionDir = newSessionDir;
11371
11666
  this.destination = nextDestination;
@@ -11384,13 +11679,13 @@ export class SessionManager {
11384
11679
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
11385
11680
  try {
11386
11681
  if (this.persist && this.#sessionFile && hadSessionFile) {
11387
- await this.#appendHeaderPatch({ cwd: resolvedCwd });
11682
+ await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
11388
11683
  await this.#rewriteFile();
11389
11684
  } else if (this.persist && this.#sessionFile && (hasAssistant || (!hadSessionFile && hadPersistedSession))) {
11390
- await this.#appendHeaderPatch({ cwd: resolvedCwd });
11685
+ await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
11391
11686
  await this.#rewriteFile();
11392
11687
  } else {
11393
- await this.#appendHeaderPatch({ cwd: resolvedCwd });
11688
+ await this.#appendHeaderPatch({ cwd: resolvedCwd }, { allowStrictClose: true });
11394
11689
  }
11395
11690
  } catch (error) {
11396
11691
  residentTransition?.dispose();
@@ -15797,6 +16092,7 @@ export class SessionManager {
15797
16092
  * Used by ACP mode where session/new must create a discoverable session immediately.
15798
16093
  */
15799
16094
  async ensureOnDisk(): Promise<void> {
16095
+ this.#assertRecoveryHydrationWritable();
15800
16096
  if (!this.persist || !this.#sessionFile) return;
15801
16097
  if (this.#readOnlyResume) return;
15802
16098
  if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
@@ -15906,7 +16202,9 @@ export class SessionManager {
15906
16202
 
15907
16203
  /** Close the persistent writer after flushing all pending data. */
15908
16204
  async close(): Promise<void> {
16205
+ await this.closeCwdMoveAdmission();
15909
16206
  await this.joinCwdTransition();
16207
+ await this.joinCwdReaders();
15910
16208
  SessionManager.releaseProcessCwdOwnership(this);
15911
16209
  // Drain any uncommitted prepared successors before releasing resources so
15912
16210
  // dispose/shutdown retains exact cleanup authority (#3138).
@@ -15948,10 +16246,16 @@ export class SessionManager {
15948
16246
  }
15949
16247
  /** Flush while open, then strictly close; retryable close skips the invalid second flush. */
15950
16248
  async flushAndCloseStrict(): Promise<SessionManagerCloseOutcome> {
16249
+ let priorFlushError: Error | undefined;
15951
16250
  if (this.#persistWriter?.getCloseState() !== "close_failed_retryable") {
15952
- await this.flush();
16251
+ try {
16252
+ await this.flush();
16253
+ } catch (error) {
16254
+ priorFlushError = toError(error);
16255
+ }
15953
16256
  }
15954
- return this.closeStrict();
16257
+ const outcome = await this.closeStrict();
16258
+ return priorFlushError && outcome.kind === "closed" ? { kind: "close_unknown", error: priorFlushError } : outcome;
15955
16259
  }
15956
16260
 
15957
16261
  /**
@@ -15961,45 +16265,114 @@ export class SessionManager {
15961
16265
  * writer closure before any destructive operation.
15962
16266
  */
15963
16267
  async closeStrict(): Promise<SessionManagerCloseOutcome> {
16268
+ this.#strictClosePending = true;
16269
+ await this.closeCwdMoveAdmission();
16270
+ await this.joinCwdTransition();
16271
+ await this.joinCwdReaders();
15964
16272
  // Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
16273
+ let preparedCleanupError: Error | undefined;
15965
16274
  try {
15966
16275
  await this.#retryPreparedNewSessionCleanups();
15967
16276
  } catch (error) {
16277
+ preparedCleanupError = toError(error);
15968
16278
  logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
15969
- error: toError(error).message,
16279
+ error: preparedCleanupError.message,
15970
16280
  });
15971
16281
  }
15972
- let outcome: SessionManagerCloseOutcome = { kind: "closed" };
15973
- await this.#queuePersistTask(async () => {
15974
- const writer = this.#persistWriter;
15975
- if (!writer) {
15976
- this.#flushed = true;
15977
- 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 };
15978
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) {
15979
16352
  try {
15980
- await writer.close();
15981
- } catch {
15982
- // 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 };
15983
16362
  }
15984
- outcome = this.#closeOutcomeFromWriter(writer);
15985
- if (outcome.kind === "closed") {
15986
- this.#flushed = true;
15987
- // Confirmed closed: release writer ownership.
15988
- this.#persistWriter = undefined;
15989
- this.#persistWriterPath = undefined;
15990
- } else if (outcome.kind === "close_unknown") {
15991
- // Quarantined (terminal): release ownership so no retry/finalizer
15992
- // touches the uncertain fd again.
15993
- this.#persistWriter = undefined;
15994
- this.#persistWriterPath = undefined;
15995
- }
15996
- // close_failed_retryable: RETAIN the writer so a later closeStrict() call
15997
- // can actually re-dispatch the OS close (ownership stays proven). The
15998
- // wrapper must not manufacture success or surrender a retryable fd.
15999
- });
16363
+ }
16364
+ const unpublishedMutation =
16365
+ this.persist &&
16366
+ (this.#strictResumeMutationPending || (this.#needsFullRewriteOnNextPersist && !this.#readOnlyResume));
16000
16367
  // Only tear down the resident blob store on a terminal outcome; a retryable
16001
16368
  // close leaves the session live for a genuine retry.
16002
- if (!this.#persistWriter) {
16369
+ if (
16370
+ !observedPersistError &&
16371
+ !this.#persistWriter &&
16372
+ (!this.persist ||
16373
+ (this.#readOnlyResume && !this.#strictResumeMutationPending) ||
16374
+ (!this.#needsFullRewriteOnNextPersist && !this.#strictResumeMutationPending))
16375
+ ) {
16003
16376
  this.#releaseResidentTextStore();
16004
16377
  this.#retireEphemeralArtifacts();
16005
16378
  try {
@@ -16010,6 +16383,15 @@ export class SessionManager {
16010
16383
  if (this.#preparedNewSessions.size === 0) this.#releaseOwnedManagedAuthority();
16011
16384
  this.#releaseClosedSessionState();
16012
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
+ }
16013
16395
  return outcome;
16014
16396
  }
16015
16397
 
@@ -16888,6 +17270,49 @@ export class SessionManager {
16888
17270
  return this.#sessionName;
16889
17271
  }
16890
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
+
16891
17316
  /** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
16892
17317
  static #sanitizeName(name: string): string {
16893
17318
  return name
@@ -16904,6 +17329,7 @@ export class SessionManager {
16904
17329
  */
16905
17330
  #assertRecoveryHydrationWritable(): void {
16906
17331
  if (this.#recoveryHydrationContext) throw new Error("recovery_hydration_not_promoted");
17332
+ if (this.#strictClosePending) throw new Error("Session manager is closing.");
16907
17333
  }
16908
17334
  async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
16909
17335
  this.#assertRecoveryHydrationWritable();
@@ -16919,12 +17345,25 @@ export class SessionManager {
16919
17345
  return true;
16920
17346
  }
16921
17347
 
16922
- 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();
16923
17355
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
16924
17356
  if (!header) return;
16925
17357
  applyHeaderPatch(header, patch);
16926
17358
  this.#headerExportRevision++;
16927
- 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);
16928
17367
  }
16929
17368
 
16930
17369
  #appendManagedRecordsSync(records: readonly (FileEntry | SessionPatchRecord)[]): void {
@@ -16957,10 +17396,6 @@ export class SessionManager {
16957
17396
  });
16958
17397
  }
16959
17398
 
16960
- async #persistPatch(record: SessionPatchRecord): Promise<void> {
16961
- await this.#persistPatches([record]);
16962
- }
16963
-
16964
17399
  async #persistPatches(records: readonly SessionPatchRecord[]): Promise<void> {
16965
17400
  if (records.length === 0) return;
16966
17401
  if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
@@ -17027,6 +17462,7 @@ export class SessionManager {
17027
17462
  }
17028
17463
  _persist(entry: SessionEntry): void {
17029
17464
  if (!this.persist || !this.#sessionFile) return;
17465
+ if (this.#strictClosePending) throw new Error("Session manager is closing.");
17030
17466
  const publishResumeBreadcrumb = this.#readOnlyResume;
17031
17467
  if (this.#persistError) throw this.#persistError;
17032
17468
 
@@ -17615,6 +18051,7 @@ export class SessionManager {
17615
18051
  */
17616
18052
  applyEntryMessageUpdates(entries: readonly SessionMessageEntry[]): void {
17617
18053
  this.#assertRecoveryHydrationWritable();
18054
+ if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
17618
18055
  this.#deactivateColdForBranchMutation();
17619
18056
  for (const updated of entries) {
17620
18057
  const canonical = this.#byId.get(updated.id);
@@ -17636,6 +18073,7 @@ export class SessionManager {
17636
18073
  options: { preserveEvictedContent?: boolean } = {},
17637
18074
  ): void {
17638
18075
  this.#assertRecoveryHydrationWritable();
18076
+ if (this.#readOnlyResume) this.#strictResumeMutationPending = true;
17639
18077
  this.#deactivateColdForBranchMutation();
17640
18078
  for (const updated of entries) {
17641
18079
  const canonical = this.#byId.get(updated.id);
@@ -17663,6 +18101,7 @@ export class SessionManager {
17663
18101
  * boundary for both managed and explicit persistent destinations.
17664
18102
  */
17665
18103
  async recoverPersistenceFailure(): Promise<void> {
18104
+ this.#assertRecoveryHydrationWritable();
17666
18105
  const persistenceError = this.#persistError;
17667
18106
  if (!persistenceError) return;
17668
18107
  if (!this.#sessionFile) throw persistenceError;
@@ -18814,6 +19253,7 @@ export class SessionManager {
18814
19253
  const header: SessionHeader = {
18815
19254
  type: "session",
18816
19255
  version: CURRENT_SESSION_VERSION,
19256
+ starredPatchVersion: 1,
18817
19257
  id: newSessionId,
18818
19258
  timestamp,
18819
19259
  cwd: this.cwd,