@gajae-code/coding-agent 0.16.7 → 0.17.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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -1286,14 +1286,14 @@ export class EphemeralBlobStore extends BlobStore {
1286
1286
  return new EphemeralBlobStore(dir, { adoptVerifiedDir: true, disposalParentDescriptor: parentDescriptor });
1287
1287
  }
1288
1288
 
1289
- #cachePut(hash: string, data: Buffer): void {
1289
+ #cachePut(hash: string, data: Buffer, ownership?: typeof TAKE_BLOB_BUFFER_OWNERSHIP): void {
1290
1290
  const existing = this.#bufferCache.get(hash);
1291
1291
  if (existing) {
1292
1292
  this.#bufferCache.delete(hash);
1293
1293
  this.#bufferCacheBytes -= existing.byteLength;
1294
1294
  }
1295
1295
  if (data.byteLength > EphemeralBlobStore.#BUFFER_CACHE_MAX_BYTES) return;
1296
- this.#bufferCache.set(hash, data);
1296
+ this.#bufferCache.set(hash, ownership === TAKE_BLOB_BUFFER_OWNERSHIP ? data : Buffer.from(data));
1297
1297
  this.#bufferCacheBytes += data.byteLength;
1298
1298
  for (const [oldHash, oldData] of this.#bufferCache) {
1299
1299
  if (this.#bufferCacheBytes <= EphemeralBlobStore.#BUFFER_CACHE_MAX_BYTES) break;
@@ -1309,18 +1309,18 @@ export class EphemeralBlobStore extends BlobStore {
1309
1309
 
1310
1310
  putSync(data: Buffer, ownership?: typeof TAKE_BLOB_BUFFER_OWNERSHIP): BlobPutResult {
1311
1311
  const result = this.#adoptedVerifiedDir ? putResidentCacheBlobSync(this.dir, data) : super.putSync(data);
1312
- this.#cachePut(result.hash, ownership === TAKE_BLOB_BUFFER_OWNERSHIP ? data : Buffer.from(data));
1312
+ this.#cachePut(result.hash, data, ownership);
1313
1313
  return result;
1314
1314
  }
1315
1315
 
1316
1316
  putImmutableSync(data: Buffer): CheckedBlobPutResult {
1317
1317
  if (this.#adoptedVerifiedDir) {
1318
1318
  const result = putResidentCacheBlobSync(this.dir, data);
1319
- this.#cachePut(result.hash, Buffer.from(data));
1319
+ this.#cachePut(result.hash, data);
1320
1320
  return makeBlobPutResult(result.hash, result.path, data.byteLength);
1321
1321
  }
1322
1322
  const result = super.putImmutableSync(data);
1323
- this.#cachePut(result.hash, Buffer.from(data));
1323
+ this.#cachePut(result.hash, data);
1324
1324
  return result;
1325
1325
  }
1326
1326
 
@@ -1347,7 +1347,7 @@ export class EphemeralBlobStore extends BlobStore {
1347
1347
  const data = this.#adoptedVerifiedDir
1348
1348
  ? readVerifiedResidentCacheBlobSync(hash, path.join(this.dir, hash))
1349
1349
  : super.getSync(hash);
1350
- if (data) this.#cachePut(hash, Buffer.from(data));
1350
+ if (data) this.#cachePut(hash, data);
1351
1351
  return data;
1352
1352
  }
1353
1353
 
@@ -1363,7 +1363,7 @@ export class EphemeralBlobStore extends BlobStore {
1363
1363
  getCheckedSync(hash: string): Buffer | null {
1364
1364
  if (this.#adoptedVerifiedDir) return this.getSync(hash);
1365
1365
  const data = super.getCheckedSync(hash);
1366
- if (data) this.#cachePut(hash, Buffer.from(data));
1366
+ if (data) this.#cachePut(hash, data);
1367
1367
  return data;
1368
1368
  }
1369
1369
 
@@ -1478,9 +1478,9 @@ export class MemoryBlobStore extends BlobStore {
1478
1478
  return this.putSync(data);
1479
1479
  }
1480
1480
 
1481
- putSync(data: Buffer): BlobPutResult {
1481
+ putSync(data: Buffer, ownership?: typeof TAKE_BLOB_BUFFER_OWNERSHIP): BlobPutResult {
1482
1482
  const hash = sha256Hex(data);
1483
- this.#store(hash, Buffer.from(data));
1483
+ this.#store(hash, ownership === TAKE_BLOB_BUFFER_OWNERSHIP ? data : Buffer.from(data));
1484
1484
  return makeBlobPutResult(hash, `memory:${hash}`);
1485
1485
  }
1486
1486
 
@@ -242,13 +242,11 @@ export function cappedExponentialWithFullJitter(
242
242
  /**
243
243
  * Legacy auto-compaction retry delay.
244
244
  *
245
- * Deliberately the mirror image of `effectiveFallbackDelay`: this path recovers
245
+ * Unlike managed fallback, this path recovers
246
246
  * Retry-After by regex over provider error prose (`#parseRetryAfterMsFromError`),
247
247
  * so it follows the documented legacy rule — `retry.maxDelayMs` caps every
248
248
  * legacy session retry delay, including provider retry-after hints. Managed
249
- * fallback stays uncapped because it retries within its own per-entry budget;
250
- * compaction has no such budget, so the final candidate would otherwise sleep
251
- * for the full server-suggested duration.
249
+ * fallback ignores these hints when scheduling its attempt-based retries.
252
250
  *
253
251
  * `maxDelayMs <= 0` means "no cap", matching `cappedExponentialWithFullJitter`.
254
252
  * A missing, NaN, or infinite hint collapses to "no usable hint".
@@ -303,13 +301,11 @@ export function describeCompactionCandidateFailures(
303
301
  );
304
302
  }
305
303
 
306
- /** Retry-After is intentionally uncapped. */
307
304
  export function effectiveFallbackDelay(
308
305
  baseDelayMs: number,
309
306
  maxDelayMs: number,
310
307
  attemptK: number,
311
- retryAfterMs: number | undefined,
312
308
  random: () => number = Math.random,
313
309
  ): number {
314
- return Math.max(cappedExponentialWithFullJitter(baseDelayMs, maxDelayMs, attemptK, random), retryAfterMs ?? 0);
310
+ return cappedExponentialWithFullJitter(baseDelayMs, maxDelayMs, attemptK, random);
315
311
  }
@@ -1000,31 +1000,446 @@ function assertOwnerOnlyApplied(pathname: string, kind: "directory" | "file"): v
1000
1000
  }
1001
1001
 
1002
1002
  /**
1003
- * Mode-only walk for managed-scope prepare self-heal.
1004
- * Throws `mode_mismatch` when any non-symlink descendant has group/other bits.
1003
+ * Bound the synchronous mode-only self-heal walk so `session/new` latency stays
1004
+ * independent of the on-disk size of a scope's `v2-<cwd>` tree. A heavily used
1005
+ * scope holds every past session's transcripts plus resident-cache blobs, and an
1006
+ * unbounded `lstat` walk on every prepare made `session/new` take ~16s on a 12 GB
1007
+ * `~/.gjc` (#5565). This is a defense-in-depth mode self-heal, not the primary
1008
+ * owner-only-on-write guarantee, so the *synchronous* detection is budgeted:
1009
+ * truncating it never blocks launch. Detection walks entries in `readdir` order
1010
+ * (freshest writes land near the top on most filesystems), so the common drift
1011
+ * source is caught in-budget. Anything past the budget is NOT assumed
1012
+ * owner-only — a bounded, streaming background self-heal
1013
+ * (`runDeferredOwnerOnlySelfHeal`) finishes the tail off the critical path.
1014
+ *
1015
+ * The owner-only *guarantee* for managed content is not this walk: it is enforced
1016
+ * per-write by the secured storage helpers (see `openManagedStore`). This walk is
1017
+ * best-effort repair of drift introduced by non-managed writers. Because the tail
1018
+ * repair is asynchronous, a truncated scan does not itself prove the tail is
1019
+ * owner-only; the contract it upholds is that the tail is eventually repaired and
1020
+ * that a *provable* failure of that repair — an unrepairable path
1021
+ * (`pendingDeferredOwnerOnlyRepairFailures`) or an aborted traversal
1022
+ * (`deferredOwnerOnlySelfHealTraversalFailures`) — fails the next prepare closed
1023
+ * rather than reporting success over an uninspected or still-drifted tail.
1024
+ */
1025
+ export const OWNER_ONLY_SELF_HEAL_MAX_ENTRIES = 8192;
1026
+ export const OWNER_ONLY_SELF_HEAL_MAX_DURATION_MS = 500;
1027
+
1028
+ interface OwnerOnlySelfHealBudget {
1029
+ deadline: number;
1030
+ remaining: number;
1031
+ }
1032
+
1033
+ /** A managed directory queued for enumeration, tagged with the identity that classified it. */
1034
+ interface OwnerOnlyDirRef {
1035
+ pathname: string;
1036
+ dev: bigint;
1037
+ ino: bigint;
1038
+ }
1039
+
1040
+ type OwnerOnlyClassification = { kind: "descend"; dev: bigint; ino: bigint } | { kind: "leaf" } | { kind: "skip" };
1041
+
1042
+ /**
1043
+ * Classify a managed descendant, recording group/other-readable drift. For a
1044
+ * directory it also returns the (dev, ino) identity from the same `lstat`, so a
1045
+ * later no-follow re-open can prove the directory was not replaced out from under
1046
+ * the walk before it is enumerated (see `opendirIfUnchanged`).
1047
+ */
1048
+ function classifyOwnerOnlyEntry(pathname: string, drifted: string[]): OwnerOnlyClassification {
1049
+ let stat: fs.BigIntStats;
1050
+ try {
1051
+ stat = fs.lstatSync(pathname, { bigint: true });
1052
+ } catch {
1053
+ return { kind: "skip" };
1054
+ }
1055
+ if (stat.isSymbolicLink()) return { kind: "skip" };
1056
+ if ((stat.mode & 0o077n) !== 0n) drifted.push(pathname);
1057
+ return stat.isDirectory() ? { kind: "descend", dev: stat.dev, ino: stat.ino } : { kind: "leaf" };
1058
+ }
1059
+
1060
+ /**
1061
+ * Open a queued managed directory for enumeration only if it is still the exact
1062
+ * directory the walk classified. A managed scope is same-user writable, so a
1063
+ * concurrent process can replace a queued directory with a symlink (or a
1064
+ * different directory) between the classifying `lstat` and this open — and both
1065
+ * walks pop/yield across that window.
1066
+ *
1067
+ * A bare `lstat(path)` check followed by `opendirSync(path)` is a check-then-use
1068
+ * race: `opendirSync` *follows* a symlink final component, so a pathname swapped
1069
+ * to a symlink after the check enumerates outside the retained scope. Instead,
1070
+ * bind the identity to the descriptor we actually open: `O_NOFOLLOW` refuses a
1071
+ * symlinked final component outright, `O_DIRECTORY` refuses a non-directory, and
1072
+ * the `fstat` on that descriptor (not on a pathname that may already have been
1073
+ * swapped) proves it is the exact directory the walk classified before any entry
1074
+ * is read. Returns `null` on any mismatch or error; the caller skips that
1075
+ * subtree. Windows lacks `O_NOFOLLOW`/`O_DIRECTORY` and reaches this walk only
1076
+ * under the fail-closed verify-first policy, so it keeps the pathname check.
1077
+ */
1078
+ function opendirIfUnchanged(ref: OwnerOnlyDirRef): fs.Dir | null {
1079
+ if (process.platform === "win32") return opendirIfUnchangedByPath(ref);
1080
+ // Prove the opened descriptor's identity no-follow, so a symlink or
1081
+ // different-directory swap of the pathname cannot be opened or enumerated.
1082
+ let fd: number;
1083
+ try {
1084
+ fd = fs.openSync(ref.pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ try {
1089
+ const stat = fs.fstatSync(fd, { bigint: true });
1090
+ if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== ref.dev || stat.ino !== ref.ino) return null;
1091
+ } catch {
1092
+ return null;
1093
+ } finally {
1094
+ fs.closeSync(fd);
1095
+ }
1096
+ // The descriptor proved identity a moment ago; open the enumeration stream and
1097
+ // re-verify no-follow that the pathname still resolves to that same inode, so a
1098
+ // swap in the narrow reopen gap is caught before any entry is enumerated. Entry
1099
+ // names are always rejoined to `ref.pathname` (never a stream-resolved path) by
1100
+ // the caller, so even a transient swap cannot escape the classified directory.
1101
+ let dir: fs.Dir;
1102
+ try {
1103
+ dir = fs.opendirSync(ref.pathname);
1104
+ } catch {
1105
+ return null;
1106
+ }
1107
+ try {
1108
+ const recheck = fs.lstatSync(ref.pathname, { bigint: true });
1109
+ if (recheck.isSymbolicLink() || !recheck.isDirectory() || recheck.dev !== ref.dev || recheck.ino !== ref.ino) {
1110
+ dir.closeSync();
1111
+ return null;
1112
+ }
1113
+ } catch {
1114
+ dir.closeSync();
1115
+ return null;
1116
+ }
1117
+ return dir;
1118
+ }
1119
+
1120
+ /** Windows check-then-open fallback (no `O_NOFOLLOW`/`O_DIRECTORY`); reached only under fail-closed verify-first. */
1121
+ function opendirIfUnchangedByPath(ref: OwnerOnlyDirRef): fs.Dir | null {
1122
+ let stat: fs.BigIntStats;
1123
+ try {
1124
+ stat = fs.lstatSync(ref.pathname, { bigint: true });
1125
+ } catch {
1126
+ return null;
1127
+ }
1128
+ if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== ref.dev || stat.ino !== ref.ino) return null;
1129
+ try {
1130
+ return fs.opendirSync(ref.pathname);
1131
+ } catch {
1132
+ return null;
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Bounded, streaming mode-only walk for managed-scope prepare self-heal.
1138
+ *
1139
+ * Enumerates every directory with a streaming `opendir` iterator instead of
1140
+ * `readdirSync`, so a single flat directory with millions of children never
1141
+ * materializes at once (bounded synchronous latency and memory). Collects the
1142
+ * group/other-readable descendants seen within the entry/time budget rather than
1143
+ * throwing on the first, so the caller can repair exactly those paths.
1144
+ *
1005
1145
  * Avoids snapshotManagedTree("") which races concurrent session writers (#3906).
1146
+ *
1147
+ * `complete` is `false` when the budget was exhausted before the walk finished:
1148
+ * an empty `drifted` then means "none found within budget", not a proven
1149
+ * owner-only tree, and the caller must schedule the deferred tail repair.
1006
1150
  */
1007
- function assertOwnerOnlyModesRecursive(directory: string): void {
1151
+ function collectOwnerOnlyModeDrift(
1152
+ root: string,
1153
+ budget: OwnerOnlySelfHealBudget,
1154
+ ): { drifted: string[]; complete: boolean } {
1155
+ const drifted: string[] = [];
1156
+ const rootClass = classifyOwnerOnlyEntry(root, drifted);
1157
+ if (rootClass.kind !== "descend") return { drifted, complete: true };
1158
+ // Explicit DFS stack of directory refs; only one directory iterator is open at
1159
+ // a time and it is closed before the next is opened. Each ref carries the
1160
+ // identity captured when the directory was classified so `opendirIfUnchanged`
1161
+ // can refuse to follow a concurrent replacement.
1162
+ const stack: OwnerOnlyDirRef[] = [{ pathname: root, dev: rootClass.dev, ino: rootClass.ino }];
1163
+ while (stack.length > 0) {
1164
+ const ref = stack.pop() as OwnerOnlyDirRef;
1165
+ const dir = opendirIfUnchanged(ref);
1166
+ // A null open means this queued directory was replaced, became a symlink, or
1167
+ // grew inaccessible between classification and open: its subtree was never
1168
+ // enumerated, so this scan did NOT cover the tree. Report incomplete — rather
1169
+ // than skipping it as if clean — so the caller schedules the deferred tail
1170
+ // walk and the descendants under `ref` stay unverified until a walk actually
1171
+ // reaches them.
1172
+ if (dir === null) return { drifted, complete: false };
1173
+ try {
1174
+ let entry = dir.readSync();
1175
+ while (entry !== null) {
1176
+ if (budget.remaining <= 0 || Date.now() >= budget.deadline) return { drifted, complete: false };
1177
+ budget.remaining -= 1;
1178
+ const child = path.join(ref.pathname, entry.name);
1179
+ const childClass = classifyOwnerOnlyEntry(child, drifted);
1180
+ if (childClass.kind === "descend")
1181
+ stack.push({ pathname: child, dev: childClass.dev, ino: childClass.ino });
1182
+ entry = dir.readSync();
1183
+ }
1184
+ } finally {
1185
+ dir.closeSync();
1186
+ }
1187
+ }
1188
+ return { drifted, complete: true };
1189
+ }
1190
+
1191
+ /** Re-secure owner-only mode on a single drifted managed path (throws on failure). */
1192
+ function resecureOwnerOnlyPath(pathname: string): void {
1008
1193
  let stat: fs.Stats;
1009
1194
  try {
1010
- stat = fs.lstatSync(directory);
1195
+ stat = fs.lstatSync(pathname);
1011
1196
  } catch {
1012
1197
  return;
1013
1198
  }
1014
1199
  if (stat.isSymbolicLink()) return;
1015
- if ((stat.mode & 0o077) !== 0) throw new Error("mode_mismatch");
1016
- if (!stat.isDirectory()) return;
1017
- let entries: fs.Dirent[];
1200
+ assertOwnerOnlyApplied(pathname, stat.isDirectory() ? "directory" : "file");
1201
+ }
1202
+
1203
+ /**
1204
+ * Best-effort variant for the background walk: a concurrent writer must not crash
1205
+ * it, but a genuine repair failure must not be silently dropped either. For a
1206
+ * descendant past the synchronous budget, this deferred tail is the ONLY
1207
+ * enforcement step, so a failed repair is recorded in `failures` — the next
1208
+ * prepare observes it, retries, and fails closed rather than reporting success
1209
+ * over a descendant that is still group/other-readable.
1210
+ */
1211
+ function resecureOwnerOnlyPathQuietly(pathname: string, failures: string[]): void {
1018
1212
  try {
1019
- entries = fs.readdirSync(directory, { withFileTypes: true });
1020
- } catch {
1021
- return;
1213
+ resecureOwnerOnlyPath(pathname);
1214
+ } catch (error) {
1215
+ failures.push(pathname);
1216
+ logger.debug("Deferred managed-scope owner-only repair failed; will retry on next prepare", {
1217
+ pathname,
1218
+ error: error instanceof Error ? error.message : String(error),
1219
+ });
1022
1220
  }
1023
- for (const entry of entries) {
1024
- assertOwnerOnlyModesRecursive(path.join(directory, entry.name));
1221
+ }
1222
+
1223
+ /** Entries inspected between event-loop yields in the deferred background walk. */
1224
+ const OWNER_ONLY_DEFERRED_SELF_HEAL_SLICE = OWNER_ONLY_SELF_HEAL_MAX_ENTRIES;
1225
+
1226
+ /**
1227
+ * Finish the owner-only self-heal that the budgeted synchronous walk truncated.
1228
+ *
1229
+ * Walks the *entire* scope tree — again with streaming `opendir` enumeration so a
1230
+ * flat high-fan-out directory stays bounded — repairing each drifted descendant
1231
+ * as it is found, and yields to the event loop every slice so it never blocks
1232
+ * `session/new` or the ACP server the way the original unbounded walk did.
1233
+ */
1234
+ async function runDeferredOwnerOnlySelfHeal(root: string, failures: string[]): Promise<void> {
1235
+ // Yield before touching the tree so scheduling never runs a walk slice on the
1236
+ // `session/new` tick — the whole tail repair stays off the critical path.
1237
+ await new Promise<void>(resolve => setImmediate(resolve));
1238
+ const rootDrift: string[] = [];
1239
+ const rootClass = classifyOwnerOnlyEntry(root, rootDrift);
1240
+ for (const pathname of rootDrift) resecureOwnerOnlyPathQuietly(pathname, failures);
1241
+ if (rootClass.kind !== "descend") return;
1242
+ const stack: OwnerOnlyDirRef[] = [{ pathname: root, dev: rootClass.dev, ino: rootClass.ino }];
1243
+ let sliceRemaining = OWNER_ONLY_DEFERRED_SELF_HEAL_SLICE;
1244
+ while (stack.length > 0) {
1245
+ const ref = stack.pop() as OwnerOnlyDirRef;
1246
+ const dir = opendirIfUnchanged(ref);
1247
+ // A null open leaves this directory's descendants unvisited, so the tail this
1248
+ // walk exists to enforce was not fully traversed. Throw — like a `Dir.readSync`
1249
+ // failure below — so the scheduler's `.catch` records a traversal failure
1250
+ // (`traversed = false`) instead of the walk finishing as if the tree were
1251
+ // fully covered.
1252
+ if (dir === null)
1253
+ throw Object.assign(
1254
+ new Error("opendir_null: directory identity changed or became inaccessible during traversal"),
1255
+ { code: "ENOENT" },
1256
+ );
1257
+ try {
1258
+ let entry = dir.readSync();
1259
+ while (entry !== null) {
1260
+ const child = path.join(ref.pathname, entry.name);
1261
+ const childDrift: string[] = [];
1262
+ const childClass = classifyOwnerOnlyEntry(child, childDrift);
1263
+ for (const pathname of childDrift) resecureOwnerOnlyPathQuietly(pathname, failures);
1264
+ if (childClass.kind === "descend")
1265
+ stack.push({ pathname: child, dev: childClass.dev, ino: childClass.ino });
1266
+ if (--sliceRemaining <= 0) {
1267
+ await new Promise<void>(resolve => setImmediate(resolve));
1268
+ sliceRemaining = OWNER_ONLY_DEFERRED_SELF_HEAL_SLICE;
1269
+ }
1270
+ entry = dir.readSync();
1271
+ }
1272
+ } finally {
1273
+ dir.closeSync();
1274
+ }
1025
1275
  }
1026
1276
  }
1027
1277
 
1278
+ /** In-flight deferred self-heals, keyed by resolved scope directory to dedupe. */
1279
+ const pendingDeferredOwnerOnlySelfHeals = new Map<string, Promise<void>>();
1280
+
1281
+ /**
1282
+ * Scopes whose most recent deferred self-heal left at least one descendant it
1283
+ * could not re-secure (resolved scope directory → the still-drifted paths). This
1284
+ * deferred tail is the sole enforcement for descendants past the synchronous
1285
+ * budget, so a failure here must not be discarded: the next prepare for the scope
1286
+ * retries these paths and fails closed if they still cannot be made owner-only.
1287
+ */
1288
+ const pendingDeferredOwnerOnlyRepairFailures = new Map<string, string[]>();
1289
+
1290
+ /**
1291
+ * Scopes whose most recent deferred self-heal aborted on a *traversal*-level
1292
+ * filesystem failure — a `Dir.readSync`/iterator or close error, as opposed to
1293
+ * one path's repair, which `resecureOwnerOnlyPathQuietly` records separately. The
1294
+ * walk then stopped mid-tree, so the tail past the synchronous budget was never
1295
+ * inspected and the traversal must not be recorded as having completed. The scope
1296
+ * stays marked here until a later walk reaches the end of the tree (or a
1297
+ * synchronous scan covers it in full), which also keeps a partial walk from
1298
+ * clearing repair failures it never got far enough to re-examine.
1299
+ */
1300
+ const deferredOwnerOnlySelfHealTraversalFailures = new Set<string>();
1301
+
1302
+ /**
1303
+ * Schedule (at most one per scope) a background self-heal of the tail the budget
1304
+ * skipped.
1305
+ *
1306
+ * Scheduling is deliberately NOT suppressed by a time-based cooldown on the last
1307
+ * completed walk. This is only reached when the synchronous scan truncated, and a
1308
+ * truncated scan is fresh evidence that the tail is unverified *now*: a managed
1309
+ * scope is same-user writable, so a new group/other-readable descendant can land
1310
+ * past the scan prefix moments after a walk completes. Suppressing on elapsed
1311
+ * time alone would let that prepare return success over a tree nobody inspected,
1312
+ * and there is no cheap unchanged-tree signal at the scan boundary that could
1313
+ * prove otherwise — verifying the tail *is* the walk. The in-flight dedupe above
1314
+ * still bounds this to one concurrent traversal per scope.
1315
+ */
1316
+ function scheduleDeferredOwnerOnlySelfHeal(directory: string): void {
1317
+ const key = path.resolve(directory);
1318
+ if (pendingDeferredOwnerOnlySelfHeals.has(key)) return;
1319
+ const failures: string[] = [];
1320
+ let traversed = true;
1321
+ const task = runDeferredOwnerOnlySelfHeal(directory, failures).catch(error => {
1322
+ // The walk stopped mid-tree, so the tail it exists to enforce was never
1323
+ // inspected. Recording this as a completed traversal would both discard that
1324
+ // fact and let the empty `failures` list clear repair failures the aborted
1325
+ // walk never reached.
1326
+ traversed = false;
1327
+ logger.debug("Deferred managed-scope owner-only self-heal failed", {
1328
+ directory,
1329
+ error: error instanceof Error ? error.message : String(error),
1330
+ });
1331
+ });
1332
+ pendingDeferredOwnerOnlySelfHeals.set(key, task);
1333
+ void task.finally(() => {
1334
+ // Preserve an unrepairable tail so the next prepare fails closed; clear any
1335
+ // prior failure only once a walk has actually traversed the whole scope.
1336
+ if (failures.length > 0) pendingDeferredOwnerOnlyRepairFailures.set(key, failures);
1337
+ else if (traversed) pendingDeferredOwnerOnlyRepairFailures.delete(key);
1338
+ if (traversed) deferredOwnerOnlySelfHealTraversalFailures.delete(key);
1339
+ else deferredOwnerOnlySelfHealTraversalFailures.add(key);
1340
+ if (pendingDeferredOwnerOnlySelfHeals.get(key) === task) pendingDeferredOwnerOnlySelfHeals.delete(key);
1341
+ });
1342
+ }
1343
+
1344
+ /** Number of scopes with an in-flight deferred owner-only self-heal (test/shutdown observability). */
1345
+ export function pendingDeferredOwnerOnlySelfHealCount(): number {
1346
+ return pendingDeferredOwnerOnlySelfHeals.size;
1347
+ }
1348
+
1349
+ /** Number of scopes with a recorded deferred owner-only repair failure (test/shutdown observability). */
1350
+ export function pendingDeferredOwnerOnlyRepairFailureCount(): number {
1351
+ return pendingDeferredOwnerOnlyRepairFailures.size;
1352
+ }
1353
+
1354
+ /** Number of scopes whose deferred walk aborted before traversing the tree (test/shutdown observability). */
1355
+ export function pendingDeferredOwnerOnlyTraversalFailureCount(): number {
1356
+ return deferredOwnerOnlySelfHealTraversalFailures.size;
1357
+ }
1358
+
1359
+ /** Await every in-flight deferred owner-only self-heal (deterministic teardown/shutdown). */
1360
+ export async function drainDeferredOwnerOnlySelfHeals(): Promise<void> {
1361
+ while (pendingDeferredOwnerOnlySelfHeals.size > 0) {
1362
+ await Promise.all([...pendingDeferredOwnerOnlySelfHeals.values()]);
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * Detect and repair owner-only mode drift under a prepared managed scope.
1368
+ *
1369
+ * Repair is targeted: only the specific descendants found group/other-readable
1370
+ * within the budget are re-secured — never the whole scope — so one early
1371
+ * readable blob cannot trigger an unbounded re-secure plus another full walk.
1372
+ * When the budget truncates the scan, a bounded background self-heal finishes the
1373
+ * remaining tail so a drifted file beyond the scan prefix is still repaired
1374
+ * rather than silently accepted as owner-only.
1375
+ */
1376
+ export function selfHealOwnerOnlyModeDrift(directory: string, policy: ManagedSessionSecurityPolicy): void {
1377
+ const key = path.resolve(directory);
1378
+ // A prior deferred repair that could not restore owner-only mode is the only
1379
+ // enforcement for its descendants. Retry it before this writer proceeds and
1380
+ // fail closed if it still cannot be secured, so a discarded background repair
1381
+ // never surfaces as an apparently successful prepare (the base implementation
1382
+ // likewise failed closed rather than accepting a group/other-readable tree).
1383
+ const priorFailures = pendingDeferredOwnerOnlyRepairFailures.get(key);
1384
+ if (priorFailures !== undefined) {
1385
+ const stillDrifted: string[] = [];
1386
+ let firstError: unknown;
1387
+ for (const pathname of priorFailures) {
1388
+ try {
1389
+ resecureOwnerOnlyPath(pathname);
1390
+ } catch (error) {
1391
+ stillDrifted.push(pathname);
1392
+ firstError ??= error;
1393
+ }
1394
+ }
1395
+ if (stillDrifted.length > 0) {
1396
+ pendingDeferredOwnerOnlyRepairFailures.set(key, stillDrifted);
1397
+ throw firstError instanceof Error ? firstError : new Error("mode_mismatch");
1398
+ }
1399
+ pendingDeferredOwnerOnlyRepairFailures.delete(key);
1400
+ }
1401
+ // A prior deferred walk that aborted mid-traversal left the tail past the
1402
+ // synchronous budget uninspected — unverified state, not a proven owner-only
1403
+ // tree. Consult it alongside the repair-failure record above: if this prepare's
1404
+ // own synchronous scan also cannot reach the end of the tree (below), reporting
1405
+ // success would accept that uninspected tail. The marker is cleared only by a
1406
+ // walk — synchronous or deferred — that actually traverses to the end.
1407
+ const priorTraversalFailure = deferredOwnerOnlySelfHealTraversalFailures.has(key);
1408
+ const budget: OwnerOnlySelfHealBudget = {
1409
+ deadline: Date.now() + OWNER_ONLY_SELF_HEAL_MAX_DURATION_MS,
1410
+ remaining: OWNER_ONLY_SELF_HEAL_MAX_ENTRIES,
1411
+ };
1412
+ const startedAt = Date.now();
1413
+ const { drifted, complete } = collectOwnerOnlyModeDrift(directory, budget);
1414
+ // A verify-first policy demands proof before trust: it must not self-heal, and
1415
+ // an incomplete scan cannot furnish that proof — fail closed on either.
1416
+ const failClosed = process.platform === "win32" && policy === "windows-existing-verify-first";
1417
+ if (drifted.length > 0) {
1418
+ if (failClosed) throw new Error("mode_mismatch");
1419
+ for (const pathname of drifted) resecureOwnerOnlyPath(pathname);
1420
+ }
1421
+ if (!complete) {
1422
+ if (failClosed) throw new Error("mode_mismatch");
1423
+ scheduleDeferredOwnerOnlySelfHeal(directory);
1424
+ logger.debug("Managed scope owner-only self-heal walk truncated; deferred repair scheduled", {
1425
+ directory,
1426
+ elapsedMs: Date.now() - startedAt,
1427
+ maxDurationMs: OWNER_ONLY_SELF_HEAL_MAX_DURATION_MS,
1428
+ maxEntries: OWNER_ONLY_SELF_HEAL_MAX_ENTRIES,
1429
+ });
1430
+ // The tail is still unverified: a prior deferred walk aborted before reaching
1431
+ // it and this synchronous scan truncated short of it too. Fail closed rather
1432
+ // than report success over an uninspected tail; the deferred walk scheduled
1433
+ // just above will clear the traversal-failure marker once it completes, so a
1434
+ // later prepare recovers on its own.
1435
+ if (priorTraversalFailure) throw new Error("mode_mismatch");
1436
+ return;
1437
+ }
1438
+ // This scan reached the end of the tree, so the tail an earlier aborted deferred
1439
+ // walk left uninspected has now been inspected and repaired synchronously.
1440
+ deferredOwnerOnlySelfHealTraversalFailures.delete(key);
1441
+ }
1442
+
1028
1443
  /**
1029
1444
  * True when a managed setup error reflects a fixable owner-only *mode* drift
1030
1445
  * (group/other permission bits) rather than an ownership or identity change.
@@ -1099,25 +1514,26 @@ export function prepareManagedSessionScopeForWriteSync(
1099
1514
  const next = buildStore();
1100
1515
  // #3951 root-store identity binding no longer walks the managed tree on
1101
1516
  // construct, so mode drift no longer surfaces as mode_mismatch there.
1102
- // Detect group/other-readable descendants with a mode-only walk — do NOT
1103
- // call snapshotManagedTree("") here: that reintroduces concurrent-writer
1104
- // identity_mismatch races (#3906) that break SessionManager.moveTo /move.
1105
- if (retainedAuthority) {
1106
- assertOwnerOnlyModesRecursive(scope.directoryPath);
1107
- }
1517
+ // Detect and repair group/other-readable descendants with a bounded,
1518
+ // streaming mode-only walk — do NOT call snapshotManagedTree("") here:
1519
+ // that reintroduces concurrent-writer identity_mismatch races (#3906)
1520
+ // that break SessionManager.moveTo / move.
1521
+ //
1522
+ // This walk is bounded best-effort defense-in-depth, NOT the owner-only
1523
+ // guarantee itself: every managed write below goes through the secured
1524
+ // storage helpers (`ensureManagedDirectory`, `store.publish*`), which
1525
+ // apply and verify owner-only mode on each object they create, so managed
1526
+ // writes are owner-only at the write boundary regardless of the walk. The
1527
+ // walk only heals drift left by non-managed writers (e.g. the resident
1528
+ // cache) into the scope. A completed synchronous scan holds this prepare
1529
+ // until the whole tree is inspected; a truncated scan hands the tail to a
1530
+ // bounded background self-heal, and a *provable* failure of that deferred
1531
+ // enforcement (an unrepairable path or an aborted traversal) fails the
1532
+ // next prepare closed rather than silently accepting a drifted tail.
1533
+ if (retainedAuthority) selfHealOwnerOnlyModeDrift(scope.directoryPath, policy);
1108
1534
  return next;
1109
1535
  };
1110
- try {
1111
- store = openManagedStore();
1112
- } catch (error) {
1113
- if (process.platform === "win32" && policy === "windows-existing-verify-first") throw error;
1114
- if (!isRecoverableOwnerOnlyModeDrift(error)) throw error;
1115
- // A prior writer left group/other-readable descendants under the scope
1116
- // (e.g. resident-cache blobs written on the explicit session path).
1117
- // Re-secure the tree in place and retry once before failing closed.
1118
- reapplyOwnerOnlyManagedTree(scope.directoryPath);
1119
- store = openManagedStore();
1120
- }
1536
+ store = openManagedStore();
1121
1537
  const binding = new TextEncoder().encode(`${JSON.stringify(bindingFor(scope))}\n`);
1122
1538
  stage = "binding_publish";
1123
1539
  try {