@gajae-code/coding-agent 0.14.2 → 0.15.0

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 (345) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/dist/types/async/job-manager.d.ts +4 -1
  3. package/dist/types/autoresearch/dashboard.d.ts +29 -4
  4. package/dist/types/autoresearch/data-context.d.ts +25 -0
  5. package/dist/types/autoresearch/git.d.ts +65 -7
  6. package/dist/types/autoresearch/harness.d.ts +58 -0
  7. package/dist/types/autoresearch/prompts.d.ts +51 -0
  8. package/dist/types/autoresearch/runs.d.ts +173 -0
  9. package/dist/types/autoresearch/types.d.ts +9 -139
  10. package/dist/types/capability/types.d.ts +20 -0
  11. package/dist/types/cli/accounts-cli.d.ts +1 -0
  12. package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
  13. package/dist/types/config/autorouting-contract.d.ts +146 -0
  14. package/dist/types/config/autorouting-generator.d.ts +40 -0
  15. package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
  16. package/dist/types/config/autorouting.d.ts +35 -0
  17. package/dist/types/config/model-discovery-manager.d.ts +8 -1
  18. package/dist/types/config/model-registry.d.ts +30 -1
  19. package/dist/types/config/provider-selection-policy.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +61 -3
  21. package/dist/types/config/settings.d.ts +3 -0
  22. package/dist/types/coordinator/contract.d.ts +1 -1
  23. package/dist/types/coordinator-mcp/durability.d.ts +29 -0
  24. package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
  26. package/dist/types/coordinator-mcp/server.d.ts +14 -0
  27. package/dist/types/crash/index-store.d.ts +3 -0
  28. package/dist/types/crash/record-loader.d.ts +2 -0
  29. package/dist/types/crash/upstream/relay.d.ts +5 -3
  30. package/dist/types/cursor.d.ts +12 -0
  31. package/dist/types/defaults/gjc-defaults.d.ts +35 -1
  32. package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
  33. package/dist/types/discovery/helpers.d.ts +1 -1
  34. package/dist/types/edit/index.d.ts +2 -1
  35. package/dist/types/eval/py/executor.d.ts +2 -0
  36. package/dist/types/extensibility/extensions/loader.d.ts +32 -0
  37. package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
  40. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  41. package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
  42. package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
  44. package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
  45. package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
  46. package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
  47. package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
  49. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
  50. package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
  51. package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
  52. package/dist/types/hooks/skill-keywords.d.ts +1 -1
  53. package/dist/types/main.d.ts +0 -10
  54. package/dist/types/modes/acp/acp-agent.d.ts +13 -6
  55. package/dist/types/modes/components/model-selector.d.ts +14 -0
  56. package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
  57. package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/types.d.ts +9 -0
  61. package/dist/types/rlm/artifacts.d.ts +4 -4
  62. package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
  63. package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
  64. package/dist/types/runtime-mcp/config.d.ts +6 -0
  65. package/dist/types/runtime-mcp/manager.d.ts +10 -0
  66. package/dist/types/sdk/acp/adapter.d.ts +0 -4
  67. package/dist/types/sdk/broker/broker.d.ts +1 -1
  68. package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
  69. package/dist/types/sdk/broker/session-index.d.ts +15 -0
  70. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
  71. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  72. package/dist/types/sdk/bus/index.d.ts +8 -0
  73. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
  74. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
  75. package/dist/types/sdk/client/client.d.ts +39 -4
  76. package/dist/types/sdk/host/host.d.ts +1 -0
  77. package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
  78. package/dist/types/sdk/host/model-pin.d.ts +29 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +2 -1
  80. package/dist/types/sdk/lifecycle-session.d.ts +13 -0
  81. package/dist/types/sdk/router/session-router.d.ts +27 -1
  82. package/dist/types/sdk/session.d.ts +11 -1
  83. package/dist/types/session/agent-session.d.ts +36 -6
  84. package/dist/types/session/artifacts.d.ts +16 -1
  85. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  86. package/dist/types/session/session-manager.d.ts +84 -2
  87. package/dist/types/session/session-staging-paths.d.ts +3 -0
  88. package/dist/types/skill-state/canonical-skills.d.ts +1 -1
  89. package/dist/types/skill-state/workflow-hud.d.ts +12 -21
  90. package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
  91. package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
  92. package/dist/types/system-prompt.d.ts +23 -0
  93. package/dist/types/task/executor.d.ts +35 -4
  94. package/dist/types/task/index.d.ts +17 -3
  95. package/dist/types/task/receipt.d.ts +3 -1
  96. package/dist/types/task/types.d.ts +144 -0
  97. package/dist/types/tools/atomic-file-write.d.ts +72 -0
  98. package/dist/types/tools/descriptors.d.ts +1 -0
  99. package/dist/types/tools/index.d.ts +36 -1
  100. package/dist/types/tools/move-session.d.ts +40 -0
  101. package/dist/types/tools/python.d.ts +12 -0
  102. package/dist/types/tools/resource-gc.d.ts +0 -3
  103. package/dist/types/tools/search.d.ts +8 -1
  104. package/dist/types/tools/skill.d.ts +9 -4
  105. package/dist/types/tools/subagent.d.ts +33 -3
  106. package/dist/types/utils/xml-escape.d.ts +2 -0
  107. package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
  108. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
  109. package/package.json +11 -12
  110. package/scripts/build-sdk-package-smoke.ts +7 -5
  111. package/scripts/check-autorouting-tier-map.ts +183 -0
  112. package/scripts/generate-sdk-operation-inventory.ts +12 -0
  113. package/scripts/generate-tool-catalog.ts +40 -5
  114. package/src/async/job-manager.ts +5 -2
  115. package/src/autoresearch/dashboard.ts +134 -314
  116. package/src/autoresearch/data-context.ts +35 -0
  117. package/src/autoresearch/git.ts +186 -26
  118. package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
  119. package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
  120. package/src/autoresearch/prompts/prompt.md +103 -0
  121. package/src/autoresearch/prompts.ts +126 -0
  122. package/src/autoresearch/runs.ts +708 -0
  123. package/src/autoresearch/types.ts +9 -150
  124. package/src/capability/index.ts +4 -4
  125. package/src/capability/types.ts +20 -0
  126. package/src/cli/accounts-cli.ts +21 -5
  127. package/src/cli/config-cli.ts +25 -1
  128. package/src/cli/fast-help.ts +1 -2
  129. package/src/cli/list-models.ts +5 -8
  130. package/src/cli/setup-cli.ts +8 -0
  131. package/src/cli.ts +2 -21
  132. package/src/commands/autoresearch.ts +33 -0
  133. package/src/commands/coordinator.ts +15 -0
  134. package/src/commands/launch.ts +2 -4
  135. package/src/commands/quick-lane.ts +1 -1
  136. package/src/commands/sdk.ts +24 -0
  137. package/src/commands/state.ts +1 -1
  138. package/src/config/autorouting-contract.ts +474 -0
  139. package/src/config/autorouting-generator.ts +238 -0
  140. package/src/config/autorouting-tier-map.ts +6108 -0
  141. package/src/config/autorouting.ts +120 -0
  142. package/src/config/model-discovery-manager.ts +42 -11
  143. package/src/config/model-registry.ts +348 -68
  144. package/src/config/provider-selection-policy.ts +60 -9
  145. package/src/config/settings-schema.ts +195 -6
  146. package/src/config/settings.ts +62 -1
  147. package/src/config.ts +2 -3
  148. package/src/coordinator/contract.ts +0 -1
  149. package/src/coordinator-mcp/codex-handoff.ts +68 -42
  150. package/src/coordinator-mcp/durability.ts +200 -0
  151. package/src/coordinator-mcp/event-webhook.ts +278 -0
  152. package/src/coordinator-mcp/question-state.ts +109 -46
  153. package/src/coordinator-mcp/server.ts +932 -504
  154. package/src/crash/index-store.ts +126 -22
  155. package/src/crash/record-loader.ts +7 -0
  156. package/src/crash/upstream/relay.ts +82 -34
  157. package/src/cursor.ts +84 -1
  158. package/src/debug/index.ts +26 -0
  159. package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
  160. package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
  161. package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
  162. package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
  163. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
  164. package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
  165. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
  166. package/src/defaults/gjc-defaults.ts +89 -2
  167. package/src/defaults/gjc-skills.generated.ts +24 -17
  168. package/src/discovery/builtin.ts +18 -5
  169. package/src/discovery/helpers.ts +6 -6
  170. package/src/edit/index.ts +5 -2
  171. package/src/eval/py/executor.ts +8 -0
  172. package/src/extensibility/extensions/loader.ts +135 -13
  173. package/src/extensibility/extensions/wrapper.ts +1 -2
  174. package/src/extensibility/gjc-plugins/schema.ts +1 -1
  175. package/src/extensibility/runtime-skill-discovery.ts +2 -2
  176. package/src/extensibility/skill-management.ts +2 -3
  177. package/src/extensibility/skills.ts +16 -8
  178. package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
  179. package/src/gjc-runtime/gc-render.ts +0 -1
  180. package/src/gjc-runtime/gc-runtime.ts +1 -11
  181. package/src/gjc-runtime/launch-tmux.ts +3 -3
  182. package/src/gjc-runtime/psmux-detect.ts +2 -2
  183. package/src/gjc-runtime/python-transcript.ts +58 -0
  184. package/src/gjc-runtime/ralplan-runtime.ts +6 -18
  185. package/src/gjc-runtime/session-layout.ts +18 -7
  186. package/src/gjc-runtime/session-state-sidecar.ts +58 -9
  187. package/src/gjc-runtime/state-renderer.ts +0 -38
  188. package/src/gjc-runtime/state-runtime.ts +43 -22
  189. package/src/gjc-runtime/state-schema.ts +1 -1
  190. package/src/gjc-runtime/tmux-common.ts +7 -7
  191. package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
  192. package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
  193. package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
  194. package/src/gjc-runtime/workflow-command-ref.ts +15 -11
  195. package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
  196. package/src/gjc-runtime/workflow-manifest.ts +43 -83
  197. package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
  198. package/src/hooks/skill-keywords.ts +3 -3
  199. package/src/hooks/skill-state.ts +2 -2
  200. package/src/internal-urls/docs-index.generated.ts +26 -24
  201. package/src/lsp/index.ts +25 -5
  202. package/src/main.ts +2 -27
  203. package/src/modes/acp/acp-agent.ts +200 -75
  204. package/src/modes/components/model-selector.ts +216 -13
  205. package/src/modes/components/oauth-selector.ts +5 -5
  206. package/src/modes/components/skill-hud/render.ts +173 -35
  207. package/src/modes/components/smart-routing-panel.ts +531 -0
  208. package/src/modes/components/tool-status-header.ts +6 -3
  209. package/src/modes/controllers/command-controller.ts +17 -7
  210. package/src/modes/controllers/input-controller.ts +44 -9
  211. package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
  212. package/src/modes/controllers/selector-controller.ts +298 -65
  213. package/src/modes/interactive-mode.ts +8 -1
  214. package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
  215. package/src/modes/types.ts +9 -1
  216. package/src/prompts/agents/planner.md +1 -1
  217. package/src/prompts/system/system-prompt.md +1 -1
  218. package/src/prompts/system/volatile-project-context.md +2 -1
  219. package/src/prompts/tools/move-session.md +15 -0
  220. package/src/prompts/tools/python.md +12 -0
  221. package/src/prompts/tools/skill-discovery.md +1 -1
  222. package/src/prompts/tools/skill.md +2 -2
  223. package/src/prompts/tools/task-summary.md +1 -0
  224. package/src/prompts/tools/task.md +6 -0
  225. package/src/rlm/artifacts.ts +12 -10
  226. package/src/runtime/optional-runtime-services.ts +3 -2
  227. package/src/runtime/workspace-tree-service.ts +11 -3
  228. package/src/runtime-mcp/config.ts +8 -1
  229. package/src/runtime-mcp/manager.ts +33 -0
  230. package/src/sdk/acp/adapter.ts +48 -1
  231. package/src/sdk/broker/broker.ts +30 -2
  232. package/src/sdk/broker/discovery.ts +217 -2
  233. package/src/sdk/broker/lifecycle.ts +385 -59
  234. package/src/sdk/broker/session-index.ts +243 -48
  235. package/src/sdk/broker/transport.ts +1 -0
  236. package/src/sdk/bus/chat-daemon-control.ts +8 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
  238. package/src/sdk/bus/index.ts +208 -11
  239. package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
  240. package/src/sdk/bus/reconciliation-store.ts +27 -5
  241. package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
  242. package/src/sdk/client/client.ts +204 -22
  243. package/src/sdk/host/host.ts +16 -0
  244. package/src/sdk/host/internal-autorouting-state.ts +13 -0
  245. package/src/sdk/host/model-pin.ts +101 -0
  246. package/src/sdk/host/reverse-leases.ts +2 -1
  247. package/src/sdk/host/session-runtime.test.ts +224 -1
  248. package/src/sdk/host/session-runtime.ts +125 -37
  249. package/src/sdk/lifecycle/service.ts +3 -1
  250. package/src/sdk/lifecycle-session.ts +42 -1
  251. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  252. package/src/sdk/router/session-router.ts +375 -36
  253. package/src/sdk/session.ts +547 -47
  254. package/src/session/account-inventory.ts +1 -1
  255. package/src/session/agent-session.ts +765 -189
  256. package/src/session/artifacts.ts +234 -4
  257. package/src/session/internal/managed-session-storage.ts +91 -4
  258. package/src/session/provider-safety-stop-hint.ts +3 -2
  259. package/src/session/session-manager.ts +775 -16
  260. package/src/session/session-staging-paths.ts +8 -0
  261. package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
  262. package/src/setup/host-plugin-setup.ts +2 -2
  263. package/src/skill-state/active-state.ts +2 -2
  264. package/src/skill-state/canonical-skills.ts +1 -1
  265. package/src/skill-state/initial-phase.ts +1 -1
  266. package/src/skill-state/workflow-hud.ts +41 -41
  267. package/src/skill-state/workflow-mutation-guard.ts +26 -7
  268. package/src/slash-commands/builtin-registry.ts +77 -0
  269. package/src/slash-commands/helpers/autorouting-status.ts +55 -0
  270. package/src/system-prompt.ts +102 -5
  271. package/src/task/discovery.ts +2 -3
  272. package/src/task/executor.ts +617 -43
  273. package/src/task/index.ts +262 -15
  274. package/src/task/receipt.ts +30 -0
  275. package/src/task/render.ts +11 -1
  276. package/src/task/types.ts +316 -0
  277. package/src/tools/atomic-file-write.ts +594 -0
  278. package/src/tools/bisect.ts +3 -2
  279. package/src/tools/descriptors.test.ts +3 -0
  280. package/src/tools/descriptors.ts +24 -0
  281. package/src/tools/image-gen.ts +5 -1
  282. package/src/tools/index.ts +62 -5
  283. package/src/tools/move-session.ts +107 -0
  284. package/src/tools/python.ts +205 -0
  285. package/src/tools/read.ts +88 -15
  286. package/src/tools/renderers.ts +2 -0
  287. package/src/tools/resource-gc.ts +8 -177
  288. package/src/tools/search.ts +30 -6
  289. package/src/tools/skill-discovery.ts +1 -0
  290. package/src/tools/skill.ts +37 -7
  291. package/src/tools/subagent-render.ts +119 -13
  292. package/src/tools/subagent.ts +91 -74
  293. package/src/tools/tool-catalog.generated.ts +64 -2
  294. package/src/tools/write.ts +16 -12
  295. package/src/utils/xml-escape.ts +14 -0
  296. package/src/web/search/providers/codex.ts +2 -2
  297. package/src/workflow/workflow-intent-diff.ts +13 -12
  298. package/src/workflow/workflow-intent-report-consumer.ts +6 -6
  299. package/dist/types/autoresearch/helpers.d.ts +0 -24
  300. package/dist/types/autoresearch/index.d.ts +0 -2
  301. package/dist/types/autoresearch/state.d.ts +0 -17
  302. package/dist/types/autoresearch/storage.d.ts +0 -142
  303. package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
  304. package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
  305. package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
  306. package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
  307. package/dist/types/commands/team.d.ts +0 -24
  308. package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
  309. package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
  310. package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
  311. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
  312. package/dist/types/gjc-runtime/team-store.d.ts +0 -168
  313. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
  314. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
  315. package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
  316. package/dist/types/rlm/index.d.ts +0 -47
  317. package/dist/types/rlm/preset.d.ts +0 -23
  318. package/dist/types/rlm/python-tool.d.ts +0 -16
  319. package/src/autoresearch/command-resume.md +0 -14
  320. package/src/autoresearch/index.ts +0 -536
  321. package/src/autoresearch/prompt.md +0 -103
  322. package/src/autoresearch/resume-message.md +0 -10
  323. package/src/autoresearch/state.ts +0 -273
  324. package/src/autoresearch/storage.ts +0 -699
  325. package/src/autoresearch/tools/init-experiment.ts +0 -272
  326. package/src/autoresearch/tools/log-experiment.ts +0 -524
  327. package/src/autoresearch/tools/run-experiment.ts +0 -475
  328. package/src/autoresearch/tools/update-notes.ts +0 -109
  329. package/src/commands/rlm.ts +0 -19
  330. package/src/commands/team.ts +0 -238
  331. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
  332. package/src/defaults/gjc/skills/team/SKILL.md +0 -467
  333. package/src/gjc-runtime/team-gc.ts +0 -49
  334. package/src/gjc-runtime/team-launch.ts +0 -404
  335. package/src/gjc-runtime/team-notify.ts +0 -326
  336. package/src/gjc-runtime/team-runtime.ts +0 -5923
  337. package/src/gjc-runtime/team-store.ts +0 -878
  338. package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
  339. package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
  340. package/src/gjc-runtime/team-workers.ts +0 -1123
  341. package/src/prompts/system/rlm-report-command.md +0 -1
  342. package/src/prompts/system/rlm-research.md +0 -23
  343. package/src/rlm/index.ts +0 -365
  344. package/src/rlm/preset.ts +0 -76
  345. package/src/rlm/python-tool.ts +0 -68
@@ -146,6 +146,74 @@ const dirFor = (agentDir: string) => path.join(agentDir, "sdk", "sessions");
146
146
  const logFor = (agentDir: string) => path.join(dirFor(agentDir), "index.jsonl");
147
147
  const snapshotFor = (agentDir: string) => path.join(dirFor(agentDir), "index.snapshot.json");
148
148
  const auditFor = (agentDir: string) => path.join(dirFor(agentDir), "index-audit.jsonl");
149
+ /**
150
+ * Idle-poll change stamp over the two index files (#4689). A polling reader
151
+ * that has already replayed the index must be able to prove "nothing changed"
152
+ * with two stats instead of re-acquiring the machine-global lock and
153
+ * re-parsing the whole log every cycle.
154
+ *
155
+ * Timestamps alone are NOT a durable-equality proof: on a coarse-resolution
156
+ * filesystem a same-size write inside one tick reports identical `mtimeMs` and
157
+ * `ctimeMs` (measured: ~98% of back-to-back same-size rewrites collide on
158
+ * both). The stamp is therefore anchored on the two fields the cooperative
159
+ * writer protocol cannot leave untouched:
160
+ * - append (`appendSync`) always grows `size`;
161
+ * - rename-replace (`replaceAtomically`, used for snapshots and rotation)
162
+ * always installs a new inode, so `ino` changes even when the replacement
163
+ * has an identical byte length (measured: 0/3000 inode collisions).
164
+ * `mtimeMs`/`ctimeMs` are retained as an extra signal for foreign in-place
165
+ * edits that keep both size and inode, which the writer protocol never does.
166
+ */
167
+ interface SessionIndexFileStamp {
168
+ readonly exists: boolean;
169
+ readonly size: number;
170
+ /**
171
+ * Kept as `bigint` so a 64-bit inode / Windows file id above
172
+ * `Number.MAX_SAFE_INTEGER` cannot collapse onto another id and make a real
173
+ * replacement read as unchanged (#4730 review).
174
+ */
175
+ readonly ino: bigint;
176
+ readonly mtimeMs: number;
177
+ readonly ctimeMs: number;
178
+ }
179
+ interface SessionIndexChangeStamp {
180
+ readonly log: SessionIndexFileStamp;
181
+ readonly snapshot: SessionIndexFileStamp;
182
+ }
183
+ async function statIndexFile(file: string): Promise<SessionIndexFileStamp> {
184
+ try {
185
+ const stat = await fs.stat(file, { bigint: true });
186
+ return {
187
+ exists: true,
188
+ size: Number(stat.size),
189
+ ino: stat.ino,
190
+ mtimeMs: Number(stat.mtimeMs),
191
+ ctimeMs: Number(stat.ctimeMs),
192
+ };
193
+ } catch (error) {
194
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
195
+ return { exists: false, size: 0, ino: 0n, mtimeMs: 0, ctimeMs: 0 };
196
+ throw error;
197
+ }
198
+ }
199
+ async function readIndexChangeStamp(agentDir: string): Promise<SessionIndexChangeStamp> {
200
+ const [log, snapshot] = await Promise.all([statIndexFile(logFor(agentDir)), statIndexFile(snapshotFor(agentDir))]);
201
+ return { log, snapshot };
202
+ }
203
+ function sameIndexChangeStamp(a: SessionIndexChangeStamp, b: SessionIndexChangeStamp): boolean {
204
+ return (
205
+ a.log.exists === b.log.exists &&
206
+ a.log.size === b.log.size &&
207
+ a.log.ino === b.log.ino &&
208
+ a.log.mtimeMs === b.log.mtimeMs &&
209
+ a.log.ctimeMs === b.log.ctimeMs &&
210
+ a.snapshot.exists === b.snapshot.exists &&
211
+ a.snapshot.size === b.snapshot.size &&
212
+ a.snapshot.ino === b.snapshot.ino &&
213
+ a.snapshot.mtimeMs === b.snapshot.mtimeMs &&
214
+ a.snapshot.ctimeMs === b.snapshot.ctimeMs
215
+ );
216
+ }
149
217
  const ROTATE_BYTES = 4 * 1024 * 1024;
150
218
  /** Coalesced heartbeat checkpoint rate cap (C2): at most one per session per minute. */
151
219
  export const SESSION_HEARTBEAT_INTERVAL_MS = 60_000;
@@ -723,35 +791,60 @@ async function syncDirectory(file: string): Promise<void> {
723
791
  if (process.platform === "win32" && (code === "EPERM" || code === "EACCES")) return;
724
792
  throw error;
725
793
  }
794
+ let syncError: unknown;
726
795
  try {
727
796
  await handle.sync();
728
797
  } catch (error) {
729
798
  const code = (error as NodeJS.ErrnoException).code;
730
- if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES")) throw error;
731
- } finally {
799
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES")) syncError = error;
800
+ }
801
+ try {
732
802
  await handle.close();
803
+ } catch (closeError) {
804
+ if (syncError) throw new AggregateError([syncError, closeError], "session-index directory sync and close failed");
805
+ throw closeError;
733
806
  }
807
+ if (syncError) throw syncError;
734
808
  }
735
809
 
736
810
  async function writeAndSync(file: string, contents: Buffer | string): Promise<void> {
737
811
  const handle = await fs.open(file, "w", 0o600);
812
+ let writeError: unknown;
738
813
  try {
739
814
  await handle.writeFile(contents);
740
815
  await handle.sync();
741
- } finally {
816
+ } catch (error) {
817
+ writeError = error;
818
+ }
819
+ try {
742
820
  await handle.close();
821
+ } catch (closeError) {
822
+ if (writeError) throw new AggregateError([writeError, closeError], "session-index write and close failed");
823
+ throw closeError;
743
824
  }
825
+ if (writeError) throw writeError;
744
826
  }
745
827
 
746
828
  async function replaceAtomically(file: string, contents: Buffer | string): Promise<void> {
747
829
  const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
830
+ let primaryError: unknown;
748
831
  try {
749
832
  await writeAndSync(temporary, contents);
750
833
  await fs.rename(temporary, file);
751
834
  await syncDirectory(file);
752
- } finally {
835
+ } catch (error) {
836
+ primaryError = error;
837
+ }
838
+ let cleanupError: unknown;
839
+ try {
753
840
  await fs.rm(temporary, { force: true });
841
+ } catch (error) {
842
+ cleanupError = error;
754
843
  }
844
+ if (primaryError && cleanupError)
845
+ throw new AggregateError([primaryError, cleanupError], "session-index replace and cleanup failed");
846
+ if (primaryError) throw primaryError;
847
+ if (cleanupError) throw cleanupError;
755
848
  }
756
849
 
757
850
  function alive(pid: number): boolean {
@@ -788,6 +881,11 @@ export class SessionIndex {
788
881
  #warnings: string[] = [];
789
882
  #logOffset = 0;
790
883
  #corruptSuffix = false;
884
+ /**
885
+ * Last change stamp observed by a completed locked pass (#4689); lets the
886
+ * polling refresh path prove "index unchanged" with two stats.
887
+ */
888
+ #changeStamp: SessionIndexChangeStamp | undefined;
791
889
  /** indexSeqs already recorded in the durable audit; null until lazily seeded. */
792
890
  #auditedSeq: Set<number> | null = null;
793
891
  constructor(agentDir: string, policy: RetentionPolicy = {}) {
@@ -836,6 +934,77 @@ export class SessionIndex {
836
934
  withSessionIndexLock("replay", this.#agentDir, () => this.#replayUnderLock()),
837
935
  );
838
936
  }
937
+ /**
938
+ * Polling-path refresh (#4689): when both index files carry the exact change
939
+ * stamp of the last completed locked pass, the in-memory projection is
940
+ * already current and the locked rescan is skipped entirely — this is what
941
+ * keeps an idle SessionRouter reconcile (2s cadence) from re-parsing and
942
+ * re-checksumming the whole index forever. An append committed before the
943
+ * stat always changes the stamp, so a miss is impossible; a change landing
944
+ * after the stat is seen on the next poll, the same TOCTOU envelope a
945
+ * locked read has. A corrupt suffix never takes the fast path: re-scanning
946
+ * preserves the existing re-diagnosis behavior. Returns true when state was
947
+ * reloaded. Authority revalidation that needs the strongest available
948
+ * snapshot inside an already-locked write (append, unregister) keeps using
949
+ * the exact locked paths.
950
+ */
951
+ async refreshIfChanged(): Promise<boolean> {
952
+ if (this.#changeStamp !== undefined && !this.#corruptSuffix) {
953
+ const stamp = await readIndexChangeStamp(this.#agentDir);
954
+ if (sameIndexChangeStamp(this.#changeStamp, stamp)) return false;
955
+ // A possible change is re-classified UNDER the lock (#4689 review):
956
+ // writers mutate these files only while holding it, so the locked
957
+ // observation is atomic with the tail/replay it selects. The
958
+ // unlocked stamp above is only the cheap "definitely unchanged" cut.
959
+ const indexPath = path.resolve(logFor(this.#agentDir));
960
+ await SessionIndex.#enqueue(indexPath, () =>
961
+ withSessionIndexLock("poll-refresh", this.#agentDir, () => this.#refreshOrReplayUnderLock()),
962
+ );
963
+ return true;
964
+ }
965
+ await this.open();
966
+ await this.refresh();
967
+ return true;
968
+ }
969
+ /**
970
+ * Locked change classification (#4689). Append-only log growth with an
971
+ * untouched snapshot tails from the last offset; anything else — in-place
972
+ * rewrite, truncation, rotation, snapshot replacement, existence
973
+ * transitions, or an append/compact interleaving that outran the unlocked
974
+ * pre-check — takes the full replay. Metadata cannot prove prefix identity
975
+ * against a writer that rewrites history in place while growing the file;
976
+ * that boundary is the cooperative locked-writer protocol, which never
977
+ * does that (appendSync, or rename-replace with a fresh snapshot).
978
+ */
979
+ async #refreshOrReplayUnderLock(): Promise<void> {
980
+ const stamp = await readIndexChangeStamp(this.#agentDir);
981
+ const prior = this.#changeStamp;
982
+ // Append-only growth with an untouched snapshot tails from the last
983
+ // offset; anything else replays in full.
984
+ // The inode is load-bearing on BOTH files here (#4730 review): tailing is
985
+ // only sound if this is the SAME log file grown in place and the SAME
986
+ // snapshot file. A rename-over installs a new inode while size and
987
+ // timestamps can all still match, so without these two comparisons a log
988
+ // that grew while the snapshot was replaced by a same-size, same-timestamp
989
+ // file would be misclassified as append-only, and the reader would tail
990
+ // from its last offset while caching a stale compacted projection.
991
+ if (
992
+ prior?.log.exists &&
993
+ stamp.log.exists &&
994
+ stamp.log.ino === prior.log.ino &&
995
+ stamp.log.size > prior.log.size &&
996
+ stamp.snapshot.exists === prior.snapshot.exists &&
997
+ stamp.snapshot.ino === prior.snapshot.ino &&
998
+ stamp.snapshot.size === prior.snapshot.size &&
999
+ stamp.snapshot.mtimeMs === prior.snapshot.mtimeMs &&
1000
+ stamp.snapshot.ctimeMs === prior.snapshot.ctimeMs
1001
+ )
1002
+ await this.#tailUnderLock();
1003
+ else await this.#replayUnderLock();
1004
+ // #replayUnderLock recaptures the stamp itself; the tail path needs it
1005
+ // captured here (refresh() does this via #refreshUnderLock).
1006
+ this.#changeStamp = await readIndexChangeStamp(this.#agentDir);
1007
+ }
839
1008
  async #replayUnderLock(): Promise<void> {
840
1009
  const scan = await this.#scan();
841
1010
  if (scan.diagnosis.status === "unsupported") throw scan.unsupportedError!;
@@ -846,6 +1015,7 @@ export class SessionIndex {
846
1015
  if (scan.diagnosis.reason === "invalid snapshot") this.#warnings.push("Invalid session index snapshot");
847
1016
  if (this.#corruptSuffix) this.#warnings.push("Corrupt session index entry; replay truncated");
848
1017
  await this.#writeAuditUnderLock();
1018
+ this.#changeStamp = await readIndexChangeStamp(this.#agentDir);
849
1019
  }
850
1020
  /** Seed the audit dedupe set once, then append records for newly-rejected events. */
851
1021
  async #writeAuditUnderLock(): Promise<void> {
@@ -1032,43 +1202,44 @@ export class SessionIndex {
1032
1202
  const indexPath = path.resolve(logFor(this.#agentDir));
1033
1203
  return await SessionIndex.#enqueue(indexPath, async () => {
1034
1204
  await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
1035
- return await withSessionIndexLock("repair", this.#agentDir, async () => {
1036
- const scan = await this.#scan();
1037
- if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
1038
- if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
1039
- const quarantineBase = path.join(dirFor(this.#agentDir), "quarantine");
1040
- await fs.mkdir(quarantineBase, { recursive: true, mode: 0o700 });
1041
- await syncDirectory(quarantineBase);
1042
- const quarantinePath = path.join(quarantineBase, `repair-${Date.now()}-${process.pid}-${randomUUID()}`);
1043
- await fs.mkdir(quarantinePath, { mode: 0o700 });
1044
- await syncDirectory(quarantinePath);
1045
- if (scan.snapshotContents)
1046
- await writeAndSync(path.join(quarantinePath, "index.snapshot.json"), scan.snapshotContents);
1047
- if (scan.logContents) await writeAndSync(path.join(quarantinePath, "index.jsonl"), scan.logContents);
1048
- await syncDirectory(path.join(quarantinePath, "index.jsonl"));
1049
- // Repair republishes the surviving history as the new snapshot, so it must
1050
- // apply the same retention the ordinary snapshot path applies. Writing the
1051
- // raw survivor set instead let one repair of a long-lived index restore an
1052
- // unbounded snapshot, and every later locked transaction then re-parsed that
1053
- // whole history while holding the index lock — the broker burns CPU and
1054
- // unrelated launches time out waiting for the lock.
1055
- const events = compactEvents([...scan.snapshotEvents, ...scan.validLogEvents], this.#policy);
1056
- const snapshot = JSON.stringify({
1057
- version: SESSION_INDEX_SNAPSHOT_VERSION,
1058
- indexSeq: scan.diagnosis.validPrefixSeq,
1059
- events,
1060
- });
1061
- await replaceAtomically(snapshotFor(this.#agentDir), snapshot);
1062
- // The republished snapshot already carries the full surviving history (after
1063
- // compaction), so the log must be truncated to match: leaving the original
1064
- // events in place keeps every subsequent #scan() parsing them under the lock
1065
- // — the same starvation the compaction above is meant to end. This mirrors
1066
- // #rotate(), which writes an empty log after snapshotting.
1067
- await replaceAtomically(logFor(this.#agentDir), "");
1068
- await this.#replayUnderLock();
1069
- return { ...scan.diagnosis, repaired: true, quarantinePath };
1070
- });
1205
+ return await withSessionIndexLock("repair", this.#agentDir, async () => await this.#repairUnderLock());
1206
+ });
1207
+ }
1208
+ async #repairUnderLock(): Promise<SessionIndexRepairResult> {
1209
+ const scan = await this.#scan();
1210
+ if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
1211
+ if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
1212
+ const quarantineBase = path.join(dirFor(this.#agentDir), "quarantine");
1213
+ await fs.mkdir(quarantineBase, { recursive: true, mode: 0o700 });
1214
+ await syncDirectory(quarantineBase);
1215
+ const quarantinePath = path.join(quarantineBase, `repair-${Date.now()}-${process.pid}-${randomUUID()}`);
1216
+ await fs.mkdir(quarantinePath, { mode: 0o700 });
1217
+ await syncDirectory(quarantinePath);
1218
+ if (scan.snapshotContents)
1219
+ await writeAndSync(path.join(quarantinePath, "index.snapshot.json"), scan.snapshotContents);
1220
+ if (scan.logContents) await writeAndSync(path.join(quarantinePath, "index.jsonl"), scan.logContents);
1221
+ await syncDirectory(path.join(quarantinePath, "index.jsonl"));
1222
+ // Repair republishes the surviving history as the new snapshot, so it must
1223
+ // apply the same retention the ordinary snapshot path applies. Writing the
1224
+ // raw survivor set instead let one repair of a long-lived index restore an
1225
+ // unbounded snapshot, and every later locked transaction then re-parsed that
1226
+ // whole history while holding the index lock — the broker burns CPU and
1227
+ // unrelated launches time out waiting for the lock.
1228
+ const events = compactEvents([...scan.snapshotEvents, ...scan.validLogEvents], this.#policy);
1229
+ const snapshot = JSON.stringify({
1230
+ version: SESSION_INDEX_SNAPSHOT_VERSION,
1231
+ indexSeq: scan.diagnosis.validPrefixSeq,
1232
+ events,
1071
1233
  });
1234
+ await replaceAtomically(snapshotFor(this.#agentDir), snapshot);
1235
+ // The republished snapshot already carries the full surviving history (after
1236
+ // compaction), so the log must be truncated to match: leaving the original
1237
+ // events in place keeps every subsequent #scan() parsing them under the lock
1238
+ // — the same starvation the compaction above is meant to end. This mirrors
1239
+ // #rotate(), which writes an empty log after snapshotting.
1240
+ await replaceAtomically(logFor(this.#agentDir), "");
1241
+ await this.#replayUnderLock();
1242
+ return { ...scan.diagnosis, repaired: true, quarantinePath };
1072
1243
  }
1073
1244
  async #tailUnderLock(snapshotSeq = this.indexSeq, allowResync = true): Promise<void> {
1074
1245
  let data: Buffer;
@@ -1129,8 +1300,15 @@ export class SessionIndex {
1129
1300
  );
1130
1301
  }
1131
1302
  async #refreshUnderLock(): Promise<void> {
1132
- await this.#tailUnderLock();
1303
+ // A newly constructed reader has no replay cursor. Refresh must still load
1304
+ // snapshot-carried authority before tailing the empty log; otherwise a cold
1305
+ // router starting after compaction projects no sessions until a later append.
1306
+ if (this.#events.length === 0 && this.#logOffset === 0) await this.#replayUnderLock();
1307
+ else await this.#tailUnderLock();
1133
1308
  await this.#writeAuditUnderLock();
1309
+ // #tailUnderLock may delegate to #replayUnderLock (which re-captures);
1310
+ // capturing here covers the tail-only and missing-log paths.
1311
+ this.#changeStamp = await readIndexChangeStamp(this.#agentDir);
1134
1312
  }
1135
1313
  get indexSeq(): number {
1136
1314
  return this.#events.at(-1)?.indexSeq ?? 0;
@@ -1156,10 +1334,25 @@ export class SessionIndex {
1156
1334
  ownIncarnation = input.processIncarnation ?? processIncarnation(input.pid);
1157
1335
  return await withSessionIndexLock("append", this.#agentDir, async () => {
1158
1336
  await this.#replayUnderLock();
1159
- if (this.#corruptSuffix)
1160
- throw new Error(
1161
- "Cannot append to corrupt session index log; run `gjc gc --repair-session-index` to quarantine evidence and retain the valid prefix",
1337
+ if (this.#corruptSuffix) {
1338
+ // A corrupt suffix used to hard-fail every append until a human ran
1339
+ // `gjc gc --repair-session-index` — but the writers that corrupt the
1340
+ // log (typically a stale long-lived broker signing events against an
1341
+ // outdated in-memory indexSeq) keep appending, so the operator-run
1342
+ // repair was re-corrupted within minutes and delegated session
1343
+ // launches stayed dead indefinitely. Run the same quarantine-backed
1344
+ // repair inline instead: evidence is preserved under
1345
+ // sessions/quarantine, the valid prefix is republished, and this
1346
+ // append proceeds against the repaired index.
1347
+ const repair = await this.#repairUnderLock();
1348
+ if (this.#corruptSuffix)
1349
+ throw new Error(
1350
+ "Cannot append to corrupt session index log; automatic repair did not converge — run `gjc gc --repair-session-index` to quarantine evidence and retain the valid prefix",
1351
+ );
1352
+ logger.warn(
1353
+ `sdk broker: session index self-repaired before append (${repair.reason ?? "corrupt suffix"}); evidence quarantined at ${repair.quarantinePath ?? "unknown"}`,
1162
1354
  );
1355
+ }
1163
1356
  const unsigned: Omit<SessionIndexEvent, "checksum"> = {
1164
1357
  ...input,
1165
1358
  version: SDK_STATE_VERSION,
@@ -1325,11 +1518,13 @@ export class SessionIndex {
1325
1518
  async #rotate(): Promise<void> {
1326
1519
  await this.#snapshotUnderLock();
1327
1520
  const file = logFor(this.#agentDir);
1328
- const temporary = `${file}.${process.pid}.tmp`;
1329
- await fs.writeFile(temporary, "", { mode: 0o600 });
1330
- await fs.rename(temporary, file);
1331
- await syncDirectory(file);
1521
+ await replaceAtomically(file, "");
1332
1522
  this.#logOffset = 0;
1523
+ // Sync in-memory state with the compacted snapshot while the lock is
1524
+ // held: without this the instance keeps pre-compaction events and a
1525
+ // fresh change stamp, so later refreshIfChanged() polls would never
1526
+ // observe the compaction (#4689 review).
1527
+ await this.#replayUnderLock();
1333
1528
  }
1334
1529
 
1335
1530
  listSessions(): SessionList {
@@ -17,6 +17,7 @@ const BROKER_OPERATIONS = new Set([
17
17
  "broker.shutdown",
18
18
  "session.control",
19
19
  "broker.lookup_lifecycle",
20
+ "model.resolve",
20
21
  ]);
21
22
  type RequestInput = Record<string, unknown>;
22
23
  type BrokerRequest = {
@@ -124,10 +124,16 @@ export type ChatDaemonAction = "stop" | "reload";
124
124
  * Discord generation 65 / Slack generation 68 fence the off-reconcile-tail
125
125
  * initial attachment replay introduced by #4542 so pre-upgrade daemon owners
126
126
  * cannot retain the earlier SessionRouter attachment contract.
127
+ * The Discord 67 / Slack 70 fence covers the SessionRouter idle-poll/change-stamp
128
+ * rollout (#4689). Both daemons construct a
129
+ * SessionRouter whose idle tick no longer re-acquires the machine-global
130
+ * session-index lock every 2s; staleness retirement moved to a 30s sweep and
131
+ * lease heartbeats no longer force an authority reconcile. A pre-upgrade owner
132
+ * would retain the old hot polling loop, so replacement is required.
127
133
  */
128
134
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
129
- discord: 66,
130
- slack: 69,
135
+ discord: 67,
136
+ slack: 70,
131
137
  };
132
138
 
133
139
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -365,6 +365,11 @@ export class ChatDaemonRuntime {
365
365
  return this.#transportHealthy?.() ?? false;
366
366
  }
367
367
 
368
+ /** Reconcile indexed attachments and optionally wait for their replay tails. */
369
+ async reconcile(options: { waitForReplay?: boolean } = {}): Promise<void> {
370
+ await this.#router.reconcile(options);
371
+ }
372
+
368
373
  async stop(): Promise<void> {
369
374
  const providerResults = await Promise.allSettled([this.#discord?.stop(), this.#slack?.stop()]);
370
375
  const discordRestartBlocked = this.#discord?.restartBlocked() ?? false;