elanous 0.2.31 → 0.2.32

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 (282) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/{1834-50625904996a8fa8.js → 1834-98b64172bc1efe77.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/69-0a2c8209c6d4a158.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8097-9530c03bcc550f02.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/app/approvals/page-504204b58c82c253.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-3535a4a69f1fafaa.js → page-a9717a67c2eecbaf.js} +1 -1
  8. package/apps/pwa/out/_next/static/chunks/app/board/page-5c9ec30aee62dfbf.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/botlab/page-584191533721fa16.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/bots/page-73c4b4bb3ac7ab5f.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/ceo/{page-5a503541353e60a8.js → page-c0f1ca371f710b06.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/chat/{page-6025101384e361a5.js → page-46430d45a9e04eac.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/consult/page-3b2ebf1dc64ff0be.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/control/{page-a645f00a06483e9a.js → page-a27b1e5a0d1b308f.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/decisions/page-25654b82094f7d22.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-e9bab7413b028b28.js → page-8ea49a4c6dd2c6e7.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/editor/page-ce5113aefea0a8d1.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/app/exec/{page-e547e6ce55f1fb33.js → page-0e5a41fac1c75396.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/field/{page-d27e1b9850fd54bf.js → page-31aaf1ed37c6df9a.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/inside/page-3dcc1fa411e1f4e0.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/intake/{page-0fec29c7ba01894b.js → page-c69247e7134d329d.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/{layout-339a07410cd6f36b.js → layout-66dc5cd3a0999abe.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/live/page-26ff7ff651a7217d.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/app/live-run/page-617b3976981efa96.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/loops/{page-2fd93ed8c9f16580.js → page-d256f5d5d9552823.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/market/{page-9b564dbff0fefc35.js → page-ce8f1907b8a6101c.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-33e182a316263116.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-814ba51385225776.js → page-32866485913d6a04.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-397d479626e5a459.js → not-found-7607cca9b84950c6.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/{page-af1bba4e3251b728.js → page-eeab86176b03dc3b.js} +1 -1
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-3ad3be5e7d3fdce9.js → page-5b93dbae3432b996.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-77a6cb90a9cf2b10.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-4da93ad35d4a8d28.js → page-f47e768b5b242b5a.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/page-eb41aa3367fc2505.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/page-4d13ab0b8a267e3f.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/page-84dad32ff0fc0831.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-3a658d5fa6d16a25.js → page-95306f892aa2d0ee.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-0157de6e85f8bb6b.js → page-940fb6c217f6b470.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-0a07363b130182a1.js → page-8c66a3b24a745630.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-841d73d4ac44ed49.js → page-7b675dfd35c36fc9.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-6f43a3d1146e6377.js → page-8bd0d3cf7665bcfc.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/setup/{page-521ee23ac8c0abfa.js → page-c219f2ed171131be.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/share/{page-a9992ee285efaa83.js → page-40f8fa21bdae955b.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/{page-3d48e85fa89ec04f.js → page-468f8313e4f1fbd9.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/page-7bbb8fa353a246c4.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/term/page-04d00ae9b0890349.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/today/{page-233c71cd5f68f0d7.js → page-4214b855d9233ea8.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/trace/{page-70151d78de2f375e.js → page-936752c7aa58762e.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/vault/{page-dcac33a3e2cb72c0.js → page-ec052ad359df1797.js} +1 -1
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-8305570551091338.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-0448145ccd5c45c1.js → page-fad650e4793adb65.js} +1 -1
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/page-d90e64743826e1d8.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-a16bcfd49af86a32.js +1 -0
  54. package/apps/pwa/out/_next/static/css/b5d88eba78a3d7bf.css +1 -0
  55. package/apps/pwa/out/approvals/index.html +1 -1
  56. package/apps/pwa/out/approvals/index.txt +14 -14
  57. package/apps/pwa/out/autopilot/index.html +1 -1
  58. package/apps/pwa/out/autopilot/index.txt +14 -14
  59. package/apps/pwa/out/board/index.html +1 -1
  60. package/apps/pwa/out/board/index.txt +14 -14
  61. package/apps/pwa/out/botlab/index.html +1 -1
  62. package/apps/pwa/out/botlab/index.txt +14 -14
  63. package/apps/pwa/out/bots/index.html +1 -1
  64. package/apps/pwa/out/bots/index.txt +14 -14
  65. package/apps/pwa/out/ceo/index.html +1 -1
  66. package/apps/pwa/out/ceo/index.txt +14 -14
  67. package/apps/pwa/out/chat/index.html +1 -1
  68. package/apps/pwa/out/chat/index.txt +14 -14
  69. package/apps/pwa/out/consult/index.html +1 -1
  70. package/apps/pwa/out/consult/index.txt +14 -14
  71. package/apps/pwa/out/control/index.html +1 -1
  72. package/apps/pwa/out/control/index.txt +14 -14
  73. package/apps/pwa/out/decisions/index.html +1 -1
  74. package/apps/pwa/out/decisions/index.txt +14 -14
  75. package/apps/pwa/out/design-check/index.html +1 -1
  76. package/apps/pwa/out/design-check/index.txt +14 -14
  77. package/apps/pwa/out/editor/index.html +1 -1
  78. package/apps/pwa/out/editor/index.txt +14 -14
  79. package/apps/pwa/out/exec/index.html +1 -1
  80. package/apps/pwa/out/exec/index.txt +14 -14
  81. package/apps/pwa/out/field/index.html +1 -1
  82. package/apps/pwa/out/field/index.txt +14 -14
  83. package/apps/pwa/out/healthz +1 -1
  84. package/apps/pwa/out/index.html +1 -1
  85. package/apps/pwa/out/index.txt +14 -14
  86. package/apps/pwa/out/inside/index.html +1 -1
  87. package/apps/pwa/out/inside/index.txt +14 -14
  88. package/apps/pwa/out/intake/index.html +1 -1
  89. package/apps/pwa/out/intake/index.txt +14 -14
  90. package/apps/pwa/out/live/index.html +1 -1
  91. package/apps/pwa/out/live/index.txt +17 -14
  92. package/apps/pwa/out/live-run/index.html +1 -1
  93. package/apps/pwa/out/live-run/index.txt +14 -14
  94. package/apps/pwa/out/loops/index.html +1 -1
  95. package/apps/pwa/out/loops/index.txt +14 -14
  96. package/apps/pwa/out/market/index.html +1 -1
  97. package/apps/pwa/out/market/index.txt +14 -14
  98. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  99. package/apps/pwa/out/missions/placeholder/index.txt +14 -14
  100. package/apps/pwa/out/morning/index.html +1 -1
  101. package/apps/pwa/out/morning/index.txt +14 -14
  102. package/apps/pwa/out/observatory/index.html +1 -1
  103. package/apps/pwa/out/observatory/index.txt +14 -14
  104. package/apps/pwa/out/ops/checklist/index.html +1 -1
  105. package/apps/pwa/out/ops/checklist/index.txt +14 -14
  106. package/apps/pwa/out/ops/release/index.html +1 -1
  107. package/apps/pwa/out/ops/release/index.txt +14 -14
  108. package/apps/pwa/out/ops/seats/index.html +1 -1
  109. package/apps/pwa/out/ops/seats/index.txt +14 -14
  110. package/apps/pwa/out/outputs/index.html +1 -1
  111. package/apps/pwa/out/outputs/index.txt +14 -14
  112. package/apps/pwa/out/reflection/index.html +1 -1
  113. package/apps/pwa/out/reflection/index.txt +15 -15
  114. package/apps/pwa/out/scheduler/index.html +1 -1
  115. package/apps/pwa/out/scheduler/index.txt +14 -14
  116. package/apps/pwa/out/sessions/index.html +1 -1
  117. package/apps/pwa/out/sessions/index.txt +14 -14
  118. package/apps/pwa/out/settings/devices/index.html +1 -1
  119. package/apps/pwa/out/settings/devices/index.txt +14 -14
  120. package/apps/pwa/out/settings/index.html +1 -1
  121. package/apps/pwa/out/settings/index.txt +14 -14
  122. package/apps/pwa/out/setup/done/index.html +1 -1
  123. package/apps/pwa/out/setup/done/index.txt +14 -14
  124. package/apps/pwa/out/setup/index.html +1 -1
  125. package/apps/pwa/out/setup/index.txt +14 -14
  126. package/apps/pwa/out/share/index.html +1 -1
  127. package/apps/pwa/out/share/index.txt +14 -14
  128. package/apps/pwa/out/showroom/index.html +1 -1
  129. package/apps/pwa/out/showroom/index.txt +14 -14
  130. package/apps/pwa/out/tasks/index.html +1 -1
  131. package/apps/pwa/out/tasks/index.txt +14 -14
  132. package/apps/pwa/out/term/index.html +1 -1
  133. package/apps/pwa/out/term/index.txt +14 -14
  134. package/apps/pwa/out/today/index.html +1 -1
  135. package/apps/pwa/out/today/index.txt +14 -14
  136. package/apps/pwa/out/trace/index.html +1 -1
  137. package/apps/pwa/out/trace/index.txt +14 -14
  138. package/apps/pwa/out/vault/index.html +1 -1
  139. package/apps/pwa/out/vault/index.txt +14 -14
  140. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  141. package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
  142. package/apps/pwa/out/workflows/index.html +1 -1
  143. package/apps/pwa/out/workflows/index.txt +14 -14
  144. package/apps/pwa/out/workspace/index.html +1 -1
  145. package/apps/pwa/out/workspace/index.txt +14 -14
  146. package/apps/pwa/out/worktrees/index.html +1 -1
  147. package/apps/pwa/out/worktrees/index.txt +14 -14
  148. package/graphs/implement-loop.yaml +5 -0
  149. package/package.json +1 -1
  150. package/scripts/ci-typecheck-changed.ts +26 -1
  151. package/scripts/measure-si-task-cpu.ts +143 -0
  152. package/scripts/ratchets/test-interference-patterns.allowlist.txt +0 -2
  153. package/scripts/release-loop/prepare-node.ts +102 -4
  154. package/src/acp/core-turn-bridge.ts +16 -4
  155. package/src/acp/elanous-extensions.ts +3 -0
  156. package/src/acp/server.ts +3 -0
  157. package/src/acp/tool-trace.ts +186 -11
  158. package/src/agent/self-ambient-cache.ts +156 -0
  159. package/src/agent/self-ambient.ts +8 -1
  160. package/src/agent/task-notification.ts +5 -0
  161. package/src/agent-substrate/pr-reviewer.ts +3 -1
  162. package/src/ask-user-question/tool.ts +22 -0
  163. package/src/autopilot/build/llm-conflict-merge.ts +56 -10
  164. package/src/autopilot/pr-manager.ts +23 -5
  165. package/src/capture/frame-state-detect.ts +39 -0
  166. package/src/capture/tui-self-report.ts +134 -6
  167. package/src/chat/index.ts +61 -15
  168. package/src/chat/pickers/modal-runtime.ts +6 -0
  169. package/src/chat/pickers/modals.ts +8 -2
  170. package/src/cli/ask-bare.ts +1 -0
  171. package/src/cli/import-cli.ts +12 -0
  172. package/src/cli/log-event-names.ts +10 -0
  173. package/src/cli/msg-cli.ts +1 -0
  174. package/src/cli/pod-cli.ts +14 -3
  175. package/src/cli/pr-cli.ts +67 -32
  176. package/src/cli/pr-land-base-read.ts +34 -0
  177. package/src/cli/pr-land-heavy-remote.ts +81 -25
  178. package/src/cli/pr-land-overlap-evidence.ts +46 -2
  179. package/src/cli/pr-land-pod-shadow-job.ts +26 -3
  180. package/src/cli/pr-land-refusal.ts +20 -5
  181. package/src/cli/pty-takeover-cli.ts +88 -14
  182. package/src/cli/sim-cli.ts +18 -0
  183. package/src/context-bus/ops-events.ts +6 -2
  184. package/src/core-turn/run-core-turn.ts +1 -0
  185. package/src/core-turn/run-goal-loop.ts +21 -0
  186. package/src/core-turn/types.ts +3 -0
  187. package/src/dashboard/acp-active-model.ts +42 -1
  188. package/src/dashboard/background-jobs-status.ts +66 -0
  189. package/src/dashboard/index.ts +117 -44
  190. package/src/dashboard/input/agent-wake.ts +242 -0
  191. package/src/dashboard/input/chat-main-entry-runtime.ts +5 -0
  192. package/src/dashboard/input/external-input-host.ts +23 -9
  193. package/src/dashboard/input/mouse-wiring.ts +108 -11
  194. package/src/dashboard/log-click-runtime.ts +118 -2
  195. package/src/dashboard/turn-preamble.ts +116 -40
  196. package/src/dashboard/turn-session-layers.ts +4 -2
  197. package/src/dashboard/turn-start-segments.ts +222 -0
  198. package/src/display/prompt-row-owner.ts +146 -0
  199. package/src/display/resize-listener.ts +33 -2
  200. package/src/fold-stack.ts +4 -2
  201. package/src/git-fs/index.ts +263 -36
  202. package/src/harness/gate-reserve.ts +87 -0
  203. package/src/harness/harness-incidents.ts +13 -0
  204. package/src/harness/harness-queue.ts +15 -0
  205. package/src/index.ts +8 -6
  206. package/src/input/external-input-queue.ts +34 -9
  207. package/src/llm/route-decision.ts +36 -0
  208. package/src/llm/tool-loop-steered-rejection.ts +8 -0
  209. package/src/llm-request-shape.ts +209 -0
  210. package/src/llm.ts +166 -267
  211. package/src/loops/orchestrator/tick.ts +24 -2
  212. package/src/maturity/feature-maturity.ts +1 -1
  213. package/src/mss/logging/render-categories.ts +5 -0
  214. package/src/ops/overview.ts +59 -2
  215. package/src/pr-tool/census.ts +19 -4
  216. package/src/pr-tool/inflight-registry.ts +143 -0
  217. package/src/pty-shell/pty-manifest.ts +12 -3
  218. package/src/pty-shell/tui-run-state.ts +34 -3
  219. package/src/seat-loop/traffic-signal.ts +2 -1
  220. package/src/self-dev/ask-launch-flow.ts +13 -0
  221. package/src/self-dev/draft-residue.ts +243 -24
  222. package/src/self-dev/shard-plan.ts +11 -3
  223. package/src/self-dev/shard-run.ts +103 -10
  224. package/src/self-implement/elanous-tui-spawn.ts +14 -0
  225. package/src/self-implement/follow-up-placement.ts +79 -0
  226. package/src/self-implement/gate-artifacts.ts +15 -4
  227. package/src/self-implement/gate-baseline.ts +96 -5
  228. package/src/self-implement/gate-cli.ts +7 -2
  229. package/src/self-implement/gate-remote.ts +26 -1
  230. package/src/self-implement/host-regate.ts +36 -4
  231. package/src/self-implement/orchestrator.ts +101 -11
  232. package/src/self-implement/run-ledger.ts +16 -14
  233. package/src/session-runtime/index.ts +22 -267
  234. package/src/shell-runner/background-surface.ts +15 -5
  235. package/src/skills/tools/agent.ts +39 -6
  236. package/src/skills/tools/list-dir.ts +11 -0
  237. package/src/skills/tools/schedule-wakeup.ts +408 -0
  238. package/src/skills/tools/shell.ts +245 -3
  239. package/src/status/bar.ts +26 -0
  240. package/src/steward/digest-delivery.ts +118 -0
  241. package/src/steward/hold-hygiene.ts +120 -0
  242. package/src/steward/triage.ts +10 -0
  243. package/src/surface/shell-runner-boot.ts +5 -2
  244. package/src/task-agent/task-hand.ts +24 -0
  245. package/src/task-orchestrator/surfaces/pod-command-job.ts +65 -11
  246. package/src/task-orchestrator/surfaces/pod-lease.ts +98 -0
  247. package/src/task-orchestrator/surfaces/pod-ledger-events.ts +1 -1
  248. package/src/task-orchestrator/surfaces/pod-pool.ts +3 -1
  249. package/src/task-orchestrator/surfaces/pod-reuse-cleanup.ts +10 -6
  250. package/src/task-orchestrator/surfaces/pod-warm-worker-pod.ts +525 -0
  251. package/src/task-orchestrator/surfaces/pod-warm-worker-runner.ts +90 -0
  252. package/src/task-orchestrator/surfaces/pod-warm-worker-runtime.ts +156 -2
  253. package/src/task-orchestrator/surfaces/self-implement-pod.ts +7 -5
  254. package/src/tui/chat/execution-badge.ts +5 -1
  255. package/src/tui/input-lag-probe.ts +230 -0
  256. package/src/tui.ts +4 -0
  257. package/src/ux-sim/scenario.ts +255 -0
  258. package/src/version/packed-revision.json +1 -1
  259. package/apps/pwa/out/_next/static/chunks/8097-92f87bd108322f66.js +0 -1
  260. package/apps/pwa/out/_next/static/chunks/app/approvals/page-bf3634a117322df7.js +0 -1
  261. package/apps/pwa/out/_next/static/chunks/app/board/page-b9f51447f757fbf2.js +0 -1
  262. package/apps/pwa/out/_next/static/chunks/app/botlab/page-2ebb8fd59b9461f9.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/bots/page-a2fc4e49a4fa4ba8.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/consult/page-f4b630cc8b5273cd.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/decisions/page-61ac22cbab1ee4ec.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/editor/page-c992eafe028b642e.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/inside/page-805ac2e137b7a121.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/live/page-349f2752d8cd37ad.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/live-run/page-d5c6dcf31c25cecc.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-62f4475d3c38fb9b.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-f219ccdbc0bf27df.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/outputs/page-456666766e5ce6fc.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/page-7404e377e8adb8c5.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/reflection/page-14456240ed2a36ad.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/tasks/page-d2ced71132fac9b2.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/term/page-f08358bdf251adb9.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-149dc1caba411bb9.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/workspace/page-9cb0c444903da65d.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-17e504c396e0e389.js +0 -1
  280. package/apps/pwa/out/_next/static/css/3f5fc6f96e2cf7b2.css +0 -1
  281. /package/apps/pwa/out/_next/static/{LSXfY0u9QtfDbejY0Ukf_ → IVKr3AlRUVLKsw2fh7eXF}/_buildManifest.js +0 -0
  282. /package/apps/pwa/out/_next/static/{LSXfY0u9QtfDbejY0Ukf_ → IVKr3AlRUVLKsw2fh7eXF}/_ssgManifest.js +0 -0
@@ -10,14 +10,31 @@
10
10
  // overlays catch up too. Both calls are wrapped in try/catch so a
11
11
  // transient paint error doesn't kill the listener.
12
12
  //
13
+ // (3) TUI-PICKER-RESIZE-SHRINK (#76 · 2026-10-17) — then ask the
14
+ // coordinator for one FORCED frame. Steps (1)+(2) bypass the
15
+ // coordinator flush, so the prompt chrome repaint in (2) writes the
16
+ // input divider straight over the bottom row of an open overlay (the
17
+ // slash picker's `╰` sits on the divider row) and nothing re-emits the
18
+ // overlay afterwards — the picker lost its bottom border until it was
19
+ // closed and reopened. A coordinator frame runs onRender → afterRender
20
+ // (chrome) → flushOverlay (modals on top), so the overlay lands last.
21
+ // `force` matters: the overlay byte-equality cache would otherwise
22
+ // skip a re-emit whose bytes match the last coordinator frame
23
+ // (e.g. 80 → 120 → 80 with no coordinator flush in between).
24
+ //
13
25
  // Non-goals (stay out of IDX-1 territory):
14
- // - Do NOT touch coordinator.flushOverlay cache state.
15
- // - Do NOT recompute modal bounds (mount-time snapshot preserved).
26
+ // - Do NOT touch coordinator.flushOverlay cache state directly.
16
27
  // - Do NOT reach into chat.ts textInput loop internals.
17
28
 
29
+ import { debug } from '../debug/log.js';
30
+
18
31
  export interface ResizeListenerDeps {
19
32
  draw: (opts?: { force?: boolean }) => void;
20
33
  promptCtl: { repaint: () => void };
34
+ /** Request one forced coordinator frame so modal overlays are
35
+ * re-emitted on top of the chrome the prompt repaint just wrote.
36
+ * Optional: hosts without a coordinator keep the legacy two-step. */
37
+ requestOverlayFrame?: () => void;
21
38
  }
22
39
 
23
40
  /** Registers the resize listener and returns a dispose fn that
@@ -28,6 +45,20 @@ export function installResizeListener(deps: ResizeListenerDeps): () => void {
28
45
  const handler = (): void => {
29
46
  try { deps.draw({ force: true }); } catch { /* swallow — paint errors shouldn't break resize */ }
30
47
  try { deps.promptCtl.repaint(); } catch { /* swallow */ }
48
+ let overlayFrame: 'requested' | 'absent' | 'failed' = 'absent';
49
+ if (deps.requestOverlayFrame) {
50
+ try {
51
+ deps.requestOverlayFrame();
52
+ overlayFrame = 'requested';
53
+ } catch {
54
+ overlayFrame = 'failed';
55
+ }
56
+ }
57
+ debug.log('display.resize', 'repaint', {
58
+ cols: process.stdout.columns ?? null,
59
+ rows: process.stdout.rows ?? null,
60
+ overlayFrame,
61
+ });
31
62
  };
32
63
  process.stdout.on('resize', handler);
33
64
  return (): void => {
package/src/fold-stack.ts CHANGED
@@ -216,8 +216,9 @@ export class FoldStack {
216
216
  /** Toggle the latest-pushed static target whose range contains
217
217
  * `lineIndex`. Returns true when a target was toggled, false
218
218
  * when none contained the line (live targets are never chosen).
219
- * Reuses the single-target splice/cascade path. */
220
- toggleAtLine(lineIndex: number): boolean {
219
+ * Reuses the single-target splice/cascade path. `logContext` (the
220
+ * click's screen frame) is merged into the expanded/recollapsed line. */
221
+ toggleAtLine(lineIndex: number, logContext: Record<string, unknown> = {}): boolean {
221
222
  const t = this.findLatestStaticContaining(lineIndex);
222
223
  if (!t) {
223
224
  this.logToggleAtLineMiss(lineIndex);
@@ -236,6 +237,7 @@ export class FoldStack {
236
237
  lineIndex,
237
238
  lineStart,
238
239
  lineEnd,
240
+ ...logContext,
239
241
  });
240
242
  }
241
243
  return toggled;
@@ -9,7 +9,7 @@
9
9
  // on its own cadence (dashboard throttles to ~2s).
10
10
 
11
11
  import { resolve } from 'node:path';
12
- import { spawnSync } from 'node:child_process';
12
+ import { execFile, spawnSync } from 'node:child_process';
13
13
  import { findGitDir, type LocatedGit } from './locate.js';
14
14
  import { readGitHead } from './read-head.js';
15
15
  import { GitFileWatcher } from './watcher.js';
@@ -31,13 +31,62 @@ export { probeDirty, probeAheadBehind, parsePorcelain } from './dirty.js';
31
31
  const SNAPSHOT_TIMEOUT_MS = 5_000;
32
32
  const SNAPSHOT_STATUS_MAX_CHARS = 2_000;
33
33
  const GIT_SNAPSHOT_TTL_MS = 3_000;
34
+ /** A cached snapshot older than this is rebuilt in the background on the next periodic dirty tick. */
35
+ const GIT_SNAPSHOT_KEEP_WARM_MS = 30_000;
34
36
 
35
37
  interface GitSnapshotCacheEntry {
36
38
  at: number;
39
+ /** Monotonic store ordering — `at` can flip within the same millisecond. */
40
+ version: number;
37
41
  value: string | null;
38
42
  }
39
43
 
40
44
  const gitSnapshotCache = new Map<string, GitSnapshotCacheEntry>();
45
+ /** The entry an invalidation (known write · HEAD move) just evicted. Only the
46
+ * stale-while-revalidate turn path reads it, and only while it is younger than
47
+ * its window: the turn is served the last snapshot (its header states when it
48
+ * was taken) while the background rebuild runs, instead of rebuilding
49
+ * synchronously on the event loop. Any later store supersedes it. */
50
+ const gitSnapshotLastKnown = new Map<string, GitSnapshotCacheEntry>();
51
+
52
+ function storeGitSnapshot(key: string, entry: Omit<GitSnapshotCacheEntry, 'version'>): void {
53
+ gitSnapshotCache.set(key, { ...entry, version: bumpGitSnapshotVersion(key) });
54
+ gitSnapshotLastKnown.delete(key);
55
+ }
56
+
57
+ function evictGitSnapshot(key: string): boolean {
58
+ const entry = gitSnapshotCache.get(key);
59
+ if (!entry) return false;
60
+ gitSnapshotCache.delete(key);
61
+ gitSnapshotLastKnown.set(key, entry);
62
+ return true;
63
+ }
64
+ /** One background snapshot rebuild per cwd and invalidation generation. */
65
+ const gitSnapshotInFlight = new Map<string, { generation: string; promise: Promise<string | null> }>();
66
+
67
+ /** Per-key monotonic store version: async rebuilds never overwrite a newer
68
+ * sync value, not even when both land in the same millisecond (`at`
69
+ * comparisons can flip). Generation guards invalidation races instead. */
70
+ const gitSnapshotVersion = new Map<string, number>();
71
+
72
+ function gitSnapshotVersionOf(key: string): number {
73
+ return gitSnapshotVersion.get(key) ?? 0;
74
+ }
75
+
76
+ function bumpGitSnapshotVersion(key: string): number {
77
+ const version = gitSnapshotVersionOf(key) + 1;
78
+ gitSnapshotVersion.set(key, version);
79
+ return version;
80
+ }
81
+
82
+ /** Test-only hold seam (production null): parks an async rebuild after its
83
+ * reads and before its store, so «a write during a rebuild wins» is provable
84
+ * deterministically instead of by racing timers. */
85
+ let gitSnapshotRebuildHold: (() => void | Promise<void>) | null = null;
86
+
87
+ export function __setGitSnapshotRebuildHoldForTest(hold: (() => void | Promise<void>) | null): void {
88
+ gitSnapshotRebuildHold = hold;
89
+ }
41
90
 
42
91
  interface CacheEntry {
43
92
  located: LocatedGit;
@@ -97,8 +146,15 @@ function ensureEntry(cwd: string): CacheEntry | null {
97
146
  // Re-read HEAD + retarget watcher whenever a watched file changes.
98
147
  // One handler covers all listeners so we don't race each subscriber.
99
148
  watcher.subscribe(() => {
149
+ const before = entry.head;
100
150
  entry.head = readGitHead(entry.located);
101
151
  entry.watcher.retargetForHead(entry.head);
152
+ // A branch/ref move makes every cached snapshot's branch + log lines wrong. Only a real
153
+ // move: macOS fs.watch also replays the ref write that happened just before the watch was
154
+ // installed (measured: refs/heads/main «change» right after the first build), and that
155
+ // spurious event must not evict a fresh snapshot.
156
+ if (headMoved(before, entry.head)) invalidateAllGitSnapshots();
157
+ else debug.log('git-fs.snapshot', 'watch-event-head-unchanged', { root: entry.located.root });
102
158
  for (const fn of entry.listeners) {
103
159
  try { fn(); } catch { /* noop */ }
104
160
  }
@@ -179,8 +235,8 @@ function viewOf(entry: CacheEntry): GitStatusView {
179
235
  /** Non-blocking twin of `refreshDirty` for the TUI: the probes run as
180
236
  * async subprocesses so a slow `git status` (large worktree, loaded
181
237
  * host) never stalls key input. Same throttle; concurrent calls share
182
- * one probe, except that `force` (a known write just happened) never
183
- * reuses a probe that started before it. */
238
+ * one probe. `force` also marks cached LLM snapshots stale and rebuilds
239
+ * them in the background — a known write happened. */
184
240
  export function refreshDirtyAsync(
185
241
  cwd: string,
186
242
  opts: { minIntervalMs?: number; force?: boolean } = {},
@@ -188,26 +244,40 @@ export function refreshDirtyAsync(
188
244
  let entry: CacheEntry | null;
189
245
  try { entry = ensureEntry(cwd); } catch (error) { return Promise.reject(error); }
190
246
  if (!entry) return Promise.resolve({ head: null, dirty: null, aheadBehind: null, lastDirtyProbeAt: 0 });
191
- // An in-flight probe (possibly a forced, post-write one) is newer than the last stored view.
247
+ // An in-flight probe (possibly a forced, post-write one) is newer than the last stored view —
248
+ // a throttled call joins it instead of returning the pre-write view (#27493 contract).
192
249
  const running = dirtyInFlight.get(entry);
193
250
  if (running && !opts.force) return running;
194
251
  const min = opts.minIntervalMs ?? 2_000;
195
252
  if (!opts.force && entry.lastDirtyProbeAt > 0 && Date.now() - entry.lastDirtyProbeAt < min) {
196
253
  return Promise.resolve(viewOf(entry));
197
254
  }
198
- const target = entry;
199
- const root = target.located.root;
255
+ const probe = probeDirtyAsyncFor(entry, { fresh: !!opts.force });
256
+ // After the post-write probe is queued, so the snapshot rebuild joins it instead of an older one.
257
+ if (opts.force) invalidateGitSnapshots(cwd);
258
+ // Keep-warm: the dashboard's periodic (non-forced) dirty tick also re-warms a snapshot that is in
259
+ // use, so a turn typed after an idle stretch is served within the stale window instead of falling
260
+ // back to the synchronous build. Only snapshots already cached are kept warm.
261
+ else if (gitSnapshotCache.has(resolve(cwd)) || gitSnapshotLastKnown.has(resolve(cwd))) void refreshGitSnapshotAsync(cwd, { ifOlderThanMs: GIT_SNAPSHOT_KEEP_WARM_MS });
262
+ return probe;
263
+ }
264
+
265
+ /** Share the in-flight probe, unless the caller needs one that started after
266
+ * now (`fresh`: a known write just happened) — then queue a new probe behind it. */
267
+ function probeDirtyAsyncFor(entry: CacheEntry, opts: { fresh: boolean }): Promise<GitStatusView> {
268
+ const running = dirtyInFlight.get(entry);
269
+ if (running && !opts.fresh) return running;
270
+ const root = entry.located.root;
200
271
  const start = (): Promise<GitStatusView> => Promise.all([probeDirtyAsync(root), probeAheadBehindAsync(root)])
201
272
  .then(([dirty, aheadBehind]) => {
202
- target.dirty = dirty;
203
- target.aheadBehind = aheadBehind;
204
- target.lastDirtyProbeAt = Date.now();
205
- return viewOf(target);
273
+ entry.dirty = dirty;
274
+ entry.aheadBehind = aheadBehind;
275
+ entry.lastDirtyProbeAt = Date.now();
276
+ return viewOf(entry);
206
277
  });
207
- // A forced probe queues behind the running one so it reads the post-write tree.
208
278
  const probe = (running ? running.then(start, start) : start())
209
- .finally(() => { if (dirtyInFlight.get(target) === probe) dirtyInFlight.delete(target); });
210
- dirtyInFlight.set(target, probe);
279
+ .finally(() => { if (dirtyInFlight.get(entry) === probe) dirtyInFlight.delete(entry); });
280
+ dirtyInFlight.set(entry, probe);
211
281
  return probe;
212
282
  }
213
283
 
@@ -217,7 +287,7 @@ export function refreshDirtyAsync(
217
287
  * fresh state after mutations. Returns null outside a git repo. */
218
288
  export function buildGitSnapshot(
219
289
  cwd: string,
220
- opts: { now?: () => number; ttlMs?: number } = {},
290
+ opts: { now?: () => number; ttlMs?: number; staleWhileRevalidateMs?: number } = {},
221
291
  ): string | null {
222
292
  const now = opts.now ?? Date.now;
223
293
  const ttlMs = opts.ttlMs ?? GIT_SNAPSHOT_TTL_MS;
@@ -228,41 +298,157 @@ export function buildGitSnapshot(
228
298
  debug.log('git-fs.snapshot', 'cache-hit', { cwd, ageMs: at - cached.at });
229
299
  return cached.value;
230
300
  }
301
+ // Stale-while-revalidate (TUI turn path): serve the recent snapshot now
302
+ // and rebuild it off the event loop for the next turn.
303
+ if (cached && opts.staleWhileRevalidateMs !== undefined && at - cached.at < opts.staleWhileRevalidateMs) {
304
+ debug.log('git-fs.snapshot', 'stale-hit', { cwd, ageMs: at - cached.at });
305
+ void refreshGitSnapshotAsync(cwd);
306
+ return cached.value;
307
+ }
308
+ // Invalidated (known write · HEAD move) and the background rebuild has not
309
+ // stored yet: serve the evicted snapshot rather than a synchronous rebuild —
310
+ // the TYPING-LAG this path exists to remove. Bounded by the same window.
311
+ const evicted = !cached ? gitSnapshotLastKnown.get(key) : undefined;
312
+ if (evicted && opts.staleWhileRevalidateMs !== undefined && at - evicted.at < opts.staleWhileRevalidateMs) {
313
+ debug.log('git-fs.snapshot', 'stale-after-invalidate', { cwd, ageMs: at - evicted.at, rebuilding: gitSnapshotInFlight.has(key) });
314
+ void refreshGitSnapshotAsync(cwd);
315
+ return evicted.value;
316
+ }
231
317
 
232
318
  const view = refreshDirty(cwd, { force: true });
233
319
  let value: string | null = null;
234
320
  if (view.head) {
235
- const branch = view.head.branch ?? `HEAD@${view.head.sha?.slice(0, 7) ?? 'unknown'}`;
236
- const mainBranch = detectDefaultBranch(cwd);
237
- const status = runGit(cwd, ['status', '--short']);
238
- const recentLog = runGit(cwd, ['log', '--oneline', '-n', '5']);
239
- const userName = runGit(cwd, ['config', 'user.name']);
240
- const truncatedStatus = status.length > SNAPSHOT_STATUS_MAX_CHARS
241
- ? `${status.slice(0, SNAPSHOT_STATUS_MAX_CHARS)}\n... (truncated; run git status for more)`
242
- : status;
243
-
244
- const parts = [
245
- 'This is the git status at the start of this turn. It is a snapshot and will not update during the turn.',
246
- `Current branch: ${branch}${view.head.isWorktree ? ' (worktree)' : ''}`,
247
- mainBranch ? `Main branch: ${mainBranch}` : '',
248
- userName ? `Git user: ${userName}` : '',
249
- view.aheadBehind
250
- ? `Upstream: ahead ${view.aheadBehind.ahead}, behind ${view.aheadBehind.behind}`
251
- : '',
252
- `Status:\n${truncatedStatus || '(clean)'}`,
253
- `Recent commits:\n${recentLog || '(none)'}`,
254
- ].filter(Boolean);
255
- value = parts.join('\n\n');
321
+ value = composeGitSnapshot(view, {
322
+ mainBranch: detectDefaultBranch(cwd),
323
+ status: runGit(cwd, ['status', '--short']),
324
+ recentLog: runGit(cwd, ['log', '--oneline', '-n', '5']),
325
+ userName: runGit(cwd, ['config', 'user.name']),
326
+ generatedAt: at,
327
+ });
256
328
  }
257
329
 
258
- gitSnapshotCache.set(key, { at, value });
330
+ storeGitSnapshot(key, { at, value });
259
331
  debug.log('git-fs.snapshot', 'built', { cwd, ageMs: 0 });
260
332
  return value;
261
333
  }
262
334
 
335
+ function composeGitSnapshot(
336
+ view: GitStatusView,
337
+ git: { mainBranch: string | null; status: string; recentLog: string; userName: string; generatedAt: number },
338
+ ): string {
339
+ const head = view.head!;
340
+ const branch = head.branch ?? `HEAD@${head.sha?.slice(0, 7) ?? 'unknown'}`;
341
+ const truncatedStatus = git.status.length > SNAPSHOT_STATUS_MAX_CHARS
342
+ ? `${git.status.slice(0, SNAPSHOT_STATUS_MAX_CHARS)}\n... (truncated; run git status for more)`
343
+ : git.status;
344
+ const parts = [
345
+ `This is the git status as of ${new Date(git.generatedAt).toISOString()}. It is a snapshot and will not update during the turn.`,
346
+ `Current branch: ${branch}${head.isWorktree ? ' (worktree)' : ''}`,
347
+ git.mainBranch ? `Main branch: ${git.mainBranch}` : '',
348
+ git.userName ? `Git user: ${git.userName}` : '',
349
+ view.aheadBehind
350
+ ? `Upstream: ahead ${view.aheadBehind.ahead}, behind ${view.aheadBehind.behind}`
351
+ : '',
352
+ `Status:\n${truncatedStatus || '(clean)'}`,
353
+ `Recent commits:\n${git.recentLog || '(none)'}`,
354
+ ].filter(Boolean);
355
+ return parts.join('\n\n');
356
+ }
357
+
358
+ /** Rebuild the snapshot for `cwd` with async subprocesses and store it.
359
+ * Concurrent calls share one rebuild. Never throws. `ifOlderThanMs` turns it
360
+ * into a keep-warm tick: a cached snapshot younger than that is left alone. */
361
+ export function refreshGitSnapshotAsync(cwd: string, opts: { ifOlderThanMs?: number } = {}): Promise<string | null> {
362
+ const key = resolve(cwd);
363
+ const cached = gitSnapshotCache.get(key);
364
+ if (cached && opts.ifOlderThanMs !== undefined && Date.now() - cached.at < opts.ifOlderThanMs) {
365
+ return Promise.resolve(cached.value);
366
+ }
367
+ const generation = snapshotGeneration(key);
368
+ const running = gitSnapshotInFlight.get(key);
369
+ if (running && running.generation === generation) return running.promise;
370
+ const startedAt = Date.now();
371
+ const startVersion = gitSnapshotVersionOf(key);
372
+ const rebuild = (async (): Promise<string | null> => {
373
+ const entry = ensureEntry(cwd);
374
+ // Joins the in-flight probe; callers that need a post-write/post-move probe queue a fresh one first.
375
+ const view = entry ? await probeDirtyAsyncFor(entry, { fresh: false }) : getGitStatusView(cwd);
376
+ let value: string | null = null;
377
+ if (view.head) {
378
+ const [mainBranch, status, recentLog, userName] = await Promise.all([
379
+ detectDefaultBranchAsync(cwd),
380
+ runGitAsync(cwd, ['status', '--short']),
381
+ runGitAsync(cwd, ['log', '--oneline', '-n', '5']),
382
+ runGitAsync(cwd, ['config', 'user.name']),
383
+ ]);
384
+ // «as of» = when the reads finished, not when the rebuild was queued.
385
+ value = composeGitSnapshot(view, { mainBranch, status, recentLog, userName, generatedAt: Date.now() });
386
+ }
387
+ // Test-only hold seam: reads are done, the store decision is next.
388
+ if (gitSnapshotRebuildHold) await gitSnapshotRebuildHold();
389
+ // An invalidation that landed mid-rebuild wins: keep the entry stale.
390
+ // A newer entry (e.g. a synchronous build that overlapped this rebuild) is
391
+ // kept — ordered by monotonic version, never by `at` (same-ms flips).
392
+ const current = gitSnapshotCache.get(key);
393
+ if (snapshotGeneration(key) === generation && !(current && current.version > startVersion)) {
394
+ storeGitSnapshot(key, { at: startedAt, value });
395
+ }
396
+ debug.log('git-fs.snapshot', 'rebuilt-async', { cwd, ms: Date.now() - startedAt });
397
+ return value;
398
+ })()
399
+ .catch(() => null)
400
+ .finally(() => { if (gitSnapshotInFlight.get(key)?.promise === rebuild) gitSnapshotInFlight.delete(key); });
401
+ gitSnapshotInFlight.set(key, { generation, promise: rebuild });
402
+ return rebuild;
403
+ }
404
+
405
+ /** Invalidation generations: a rebuild only stores its result when no
406
+ * invalidation (per cwd or global) landed while it was running. */
407
+ const gitSnapshotGeneration = new Map<string, number>();
408
+ let allGitSnapshotsGeneration = 0;
409
+
410
+ function snapshotGeneration(key: string): string {
411
+ return `${allGitSnapshotsGeneration}:${gitSnapshotGeneration.get(key) ?? 0}`;
412
+ }
413
+
414
+ function invalidateAllGitSnapshots(): void {
415
+ // Snapshots that were in use (cached or being rebuilt) are re-warmed for the next turn.
416
+ const inUse = new Set([...gitSnapshotCache.keys(), ...gitSnapshotInFlight.keys()]);
417
+ for (const key of [...gitSnapshotCache.keys()]) evictGitSnapshot(key);
418
+ allGitSnapshotsGeneration += 1;
419
+ // Probes already running read the old ref's upstream — queue one fresh probe per repo behind
420
+ // them, so the rebuilds below join it instead.
421
+ const entries = new Set<CacheEntry>();
422
+ for (const key of inUse) {
423
+ const entry = ensureEntry(key);
424
+ if (entry) entries.add(entry);
425
+ }
426
+ for (const entry of entries) void probeDirtyAsyncFor(entry, { fresh: true });
427
+ for (const key of inUse) void refreshGitSnapshotAsync(key);
428
+ }
429
+
430
+ /** A known write happened: the cached snapshot must not be served again.
431
+ * When a snapshot was in use (cached or being rebuilt), rebuild it in the
432
+ * background so the next turn usually still hits. */
433
+ function invalidateGitSnapshots(cwd: string): void {
434
+ const key = resolve(cwd);
435
+ const wasCached = evictGitSnapshot(key);
436
+ const wasRebuilding = gitSnapshotInFlight.has(key);
437
+ gitSnapshotGeneration.set(key, (gitSnapshotGeneration.get(key) ?? 0) + 1);
438
+ // An in-flight rebuild of the old generation will be discarded — schedule its replacement.
439
+ if (wasCached || wasRebuilding) void refreshGitSnapshotAsync(cwd);
440
+ }
441
+
442
+ function headMoved(before: GitHeadInfo | null | undefined, after: GitHeadInfo | null | undefined): boolean {
443
+ if (!before || !after) return before !== after;
444
+ return before.branch !== after.branch || before.sha !== after.sha || before.detached !== after.detached;
445
+ }
446
+
263
447
  /** Explicitly discard cached git snapshots, including cached non-repositories. */
264
448
  export function clearGitSnapshotCache(): void {
265
449
  gitSnapshotCache.clear();
450
+ gitSnapshotLastKnown.clear();
451
+ allGitSnapshotsGeneration += 1;
266
452
  }
267
453
 
268
454
  function detectDefaultBranch(cwd: string): string | null {
@@ -281,6 +467,34 @@ function detectDefaultBranch(cwd: string): string | null {
281
467
  return null;
282
468
  }
283
469
 
470
+ async function detectDefaultBranchAsync(cwd: string): Promise<string | null> {
471
+ const symbolic = await runGitAsync(cwd, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
472
+ if (symbolic.startsWith('refs/remotes/origin/')) {
473
+ return symbolic.slice('refs/remotes/origin/'.length).trim() || null;
474
+ }
475
+ for (const candidate of ['main', 'master', 'develop']) {
476
+ if (await gitSucceeds(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`])) return candidate;
477
+ }
478
+ return null;
479
+ }
480
+
481
+ function gitSucceeds(cwd: string, args: string[]): Promise<boolean> {
482
+ return new Promise((resolveOk) => {
483
+ try { execFile('git', args, { cwd, timeout: SNAPSHOT_TIMEOUT_MS }, (error) => resolveOk(!error)); }
484
+ catch { resolveOk(false); }
485
+ });
486
+ }
487
+
488
+ function runGitAsync(cwd: string, args: string[]): Promise<string> {
489
+ return new Promise((resolveOut) => {
490
+ try {
491
+ execFile('git', args, { cwd, encoding: 'utf8', timeout: SNAPSHOT_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024 }, (error, stdout) => {
492
+ resolveOut(error ? '' : String(stdout ?? '').trim());
493
+ });
494
+ } catch { resolveOut(''); }
495
+ });
496
+ }
497
+
284
498
  function runGit(cwd: string, args: string[]): string {
285
499
  const res = spawnSync('git', args, {
286
500
  cwd,
@@ -292,10 +506,23 @@ function runGit(cwd: string, args: string[]): string {
292
506
  return String(res.stdout ?? '').trim();
293
507
  }
294
508
 
509
+ /** Test helper — the cached snapshot for `cwd` without building one. */
510
+ export function __peekGitSnapshot(cwd: string): string | null | undefined {
511
+ return gitSnapshotCache.get(resolve(cwd))?.value;
512
+ }
513
+
514
+ /** Test helper — what a HEAD move does to cached snapshots. */
515
+ export function __invalidateAllGitSnapshotsForTest(): void {
516
+ invalidateAllGitSnapshots();
517
+ }
518
+
295
519
  /** Test helper — drop the cache + dispose every watcher. */
296
520
  export function __resetGitFsCache(): void {
297
521
  for (const [, entry] of cache) entry.watcher.dispose();
298
522
  cache.clear();
299
523
  clearGitSnapshotCache();
524
+ gitSnapshotInFlight.clear();
300
525
  dirtyInFlight.clear();
526
+ gitSnapshotGeneration.clear();
527
+ gitSnapshotVersion.clear();
301
528
  }
@@ -0,0 +1,87 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { releaseLedgerRoot } from '../instance/resolve.js';
6
+ import type { QueueSeat } from './harness-queue.js';
7
+
8
+ /** Shadow calculation only: reserve one of the configured per-seat gate slots while the release gate runs. */
9
+ export function gateReserve({ releaseGateActive, seat, configuredGateCap }: {
10
+ releaseGateActive: boolean; seat: QueueSeat; configuredGateCap: number;
11
+ }): { active: boolean; wouldCap: number } {
12
+ void seat;
13
+ return { active: releaseGateActive, wouldCap: releaseGateActive ? Math.max(0, configuredGateCap - 1) : configuredGateCap };
14
+ }
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const MAX_LEDGER_READS = 32;
18
+ const MAX_PROBE_MS = 1_500;
19
+
20
+ /** Read-only, bounded process and graph-run probe; unavailable or incomplete observation is inactive. */
21
+ export async function detectReleaseGateActive(deps: {
22
+ processes?: () => string | Promise<string>;
23
+ runsRoot?: string;
24
+ read?: (path: string) => string | Promise<string>;
25
+ list?: (path: string) => string[] | Promise<string[]>;
26
+ modifiedAt?: (path: string) => number | Promise<number>;
27
+ /** Test seam for the shared probe deadline; production defaults to 1.5 seconds. */
28
+ timeoutMs?: number;
29
+ } = {}): Promise<boolean> {
30
+ const controller = new AbortController();
31
+ const timeoutMs = deps.timeoutMs ?? MAX_PROBE_MS;
32
+ const deadline = Date.now() + timeoutMs;
33
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
34
+ const timeout = new Promise<never>((_, reject) => {
35
+ timeoutId = setTimeout(() => {
36
+ controller.abort();
37
+ reject(new Error('release gate observation timed out'));
38
+ }, timeoutMs);
39
+ });
40
+ const bounded = <T>(operation: T | Promise<T>): Promise<T> => Promise.race([Promise.resolve(operation), timeout]);
41
+ try {
42
+ const argv = await bounded((deps.processes ?? (async () => {
43
+ const result = await execFileAsync('ps', ['-eo', 'pid=,args='],
44
+ { encoding: 'utf8', timeout: 1_000, maxBuffer: 4 * 1024 * 1024, signal: controller.signal });
45
+ return result.stdout;
46
+ }))());
47
+ if (Date.now() >= deadline) return false;
48
+ const pids = new Set(argv.split('\n').flatMap((line) => {
49
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
50
+ // Same release run/resume argv fingerprint as RAIL-SAFE, bound here to the graph-run owner's PID.
51
+ return match && /(?:^|\s)(?:\S*\/)?elanous\.mjs\s+(?:(?:--test(?:=\S+)?|--config-dir\s+\S+)\s+)*release\s+(?:run|resume)(?=\s|$)/.test(match[2]!)
52
+ ? [Number(match[1])] : [];
53
+ }));
54
+ if (!pids.size) return false;
55
+ const dir = join(deps.runsRoot ?? releaseLedgerRoot(), 'graph-runs', 'release-loop');
56
+ let files: string[];
57
+ try { files = await bounded(deps.list ? deps.list(dir) : fs.readdir(dir)); }
58
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
59
+ // Inspect metadata for every ledger before choosing the newest: directory order is not recency.
60
+ // A slow or huge directory expires the common deadline, then fails open without scanning further.
61
+ const candidates = files.filter((file) => /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(file));
62
+ const modifiedAt = deps.modifiedAt ?? (async (path: string) => (await fs.stat(path)).mtimeMs);
63
+ const recent: { file: string; at: number }[] = [];
64
+ for (let offset = 0; offset < candidates.length && Date.now() < deadline; offset += 32) {
65
+ const batch = candidates.slice(offset, offset + 32);
66
+ const times = await bounded(Promise.all(batch.map(async (file) => {
67
+ try { return await modifiedAt(join(dir, file)); } catch { return -1; }
68
+ })));
69
+ for (let i = 0; i < batch.length; i++) if (times[i]! >= 0) recent.push({ file: batch[i]!, at: times[i]! });
70
+ }
71
+ recent.sort((a, b) => b.at - a.at);
72
+ for (const { file } of recent.slice(0, MAX_LEDGER_READS)) {
73
+ if (Date.now() >= deadline) return false;
74
+ let run: {
75
+ pid?: number; status?: string; currentNode?: { nodeId: string }; path?: string[]; nodes?: { nodeId: string; ok: boolean }[];
76
+ };
77
+ try { run = JSON.parse(await bounded((deps.read ?? ((path: string) => fs.readFile(path,
78
+ { encoding: 'utf8', signal: controller.signal })))(join(dir, file)))) as typeof run; }
79
+ catch { continue; }
80
+ if (run?.status === 'running' && run.pid && pids.has(run.pid)
81
+ && run.currentNode?.nodeId === 'gate' && run.path?.at(-1) === 'gate'
82
+ && Array.isArray(run.nodes) && run.path.length === run.nodes.length + 1) return true;
83
+ }
84
+ return false;
85
+ } catch { return false; }
86
+ finally { clearTimeout(timeoutId); controller.abort(); }
87
+ }
@@ -180,6 +180,19 @@ export function detectBursts(rows: readonly RunExit[], { windowMs = 60_000, thre
180
180
  return bursts.sort((a, b) => Date.parse(a.firstAt) - Date.parse(b.firstAt) || a.reason.localeCompare(b.reason));
181
181
  }
182
182
 
183
+ /** Common-cause candidates: same hostname+reason exits within a 60-minute window, across seats and entrances; hostless rows stay unobserved. */
184
+ export function detectCommonCauseBursts(rows: readonly RunExit[]): IncidentBurst[] {
185
+ const unique = uniqueRunExits(rows).filter((row) => row.hostname);
186
+ const bursts: IncidentBurst[] = [];
187
+ for (const group of groupRunExits(unique, (row) => JSON.stringify([row.reason, row.hostname]))) {
188
+ const hostname = group[0]!.hostname;
189
+ for (const members of burstMemberWindows(group, HOSTNAME_BURST_WINDOW_MS, 3)) {
190
+ bursts.push(burstFromMembers(members, { ...(hostname ? { hostname } : {}) }));
191
+ }
192
+ }
193
+ return bursts.sort((a, b) => Date.parse(a.firstAt) - Date.parse(b.firstAt) || a.reason.localeCompare(b.reason));
194
+ }
195
+
183
196
  export function recentBurst(root: string, now: Date | number, { lookbackMs = 15 * 60_000, seat, entrance }: {
184
197
  lookbackMs?: number; seat?: string; entrance?: string;
185
198
  } = {}): IncidentBurst | null {
@@ -21,6 +21,7 @@ import { hostLeaseCounts, leaseHasPendingPod } from '../pod-lease/host-lease.js'
21
21
  import { leaseKubectl, measurePoolLease } from '../task-orchestrator/surfaces/pod-lease.js';
22
22
  import { parsePodPool, podPoolHostLease, resolvePodPoolSpec } from '../task-orchestrator/surfaces/pod-pool.js';
23
23
  import { writeHarnessQueueReceipt } from './harness-queue-child.js';
24
+ import { detectReleaseGateActive, gateReserve } from './gate-reserve.js';
24
25
  import { adviseLaunch, compareLaunchAdvice, type LaunchAdvice } from './launch-advice.js';
25
26
  import { finishAdvice, measureFinish, recordFinishHistory, type FinishMetrics } from '../loops/orchestrator/finish-rate.js';
26
27
  import { collectDraftMetrics, DRAFT_NEEDS_OWNER_SEAT_CAP, shouldHoldSeatNewLaunch, type DraftMetrics } from '../self-dev/draft-sweep.js';
@@ -85,6 +86,8 @@ export interface HarnessQueueDeps {
85
86
  pool?: () => QueuePool;
86
87
  /** Shadow-only advice seam; exceptions must not affect the queue decision. */
87
88
  advice?: typeof adviseLaunch;
89
+ /** Gate-reserve observation only; never changes seatCapDetails or decideSpawn inputs. */
90
+ releaseGateActive?: () => boolean | Promise<boolean>;
88
91
  launch?: (item: QueueItem, args: string[], root: string) => Promise<number>;
89
92
  /** Test seam: runs after a direct CLI call enqueued its row and before that call's own tick (race tests). */
90
93
  afterEnqueue?: (item: QueueItem) => void | Promise<void>;
@@ -1495,6 +1498,10 @@ async function tickHarnessQueueOnce(deps: HarnessQueueDeps, requestedId: string
1495
1498
  }
1496
1499
  return waiting();
1497
1500
  }
1501
+ // Shadow measurement runs outside the approval path. One async probe serves all candidates in this tick.
1502
+ const gateActivePromise = !childUniverses?.unreadable.length
1503
+ ? Promise.resolve().then(() => (deps.releaseGateActive ?? detectReleaseGateActive)()).catch(() => false)
1504
+ : undefined;
1498
1505
  // NEEDS-OWNER-CENSUS: one draft census per tick, shared by every candidate. A full census pages all
1499
1506
  // open drafts, the 48h closed cohort, and one claim-comment gh call per open draft — re-running it
1500
1507
  // per candidate multiplied those calls by the seat count and raced the rate limit the seat-loop tick
@@ -1516,6 +1523,14 @@ async function tickHarnessQueueOnce(deps: HarnessQueueDeps, requestedId: string
1516
1523
  const injectedCap = deps.cap?.(item.seat);
1517
1524
  const details = seatCapDetails({ seat: item.seat, caps, gate, injectedCap });
1518
1525
  const active = seatActive(item.seat);
1526
+ // An unreadable child universe makes the running count incomplete; do not emit a misleading cap sample.
1527
+ if (gateActivePromise) void gateActivePromise.then((gateActive) => {
1528
+ try {
1529
+ const reserve = gateReserve({ releaseGateActive: gateActive === true, seat: item.seat, configuredGateCap: gate?.[item.seat] ?? 4 });
1530
+ debug.log('harness.queue', 'gate-reserve-shadow', { seat: item.seat, active: reserve.active, wouldCap: reserve.wouldCap,
1531
+ cap: details.cap, running: active });
1532
+ } catch { /* Shadow observation cannot affect the decision. */ }
1533
+ });
1519
1534
  if (active >= details.cap) { blocked.set(item.seat, { reason: seatLine(item.seat, active, details), active }); continue; }
1520
1535
  if (!pool && !poolReason) {
1521
1536
  try { pool = (deps.pool ?? readHarnessQueuePool)(); }