@repo-os/repoos 0.5.45
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.
- package/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
|
@@ -0,0 +1,4070 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent runner: launches the repo's default coding agent against a task in
|
|
3
|
+
* its worktree, tracks running processes, and supports graceful pause.
|
|
4
|
+
*
|
|
5
|
+
* Zero runtime deps — `node:child_process` only. Everything here is best-effort:
|
|
6
|
+
* a missing CLI or a broken agent config must never crash the server or block
|
|
7
|
+
* an HTTP response, so spawns are async-fired and failures surface as events.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
12
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
import { agentsForConfig, defaultMaxConcurrentAgents } from "../core/config.js";
|
|
15
|
+
import { fileCommittedClean, currentBranch } from "../core/git.js";
|
|
16
|
+
import { buildIndex } from "../core/indexer.js";
|
|
17
|
+
import { parseTask, serializeTask, recordChange } from "../core/task.js";
|
|
18
|
+
import { patchTaskFile } from "./write.js";
|
|
19
|
+
import { stripAnsi } from "./done.js";
|
|
20
|
+
import { getRepoOSDb } from "../core/db.js";
|
|
21
|
+
import { listSkills } from "./routes/helpers.js";
|
|
22
|
+
export const HANDOFF_READY_SIGNAL = "::repoos-handoff-ready::";
|
|
23
|
+
/**
|
|
24
|
+
* The exact line a sandboxed agent emits to request ITS managed preview
|
|
25
|
+
* (#0121). Like the handoff signal, it is an output-only intent: the agent
|
|
26
|
+
* never touches localhost or picks a port — the runner binds it to the live
|
|
27
|
+
* run and the server starts/probes the preview on the agent's behalf.
|
|
28
|
+
*/
|
|
29
|
+
export const PREVIEW_REQUEST_SIGNAL = "::repoos-preview-request::";
|
|
30
|
+
/**
|
|
31
|
+
* The transcript marker appended when a user interrupts an in-flight AI chat
|
|
32
|
+
* response. Shared so the runner and the CTO manager (and tests) never drift.
|
|
33
|
+
*/
|
|
34
|
+
export const INTERRUPTED_MARKER = "— response interrupted —";
|
|
35
|
+
const now = () => new Date().toISOString();
|
|
36
|
+
/** Hard cap on a session transcript (drop oldest lines beyond this). */
|
|
37
|
+
const OUTPUT_CAP_BYTES = 256 * 1024;
|
|
38
|
+
/** Path to the durable agent registry (0214). */
|
|
39
|
+
function registryPath(cacheDir) {
|
|
40
|
+
return join(cacheDir, "agents.json");
|
|
41
|
+
}
|
|
42
|
+
/** Path to the pending-handoffs store (#0235). */
|
|
43
|
+
function pendingHandoffsPath(cacheDir) {
|
|
44
|
+
return join(cacheDir, "pending-handoffs.json");
|
|
45
|
+
}
|
|
46
|
+
/** Read the pending handoff store; returns empty when missing or corrupted. */
|
|
47
|
+
function readPendingHandoffs(cacheDir) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(pendingHandoffsPath(cacheDir), "utf8");
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (Array.isArray(parsed.requests)) {
|
|
52
|
+
return {
|
|
53
|
+
requests: parsed.requests.filter((r) => typeof r.taskId === "string" &&
|
|
54
|
+
typeof r.runId === "string" &&
|
|
55
|
+
typeof r.branch === "string" &&
|
|
56
|
+
typeof r.workdir === "string"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* missing or corrupt — start fresh */
|
|
62
|
+
}
|
|
63
|
+
return { requests: [] };
|
|
64
|
+
}
|
|
65
|
+
/** Persist the pending handoff store atomically (best-effort). */
|
|
66
|
+
function writePendingHandoffs(cacheDir, store) {
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
69
|
+
const file = pendingHandoffsPath(cacheDir);
|
|
70
|
+
const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(temp, JSON.stringify(store, null, 2), "utf8");
|
|
73
|
+
renameSync(temp, file);
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (existsSync(temp))
|
|
77
|
+
unlinkSync(temp);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* best-effort */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Read the durable registry; returns empty when missing or corrupted. */
|
|
85
|
+
function readRegistry(cacheDir) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = readFileSync(registryPath(cacheDir), "utf8");
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
if (Array.isArray(parsed.entries)) {
|
|
90
|
+
return {
|
|
91
|
+
entries: parsed.entries.filter((e) => typeof e.taskId === "string" &&
|
|
92
|
+
typeof e.pid === "number" &&
|
|
93
|
+
typeof e.workdir === "string"),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* missing or corrupt — start fresh */
|
|
99
|
+
}
|
|
100
|
+
return { entries: [] };
|
|
101
|
+
}
|
|
102
|
+
/** Persist the durable registry atomically (best-effort). */
|
|
103
|
+
function writeRegistry(cacheDir, registry) {
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
106
|
+
const file = registryPath(cacheDir);
|
|
107
|
+
const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(temp, JSON.stringify(registry, null, 2), "utf8");
|
|
110
|
+
renameSync(temp, file);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (existsSync(temp))
|
|
114
|
+
unlinkSync(temp);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* best-effort */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Default silence window before a still-running turn is flagged as possibly
|
|
123
|
+
* stalled (0080). Conservative on purpose: silence alone is never proof of a
|
|
124
|
+
* hang (the agent could just be thinking through a slow step), so this only
|
|
125
|
+
* fires a neutral "may be stalled" warning, never a definitive "it's dead."
|
|
126
|
+
* `AgentRunner`'s constructor accepts an override for tests.
|
|
127
|
+
*/
|
|
128
|
+
export const DEFAULT_STALL_TIMEOUT_MS = 90_000;
|
|
129
|
+
/**
|
|
130
|
+
* Estimate cost from token count when the CLI doesn't report it explicitly.
|
|
131
|
+
* Uses rough pricing for common models: claude 3.5 sonnet $3/M input, $15/M output.
|
|
132
|
+
* This is a fallback when extractUsage yields no cost; never fabricates when
|
|
133
|
+
* the CLI provides no data at all.
|
|
134
|
+
*/
|
|
135
|
+
export function estimateCostUsd(tokens) {
|
|
136
|
+
if (!tokens || tokens < 1)
|
|
137
|
+
return undefined;
|
|
138
|
+
const avgCostPerToken = (3 + 15) / 2 / 1_000_000;
|
|
139
|
+
return Math.max(0.001, tokens * avgCostPerToken);
|
|
140
|
+
}
|
|
141
|
+
export function extractUsage(raw) {
|
|
142
|
+
const out = {};
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
if (parsed && typeof parsed === "object") {
|
|
146
|
+
const obj = parsed;
|
|
147
|
+
const inputTokens = inputTokensFromObject(obj);
|
|
148
|
+
if (inputTokens !== undefined)
|
|
149
|
+
out.inputTokens = inputTokens;
|
|
150
|
+
const outputTokens = outputTokensFromObject(obj);
|
|
151
|
+
if (outputTokens !== undefined)
|
|
152
|
+
out.outputTokens = outputTokens;
|
|
153
|
+
const totalTokens = tokensFromObject(obj);
|
|
154
|
+
if (totalTokens !== undefined)
|
|
155
|
+
out.totalTokens = totalTokens;
|
|
156
|
+
const cost = costFromObject(obj);
|
|
157
|
+
if (cost !== undefined)
|
|
158
|
+
out.costUsd = cost;
|
|
159
|
+
const cache = cacheTokensFromObject(obj);
|
|
160
|
+
if (cache.cacheReadTokens !== undefined)
|
|
161
|
+
out.cacheReadTokens = cache.cacheReadTokens;
|
|
162
|
+
if (cache.cacheCreationTokens !== undefined)
|
|
163
|
+
out.cacheCreationTokens = cache.cacheCreationTokens;
|
|
164
|
+
const turns = turnsFromObject(obj);
|
|
165
|
+
if (turns !== undefined)
|
|
166
|
+
out.turns = turns;
|
|
167
|
+
// opencode emits one `step_finish` per model round-trip; its token/cost
|
|
168
|
+
// figures are that step's delta, never a running total — fold by SUM.
|
|
169
|
+
if (obj.type === "step_finish" || obj.type === "step-finish")
|
|
170
|
+
out.deltas = true;
|
|
171
|
+
// claude's terminal turn summary — authoritative for the whole turn.
|
|
172
|
+
if (obj.type === "result" && typeof obj.total_cost_usd === "number") {
|
|
173
|
+
out.authoritative = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* not JSON — fall through to text patterns below */
|
|
179
|
+
}
|
|
180
|
+
if (out.totalTokens === undefined) {
|
|
181
|
+
const m = raw.match(/\btotal[_ ]tokens\b["'\s:=]+([\d,]+)/i) ?? raw.match(/\b([\d,]+)\s+tokens\b/i);
|
|
182
|
+
if (m) {
|
|
183
|
+
const n = Number(m[1].replace(/,/g, ""));
|
|
184
|
+
if (Number.isFinite(n))
|
|
185
|
+
out.totalTokens = n;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (out.costUsd === undefined) {
|
|
189
|
+
const m = raw.match(/total cost[:\s]+\$?([\d.]+)/i) ?? raw.match(/\bcost_usd\b["'\s:=]+\$?([\d.]+)/i);
|
|
190
|
+
if (m) {
|
|
191
|
+
const n = Number(m[1]);
|
|
192
|
+
if (Number.isFinite(n))
|
|
193
|
+
out.costUsd = n;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Kiro CLI emits a credits footer on stderr: " ▸ Credits: 0.15 • Time: 12s"
|
|
197
|
+
// Map credits → costUsd so the task panel shows the charge. Note: for Kiro
|
|
198
|
+
// sessions, costUsd holds credits (Kiro's billing unit), not US dollars.
|
|
199
|
+
if (out.costUsd === undefined) {
|
|
200
|
+
const m = raw.match(/▸\s*Credits:\s*([\d.]+)/i);
|
|
201
|
+
if (m) {
|
|
202
|
+
const n = Number(m[1]);
|
|
203
|
+
if (Number.isFinite(n))
|
|
204
|
+
out.costUsd = n;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Fold usage/cost found in a raw output line into a running total, clamped to
|
|
211
|
+
* never move backward (some CLIs report a running total, some reset per turn).
|
|
212
|
+
* Mirrors `AgentRunner.applyUsage` so one-shot roles aggregate the same way the
|
|
213
|
+
* streaming engineer runner does. Never fabricates a number — absent fields are
|
|
214
|
+
* simply left untouched.
|
|
215
|
+
*/
|
|
216
|
+
export function foldUsage(total, raw) {
|
|
217
|
+
const found = extractUsage(raw);
|
|
218
|
+
// Three fold modes:
|
|
219
|
+
// - authoritative (claude `result`): overwrite — the turn's real total.
|
|
220
|
+
// - deltas (opencode `step_finish`): SUM — each event is one round-trip.
|
|
221
|
+
// - otherwise (codex/claude running totals): Math.max — count up, never
|
|
222
|
+
// flicker down on a transient mid-stream figure.
|
|
223
|
+
const merge = (key) => {
|
|
224
|
+
const v = found[key];
|
|
225
|
+
if (v === undefined)
|
|
226
|
+
return;
|
|
227
|
+
total[key] = found.authoritative
|
|
228
|
+
? v
|
|
229
|
+
: found.deltas
|
|
230
|
+
? (total[key] ?? 0) + v
|
|
231
|
+
: Math.max(total[key] ?? 0, v);
|
|
232
|
+
};
|
|
233
|
+
merge("inputTokens");
|
|
234
|
+
merge("outputTokens");
|
|
235
|
+
merge("totalTokens");
|
|
236
|
+
merge("costUsd");
|
|
237
|
+
merge("cacheReadTokens");
|
|
238
|
+
merge("cacheCreationTokens");
|
|
239
|
+
// Turns are a per-event delta — always accumulate.
|
|
240
|
+
if (found.turns !== undefined)
|
|
241
|
+
total.turns = (total.turns ?? 0) + found.turns;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Classify the cost source for a recorded session (0230). Authoritative
|
|
245
|
+
* CLI-reported cost wins; Kiro is flagged as its own unit (credits), never
|
|
246
|
+
* passed off as USD. Callers that compute a token-based estimate (the engineer
|
|
247
|
+
* runner) set `costSource` to "estimate" themselves — this only reports whether
|
|
248
|
+
* a real CLI figure was present.
|
|
249
|
+
*/
|
|
250
|
+
export function usageCostSource(agent, usage) {
|
|
251
|
+
if (usage.costUsd)
|
|
252
|
+
return agent.cli === "kiro" ? "kiro-credits" : "extractUsage";
|
|
253
|
+
return "none";
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Map a runner session key to the REAL task id it belongs to (0230). Engineer
|
|
257
|
+
* and review sessions are keyed by the task id directly. PM chats are keyed by
|
|
258
|
+
* a synthetic `pm-task-v2:<id>` id whose suffix is the actual task — attribute
|
|
259
|
+
* them under that real id so PM cost/tokens aggregate per-task. Non-task chats
|
|
260
|
+
* (guide) have no task and return null. Per-user PM sessions (0248) append
|
|
261
|
+
* `::<email>` after the task id; that suffix is stripped here too so cost
|
|
262
|
+
* attribution doesn't treat "<id>::<email>" as the task id.
|
|
263
|
+
*/
|
|
264
|
+
export function resolveSessionTaskId(taskKey) {
|
|
265
|
+
if (!taskKey)
|
|
266
|
+
return null;
|
|
267
|
+
// Board-level chat keys belong to no task — attribute them to nothing so
|
|
268
|
+
// they surface on the board panel only, never a task drawer (0331).
|
|
269
|
+
if (NON_TASK_SESSION_KEYS.has(taskKey.toLowerCase()))
|
|
270
|
+
return null;
|
|
271
|
+
// Per-task Debugger chats (`debugger:<taskId>`, #0337) are a separate role,
|
|
272
|
+
// not a task's engineering work — never attribute their cost/tokens to a task
|
|
273
|
+
// named "debugger:<id>" (which would otherwise surface as a phantom board task).
|
|
274
|
+
if (taskKey.startsWith("debugger:"))
|
|
275
|
+
return null;
|
|
276
|
+
// Each alternative has a strict literal prefix so nothing else is captured;
|
|
277
|
+
// covers the current `pm-task-v2:<id>` scheme (with or without a `::<email>`
|
|
278
|
+
// per-user suffix) and the legacy `pm-task:<id>` / `pm:<id>` forms, without
|
|
279
|
+
// mis-parsing their suffixes (0230 / review).
|
|
280
|
+
const pm = taskKey.match(/^pm-task-v2:([^:]+)(?:::.*)?$/i) ??
|
|
281
|
+
taskKey.match(/^pm-task:(.+)$/i) ??
|
|
282
|
+
taskKey.match(/^pm:(.+)$/i);
|
|
283
|
+
if (pm)
|
|
284
|
+
return pm[1] || null;
|
|
285
|
+
// Durable review sessions are keyed by `review:<taskId>` (0288); strip the
|
|
286
|
+
// prefix so any DB attribution lands on the real task.
|
|
287
|
+
if (/^review:/i.test(taskKey))
|
|
288
|
+
return taskKey.replace(/^review:/i, "") || null;
|
|
289
|
+
return taskKey;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Persistent session key for Ross's board-level chat. Must match the id the
|
|
293
|
+
* guide client subscribes to and persists under (RepoGuideChat.vue uses
|
|
294
|
+
* "repoos-guide"); a previous refactor drifted this to "__repoos-guide__",
|
|
295
|
+
* which hid the guide's running/stop state from the UI.
|
|
296
|
+
*/
|
|
297
|
+
export const REPO_GUIDE_SESSION_ID = "repoos-guide";
|
|
298
|
+
/** Persistent session id for the Debugger's bug-paste conversation. */
|
|
299
|
+
export const debuggerSessionId = "__repoos-debugger__";
|
|
300
|
+
/**
|
|
301
|
+
* Runner session keys that are conversations with board-level roles rather
|
|
302
|
+
* than task-scoped work. `repoos-guide` is Ross's chat (REPO_GUIDE_SESSION_ID
|
|
303
|
+
* in routes/info.ts); `__repoos-guide__` is the key a previous refactor
|
|
304
|
+
* drifted to; `__repoos-debugger__` is the Debugger's chat (debuggerSessionId
|
|
305
|
+
* above).
|
|
306
|
+
*/
|
|
307
|
+
const NON_TASK_SESSION_KEYS = new Set([
|
|
308
|
+
REPO_GUIDE_SESSION_ID,
|
|
309
|
+
"__repoos-guide__",
|
|
310
|
+
debuggerSessionId,
|
|
311
|
+
]);
|
|
312
|
+
/** Exact agent-name → sessionType mappings for the built-in roles (0331). */
|
|
313
|
+
const EXACT_ROLE_TYPES = {
|
|
314
|
+
ross: "guide",
|
|
315
|
+
guide: "guide",
|
|
316
|
+
"repoos guide": "guide",
|
|
317
|
+
"repoos-guide": "guide",
|
|
318
|
+
engineer: "engineer",
|
|
319
|
+
reviewer: "reviewer",
|
|
320
|
+
review: "reviewer",
|
|
321
|
+
pm: "pm",
|
|
322
|
+
cto: "cto",
|
|
323
|
+
debugger: "debugger",
|
|
324
|
+
"tech-debt": "tech-debt",
|
|
325
|
+
"tech debt": "tech-debt",
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Classify a runner session into the role label the board's "AI usage — all
|
|
329
|
+
* roles" panel groups by (0331). Exact matches on the built-in role names win
|
|
330
|
+
* first: the legacy "RepoOS Guide" name used to be swallowed into "engineer"
|
|
331
|
+
* because an earlier `includes("repoos")` probe ran before the guide check —
|
|
332
|
+
* classify on what the agent IS, not on a substring an earlier rule grabbed.
|
|
333
|
+
* Unknown names fall through ordered substring probes (guide before the rest
|
|
334
|
+
* so "ross guide"-style hybrids still land right), then to "task" when the
|
|
335
|
+
* session is keyed to a task at all, "chat" when it is not.
|
|
336
|
+
*/
|
|
337
|
+
export function classifySessionType(agentName, taskKey) {
|
|
338
|
+
const name = (agentName ?? "").trim().toLowerCase();
|
|
339
|
+
const exact = EXACT_ROLE_TYPES[name];
|
|
340
|
+
if (exact)
|
|
341
|
+
return exact;
|
|
342
|
+
if (name.includes("guide") || name.includes("ross"))
|
|
343
|
+
return "guide";
|
|
344
|
+
if (name.includes("debugger"))
|
|
345
|
+
return "debugger";
|
|
346
|
+
if (name.includes("review"))
|
|
347
|
+
return "reviewer";
|
|
348
|
+
if (name.includes("engineer"))
|
|
349
|
+
return "engineer";
|
|
350
|
+
if (name.includes("pm"))
|
|
351
|
+
return "pm";
|
|
352
|
+
if (name.includes("cto"))
|
|
353
|
+
return "cto";
|
|
354
|
+
if (name.includes("tech"))
|
|
355
|
+
return "tech-debt";
|
|
356
|
+
return taskKey ? "task" : "chat";
|
|
357
|
+
}
|
|
358
|
+
/** Input tokens from a `usage`-shaped JSON object, or undefined if absent. */
|
|
359
|
+
function inputTokensFromObject(obj) {
|
|
360
|
+
const usage = findUsage(obj);
|
|
361
|
+
if (usage && typeof usage.input_tokens === "number")
|
|
362
|
+
return usage.input_tokens;
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
/** Output tokens from a `usage`-shaped JSON object, or undefined if absent. */
|
|
366
|
+
function outputTokensFromObject(obj) {
|
|
367
|
+
const usage = findUsage(obj);
|
|
368
|
+
if (usage && typeof usage.output_tokens === "number")
|
|
369
|
+
return usage.output_tokens;
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Locate a `usage`-shaped block: top-level (codex `usage` events and claude's
|
|
374
|
+
* terminal `result`) or nested at `message.usage` (claude per-`assistant`
|
|
375
|
+
* events, 0109). Also recognizes opencode's `step_finish` shape, whose tokens
|
|
376
|
+
* live at `part.tokens.{total,input,output}` and cost at `part.cost` — a
|
|
377
|
+
* different field layout entirely, normalized here to the `input_tokens` /
|
|
378
|
+
* `output_tokens` / `total_tokens` / `cost_usd` names the callers expect.
|
|
379
|
+
*/
|
|
380
|
+
function findUsage(obj) {
|
|
381
|
+
if (obj.usage && typeof obj.usage === "object")
|
|
382
|
+
return obj.usage;
|
|
383
|
+
const msg = obj.message;
|
|
384
|
+
if (msg && typeof msg === "object") {
|
|
385
|
+
const m = msg;
|
|
386
|
+
if (m.usage && typeof m.usage === "object")
|
|
387
|
+
return m.usage;
|
|
388
|
+
}
|
|
389
|
+
const part = obj.part;
|
|
390
|
+
if (part && typeof part === "object") {
|
|
391
|
+
const p = part;
|
|
392
|
+
const tokens = p.tokens;
|
|
393
|
+
if (tokens && typeof tokens === "object") {
|
|
394
|
+
const t = tokens;
|
|
395
|
+
// opencode nests cache counts under `tokens.cache = { read, write }`.
|
|
396
|
+
const cache = (t.cache && typeof t.cache === "object" ? t.cache : {});
|
|
397
|
+
return {
|
|
398
|
+
...(typeof t.input === "number" ? { input_tokens: t.input } : {}),
|
|
399
|
+
...(typeof t.output === "number" ? { output_tokens: t.output } : {}),
|
|
400
|
+
...(typeof t.total === "number" ? { total_tokens: t.total } : {}),
|
|
401
|
+
...(typeof p.cost === "number" ? { cost_usd: p.cost } : {}),
|
|
402
|
+
...(typeof cache.read === "number" ? { cache_read_input_tokens: cache.read } : {}),
|
|
403
|
+
...(typeof cache.write === "number" ? { cache_creation_input_tokens: cache.write } : {}),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Token count from a `usage`-shaped JSON object, or undefined if absent.
|
|
411
|
+
*
|
|
412
|
+
* Only billable input+output (or an explicit `total_tokens`) count toward the
|
|
413
|
+
* headline number: claude's `cache_creation_input_tokens` / `cache_read_input_tokens`
|
|
414
|
+
* bill at different rates, so summing them into the same "tokens" figure would
|
|
415
|
+
* be fabricated precision (0109). The terminal `result` event reports the
|
|
416
|
+
* authoritative turn total, so the running figure converges on it.
|
|
417
|
+
*/
|
|
418
|
+
function tokensFromObject(obj) {
|
|
419
|
+
const usage = findUsage(obj);
|
|
420
|
+
if (usage) {
|
|
421
|
+
if (typeof usage.total_tokens === "number")
|
|
422
|
+
return usage.total_tokens;
|
|
423
|
+
const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
424
|
+
const output = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
425
|
+
if (input || output)
|
|
426
|
+
return input + output;
|
|
427
|
+
}
|
|
428
|
+
if (typeof obj.total_tokens === "number")
|
|
429
|
+
return obj.total_tokens;
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
/** Cost (USD) from a `usage`-shaped JSON object, or undefined if absent. */
|
|
433
|
+
function costFromObject(obj) {
|
|
434
|
+
// claude's terminal `result` event reports the authoritative per-turn cost.
|
|
435
|
+
if (typeof obj.total_cost_usd === "number")
|
|
436
|
+
return obj.total_cost_usd;
|
|
437
|
+
if (typeof obj.cost_usd === "number")
|
|
438
|
+
return obj.cost_usd;
|
|
439
|
+
const usage = findUsage(obj);
|
|
440
|
+
if (usage) {
|
|
441
|
+
if (typeof usage.cost_usd === "number")
|
|
442
|
+
return usage.cost_usd;
|
|
443
|
+
if (typeof usage.total_cost_usd === "number")
|
|
444
|
+
return usage.total_cost_usd;
|
|
445
|
+
}
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Prompt-cache token counts from a `usage`-shaped object. Provider field names
|
|
450
|
+
* vary; `findUsage` already normalizes opencode's `part.tokens.cache.{read,
|
|
451
|
+
* write}`. Claude: `cache_read_input_tokens` / `cache_creation_input_tokens`.
|
|
452
|
+
* Codex: `cached_input_tokens` (read only). All absent when a CLI reports no
|
|
453
|
+
* cache figures — never fabricated.
|
|
454
|
+
*/
|
|
455
|
+
function cacheTokensFromObject(obj) {
|
|
456
|
+
const usage = findUsage(obj);
|
|
457
|
+
if (!usage)
|
|
458
|
+
return {};
|
|
459
|
+
const num = (v) => (typeof v === "number" && v >= 0 ? v : undefined);
|
|
460
|
+
const read = num(usage.cache_read_input_tokens) ??
|
|
461
|
+
num(usage.cached_input_tokens) ??
|
|
462
|
+
num(usage.cache_read_tokens);
|
|
463
|
+
const creation = num(usage.cache_creation_input_tokens) ?? num(usage.cache_creation_tokens);
|
|
464
|
+
const out = {};
|
|
465
|
+
if (read !== undefined)
|
|
466
|
+
out.cacheReadTokens = read;
|
|
467
|
+
if (creation !== undefined)
|
|
468
|
+
out.cacheCreationTokens = creation;
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Model round-trips this event accounts for (a DELTA — see ExtractedUsage.turns).
|
|
473
|
+
* Only the two clear cases: claude's terminal `result` carries `num_turns` for
|
|
474
|
+
* that invocation; opencode emits one `step_finish` per model round-trip. Other
|
|
475
|
+
* CLIs report nothing countable yet.
|
|
476
|
+
*/
|
|
477
|
+
function turnsFromObject(obj) {
|
|
478
|
+
if (obj.type === "step_finish" || obj.type === "step-finish")
|
|
479
|
+
return 1;
|
|
480
|
+
if (obj.type === "result" && typeof obj.num_turns === "number" && obj.num_turns >= 0) {
|
|
481
|
+
return obj.num_turns;
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
/** On-disk transcript schema. Bump when persisted fields change incompatibly. */
|
|
486
|
+
const SESSION_FILE_VERSION = 1;
|
|
487
|
+
const SESSION_WRITE_DELAY_MS = 500;
|
|
488
|
+
const SESSION_RETENTION_DAYS = 30;
|
|
489
|
+
const SESSION_RETENTION_COUNT = 1000;
|
|
490
|
+
/** Best-effort session-id extraction from agent output (opencode / claude). */
|
|
491
|
+
const SESSION_ID_PATTERNS = [
|
|
492
|
+
/"session_id"\s*:\s*"([^"]+)"/,
|
|
493
|
+
/session[ \t]+id[:\s]*["']?([A-Za-z0-9][A-Za-z0-9_.-]{5,})/i,
|
|
494
|
+
];
|
|
495
|
+
/**
|
|
496
|
+
* Which structured-event engine (if any) a session should use for a CLI. Both
|
|
497
|
+
* structured engines parse newline-delimited JSON events; they differ only in
|
|
498
|
+
* event shape (`part` vs `message.content[]`), so each gets its own parser
|
|
499
|
+
* branch in `appendLine`.
|
|
500
|
+
*/
|
|
501
|
+
function engineForCli(cli) {
|
|
502
|
+
if (cli === "claude code")
|
|
503
|
+
return "claude";
|
|
504
|
+
if (cli === "github copilot")
|
|
505
|
+
return "copilot";
|
|
506
|
+
if (cli === "qwen code")
|
|
507
|
+
return "qwen";
|
|
508
|
+
if (cli === "codex")
|
|
509
|
+
return "codex";
|
|
510
|
+
if (cli === "kiro")
|
|
511
|
+
return "kiro";
|
|
512
|
+
return "opencode";
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Render a tool input as display text: a bash call shows its command, an
|
|
516
|
+
* object input becomes pretty JSON, and strings pass through untouched.
|
|
517
|
+
*/
|
|
518
|
+
function toolInputText(input) {
|
|
519
|
+
if (typeof input === "string")
|
|
520
|
+
return input;
|
|
521
|
+
if (input === undefined || input === null)
|
|
522
|
+
return undefined;
|
|
523
|
+
if (typeof input === "object") {
|
|
524
|
+
const obj = input;
|
|
525
|
+
if (typeof obj.command === "string" && obj.command)
|
|
526
|
+
return obj.command;
|
|
527
|
+
try {
|
|
528
|
+
const s = JSON.stringify(obj, null, 2);
|
|
529
|
+
return s && s !== "{}" ? s : undefined;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return String(input);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return String(input);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Render a tool output as display text: strings pass through untouched and
|
|
539
|
+
* object outputs become pretty JSON. Falls back to the error payload when the
|
|
540
|
+
* call failed.
|
|
541
|
+
*/
|
|
542
|
+
function toolOutputText(output) {
|
|
543
|
+
if (typeof output === "string" && output)
|
|
544
|
+
return output;
|
|
545
|
+
if (output === undefined || output === null)
|
|
546
|
+
return undefined;
|
|
547
|
+
if (typeof output === "object") {
|
|
548
|
+
try {
|
|
549
|
+
const s = JSON.stringify(output, null, 2);
|
|
550
|
+
return s && s !== "{}" ? s : undefined;
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
return String(output);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const s = String(output);
|
|
557
|
+
return s ? s : undefined;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Parse one line of opencode's `--format json` stream into a structured
|
|
561
|
+
* transcript entry. Returns null for malformed lines and for event types we
|
|
562
|
+
* don't surface (session-id, title, reasoning, …) — callers fall back to the
|
|
563
|
+
* plain-line handling in those cases.
|
|
564
|
+
*/
|
|
565
|
+
export function parseJsonEvent(raw) {
|
|
566
|
+
let parsed;
|
|
567
|
+
try {
|
|
568
|
+
parsed = JSON.parse(raw);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
574
|
+
return null;
|
|
575
|
+
const ev = parsed;
|
|
576
|
+
const type = typeof ev.type === "string" ? ev.type : "";
|
|
577
|
+
if (!type)
|
|
578
|
+
return null;
|
|
579
|
+
const sessionID = typeof ev.sessionID === "string" && ev.sessionID ? ev.sessionID : undefined;
|
|
580
|
+
const part = ev.part ?? {};
|
|
581
|
+
switch (type) {
|
|
582
|
+
case "text": {
|
|
583
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
584
|
+
if (!text.trim())
|
|
585
|
+
return null;
|
|
586
|
+
return { entry: { type: "text", text }, sessionID };
|
|
587
|
+
}
|
|
588
|
+
case "tool_use": {
|
|
589
|
+
const tool = typeof part.tool === "string" ? part.tool : "";
|
|
590
|
+
if (!tool)
|
|
591
|
+
return null;
|
|
592
|
+
const state = part.state ?? {};
|
|
593
|
+
const status = typeof state.status === "string" ? state.status : undefined;
|
|
594
|
+
const input = toolInputText(state.input);
|
|
595
|
+
const output = toolOutputText(state.output ?? state.error);
|
|
596
|
+
return {
|
|
597
|
+
entry: {
|
|
598
|
+
type: "tool",
|
|
599
|
+
tool,
|
|
600
|
+
...(input ? { input } : {}),
|
|
601
|
+
...(output ? { output } : {}),
|
|
602
|
+
...(status ? { state: status } : {}),
|
|
603
|
+
},
|
|
604
|
+
sessionID,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
case "step_start":
|
|
608
|
+
return { entry: { type: "step", kind: "start", at: now() }, sessionID };
|
|
609
|
+
case "step_finish":
|
|
610
|
+
return {
|
|
611
|
+
entry: {
|
|
612
|
+
type: "step",
|
|
613
|
+
kind: "finish",
|
|
614
|
+
at: now(),
|
|
615
|
+
...(typeof part.reason === "string" && part.reason ? { reason: part.reason } : {}),
|
|
616
|
+
},
|
|
617
|
+
sessionID,
|
|
618
|
+
};
|
|
619
|
+
case "error": {
|
|
620
|
+
const err = ev.error ?? {};
|
|
621
|
+
const data = err.data ?? {};
|
|
622
|
+
const msg = typeof data.message === "string" && data.message
|
|
623
|
+
? data.message
|
|
624
|
+
: typeof err.name === "string" && err.name
|
|
625
|
+
? err.name
|
|
626
|
+
: "";
|
|
627
|
+
if (!msg)
|
|
628
|
+
return null;
|
|
629
|
+
return { entry: { type: "sys", d: `error: ${msg}` }, sessionID };
|
|
630
|
+
}
|
|
631
|
+
case "file-update": {
|
|
632
|
+
// Older opencode emitted file-write events directly; surface the path.
|
|
633
|
+
const path = typeof ev.path === "string" && ev.path
|
|
634
|
+
? ev.path
|
|
635
|
+
: typeof part.path === "string"
|
|
636
|
+
? part.path
|
|
637
|
+
: "";
|
|
638
|
+
if (!path)
|
|
639
|
+
return null;
|
|
640
|
+
return { entry: { type: "sys", d: `✎ ${path}` }, sessionID };
|
|
641
|
+
}
|
|
642
|
+
default:
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Render a claude tool_result `content` (string or a list of text blocks). */
|
|
647
|
+
function toolResultText(content) {
|
|
648
|
+
if (typeof content === "string" && content)
|
|
649
|
+
return content;
|
|
650
|
+
if (Array.isArray(content)) {
|
|
651
|
+
const text = content
|
|
652
|
+
.filter((b) => !!b && typeof b === "object")
|
|
653
|
+
.map((b) => (typeof b.text === "string" ? b.text : ""))
|
|
654
|
+
.join("\n");
|
|
655
|
+
return text || undefined;
|
|
656
|
+
}
|
|
657
|
+
if (typeof content === "object" && content !== null) {
|
|
658
|
+
try {
|
|
659
|
+
const s = JSON.stringify(content);
|
|
660
|
+
return s && s !== "{}" ? s : undefined;
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
/** A claude `assistant` event's first surfaceable content block, if any. */
|
|
669
|
+
function claudeAssistantEntry(content) {
|
|
670
|
+
for (const block of content) {
|
|
671
|
+
if (typeof block.type !== "string")
|
|
672
|
+
continue;
|
|
673
|
+
if (block.type === "text") {
|
|
674
|
+
const text = typeof block.text === "string" ? block.text : "";
|
|
675
|
+
if (!text.trim())
|
|
676
|
+
continue;
|
|
677
|
+
return { entry: { type: "text", text } };
|
|
678
|
+
}
|
|
679
|
+
if (block.type === "tool_use") {
|
|
680
|
+
const id = typeof block.id === "string" ? block.id : "";
|
|
681
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
682
|
+
if (!id || !name)
|
|
683
|
+
continue;
|
|
684
|
+
const input = toolInputText(block.input);
|
|
685
|
+
return { pendingTool: { id, name, ...(input ? { input } : {}) } };
|
|
686
|
+
}
|
|
687
|
+
// thinking / unknown blocks are not surfaced — same policy as opencode's
|
|
688
|
+
// reasoning events. Swallowed by the caller, never dumped as raw JSON.
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Parse one line of claude code's `--output-format stream-json` stream into a
|
|
694
|
+
* structured transcript result. Mirrors `parseJsonEvent` for opencode but with
|
|
695
|
+
* claude's event shapes: content is nested under `message.content[]` (not
|
|
696
|
+
* `part`), the session id is a real `session_id` field on `system/init`, and
|
|
697
|
+
* tool results arrive as separate `user` events. Recognized-but-voiceless
|
|
698
|
+
* events (rate_limit, thinking-only assistant messages, the terminal `result`)
|
|
699
|
+
* are returned without an `entry` so the runner swallows them instead of
|
|
700
|
+
* dumping raw JSON into the transcript. `null` for anything else — callers
|
|
701
|
+
* fall back to the plain-line path.
|
|
702
|
+
*/
|
|
703
|
+
export function parseClaudeEvent(raw) {
|
|
704
|
+
let parsed;
|
|
705
|
+
try {
|
|
706
|
+
parsed = JSON.parse(raw);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
712
|
+
return null;
|
|
713
|
+
const ev = parsed;
|
|
714
|
+
const type = typeof ev.type === "string" ? ev.type : "";
|
|
715
|
+
if (!type)
|
|
716
|
+
return null;
|
|
717
|
+
const sessionID = typeof ev.session_id === "string" && ev.session_id ? ev.session_id : undefined;
|
|
718
|
+
const content = Array.isArray(ev.message?.content)
|
|
719
|
+
? ev.message.content
|
|
720
|
+
: [];
|
|
721
|
+
switch (type) {
|
|
722
|
+
case "system": {
|
|
723
|
+
const subtype = typeof ev.subtype === "string" ? ev.subtype : "";
|
|
724
|
+
if (subtype === "init") {
|
|
725
|
+
// The stream's real session id lives here; the runner captures it for
|
|
726
|
+
// `--resume` on follow-up turns instead of regex-scraping prose.
|
|
727
|
+
return { sessionID };
|
|
728
|
+
}
|
|
729
|
+
if (subtype === "thinking_tokens") {
|
|
730
|
+
// A thinking phase begins — a step boundary the UI renders as a start
|
|
731
|
+
// marker (visually dropped in favor of a quiet timeline).
|
|
732
|
+
return { entry: { type: "step", kind: "start", at: now() }, sessionID };
|
|
733
|
+
}
|
|
734
|
+
if (subtype === "post_turn_summary") {
|
|
735
|
+
// The turn's work is done — the boundary before the `result` line.
|
|
736
|
+
return { entry: { type: "step", kind: "finish", at: now() }, sessionID };
|
|
737
|
+
}
|
|
738
|
+
// Unknown system subtypes: swallow, don't dump.
|
|
739
|
+
return { sessionID };
|
|
740
|
+
}
|
|
741
|
+
case "assistant": {
|
|
742
|
+
const surfaced = claudeAssistantEntry(content);
|
|
743
|
+
if (surfaced)
|
|
744
|
+
return { ...surfaced, sessionID };
|
|
745
|
+
// Thinking-only (or empty) assistant events are voiceless — swallow.
|
|
746
|
+
return { sessionID };
|
|
747
|
+
}
|
|
748
|
+
case "user": {
|
|
749
|
+
for (const block of content) {
|
|
750
|
+
if (typeof block.type !== "string" || block.type !== "tool_result")
|
|
751
|
+
continue;
|
|
752
|
+
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
753
|
+
if (!id)
|
|
754
|
+
continue;
|
|
755
|
+
const contentText = toolResultText(block.content);
|
|
756
|
+
return {
|
|
757
|
+
toolResult: {
|
|
758
|
+
id,
|
|
759
|
+
...(contentText ? { content: contentText } : {}),
|
|
760
|
+
...(block.is_error === true ? { isError: true } : {}),
|
|
761
|
+
},
|
|
762
|
+
sessionID,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return { sessionID };
|
|
766
|
+
}
|
|
767
|
+
case "result": {
|
|
768
|
+
// Authoritative usage/cost totals — consumed by extractUsage on the raw
|
|
769
|
+
// line; the `result` text duplicates the final assistant text, so it is
|
|
770
|
+
// never surfaced as its own entry.
|
|
771
|
+
return { sessionID };
|
|
772
|
+
}
|
|
773
|
+
case "rate_limit_event":
|
|
774
|
+
return { sessionID };
|
|
775
|
+
case "stream_event": {
|
|
776
|
+
// Anthropic API streaming events wrapped by claude code CLI (0151).
|
|
777
|
+
// These are intermediate streaming updates (content_block_delta,
|
|
778
|
+
// content_block_stop, etc.) that are not surfaced as their own entries —
|
|
779
|
+
// they are aggregated by the API layer and only the final "message" or
|
|
780
|
+
// "assistant" event is rendered to the transcript. Swallow them here with
|
|
781
|
+
// the session id extracted from the outer wrapper.
|
|
782
|
+
const streamSessionId = typeof ev.session_id === "string" && ev.session_id ? ev.session_id : undefined;
|
|
783
|
+
return { sessionID: streamSessionId };
|
|
784
|
+
}
|
|
785
|
+
default:
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
/** Text-bearing values in Copilot tool results and error payloads. */
|
|
790
|
+
function copilotText(value) {
|
|
791
|
+
if (typeof value === "string" && value)
|
|
792
|
+
return value;
|
|
793
|
+
if (!value || typeof value !== "object")
|
|
794
|
+
return undefined;
|
|
795
|
+
const obj = value;
|
|
796
|
+
for (const key of ["content", "text", "message", "error"]) {
|
|
797
|
+
if (typeof obj[key] === "string" && obj[key])
|
|
798
|
+
return obj[key];
|
|
799
|
+
}
|
|
800
|
+
return toolOutputText(value);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Parse Copilot CLI's `--output-format json` JSONL stream. The CLI emits
|
|
804
|
+
* lifecycle telemetry alongside assistant and tool events; lifecycle records
|
|
805
|
+
* are recognized and swallowed so the Agent tab remains a useful transcript.
|
|
806
|
+
*/
|
|
807
|
+
export function parseCopilotEvent(raw) {
|
|
808
|
+
let parsed;
|
|
809
|
+
try {
|
|
810
|
+
parsed = JSON.parse(raw);
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
if (!parsed || typeof parsed !== "object")
|
|
816
|
+
return null;
|
|
817
|
+
const event = parsed;
|
|
818
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
819
|
+
if (!type)
|
|
820
|
+
return null;
|
|
821
|
+
const data = event.data ?? {};
|
|
822
|
+
const sessionID = typeof event.sessionId === "string" && event.sessionId ? event.sessionId : undefined;
|
|
823
|
+
if (type === "assistant.message_delta")
|
|
824
|
+
return { sessionID };
|
|
825
|
+
if (type === "assistant.message") {
|
|
826
|
+
const text = typeof data.content === "string" ? data.content : "";
|
|
827
|
+
return text ? { entry: { type: "text", text }, sessionID } : { sessionID };
|
|
828
|
+
}
|
|
829
|
+
if (type === "tool.execution_start") {
|
|
830
|
+
const tool = typeof data.toolName === "string"
|
|
831
|
+
? data.toolName
|
|
832
|
+
: typeof data.tool === "string"
|
|
833
|
+
? data.tool
|
|
834
|
+
: "";
|
|
835
|
+
if (!tool)
|
|
836
|
+
return { sessionID };
|
|
837
|
+
const input = toolInputText(data.arguments ?? data.input);
|
|
838
|
+
return {
|
|
839
|
+
entry: { type: "tool", tool, ...(input ? { input } : {}), state: "running" },
|
|
840
|
+
sessionID,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (type === "tool.execution_complete") {
|
|
844
|
+
const tool = typeof data.toolName === "string"
|
|
845
|
+
? data.toolName
|
|
846
|
+
: typeof data.tool === "string"
|
|
847
|
+
? data.tool
|
|
848
|
+
: "tool";
|
|
849
|
+
const input = toolInputText(data.arguments ?? data.input);
|
|
850
|
+
const output = copilotText(data.result ?? data.output ?? data.error);
|
|
851
|
+
return {
|
|
852
|
+
entry: {
|
|
853
|
+
type: "tool",
|
|
854
|
+
tool,
|
|
855
|
+
...(input ? { input } : {}),
|
|
856
|
+
...(output ? { output } : {}),
|
|
857
|
+
...(data.error ? { state: "error" } : { state: "completed" }),
|
|
858
|
+
},
|
|
859
|
+
sessionID,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
if (type === "error" || type.endsWith(".error")) {
|
|
863
|
+
const message = copilotText(data.error ?? data.message ?? data);
|
|
864
|
+
return message ? { entry: { type: "sys", d: `error: ${message}` }, sessionID } : { sessionID };
|
|
865
|
+
}
|
|
866
|
+
if (type === "result" ||
|
|
867
|
+
type.startsWith("session.") ||
|
|
868
|
+
type.startsWith("model.") ||
|
|
869
|
+
type.startsWith("mcp."))
|
|
870
|
+
return { sessionID };
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Qwen's stream-json is Claude-compatible for assistant/tool content, but
|
|
875
|
+
* versions in the wild also emit the shorter `{type, content}` form. Keep
|
|
876
|
+
* this adapter deliberately tolerant so a CLI upgrade cannot turn the chat
|
|
877
|
+
* into a wall of JSON.
|
|
878
|
+
*/
|
|
879
|
+
export function parseQwenEvent(raw) {
|
|
880
|
+
let value;
|
|
881
|
+
try {
|
|
882
|
+
value = JSON.parse(raw);
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
if (!value || typeof value !== "object")
|
|
888
|
+
return null;
|
|
889
|
+
const event = value;
|
|
890
|
+
const sessionID = typeof event.session_id === "string"
|
|
891
|
+
? event.session_id
|
|
892
|
+
: typeof event.sessionId === "string"
|
|
893
|
+
? event.sessionId
|
|
894
|
+
: undefined;
|
|
895
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
896
|
+
if (!type)
|
|
897
|
+
return null;
|
|
898
|
+
const message = event.message && typeof event.message === "object"
|
|
899
|
+
? event.message
|
|
900
|
+
: undefined;
|
|
901
|
+
const content = Array.isArray(message?.content)
|
|
902
|
+
? message.content
|
|
903
|
+
: typeof event.content === "string"
|
|
904
|
+
? [{ type: "text", text: event.content }]
|
|
905
|
+
: [];
|
|
906
|
+
if (type === "system" || type === "session_start" || type === "session_started") {
|
|
907
|
+
return { sessionID };
|
|
908
|
+
}
|
|
909
|
+
if (type === "assistant" || type === "message" || type === "assistant_message") {
|
|
910
|
+
const surfaced = claudeAssistantEntry(content);
|
|
911
|
+
return surfaced ? { ...surfaced, sessionID } : { sessionID };
|
|
912
|
+
}
|
|
913
|
+
if (type === "content_block_delta" || type === "message_delta") {
|
|
914
|
+
const delta = event.delta && typeof event.delta === "object"
|
|
915
|
+
? event.delta
|
|
916
|
+
: undefined;
|
|
917
|
+
const text = typeof delta?.text === "string"
|
|
918
|
+
? delta.text
|
|
919
|
+
: typeof event.text === "string"
|
|
920
|
+
? event.text
|
|
921
|
+
: "";
|
|
922
|
+
return text ? { entry: { type: "text", text }, sessionID } : { sessionID };
|
|
923
|
+
}
|
|
924
|
+
if (type === "user") {
|
|
925
|
+
for (const block of content) {
|
|
926
|
+
if (block.type !== "tool_result" || typeof block.tool_use_id !== "string")
|
|
927
|
+
continue;
|
|
928
|
+
return {
|
|
929
|
+
toolResult: {
|
|
930
|
+
id: block.tool_use_id,
|
|
931
|
+
...(toolResultText(block.content) ? { content: toolResultText(block.content) } : {}),
|
|
932
|
+
...(block.is_error === true ? { isError: true } : {}),
|
|
933
|
+
},
|
|
934
|
+
sessionID,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (content.length) {
|
|
939
|
+
const surfaced = claudeAssistantEntry(content);
|
|
940
|
+
return surfaced ? { ...surfaced, sessionID } : { sessionID };
|
|
941
|
+
}
|
|
942
|
+
if (["result", "turn_complete", "turn.completed", "rate_limit_event"].includes(type)) {
|
|
943
|
+
return { sessionID };
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
/** Parse Codex `exec --json` JSONL into the same chat cards as OpenCode. */
|
|
948
|
+
export function parseCodexEvent(raw) {
|
|
949
|
+
let value;
|
|
950
|
+
try {
|
|
951
|
+
value = JSON.parse(raw);
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
if (!value || typeof value !== "object")
|
|
957
|
+
return null;
|
|
958
|
+
const event = value;
|
|
959
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
960
|
+
const sessionID = typeof event.thread_id === "string"
|
|
961
|
+
? event.thread_id
|
|
962
|
+
: typeof event.session_id === "string"
|
|
963
|
+
? event.session_id
|
|
964
|
+
: undefined;
|
|
965
|
+
if (!type)
|
|
966
|
+
return null;
|
|
967
|
+
const item = event.item && typeof event.item === "object"
|
|
968
|
+
? event.item
|
|
969
|
+
: undefined;
|
|
970
|
+
if (type === "thread.started")
|
|
971
|
+
return { sessionID };
|
|
972
|
+
if (type === "item.updated" && typeof event.delta === "string") {
|
|
973
|
+
return event.delta.trim()
|
|
974
|
+
? { entry: { type: "text", text: event.delta }, sessionID }
|
|
975
|
+
: { sessionID };
|
|
976
|
+
}
|
|
977
|
+
if (type === "error" || type === "turn.failed") {
|
|
978
|
+
const message = typeof event.message === "string"
|
|
979
|
+
? event.message
|
|
980
|
+
: typeof event.error === "string"
|
|
981
|
+
? event.error
|
|
982
|
+
: undefined;
|
|
983
|
+
return message ? { entry: { type: "sys", d: `error: ${message}` }, sessionID } : { sessionID };
|
|
984
|
+
}
|
|
985
|
+
if (!item)
|
|
986
|
+
return { sessionID };
|
|
987
|
+
const itemType = typeof item.type === "string" ? item.type : "";
|
|
988
|
+
if (itemType === "agent_message") {
|
|
989
|
+
const text = typeof item.text === "string" ? item.text : typeof item.delta === "string" ? item.delta : "";
|
|
990
|
+
return text.trim() ? { entry: { type: "text", text }, sessionID } : { sessionID };
|
|
991
|
+
}
|
|
992
|
+
if (itemType === "command_execution") {
|
|
993
|
+
const command = typeof item.command === "string" ? item.command : undefined;
|
|
994
|
+
const output = typeof item.aggregated_output === "string"
|
|
995
|
+
? item.aggregated_output
|
|
996
|
+
: typeof item.output === "string"
|
|
997
|
+
? item.output
|
|
998
|
+
: undefined;
|
|
999
|
+
const state = typeof item.status === "string"
|
|
1000
|
+
? item.status
|
|
1001
|
+
: typeof item.exit_code === "number"
|
|
1002
|
+
? item.exit_code === 0
|
|
1003
|
+
? "completed"
|
|
1004
|
+
: "error"
|
|
1005
|
+
: undefined;
|
|
1006
|
+
return {
|
|
1007
|
+
entry: {
|
|
1008
|
+
type: "tool",
|
|
1009
|
+
tool: "shell",
|
|
1010
|
+
...(command ? { input: command } : {}),
|
|
1011
|
+
...(output ? { output } : {}),
|
|
1012
|
+
...(state ? { state } : {}),
|
|
1013
|
+
},
|
|
1014
|
+
sessionID,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
if (itemType === "file_change" || itemType === "file_changes") {
|
|
1018
|
+
const changes = item.changes ?? item.files;
|
|
1019
|
+
const detail = typeof changes === "string" ? changes : toolOutputText(changes);
|
|
1020
|
+
return {
|
|
1021
|
+
entry: { type: "tool", tool: "file changes", ...(detail ? { output: detail } : {}) },
|
|
1022
|
+
sessionID,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
return { sessionID };
|
|
1026
|
+
}
|
|
1027
|
+
/** Byte estimate of one entry, for the transcript cap. */
|
|
1028
|
+
function entryBytes(e) {
|
|
1029
|
+
const legacy = e;
|
|
1030
|
+
if (typeof legacy.d === "string")
|
|
1031
|
+
return legacy.d.length + 1;
|
|
1032
|
+
return JSON.stringify(e).length + 1;
|
|
1033
|
+
}
|
|
1034
|
+
/** Branch derived from a task title, mirroring the UI's `feat/<slug>` rule. */
|
|
1035
|
+
export function deriveBranch(title) {
|
|
1036
|
+
const slug = title
|
|
1037
|
+
.toLowerCase()
|
|
1038
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1039
|
+
.replace(/^-|-$/g, "")
|
|
1040
|
+
.slice(0, 40);
|
|
1041
|
+
return `feat/${slug || "task"}`;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Role-name matching is case-insensitive: the Agents page stores agent names
|
|
1045
|
+
* verbatim (it only lowercases when checking duplicates), so a stored
|
|
1046
|
+
* `"CTO"`/`"Reviewer"`/`"PM"` must still resolve to its role. This bit the CTO
|
|
1047
|
+
* (0174): the merged `repoos.toml` stored `name = "CTO"`, so the exact-match
|
|
1048
|
+
* `"cto"` lookup always returned null and the agent read as permanently
|
|
1049
|
+
* disabled.
|
|
1050
|
+
*/
|
|
1051
|
+
function matchesRole(a, role) {
|
|
1052
|
+
return a.name.toLowerCase() === role;
|
|
1053
|
+
}
|
|
1054
|
+
/** Resolve the enabled `engineer` agent, or null when none is configured. */
|
|
1055
|
+
export function resolveEngineer(config) {
|
|
1056
|
+
const list = agentsForConfig(config);
|
|
1057
|
+
return list.find((a) => a.enabled && matchesRole(a, "engineer")) ?? null;
|
|
1058
|
+
}
|
|
1059
|
+
/** Resolve the enabled `pm` agent, or null when none is configured. */
|
|
1060
|
+
export function resolvePmAgent(config) {
|
|
1061
|
+
const list = agentsForConfig(config);
|
|
1062
|
+
return list.find((a) => a.enabled && matchesRole(a, "pm")) ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Resolve the enabled `reviewer` agent, or null when it is disabled/absent.
|
|
1066
|
+
*
|
|
1067
|
+
* This is the single source of truth for whether a task landing in `review`
|
|
1068
|
+
* gets an automatic agent review (0101): the Agents page writes the toggle
|
|
1069
|
+
* into `repoos.toml`, and null here means "no agent review runs".
|
|
1070
|
+
*/
|
|
1071
|
+
export function resolveReviewer(config) {
|
|
1072
|
+
const list = agentsForConfig(config);
|
|
1073
|
+
return list.find((a) => a.enabled && matchesRole(a, "reviewer")) ?? null;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Resolve the reviewer agent for a specific task, honoring per-task review
|
|
1077
|
+
* overrides (set via the Review tab's selector, like Dev/PM overrides).
|
|
1078
|
+
*
|
|
1079
|
+
* When the task has no review override, this behaves exactly as the global
|
|
1080
|
+
* `resolveReviewer` — the Agents-page reviewer. When a `reviewAgentOverride`
|
|
1081
|
+
* is set, the enabled agent with that name is used (falling back to the global
|
|
1082
|
+
* reviewer base when only CLI/model are overridden); `reviewCliOverride` /
|
|
1083
|
+
* `reviewModelOverride` then replace the agent's CLI and model.
|
|
1084
|
+
*/
|
|
1085
|
+
export function resolveReviewerForTask(config, task) {
|
|
1086
|
+
const modelPinned = isModelOverridePinned(task.reviewModelOverride);
|
|
1087
|
+
const hasOverride = task.reviewAgentOverride || task.reviewCliOverride || modelPinned;
|
|
1088
|
+
if (!hasOverride) {
|
|
1089
|
+
return resolveReviewer(config);
|
|
1090
|
+
}
|
|
1091
|
+
const list = agentsForConfig(config);
|
|
1092
|
+
const baseName = task.reviewAgentOverride || "reviewer";
|
|
1093
|
+
const base = list.find((a) => a.enabled && matchesRole(a, baseName)) ?? null;
|
|
1094
|
+
if (!base)
|
|
1095
|
+
return null;
|
|
1096
|
+
return {
|
|
1097
|
+
...base,
|
|
1098
|
+
...(task.reviewCliOverride ? { cli: task.reviewCliOverride } : {}),
|
|
1099
|
+
...(modelPinned ? { model: task.reviewModelOverride } : {}),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
export function resolveCto(config) {
|
|
1103
|
+
const list = agentsForConfig(config);
|
|
1104
|
+
return list.find((a) => a.enabled && matchesRole(a, "cto")) ?? null;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Resolve the agent for a specific task, honoring per-task overrides.
|
|
1108
|
+
*
|
|
1109
|
+
* When the task has an `agentOverride`, the enabled agent with that name is
|
|
1110
|
+
* used (falling back to the base agent when the override name matches). When
|
|
1111
|
+
* only `cliOverride` or `modelOverride` are set (without a different agent
|
|
1112
|
+
* name), the base agent's name and instructions are kept but cli/model are
|
|
1113
|
+
* overridden.
|
|
1114
|
+
*
|
|
1115
|
+
* @param config Global config (provides the agents list and defaults).
|
|
1116
|
+
* @param task The task being run (may carry overrides).
|
|
1117
|
+
* @param role The role to resolve when no agent override is set (default: "engineer").
|
|
1118
|
+
* @returns A merged Agent, or null when no matching enabled agent exists.
|
|
1119
|
+
*/
|
|
1120
|
+
/**
|
|
1121
|
+
* True when a model-override field carries a real pin, not the "default"
|
|
1122
|
+
* sentinel `AGENT_MODELS` offers for "use the base agent's own configured
|
|
1123
|
+
* model, whatever that is" (config.ts). Every per-task model-override
|
|
1124
|
+
* resolver (this file's `resolveAgentForTask`/`resolveReviewerForTask`, and
|
|
1125
|
+
* routes/tasks.ts's inline PM-override logic) MUST run the override string
|
|
1126
|
+
* through this before treating it as active — bare truthiness treats the
|
|
1127
|
+
* literal string `"default"` as a real pin and force-overwrites the base
|
|
1128
|
+
* agent's actual configured model with the string `"default"`, which then
|
|
1129
|
+
* skips `--model` entirely (see `modelArgs` below) and falls back to the
|
|
1130
|
+
* underlying CLI's own raw default — NOT the model configured on the
|
|
1131
|
+
* Agents page. Confirmed live: tasks kept ending up running
|
|
1132
|
+
* opencode+(no model flag) instead of the configured model every time a
|
|
1133
|
+
* Dev/Review/PM override dropdown was left on (or reset to) "Default".
|
|
1134
|
+
*/
|
|
1135
|
+
export function isModelOverridePinned(model) {
|
|
1136
|
+
return !!model && model !== "default";
|
|
1137
|
+
}
|
|
1138
|
+
export function resolveAgentForTask(config, task, role = "engineer") {
|
|
1139
|
+
const list = agentsForConfig(config);
|
|
1140
|
+
const modelPinned = isModelOverridePinned(task.modelOverride);
|
|
1141
|
+
const hasOverride = task.agentOverride || task.cliOverride || modelPinned;
|
|
1142
|
+
if (!hasOverride) {
|
|
1143
|
+
return list.find((a) => a.enabled && a.name === role) ?? null;
|
|
1144
|
+
}
|
|
1145
|
+
// Resolve the base agent: use the override name if set, else the role.
|
|
1146
|
+
const baseName = task.agentOverride || role;
|
|
1147
|
+
const base = list.find((a) => a.enabled && a.name === baseName) ?? null;
|
|
1148
|
+
if (!base)
|
|
1149
|
+
return null;
|
|
1150
|
+
// Merge overrides onto the base agent.
|
|
1151
|
+
return {
|
|
1152
|
+
...base,
|
|
1153
|
+
...(task.cliOverride ? { cli: task.cliOverride } : {}),
|
|
1154
|
+
...(modelPinned ? { model: task.modelOverride } : {}),
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
/** Resolve the enabled built-in repository assistant (by current "Ross" name or legacy "RepoOS Guide"). */
|
|
1158
|
+
export function resolveRepoGuide(config) {
|
|
1159
|
+
const agents = agentsForConfig(config);
|
|
1160
|
+
return (agents.find((agent) => agent.enabled &&
|
|
1161
|
+
(agent.name.toLowerCase() === "ross" || agent.name.toLowerCase() === "repoos guide")) ?? null);
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Map an agent `cli` string to the binary + args that run it headless.
|
|
1165
|
+
*
|
|
1166
|
+
* opencode re-resolves its project directory from `--git-common-dir`, which
|
|
1167
|
+
* for a linked worktree points at the main repo's `.git` — so every worktree
|
|
1168
|
+
* path would be treated as `external_directory` and auto-rejected. `--dir`
|
|
1169
|
+
* forces the worktree path explicitly. claude code and qwen code use the spawn
|
|
1170
|
+
* `cwd` and need no flag.
|
|
1171
|
+
*
|
|
1172
|
+
* Verified flags (0042/0043):
|
|
1173
|
+
* - claude code: `claude -p <prompt> --output-format stream-json --verbose
|
|
1174
|
+
* --dangerously-skip-permissions` (print mode, 0109). stream-json emits one
|
|
1175
|
+
* JSON event per line (assistant text, tool calls, results) that streams
|
|
1176
|
+
* live instead of claude buffering all stdout until exit — the #0070/#0101/
|
|
1177
|
+
* #0080 blank-Agent-tab fix. `--verbose` is required alongside stream-json
|
|
1178
|
+
* in print mode. The permission flag is REQUIRED here, not optional: the
|
|
1179
|
+
* agent is spawned with stdin ignored, so no approval prompt can ever reach
|
|
1180
|
+
* a human. Without it every non-read-only command (`bun`, `repoos check`,
|
|
1181
|
+
* writes) is denied instantly, the agent stalls explaining it needs
|
|
1182
|
+
* approval, and the task is left stuck in `active` with no changes. Same
|
|
1183
|
+
* intent as codex's `--sandbox workspace-write` below; the blast radius is
|
|
1184
|
+
* the task's own git worktree.
|
|
1185
|
+
* - qwen code: `qwen -p <prompt> --output-format stream-json` — stream-json
|
|
1186
|
+
* emits one JSON event per line, which streams live and carries a
|
|
1187
|
+
* `session_id` RepoOS can resume.
|
|
1188
|
+
* - codex: `codex exec <prompt> --json --sandbox workspace-write` — `--json`
|
|
1189
|
+
* streams newline-delimited events; `--sandbox workspace-write` lets the
|
|
1190
|
+
* agent edit files inside the worktree (the default is read-only).
|
|
1191
|
+
* - opencode: `opencode run --format json --dir <cwd> --auto <prompt>` —
|
|
1192
|
+
* `--auto` ("auto-approve permissions that are not explicitly denied") is
|
|
1193
|
+
* REQUIRED for the same reason claude's flag is: stdin is ignored, so a
|
|
1194
|
+
* permission prompt can never be answered. Without it, opencode blocks on
|
|
1195
|
+
* its first gated tool call and hangs indefinitely — confirmed live on
|
|
1196
|
+
* #0069, which sat at ~1% CPU with zero commits for ~2 hours before being
|
|
1197
|
+
* killed. Same blast radius as the other engines: the task's own worktree.
|
|
1198
|
+
*/
|
|
1199
|
+
function modelArgs(cli, model) {
|
|
1200
|
+
if (!model || model === "default")
|
|
1201
|
+
return [];
|
|
1202
|
+
if (cli === "codex")
|
|
1203
|
+
return ["--model", model];
|
|
1204
|
+
return ["--model", model];
|
|
1205
|
+
}
|
|
1206
|
+
const COPILOT_TOOL_PERMISSIONS = [
|
|
1207
|
+
"--allow-tool",
|
|
1208
|
+
"write",
|
|
1209
|
+
"--allow-tool",
|
|
1210
|
+
"shell(bun:*)",
|
|
1211
|
+
"--allow-tool",
|
|
1212
|
+
"shell(node:*)",
|
|
1213
|
+
"--allow-tool",
|
|
1214
|
+
"shell(npm:*)",
|
|
1215
|
+
"--allow-tool",
|
|
1216
|
+
"shell(npx:*)",
|
|
1217
|
+
"--allow-tool",
|
|
1218
|
+
"shell(git:*)",
|
|
1219
|
+
"--allow-tool",
|
|
1220
|
+
"shell(curl:*)",
|
|
1221
|
+
"--allow-tool",
|
|
1222
|
+
"shell(ls)",
|
|
1223
|
+
"--allow-tool",
|
|
1224
|
+
"shell(cat)",
|
|
1225
|
+
];
|
|
1226
|
+
function copilotArgs(options) {
|
|
1227
|
+
return [
|
|
1228
|
+
"--output-format",
|
|
1229
|
+
"json",
|
|
1230
|
+
"--no-ask-user",
|
|
1231
|
+
"--no-auto-update",
|
|
1232
|
+
"--no-remote",
|
|
1233
|
+
"--no-remote-export",
|
|
1234
|
+
...(options.write ? COPILOT_TOOL_PERMISSIONS : []),
|
|
1235
|
+
];
|
|
1236
|
+
}
|
|
1237
|
+
function cliCommand(agent, mission, cwd) {
|
|
1238
|
+
const { cli, model } = agent;
|
|
1239
|
+
if (cli === "claude code") {
|
|
1240
|
+
return {
|
|
1241
|
+
cmd: "claude",
|
|
1242
|
+
args: [
|
|
1243
|
+
"-p",
|
|
1244
|
+
mission,
|
|
1245
|
+
...modelArgs(cli, model),
|
|
1246
|
+
"--output-format",
|
|
1247
|
+
"stream-json",
|
|
1248
|
+
"--include-partial-messages",
|
|
1249
|
+
"--verbose",
|
|
1250
|
+
"--dangerously-skip-permissions",
|
|
1251
|
+
],
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
if (cli === "qwen code") {
|
|
1255
|
+
return {
|
|
1256
|
+
cmd: "qwen",
|
|
1257
|
+
args: [
|
|
1258
|
+
"-p",
|
|
1259
|
+
mission,
|
|
1260
|
+
...modelArgs(cli, model),
|
|
1261
|
+
"--output-format",
|
|
1262
|
+
"stream-json",
|
|
1263
|
+
"--include-partial-messages",
|
|
1264
|
+
],
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
if (cli === "codex") {
|
|
1268
|
+
return {
|
|
1269
|
+
cmd: "codex",
|
|
1270
|
+
args: ["exec", mission, ...modelArgs(cli, model), "--json", "--sandbox", "workspace-write"],
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (cli === "github copilot") {
|
|
1274
|
+
return {
|
|
1275
|
+
cmd: "copilot",
|
|
1276
|
+
args: ["-p", mission, ...modelArgs(cli, model), ...copilotArgs({ write: true })],
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
if (cli === "kiro") {
|
|
1280
|
+
// --no-interactive: headless mode (no TUI, answer is printed to stdout).
|
|
1281
|
+
// --trust-all-tools: REQUIRED — stdin is ignored so no approval prompt can
|
|
1282
|
+
// ever be answered. Without it every file write/shell call is denied and
|
|
1283
|
+
// the agent stalls. Same blast radius as claude's --dangerously-skip-permissions:
|
|
1284
|
+
// the task's own git worktree.
|
|
1285
|
+
return {
|
|
1286
|
+
cmd: "kiro-cli",
|
|
1287
|
+
args: ["chat", "--no-interactive", "--trust-all-tools", ...modelArgs(cli, model), mission],
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
// default: opencode's headless `run` mode. `--format json` streams one JSON
|
|
1291
|
+
// event per line (step_start / text / tool_use / step_finish / error) that
|
|
1292
|
+
// the runner parses into structured transcript entries. `--dir` (0044) keeps
|
|
1293
|
+
// the worktree path explicit so linked-worktree paths are never auto-rejected.
|
|
1294
|
+
return {
|
|
1295
|
+
cmd: "opencode",
|
|
1296
|
+
args: ["run", "--format", "json", "--dir", cwd, ...modelArgs(cli, model), "--auto", mission],
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Map a follow-up turn to a resume invocation that continues the SAME session.
|
|
1301
|
+
* claude: `-p --resume <id>` + stream-json/--verbose (falls back to
|
|
1302
|
+
* `-c --continue` when the id is unknown). opencode: `run --format json
|
|
1303
|
+
* --session <id>`. qwen: `--resume <id>` / `--continue` with `-p` +
|
|
1304
|
+
* stream-json. codex: `exec resume <id>` / `exec resume --last`.
|
|
1305
|
+
* All degrade to a fresh run with the user's text if resume metadata is
|
|
1306
|
+
* unavailable — the turn still happens.
|
|
1307
|
+
*/
|
|
1308
|
+
function resumeCommand(agent, text, sessionId, cwd) {
|
|
1309
|
+
const { cli, model } = agent;
|
|
1310
|
+
if (cli === "claude code") {
|
|
1311
|
+
return {
|
|
1312
|
+
cmd: "claude",
|
|
1313
|
+
args: [
|
|
1314
|
+
"-p",
|
|
1315
|
+
...(sessionId ? ["--resume", sessionId] : ["-c", "--continue"]),
|
|
1316
|
+
text,
|
|
1317
|
+
...modelArgs(cli, model),
|
|
1318
|
+
"--output-format",
|
|
1319
|
+
"stream-json",
|
|
1320
|
+
"--include-partial-messages",
|
|
1321
|
+
"--verbose",
|
|
1322
|
+
"--dangerously-skip-permissions",
|
|
1323
|
+
],
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
if (cli === "qwen code") {
|
|
1327
|
+
return {
|
|
1328
|
+
cmd: "qwen",
|
|
1329
|
+
args: [
|
|
1330
|
+
...(sessionId ? ["--resume", sessionId] : ["--continue"]),
|
|
1331
|
+
"-p",
|
|
1332
|
+
text,
|
|
1333
|
+
...modelArgs(cli, model),
|
|
1334
|
+
"--output-format",
|
|
1335
|
+
"stream-json",
|
|
1336
|
+
"--include-partial-messages",
|
|
1337
|
+
],
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
if (cli === "codex") {
|
|
1341
|
+
return {
|
|
1342
|
+
cmd: "codex",
|
|
1343
|
+
args: [
|
|
1344
|
+
"exec",
|
|
1345
|
+
"--sandbox",
|
|
1346
|
+
"workspace-write",
|
|
1347
|
+
"resume",
|
|
1348
|
+
...modelArgs(cli, model),
|
|
1349
|
+
"--json",
|
|
1350
|
+
...(sessionId ? [sessionId] : ["--last"]),
|
|
1351
|
+
text,
|
|
1352
|
+
],
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
if (cli === "github copilot") {
|
|
1356
|
+
// Never use --continue here: it could attach a different task's most
|
|
1357
|
+
// recent Copilot session. Without a saved id, start a fresh safe turn.
|
|
1358
|
+
return {
|
|
1359
|
+
cmd: "copilot",
|
|
1360
|
+
args: [
|
|
1361
|
+
"-p",
|
|
1362
|
+
text,
|
|
1363
|
+
...(sessionId ? [`--resume=${sessionId}`] : []),
|
|
1364
|
+
...modelArgs(cli, model),
|
|
1365
|
+
...copilotArgs({ write: true }),
|
|
1366
|
+
],
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
if (cli === "kiro") {
|
|
1370
|
+
// --resume-id <uuid>: resume a specific session by UUID (preferred).
|
|
1371
|
+
// -r / --resume: fall back to most-recent session in cwd when no id yet.
|
|
1372
|
+
return {
|
|
1373
|
+
cmd: "kiro-cli",
|
|
1374
|
+
args: [
|
|
1375
|
+
"chat",
|
|
1376
|
+
"--no-interactive",
|
|
1377
|
+
"--trust-all-tools",
|
|
1378
|
+
...(sessionId ? ["--resume-id", sessionId] : ["-r"]),
|
|
1379
|
+
...modelArgs(cli, model),
|
|
1380
|
+
text,
|
|
1381
|
+
],
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
return {
|
|
1385
|
+
cmd: "opencode",
|
|
1386
|
+
args: [
|
|
1387
|
+
"run",
|
|
1388
|
+
"--format",
|
|
1389
|
+
"json",
|
|
1390
|
+
...(sessionId ? ["--session", sessionId] : []),
|
|
1391
|
+
...(cwd ? ["--dir", cwd] : []),
|
|
1392
|
+
...modelArgs(cli, model),
|
|
1393
|
+
"--auto",
|
|
1394
|
+
text,
|
|
1395
|
+
],
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
/** Build the read-only mission used by the persistent repository chat. */
|
|
1399
|
+
export function repoGuidePrompt(question, repositoryContext, agent) {
|
|
1400
|
+
return `You are Ross, the always-available assistant for the repository in your current working directory.
|
|
1401
|
+
|
|
1402
|
+
${agent.instructions ?? "Answer questions about RepoOS and this repository."}
|
|
1403
|
+
|
|
1404
|
+
Rules:
|
|
1405
|
+
- Answer the user's question directly and concisely.
|
|
1406
|
+
- Ground answers in the repository context below and inspect repository files when useful.
|
|
1407
|
+
- You may read files and run read-only discovery commands. Never edit files, change task status, commit, launch servers, or start agents.
|
|
1408
|
+
- When discussing tasks, include task IDs and current statuses when relevant.
|
|
1409
|
+
- If the repository does not support a claim, say what you could not verify.
|
|
1410
|
+
|
|
1411
|
+
Current repository context:
|
|
1412
|
+
${repositoryContext}
|
|
1413
|
+
|
|
1414
|
+
User question:
|
|
1415
|
+
${question}`;
|
|
1416
|
+
}
|
|
1417
|
+
/** The Debugger agent's role name, used to route its chat prompt. */
|
|
1418
|
+
export const DEBUGGER_NAME = "debugger";
|
|
1419
|
+
/**
|
|
1420
|
+
* The Debugger agent: a chat-first bug diagnostician (no background scan).
|
|
1421
|
+
* Its CLI/model can be overridden from the persisted built-in agent state so
|
|
1422
|
+
* the inline "coding agent + model" selector on the Agents page actually drives
|
|
1423
|
+
* which model the Debugger runs on.
|
|
1424
|
+
*/
|
|
1425
|
+
export function debuggerAgent(override) {
|
|
1426
|
+
// previously "big pickle"
|
|
1427
|
+
const base = { cli: "opencode", model: "deepinfra/deepseek-ai/DeepSeek-V4-Flash-0731" };
|
|
1428
|
+
return {
|
|
1429
|
+
name: DEBUGGER_NAME,
|
|
1430
|
+
cli: override?.cli || base.cli,
|
|
1431
|
+
model: override?.model || base.model,
|
|
1432
|
+
enabled: true,
|
|
1433
|
+
instructions: "You are the Debugger, a bug diagnostician. When you're handed a pasted bug report, stack trace, or error message, identify the root cause and suggest a concrete, actionable fix. Ask for more context only when the report is too ambiguous to diagnose. Ground your diagnosis in the repository when the pasted text references code you can inspect.",
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
export function debuggerPrompt(question, repositoryContext, agent) {
|
|
1437
|
+
return `You are the Debugger, the agent you copy a failing report to for a clear diagnosis.
|
|
1438
|
+
|
|
1439
|
+
${agent.instructions ?? "Diagnose the root cause and suggest a fix."}
|
|
1440
|
+
|
|
1441
|
+
Rules:
|
|
1442
|
+
- Identify the root cause, not just the symptom, and explain your reasoning briefly.
|
|
1443
|
+
- Give a concrete, actionable suggested fix (code or config where appropriate).
|
|
1444
|
+
- You may read repository files and run read-only discovery commands to verify.
|
|
1445
|
+
- Never edit files, change task status, commit, launch servers, or start agents.
|
|
1446
|
+
- If you cannot determine the cause with confidence, say exactly what is missing.
|
|
1447
|
+
|
|
1448
|
+
The pasted bug / error / question is below. Current repository context:
|
|
1449
|
+
${repositoryContext}
|
|
1450
|
+
|
|
1451
|
+
Bug report:
|
|
1452
|
+
${question}`;
|
|
1453
|
+
}
|
|
1454
|
+
/** Build the writable task-management mission used only by the PM chat. */
|
|
1455
|
+
export function taskPmPrompt(request, taskContext, agent) {
|
|
1456
|
+
return `You are the Product Manager for RepoOS, working on the task below. You are not Ross or the read-only repository assistant.
|
|
1457
|
+
|
|
1458
|
+
${agent.instructions ?? "Own the roadmap and keep task specifications accurate."}
|
|
1459
|
+
|
|
1460
|
+
Rules:
|
|
1461
|
+
- You may create or update tasks, including task body, metadata, and status, only through RepoOS CLI commands (e.g. \`repoos new\`, \`repoos update\`, \`repoos mv\`). Never call the RepoOS HTTP API directly (no \`curl\`/fetch against localhost) — it requires a browser session and is not reachable from your sandbox.
|
|
1462
|
+
- Never edit \`work/*.md\` files directly. Never move task files between folders.
|
|
1463
|
+
- Do not implement product code, commit code, merge branches, or start servers unless the user explicitly asks for that separately.
|
|
1464
|
+
- Explain the requested task change briefly after applying it, including the task ID and what changed.
|
|
1465
|
+
|
|
1466
|
+
Task context:
|
|
1467
|
+
${taskContext}
|
|
1468
|
+
|
|
1469
|
+
User request:
|
|
1470
|
+
${request}`;
|
|
1471
|
+
}
|
|
1472
|
+
/** The mission handed to the coding agent: instructions + task pointer. */
|
|
1473
|
+
const SKILL_ROUTING_HINTS = {
|
|
1474
|
+
"frontend-design": [
|
|
1475
|
+
"frontend",
|
|
1476
|
+
"ui",
|
|
1477
|
+
"ux",
|
|
1478
|
+
"vue",
|
|
1479
|
+
"css",
|
|
1480
|
+
"html",
|
|
1481
|
+
"component",
|
|
1482
|
+
"layout",
|
|
1483
|
+
"page",
|
|
1484
|
+
"style",
|
|
1485
|
+
"accessibility",
|
|
1486
|
+
],
|
|
1487
|
+
"code-review": ["review", "reviewer", "sign-off", "signoff", "diff", "regression"],
|
|
1488
|
+
};
|
|
1489
|
+
/** Pick a small relevant subset; role defaults are preferences, never an unconditional prompt append. */
|
|
1490
|
+
export function selectSkillsForRun(task, agent, config) {
|
|
1491
|
+
const taskText = `${task.title} ${task.area} ${task.body}`.toLowerCase();
|
|
1492
|
+
const words = new Set(taskText.match(/[a-z][a-z0-9-]{2,}/g) ?? []);
|
|
1493
|
+
const preferred = new Set(agent.skills ?? []);
|
|
1494
|
+
return listSkills(config)
|
|
1495
|
+
.map((skill) => {
|
|
1496
|
+
const hints = SKILL_ROUTING_HINTS[skill.name] ?? [];
|
|
1497
|
+
const descriptionWords = skill.description.toLowerCase().match(/[a-z][a-z0-9-]{3,}/g) ?? [];
|
|
1498
|
+
const score = hints.filter((hint) => taskText.includes(hint)).length * 2 +
|
|
1499
|
+
descriptionWords.filter((word) => words.has(word)).length +
|
|
1500
|
+
(preferred.has(skill.name) &&
|
|
1501
|
+
(hints.some((hint) => taskText.includes(hint)) ||
|
|
1502
|
+
descriptionWords.some((word) => words.has(word)))
|
|
1503
|
+
? 1
|
|
1504
|
+
: 0);
|
|
1505
|
+
return { skill, score };
|
|
1506
|
+
})
|
|
1507
|
+
.filter(({ score }) => score >= 2)
|
|
1508
|
+
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
|
|
1509
|
+
.slice(0, 3)
|
|
1510
|
+
.map(({ skill }) => skill);
|
|
1511
|
+
}
|
|
1512
|
+
function missionFor(task, branch, workdir, agent, config, contextPack, resumePreamble, enabledSkills = []) {
|
|
1513
|
+
// Source edits stay inside the sandbox. RepoOS owns the privileged Git and
|
|
1514
|
+
// canonical-board mutations after the structured signal below (ADR-0005).
|
|
1515
|
+
const worktreeTask = join(workdir, relative(config.root, task.path));
|
|
1516
|
+
const baseBranch = currentBranch(config.root) ?? "main";
|
|
1517
|
+
const parts = [];
|
|
1518
|
+
if (contextPack) {
|
|
1519
|
+
parts.push(contextPack);
|
|
1520
|
+
parts.push("");
|
|
1521
|
+
}
|
|
1522
|
+
if (resumePreamble) {
|
|
1523
|
+
parts.push(resumePreamble);
|
|
1524
|
+
parts.push("");
|
|
1525
|
+
}
|
|
1526
|
+
parts.push(agent.instructions?.trim() ? agent.instructions.trim() : "Implement this task.", "");
|
|
1527
|
+
// Skills are intentionally explicit: a repository may contain many
|
|
1528
|
+
// procedures, but an agent sees only the skills enabled for its role. Resolve
|
|
1529
|
+
// names through the discovered list rather than constructing paths from
|
|
1530
|
+
// config input, so a stale or malicious name can never escape skillsDir.
|
|
1531
|
+
//
|
|
1532
|
+
// CACHING: `enabledSkills` is now chosen per-run by keyword scoring
|
|
1533
|
+
// (selectSkillsForRun), so this block's content VARIES from one task to the
|
|
1534
|
+
// next. Today the whole mission is one `-p` string and the downstream CLI
|
|
1535
|
+
// caches on its own conversation prefix, so a turn-1 cost is all this adds
|
|
1536
|
+
// (resume turns never re-send it). But if this is ever split into a cached
|
|
1537
|
+
// system prefix + a variable user turn, per-task-varying content sitting
|
|
1538
|
+
// HERE — before the stable fail-safe checklist below — would bust the cache
|
|
1539
|
+
// for everything after it. Move skills last (or into their own turn) before
|
|
1540
|
+
// introducing any such prefix cache.
|
|
1541
|
+
if (enabledSkills.length) {
|
|
1542
|
+
parts.push("## Enabled repository skills", "");
|
|
1543
|
+
for (const skill of enabledSkills) {
|
|
1544
|
+
try {
|
|
1545
|
+
const text = readFileSync(join(config.root, skill.path), "utf8").trim();
|
|
1546
|
+
if (text)
|
|
1547
|
+
parts.push(`### ${skill.name}`, "", text, "");
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
// A skill may disappear after configuration was saved. Launching work
|
|
1551
|
+
// must remain safe and useful in that case.
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
parts.push(`Task #${task.id}: ${task.title}`, `Working directory: ${workdir} (a git worktree checked out on branch ${branch} — work here).`, `Task file (read this worktree copy for the specification): ${worktreeTask}`, "", "Run this fail-safe checklist IN ORDER. Do not stop until it is fully checked off:", "", "1. Read the task file and implement what it describes.", `2. Run \`REPOOS_CHECK_CHANGED=${baseBranch} repoos check\` and confirm it passes (build, typecheck, tests scoped to what your branch changed vs ${baseBranch}, UI smoke test). It MUST be green before requesting handoff. RepoOS re-verifies it server-side before finalizing your handoff, and runs the full unscoped test suite again when your branch actually merges — so this scoped run is a fast correctness check, not the final word.`, "3. Do not run git add/commit and do not edit the main checkout; those privileged paths are intentionally outside your sandbox.", " RepoOS commits only source, work, docs, and config files to the branch — never `dist/` or `screenshots/`; build artifacts created by `repoos check` stay local.", `4. When the implementation is ready, finish your response with this exact line: ${HANDOFF_READY_SIGNAL}`, "5. Stop. RepoOS will independently run `repoos check`, commit the implementation, set the worktree task to review, and update the canonical board copy.", "", "If you are blocked or need a decision from the human:", "1. Explain the blocker clearly and do NOT emit the handoff-ready signal.", "2. Leave the task active and STOP so the same worktree/session can be resumed.", "", "Work in turns: finish the requested work, then stop and report. The session can be continued later with follow-up instructions from the user.", "", "## Managed previews are server-owned — never run `repoos serve` yourself", "", "RepoOS owns previews and the control-plane port. Do NOT launch `repoos serve` directly, do not choose a port, and never run a long-lived serve process: direct serve attempts from managed agent processes are rejected. Preview ports and lifecycle are managed for you.", "", "Preview requests are the human's to make, not yours. Do NOT automatically request a preview before handoff or as a routine part of finishing a task. If the human wants to inspect a change in the browser, they request the preview manually from the UI — no action needed from you.", "", "Only use the preview request signal below if the human explicitly asks you to verify something the way a browser would see it (or to confirm your change serves). It is idempotent — repeat requests return the same task preview. Do NOT call the control-plane HTTP API and do NOT use `curl`: your sandbox may have no localhost network access. Instead, include this exact line in your response so RepoOS can start and verify the preview for you:", "", ` ${PREVIEW_REQUEST_SIGNAL}`, "", "RepoOS validates the request against your live run, starts the preview from your worktree, probes it server-side (health + static page), and records the result — the preview URL and the probe outcome — in your task transcript as trusted system entries. You never need a port or localhost.", "", "If the preview fails, RepoOS records an actionable reason in your transcript. Leave the task active and stop so the same worktree/session can be resumed. Do NOT retry the request repeatedly, and never fall back to launching your own server.", "", "If this working directory has no build artifacts yet, build before relying on the `repoos` CLI — it warns when its build is stale.");
|
|
1556
|
+
return parts.join("\n");
|
|
1557
|
+
}
|
|
1558
|
+
/** Default ceiling on a one-shot agent run (agent rewrites can be slow). */
|
|
1559
|
+
const PROMPT_TIMEOUT_MS = 180_000;
|
|
1560
|
+
/**
|
|
1561
|
+
* Map an agent `cli` to a one-shot (print mode) invocation that writes its
|
|
1562
|
+
* answer to stdout. opencode: `run`, claude: `-p`, qwen: `-p`, codex:
|
|
1563
|
+
* `exec`. Explicit configured models are forwarded with the driver's model
|
|
1564
|
+
* flag; `default` intentionally omits the flag and lets the CLI resolve it.
|
|
1565
|
+
*/
|
|
1566
|
+
export function promptCommand(agent, prompt) {
|
|
1567
|
+
const extra = modelArgs(agent.cli, agent.model);
|
|
1568
|
+
if (agent.cli === "claude code")
|
|
1569
|
+
return { cmd: "claude", args: ["-p", prompt, ...extra] };
|
|
1570
|
+
if (agent.cli === "qwen code")
|
|
1571
|
+
return { cmd: "qwen", args: ["-p", prompt, ...extra] };
|
|
1572
|
+
if (agent.cli === "codex")
|
|
1573
|
+
return { cmd: "codex", args: ["exec", prompt, ...extra] };
|
|
1574
|
+
if (agent.cli === "github copilot") {
|
|
1575
|
+
return {
|
|
1576
|
+
cmd: "copilot",
|
|
1577
|
+
// One-shot callers (PM/freeform and model probes) consume the final
|
|
1578
|
+
// response, not the streaming transcript. Keep JSONL exclusive to the
|
|
1579
|
+
// AgentRunner so freeform task parsing receives markdown.
|
|
1580
|
+
args: [
|
|
1581
|
+
"-p",
|
|
1582
|
+
prompt,
|
|
1583
|
+
...extra,
|
|
1584
|
+
"--no-ask-user",
|
|
1585
|
+
"--no-auto-update",
|
|
1586
|
+
"--no-remote",
|
|
1587
|
+
"--no-remote-export",
|
|
1588
|
+
],
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
if (agent.cli === "kiro") {
|
|
1592
|
+
return {
|
|
1593
|
+
cmd: "kiro-cli",
|
|
1594
|
+
args: ["chat", "--no-interactive", "--trust-all-tools", ...extra, prompt],
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
return { cmd: "opencode", args: ["run", ...extra, prompt] };
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Map an agent `cli` to a one-shot PM authoring invocation (0335). Same
|
|
1601
|
+
* print-mode shape as `promptCommand`, with one difference that matters for
|
|
1602
|
+
* the usage tab: structured output flags wherever the driver offers them, so
|
|
1603
|
+
* `runPrompt`'s extractUsage/foldUsage sees real tokens/cost and the initial
|
|
1604
|
+
* PM flesh-out lands in the ledger with figures instead of a blank row —
|
|
1605
|
+
* exactly the treatment the reviewer got in 0273. Unlike `reviewCommand`,
|
|
1606
|
+
* no permission-bypass flags: the PM only authors text (its output is applied
|
|
1607
|
+
* via `patchTaskFile`), so it must not be able to write files or run tools.
|
|
1608
|
+
*
|
|
1609
|
+
* The JSONL/JSON stdout this produces is parsed back to the final answer with
|
|
1610
|
+
* `extractOneShotReportText` and rendered live with `parseOneShotLine` — the
|
|
1611
|
+
* same pairing the reviewer/CTO one-shots use.
|
|
1612
|
+
*/
|
|
1613
|
+
export function pmCommand(agent, prompt, cwd) {
|
|
1614
|
+
const extra = modelArgs(agent.cli, agent.model);
|
|
1615
|
+
if (agent.cli === "claude code") {
|
|
1616
|
+
// stream-json — the terminal `result` event carries authoritative tokens
|
|
1617
|
+
// and total_cost_usd for the whole turn (0273).
|
|
1618
|
+
return {
|
|
1619
|
+
cmd: "claude",
|
|
1620
|
+
args: [
|
|
1621
|
+
"-p",
|
|
1622
|
+
prompt,
|
|
1623
|
+
...extra,
|
|
1624
|
+
"--output-format",
|
|
1625
|
+
"stream-json",
|
|
1626
|
+
"--include-partial-messages",
|
|
1627
|
+
"--verbose",
|
|
1628
|
+
],
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
if (agent.cli === "qwen code") {
|
|
1632
|
+
// Mirrors the claude-compatible stream-json so usage is captured.
|
|
1633
|
+
return {
|
|
1634
|
+
cmd: "qwen",
|
|
1635
|
+
args: [
|
|
1636
|
+
"-p",
|
|
1637
|
+
prompt,
|
|
1638
|
+
...extra,
|
|
1639
|
+
"--output-format",
|
|
1640
|
+
"stream-json",
|
|
1641
|
+
"--include-partial-messages",
|
|
1642
|
+
],
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
if (agent.cli === "codex") {
|
|
1646
|
+
// `--json` streams usage events; the default (read-only) sandbox is the
|
|
1647
|
+
// right blast radius for an authoring pass.
|
|
1648
|
+
return { cmd: "codex", args: ["exec", prompt, ...extra, "--json"] };
|
|
1649
|
+
}
|
|
1650
|
+
if (agent.cli === "github copilot") {
|
|
1651
|
+
// copilotArgs already emits `--output-format json`, so usage is captured;
|
|
1652
|
+
// write:false keeps the authoring pass read-only.
|
|
1653
|
+
return {
|
|
1654
|
+
cmd: "copilot",
|
|
1655
|
+
args: ["-p", prompt, ...extra, ...copilotArgs({ write: false })],
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
if (agent.cli === "kiro") {
|
|
1659
|
+
// No structured one-shot mode is verified for kiro — plain chat, matching
|
|
1660
|
+
// `promptCommand` (usage stays unreported for this driver).
|
|
1661
|
+
return {
|
|
1662
|
+
cmd: "kiro-cli",
|
|
1663
|
+
args: ["chat", "--no-interactive", "--trust-all-tools", ...extra, prompt],
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
// opencode: `--format json` separates the final answer from step-by-step
|
|
1667
|
+
// narration (0264 vs 0253) and its `step_finish` events carry per-call
|
|
1668
|
+
// usage deltas; `--dir` pins the repo root. Deliberately NO `--auto`: that
|
|
1669
|
+
// flag auto-approves gated tool calls, and the PM must not be able to write
|
|
1670
|
+
// files or run tools. A stray gated call blocks until the run's timeout —
|
|
1671
|
+
// the acceptable failure mode (the draft is kept with its original prompt),
|
|
1672
|
+
// and the same behavior the plain `promptCommand` path had.
|
|
1673
|
+
return {
|
|
1674
|
+
cmd: "opencode",
|
|
1675
|
+
args: ["run", "--format", "json", "--dir", cwd, ...extra, prompt],
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Map an agent `cli` to a one-shot REVIEW invocation: the agent inspects a
|
|
1680
|
+
* worktree it must not modify and prints its report to stdout.
|
|
1681
|
+
*
|
|
1682
|
+
* Same print-mode shape as `promptCommand`, with one difference that matters:
|
|
1683
|
+
* a reviewer has to READ the worktree (git diff, file reads, maybe the test
|
|
1684
|
+
* suite), and stdin is ignored — so any engine that gates those calls behind a
|
|
1685
|
+
* permission prompt would hang until the timeout instead of reporting. Only
|
|
1686
|
+
* flags already verified for this repo's drivers are used:
|
|
1687
|
+
* - claude code: `--dangerously-skip-permissions` (same reason as the runner's
|
|
1688
|
+
* start turns — nobody can answer a prompt).
|
|
1689
|
+
* - opencode: `--dir <cwd>` so a linked-worktree path is not auto-rejected,
|
|
1690
|
+
* plus `--auto` so gated tool calls resolve instead of blocking.
|
|
1691
|
+
* - codex: `exec` alone — its default sandbox is already read-only, which is
|
|
1692
|
+
* exactly the blast radius a reviewer should have.
|
|
1693
|
+
* - qwen code: plain `-p`, matching `promptCommand`.
|
|
1694
|
+
* - opencode: `--format json`, matching the interactive runner (`cliCommand`
|
|
1695
|
+
* below). Without it, `run`'s plain-text stdout interleaves the model's
|
|
1696
|
+
* step-by-step narration ("Let me look at...") with its final answer, and
|
|
1697
|
+
* nothing separates the two — the whole transcript ends up as the review
|
|
1698
|
+
* report (0264 vs 0253). `extractOneShotReportText` below picks the final
|
|
1699
|
+
* text event back out of the JSON stream.
|
|
1700
|
+
*/
|
|
1701
|
+
export function reviewCommand(agent, prompt, cwd) {
|
|
1702
|
+
const extra = modelArgs(agent.cli, agent.model);
|
|
1703
|
+
if (agent.cli === "claude code") {
|
|
1704
|
+
// stream-json — same structured usage output the engineer's `cliCommand`
|
|
1705
|
+
// uses — so `runPrompt`'s extractUsage/foldUsage sees real tokens/cost and
|
|
1706
|
+
// the reviewer lands in the ledger (0273). The review stays read-only
|
|
1707
|
+
// (no codex-style write sandbox is involved for claude).
|
|
1708
|
+
return {
|
|
1709
|
+
cmd: "claude",
|
|
1710
|
+
args: [
|
|
1711
|
+
"-p",
|
|
1712
|
+
prompt,
|
|
1713
|
+
...extra,
|
|
1714
|
+
"--output-format",
|
|
1715
|
+
"stream-json",
|
|
1716
|
+
"--include-partial-messages",
|
|
1717
|
+
"--verbose",
|
|
1718
|
+
"--dangerously-skip-permissions",
|
|
1719
|
+
],
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
if (agent.cli === "qwen code") {
|
|
1723
|
+
// Mirrors the engineer's claude-compatible stream-json so usage is captured.
|
|
1724
|
+
return {
|
|
1725
|
+
cmd: "qwen",
|
|
1726
|
+
args: [
|
|
1727
|
+
"-p",
|
|
1728
|
+
prompt,
|
|
1729
|
+
...extra,
|
|
1730
|
+
"--output-format",
|
|
1731
|
+
"stream-json",
|
|
1732
|
+
"--include-partial-messages",
|
|
1733
|
+
],
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
if (agent.cli === "codex") {
|
|
1737
|
+
// `--json` streams usage events; the default (read-only) sandbox is exactly
|
|
1738
|
+
// the blast radius a reviewer should have — no `--sandbox workspace-write`.
|
|
1739
|
+
return { cmd: "codex", args: ["exec", prompt, ...extra, "--json"] };
|
|
1740
|
+
}
|
|
1741
|
+
if (agent.cli === "github copilot") {
|
|
1742
|
+
// copilotArgs already emits `--output-format json`, so usage is captured.
|
|
1743
|
+
return {
|
|
1744
|
+
cmd: "copilot",
|
|
1745
|
+
args: ["-p", prompt, ...extra, ...copilotArgs({ write: false })],
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
if (agent.cli === "kiro") {
|
|
1749
|
+
// Review is read-only; --trust-all-tools still needed so file reads
|
|
1750
|
+
// aren't gated behind a prompt that can never be answered (stdin ignored).
|
|
1751
|
+
return {
|
|
1752
|
+
cmd: "kiro-cli",
|
|
1753
|
+
args: ["chat", "--no-interactive", "--trust-all-tools", ...extra, prompt],
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
return {
|
|
1757
|
+
cmd: "opencode",
|
|
1758
|
+
args: ["run", "--format", "json", "--dir", cwd, ...extra, "--auto", prompt],
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Turn one line of a one-shot (review/CTO) agent's stdout into a transcript
|
|
1763
|
+
* entry for live display. Structured event streams (opencode `--format json`,
|
|
1764
|
+
* claude/qwen stream-json, codex `--json`, copilot json) get the same per-line
|
|
1765
|
+
* parsing the interactive runner applies, so the review transcript reads
|
|
1766
|
+
* cleanly instead of dumping raw JSON. A recognized-but-voiceless structured
|
|
1767
|
+
* event (claude `result`, `system/init`, a thinking-only assistant message …)
|
|
1768
|
+
* returns null so the caller can swallow it; a non-JSON line (warnings, plain
|
|
1769
|
+
* text from kiro) passes through as a plain `{s, d}` entry.
|
|
1770
|
+
*/
|
|
1771
|
+
export function parseOneShotLine(cli, raw) {
|
|
1772
|
+
let isJson = false;
|
|
1773
|
+
try {
|
|
1774
|
+
isJson = raw.trim().startsWith("{");
|
|
1775
|
+
void JSON.parse(raw);
|
|
1776
|
+
}
|
|
1777
|
+
catch {
|
|
1778
|
+
return { s: "out", d: raw };
|
|
1779
|
+
}
|
|
1780
|
+
if (!isJson)
|
|
1781
|
+
return { s: "out", d: raw };
|
|
1782
|
+
if (cli === "opencode") {
|
|
1783
|
+
const parsed = parseJsonEvent(raw);
|
|
1784
|
+
return parsed ? parsed.entry : null;
|
|
1785
|
+
}
|
|
1786
|
+
if (cli === "claude code") {
|
|
1787
|
+
const parsed = parseClaudeEvent(raw);
|
|
1788
|
+
return parsed?.entry ?? null;
|
|
1789
|
+
}
|
|
1790
|
+
if (cli === "qwen code") {
|
|
1791
|
+
const parsed = parseQwenEvent(raw);
|
|
1792
|
+
return parsed?.entry ?? null;
|
|
1793
|
+
}
|
|
1794
|
+
if (cli === "codex") {
|
|
1795
|
+
const parsed = parseCodexEvent(raw);
|
|
1796
|
+
return parsed?.entry ?? null;
|
|
1797
|
+
}
|
|
1798
|
+
if (cli === "github copilot") {
|
|
1799
|
+
const parsed = parseCopilotEvent(raw);
|
|
1800
|
+
return parsed?.entry ?? null;
|
|
1801
|
+
}
|
|
1802
|
+
return { s: "out", d: raw };
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Isolate the final report/answer from a one-shot agent's full captured
|
|
1806
|
+
* stdout. Structured JSONL engines (opencode `--format json`, claude/qwen
|
|
1807
|
+
* stream-json, codex `--json`, copilot json) carry the model's whole narration
|
|
1808
|
+
* as a sequence of `text` events interleaved with tool calls — the actual final
|
|
1809
|
+
* answer is the LAST `text` event, so this discards the rest (0264 vs 0253).
|
|
1810
|
+
* Non-JSON one-shot CLIs (kiro) print only the final answer, so their raw
|
|
1811
|
+
* output IS the report. Falls back to the raw output if no text event parses,
|
|
1812
|
+
* so a format change never yields an empty report.
|
|
1813
|
+
*/
|
|
1814
|
+
export function extractOneShotReportText(cli, rawOutput) {
|
|
1815
|
+
const trimmed = rawOutput.trim();
|
|
1816
|
+
if (!trimmed)
|
|
1817
|
+
return trimmed;
|
|
1818
|
+
let last = "";
|
|
1819
|
+
for (const line of trimmed.split("\n")) {
|
|
1820
|
+
if (!line.trim())
|
|
1821
|
+
continue;
|
|
1822
|
+
const entry = parseOneShotLine(cli, line);
|
|
1823
|
+
if (entry && "type" in entry && entry.type === "text")
|
|
1824
|
+
last = entry.text;
|
|
1825
|
+
}
|
|
1826
|
+
return (last || trimmed).trim();
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Run a coding agent once, non-interactively, and capture its full stdout.
|
|
1830
|
+
* Unlike the streaming runner this is synchronous — callers wait for the whole
|
|
1831
|
+
* answer (e.g. freeform task creation). Never throws: failures and timeouts
|
|
1832
|
+
* resolve as `{ ok: false, error }` so an HTTP handler can respond cleanly.
|
|
1833
|
+
*
|
|
1834
|
+
* `onLine` streams each complete stdout line as it arrives (line-buffered, the
|
|
1835
|
+
* same shape the runner uses) so a caller can forward live progress over SSE
|
|
1836
|
+
* without waiting for the run to finish. It is called for real output only —
|
|
1837
|
+
* never for a synthetic failure — and a trailing partial line is flushed on
|
|
1838
|
+
* exit.
|
|
1839
|
+
*
|
|
1840
|
+
* `command` overrides the invocation (default: `promptCommand`) so a caller
|
|
1841
|
+
* that needs different flags — the review agent's read-the-worktree mode —
|
|
1842
|
+
* reuses this spawn/timeout/capture handling instead of duplicating it.
|
|
1843
|
+
* `onSpawn` hands the live child to the caller so it can be killed early.
|
|
1844
|
+
*
|
|
1845
|
+
* The child inherits the server's env verbatim: unlike the streaming runner,
|
|
1846
|
+
* NO `REPOOS_API_URL` / `REPOOS_TASK_ID` is injected, so a one-shot agent has
|
|
1847
|
+
* no pointer at the control plane's task endpoints.
|
|
1848
|
+
*/
|
|
1849
|
+
export function runPrompt(agent, prompt, opts = {}) {
|
|
1850
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1851
|
+
const timeoutMs = opts.timeoutMs ?? PROMPT_TIMEOUT_MS;
|
|
1852
|
+
return new Promise((resolve) => {
|
|
1853
|
+
const { cmd, args } = opts.command ?? promptCommand(agent, prompt);
|
|
1854
|
+
let proc;
|
|
1855
|
+
const startedAt = Date.now();
|
|
1856
|
+
try {
|
|
1857
|
+
proc = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
1858
|
+
}
|
|
1859
|
+
catch (err) {
|
|
1860
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1861
|
+
resolve({ ok: false, error: `could not launch ${cmd}: ${reason}` });
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
opts.onSpawn?.(proc);
|
|
1865
|
+
const out = [];
|
|
1866
|
+
const errOut = [];
|
|
1867
|
+
let pending = "";
|
|
1868
|
+
// The full stdout, folded through the same usage/extract path the streaming
|
|
1869
|
+
// runner uses, so one-shot roles (reviewer/CTO) persist real CLI-reported
|
|
1870
|
+
// tokens/cost instead of zeros (0230). `costUsd` holds Kiro credits when
|
|
1871
|
+
// this is a Kiro session (extractUsage maps its credits footer) — callers
|
|
1872
|
+
// must present it as credits, never as USD.
|
|
1873
|
+
const usage = {};
|
|
1874
|
+
const forwardChunk = (c) => {
|
|
1875
|
+
out.push(c);
|
|
1876
|
+
pending += c.toString("utf8").replace(/\r/g, "\n");
|
|
1877
|
+
const parts = pending.split("\n");
|
|
1878
|
+
pending = parts.pop() ?? "";
|
|
1879
|
+
for (const part of parts) {
|
|
1880
|
+
if (part.length === 0)
|
|
1881
|
+
continue;
|
|
1882
|
+
// Fold usage regardless of whether a consumer wants live lines, so the
|
|
1883
|
+
// one-shot result always carries CLI-reported tokens/cost (0230).
|
|
1884
|
+
foldUsage(usage, part);
|
|
1885
|
+
opts.onLine?.(part);
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
proc.stdout?.on("data", forwardChunk);
|
|
1889
|
+
proc.stderr?.on("data", (c) => errOut.push(c));
|
|
1890
|
+
const timer = setTimeout(() => {
|
|
1891
|
+
try {
|
|
1892
|
+
proc.kill("SIGKILL");
|
|
1893
|
+
}
|
|
1894
|
+
catch {
|
|
1895
|
+
/* already gone */
|
|
1896
|
+
}
|
|
1897
|
+
resolve({
|
|
1898
|
+
ok: false,
|
|
1899
|
+
error: `the ${agent.cli} agent timed out after ${Math.round(timeoutMs / 1000)}s`,
|
|
1900
|
+
});
|
|
1901
|
+
}, timeoutMs);
|
|
1902
|
+
const done = () => {
|
|
1903
|
+
clearTimeout(timer);
|
|
1904
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1905
|
+
// Flush a trailing line with no final newline so nothing is held back.
|
|
1906
|
+
if (opts.onLine && pending.trim()) {
|
|
1907
|
+
foldUsage(usage, pending.trimEnd());
|
|
1908
|
+
opts.onLine(pending.trimEnd());
|
|
1909
|
+
}
|
|
1910
|
+
pending = "";
|
|
1911
|
+
const output = stripAnsi(Buffer.concat(out).toString("utf8").trim());
|
|
1912
|
+
const stderr = Buffer.concat(errOut).toString("utf8").trim();
|
|
1913
|
+
// The line stream may drop usage when a CLI emits no trailing newline, so
|
|
1914
|
+
// fold the full accumulated stdout as a backstop (idempotent via Math.max).
|
|
1915
|
+
foldUsage(usage, output);
|
|
1916
|
+
const usageFields = {
|
|
1917
|
+
elapsedMs,
|
|
1918
|
+
inputTokens: usage.inputTokens,
|
|
1919
|
+
outputTokens: usage.outputTokens,
|
|
1920
|
+
totalTokens: usage.totalTokens,
|
|
1921
|
+
costUsd: usage.costUsd,
|
|
1922
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
1923
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
1924
|
+
turns: usage.turns,
|
|
1925
|
+
};
|
|
1926
|
+
if (output) {
|
|
1927
|
+
resolve({ ok: true, output, ...usageFields });
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const reason = stderr ? stderr.split("\n").slice(-3).join(" ").trim() : "no output produced";
|
|
1931
|
+
resolve({ ok: false, error: `${cmd} exited without output: ${reason}`, ...usageFields });
|
|
1932
|
+
};
|
|
1933
|
+
// `close` (not `exit`) fires only after stdio has drained, so a trailing
|
|
1934
|
+
// line with no final newline is still readable when we flush it.
|
|
1935
|
+
proc.on("close", () => done());
|
|
1936
|
+
proc.on("error", (err) => {
|
|
1937
|
+
clearTimeout(timer);
|
|
1938
|
+
resolve({ ok: false, error: `could not launch ${cmd}: ${err.message}` });
|
|
1939
|
+
});
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Persist a one-shot `runPrompt` run to the sessions DB (0311). One-shot AI
|
|
1944
|
+
* work — freeform task/doc authoring, the auto-engineering dispatch pass —
|
|
1945
|
+
* bypasses the AgentRunner, so nothing books it unless the caller does. This
|
|
1946
|
+
* is the shared path for those: it folds the `PromptResult` (elapsed, tokens,
|
|
1947
|
+
* cost) into a session row, classifying `costSource` the same way
|
|
1948
|
+
* `recordSessionToDb` does — a real CLI figure wins, a token-only run gets an
|
|
1949
|
+
* estimate, Kiro credits are never presented as USD. Best-effort: a DB failure
|
|
1950
|
+
* never propagates to the request.
|
|
1951
|
+
*
|
|
1952
|
+
* `taskId` is null for board-level work that belongs to no single task; those
|
|
1953
|
+
* rows still roll into every board-level aggregation (getSessionTypeStats /
|
|
1954
|
+
* getDailyTotals / board summary), just not a task drawer.
|
|
1955
|
+
*/
|
|
1956
|
+
export function recordOneShotSession(repoRoot, agent, result, opts) {
|
|
1957
|
+
const db = getRepoOSDb(repoRoot);
|
|
1958
|
+
if (!db)
|
|
1959
|
+
return;
|
|
1960
|
+
try {
|
|
1961
|
+
const endedAt = new Date().toISOString();
|
|
1962
|
+
const elapsedMs = result.elapsedMs ?? 0;
|
|
1963
|
+
const totalTokens = result.totalTokens ?? undefined;
|
|
1964
|
+
let costUsd = result.costUsd ?? undefined;
|
|
1965
|
+
let costSource = "none";
|
|
1966
|
+
if (totalTokens && !costUsd) {
|
|
1967
|
+
costUsd = estimateCostUsd(totalTokens);
|
|
1968
|
+
costSource = "estimate";
|
|
1969
|
+
}
|
|
1970
|
+
else if (result.costUsd) {
|
|
1971
|
+
costSource = agent.cli === "kiro" ? "kiro-credits" : "extractUsage";
|
|
1972
|
+
}
|
|
1973
|
+
db.upsertSession({
|
|
1974
|
+
sessionId: opts.sessionId ??
|
|
1975
|
+
`${opts.sessionType}:${opts.taskId ?? "board"}:${endedAt}:${randomUUID()}`,
|
|
1976
|
+
sessionType: opts.sessionType,
|
|
1977
|
+
taskId: opts.taskId ?? undefined,
|
|
1978
|
+
agent: agent.name,
|
|
1979
|
+
model: agent.model,
|
|
1980
|
+
codingAgent: agent.cli,
|
|
1981
|
+
startedAt: new Date(Date.parse(endedAt) - elapsedMs).toISOString(),
|
|
1982
|
+
endedAt,
|
|
1983
|
+
elapsedMs,
|
|
1984
|
+
inputTokens: result.inputTokens ?? undefined,
|
|
1985
|
+
outputTokens: result.outputTokens ?? undefined,
|
|
1986
|
+
totalTokens,
|
|
1987
|
+
cacheReadTokens: result.cacheReadTokens ?? undefined,
|
|
1988
|
+
cacheCreationTokens: result.cacheCreationTokens ?? undefined,
|
|
1989
|
+
turns: result.turns ?? undefined,
|
|
1990
|
+
costUsd,
|
|
1991
|
+
costSource,
|
|
1992
|
+
status: result.ok ? "finished" : "errored",
|
|
1993
|
+
lastActivityAt: endedAt,
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
catch {
|
|
1997
|
+
// Database recording is best-effort and must never crash the caller.
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
export class AgentRunner {
|
|
2001
|
+
entries = new Map();
|
|
2002
|
+
sessions = new Map();
|
|
2003
|
+
config;
|
|
2004
|
+
/**
|
|
2005
|
+
* Cap on simultaneously-spawned agent CLI processes — each one may
|
|
2006
|
+
* itself run a build/test worker pool sized to the host's core count, so
|
|
2007
|
+
* unlimited concurrent agents oversubscribe the machine. Configurable via
|
|
2008
|
+
* `maxConcurrentAgents` in repoos.toml; "auto" (unset) sizes it to this
|
|
2009
|
+
* machine's CPU count so the same repo behaves on a laptop and a desktop.
|
|
2010
|
+
*/
|
|
2011
|
+
maxConcurrentAgents;
|
|
2012
|
+
/** Ids (taskId or chat sessionId) with a queued start/send waiting for a free slot, mapped to when they were queued. */
|
|
2013
|
+
queuedIds = new Map();
|
|
2014
|
+
/** FIFO of deferred spawns, drained as running agents exit (see cleanup()). */
|
|
2015
|
+
startQueue = [];
|
|
2016
|
+
emit;
|
|
2017
|
+
logger;
|
|
2018
|
+
sessionsDir;
|
|
2019
|
+
cacheDir;
|
|
2020
|
+
logDir;
|
|
2021
|
+
db;
|
|
2022
|
+
writeDelayMs;
|
|
2023
|
+
retentionDays;
|
|
2024
|
+
retentionCount;
|
|
2025
|
+
clock;
|
|
2026
|
+
writeTimers = new Map();
|
|
2027
|
+
pendingCompletion = new Set();
|
|
2028
|
+
/**
|
|
2029
|
+
* The main RepoOS server's own API URL, injected into every agent process so
|
|
2030
|
+
* agents with host networking have an informational pointer at the ACTUAL
|
|
2031
|
+
* control plane, never a hardcoded port. Managed previews are requested by
|
|
2032
|
+
* signal (#0121), so this is not a requirement — it stays for diagnostics
|
|
2033
|
+
* and future HTTP-capable drivers.
|
|
2034
|
+
*/
|
|
2035
|
+
apiUrl;
|
|
2036
|
+
stallTimeoutMs;
|
|
2037
|
+
stallTimer;
|
|
2038
|
+
authorizedHandoffs = new Map();
|
|
2039
|
+
handoffsInFlight = new Set();
|
|
2040
|
+
onHandoff;
|
|
2041
|
+
/**
|
|
2042
|
+
* Fired on completion of a durable REVIEW turn (0288) — including for an
|
|
2043
|
+
* entry re-attached after a reload, whose process finishes on the new
|
|
2044
|
+
* server. The owning ReviewManager finalizes the report from this hook
|
|
2045
|
+
* rather than a post-spawn continuation that would die with the old process.
|
|
2046
|
+
* `reviewKind` (run vs chat) and `exitedCleanly` are threaded through so the
|
|
2047
|
+
* ReviewManager can finalize an adopted turn in the right mode and decide
|
|
2048
|
+
* whether it finished cleanly.
|
|
2049
|
+
*/
|
|
2050
|
+
onReviewDone;
|
|
2051
|
+
/**
|
|
2052
|
+
* Tasks a human deliberately paused (via POST /api/tasks/:id/pause, 0070).
|
|
2053
|
+
* The task stays `active` with no process, so without this the watchdog would
|
|
2054
|
+
* treat a legitimately-paused task as stuck and disturb it (#0180). In-memory
|
|
2055
|
+
* only: a paused task across a server restart loses the marker and reads as a
|
|
2056
|
+
* stopped agent — the watchdog then surfaces it, which is the correct default
|
|
2057
|
+
* (the paused process is, after all, gone).
|
|
2058
|
+
*/
|
|
2059
|
+
pausedTasks = new Set();
|
|
2060
|
+
/**
|
|
2061
|
+
* Preview capabilities minted per clean run (#0121). Superseded when a new
|
|
2062
|
+
* run starts for the same task, so a stale request can never start a preview
|
|
2063
|
+
* against a different worktree.
|
|
2064
|
+
*/
|
|
2065
|
+
authorizedPreviews = new Map();
|
|
2066
|
+
onPreviewRequest;
|
|
2067
|
+
/** Resolve a task from the repo task index by ID. Used to populate task/branch on resume turns. */
|
|
2068
|
+
getTask;
|
|
2069
|
+
/**
|
|
2070
|
+
* `opts.stallTimeoutMs` overrides the 90s default (tests use a small value
|
|
2071
|
+
* so stall detection doesn't need a real 90s wait); `opts.stallCheckIntervalMs`
|
|
2072
|
+
* overrides the poll cadence, which otherwise scales with the timeout.
|
|
2073
|
+
*/
|
|
2074
|
+
constructor(config, emit, opts = {}) {
|
|
2075
|
+
this.config = config;
|
|
2076
|
+
this.maxConcurrentAgents = config.maxConcurrentAgents ?? defaultMaxConcurrentAgents();
|
|
2077
|
+
this.emit = emit;
|
|
2078
|
+
this.logger = opts.logger;
|
|
2079
|
+
this.onHandoff = opts.onHandoff;
|
|
2080
|
+
this.onPreviewRequest = opts.onPreviewRequest;
|
|
2081
|
+
this.onReviewDone = opts.onReviewDone;
|
|
2082
|
+
this.getTask = opts.getTask;
|
|
2083
|
+
this.db = getRepoOSDb(config.root);
|
|
2084
|
+
this.cacheDir = join(config.root, config.cacheDir);
|
|
2085
|
+
this.sessionsDir = join(this.cacheDir, "sessions");
|
|
2086
|
+
this.logDir = join(this.cacheDir, "agent-logs");
|
|
2087
|
+
this.writeDelayMs = opts.writeDelayMs ?? SESSION_WRITE_DELAY_MS;
|
|
2088
|
+
this.retentionDays = opts.retentionDays ?? SESSION_RETENTION_DAYS;
|
|
2089
|
+
this.retentionCount = opts.retentionCount ?? SESSION_RETENTION_COUNT;
|
|
2090
|
+
this.clock = opts.now ?? (() => new Date());
|
|
2091
|
+
this.stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
|
|
2092
|
+
const checkMs = opts.stallCheckIntervalMs ??
|
|
2093
|
+
Math.max(20, Math.min(5000, Math.floor(this.stallTimeoutMs / 3)));
|
|
2094
|
+
this.stallTimer = setInterval(() => this.checkStalls(), checkMs);
|
|
2095
|
+
this.stallTimer.unref();
|
|
2096
|
+
this.loadHotSessions();
|
|
2097
|
+
this.pruneSessions();
|
|
2098
|
+
}
|
|
2099
|
+
/** Stop the stall-check timer (server shutdown / test cleanup). Idempotent. */
|
|
2100
|
+
dispose() {
|
|
2101
|
+
clearInterval(this.stallTimer);
|
|
2102
|
+
for (const entry of this.entries.values()) {
|
|
2103
|
+
for (const tailer of entry.tailers ?? [])
|
|
2104
|
+
clearInterval(tailer.timer);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Re-attach to agent children that survived a server restart (0214).
|
|
2109
|
+
* Reads the durable registry, checks PID aliveness, and for each still-live
|
|
2110
|
+
* child tail-catches the log file and restores the in-memory entry/session
|
|
2111
|
+
* so `isRunning()` reports true and SSE streaming resumes from this point.
|
|
2112
|
+
* Stale entries (PID dead) are dropped — this must not resurrect dead sessions.
|
|
2113
|
+
*/
|
|
2114
|
+
adoptRunningAgents() {
|
|
2115
|
+
const registry = readRegistry(this.cacheDir);
|
|
2116
|
+
const live = [];
|
|
2117
|
+
for (const rec of registry.entries) {
|
|
2118
|
+
if (this.entries.has(rec.taskId))
|
|
2119
|
+
continue;
|
|
2120
|
+
let alive = false;
|
|
2121
|
+
try {
|
|
2122
|
+
process.kill(rec.pid, 0);
|
|
2123
|
+
alive = true;
|
|
2124
|
+
}
|
|
2125
|
+
catch {
|
|
2126
|
+
/* PID is dead — drop it */
|
|
2127
|
+
}
|
|
2128
|
+
const isReview = rec.kind === "review";
|
|
2129
|
+
// A dead non-review entry is stale — drop it (must not resurrect dead
|
|
2130
|
+
// sessions). A DEAD REVIEW entry, however, means the review process
|
|
2131
|
+
// finished sometime during the reload handoff before this process could
|
|
2132
|
+
// adopt it — its durable log still holds the full report, so we replay
|
|
2133
|
+
// it and hand completion to the new ReviewManager (0288). By the time the
|
|
2134
|
+
// deferred hook fires, the server has built `reviews`.
|
|
2135
|
+
if (!alive && !isReview)
|
|
2136
|
+
continue;
|
|
2137
|
+
const outLog = join(this.logDir, `${rec.taskId}.out.log`);
|
|
2138
|
+
const errLog = join(this.logDir, `${rec.taskId}.err.log`);
|
|
2139
|
+
// Restore the session so the transcript is pre-loaded for the task.
|
|
2140
|
+
let session = this.sessions.get(rec.taskId) ?? this.loadSession(rec.taskId);
|
|
2141
|
+
if (!session) {
|
|
2142
|
+
session = this.emptySession();
|
|
2143
|
+
session.workdir = rec.workdir;
|
|
2144
|
+
session.engine = "plain";
|
|
2145
|
+
}
|
|
2146
|
+
session.workdir = rec.workdir;
|
|
2147
|
+
session.turnStartedAt = session.turnStartedAt ?? now();
|
|
2148
|
+
session.lastOutputAt = session.lastOutputAt ?? now();
|
|
2149
|
+
this.sessions.set(rec.taskId, session);
|
|
2150
|
+
// Tail-catch: read any log output written during the handoff gap and
|
|
2151
|
+
// replay it into the session so the transcript isn't missing a chunk.
|
|
2152
|
+
// Separate logs preserve stdout/stderr type during the handoff gap.
|
|
2153
|
+
try {
|
|
2154
|
+
this.replayLog(rec.taskId, outLog, "out");
|
|
2155
|
+
this.replayLog(rec.taskId, errLog, "err");
|
|
2156
|
+
// Compatibility with an early 0214 build that used one tagged log.
|
|
2157
|
+
this.replayLog(rec.taskId, join(this.logDir, `${rec.taskId}.log`), "out", true);
|
|
2158
|
+
}
|
|
2159
|
+
catch {
|
|
2160
|
+
/* best-effort */
|
|
2161
|
+
}
|
|
2162
|
+
if (!alive && isReview) {
|
|
2163
|
+
// The reviewer already finished: finalize its report and drop the entry.
|
|
2164
|
+
this.removeRegistryEntry(rec.taskId);
|
|
2165
|
+
session.accumulatedMs = Date.now() - Date.parse(session.turnStartedAt ?? now());
|
|
2166
|
+
// Defer so `reviews` (and safe handler wiring) exists before we finalize.
|
|
2167
|
+
setTimeout(() => {
|
|
2168
|
+
try {
|
|
2169
|
+
this.onReviewDone?.(rec.taskId, false, rec.reviewKind);
|
|
2170
|
+
}
|
|
2171
|
+
catch {
|
|
2172
|
+
/* best-effort */
|
|
2173
|
+
}
|
|
2174
|
+
}, 0);
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
live.push(rec);
|
|
2178
|
+
// Start tailing the log file for live output, and register a fake Entry
|
|
2179
|
+
// so isRunning() is true and the task reads as in-flight. The real ChildProcess
|
|
2180
|
+
// is referenced by PID, not held directly — we poll for aliveness instead.
|
|
2181
|
+
const tailers = [
|
|
2182
|
+
this.tailLog(rec.taskId, outLog, "out", true),
|
|
2183
|
+
this.tailLog(rec.taskId, errLog, "err", true),
|
|
2184
|
+
];
|
|
2185
|
+
this.entries.set(rec.taskId, {
|
|
2186
|
+
startedAt: now(),
|
|
2187
|
+
workdir: rec.workdir,
|
|
2188
|
+
task: undefined,
|
|
2189
|
+
branch: rec.branch,
|
|
2190
|
+
runId: rec.runId,
|
|
2191
|
+
handoffRequested: false,
|
|
2192
|
+
previewRequested: false,
|
|
2193
|
+
adoptedPid: rec.pid,
|
|
2194
|
+
review: isReview,
|
|
2195
|
+
reviewKind: rec.kind === "review" ? rec.reviewKind : undefined,
|
|
2196
|
+
tailers,
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
if (live.length > 0) {
|
|
2200
|
+
writeRegistry(this.cacheDir, { entries: live });
|
|
2201
|
+
}
|
|
2202
|
+
else {
|
|
2203
|
+
this.clearRegistry();
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
/** Replay a durable log into a transcript, optionally decoding the legacy tagged format. */
|
|
2207
|
+
replayLog(taskId, logFile, stream, tagged = false) {
|
|
2208
|
+
if (!existsSync(logFile))
|
|
2209
|
+
return;
|
|
2210
|
+
const data = readFileSync(logFile, "utf8");
|
|
2211
|
+
// Replay rebuilds the transcript only — usage was already folded live and
|
|
2212
|
+
// (for a hot session being re-adopted) re-folding here would double-count,
|
|
2213
|
+
// most visibly the SUM-folded opencode deltas and `turns`. `applyUsage`
|
|
2214
|
+
// still appends to `session_usage_events` (deduped), so no event is lost.
|
|
2215
|
+
this.replayingUsage = true;
|
|
2216
|
+
try {
|
|
2217
|
+
for (const line of data.split("\n")) {
|
|
2218
|
+
if (!line)
|
|
2219
|
+
continue;
|
|
2220
|
+
if (tagged && line.startsWith("O:"))
|
|
2221
|
+
this.appendLine(taskId, "out", line.slice(2));
|
|
2222
|
+
else if (tagged && line.startsWith("E:"))
|
|
2223
|
+
this.appendLine(taskId, "err", line.slice(2));
|
|
2224
|
+
else
|
|
2225
|
+
this.appendLine(taskId, stream, line);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
finally {
|
|
2229
|
+
this.replayingUsage = false;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
/** True only while `replayLog` is rebuilding a transcript — see `applyUsage`. */
|
|
2233
|
+
replayingUsage = false;
|
|
2234
|
+
/** Tail one durable stream file into the live transcript. */
|
|
2235
|
+
tailLog(taskId, logFile, stream, startAtEnd = false) {
|
|
2236
|
+
// `stat.size` is a byte offset. Keep it in bytes and decode only the new
|
|
2237
|
+
// buffer range; slicing a decoded string with that offset loses output as
|
|
2238
|
+
// soon as an agent writes emoji, CJK, or any other multi-byte UTF-8.
|
|
2239
|
+
let lastSize = 0;
|
|
2240
|
+
let pending = "";
|
|
2241
|
+
let decoder = new StringDecoder("utf8");
|
|
2242
|
+
if (startAtEnd) {
|
|
2243
|
+
try {
|
|
2244
|
+
lastSize = statSync(logFile).size;
|
|
2245
|
+
}
|
|
2246
|
+
catch {
|
|
2247
|
+
/* missing log */
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
const drain = () => {
|
|
2251
|
+
try {
|
|
2252
|
+
const currentSize = statSync(logFile).size;
|
|
2253
|
+
// A new turn truncates the same durable log path. Reset both the byte
|
|
2254
|
+
// cursor and decoder rather than treating the truncated file as idle.
|
|
2255
|
+
if (currentSize < lastSize) {
|
|
2256
|
+
lastSize = 0;
|
|
2257
|
+
decoder = new StringDecoder("utf8");
|
|
2258
|
+
}
|
|
2259
|
+
if (currentSize > lastSize) {
|
|
2260
|
+
const data = readFileSync(logFile);
|
|
2261
|
+
const delta = decoder.write(data.subarray(lastSize));
|
|
2262
|
+
lastSize = currentSize;
|
|
2263
|
+
const lines = (pending + delta).replace(/\r/g, "\n").split("\n");
|
|
2264
|
+
pending = lines.pop() ?? "";
|
|
2265
|
+
for (const line of lines) {
|
|
2266
|
+
if (line.length === 0)
|
|
2267
|
+
continue;
|
|
2268
|
+
this.appendLine(taskId, stream, line);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
catch (err) {
|
|
2273
|
+
/* best-effort on any other error */
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
const interval = setInterval(() => {
|
|
2277
|
+
if (!this.entries.has(taskId)) {
|
|
2278
|
+
clearInterval(interval);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
drain();
|
|
2282
|
+
}, 200);
|
|
2283
|
+
interval.unref();
|
|
2284
|
+
// `pending` is local to this tailer (rather than Session.pending), so a
|
|
2285
|
+
// process that exits without its final newline needs an explicit flush.
|
|
2286
|
+
// Route it through appendLine to preserve structured JSON parsing.
|
|
2287
|
+
const flush = () => {
|
|
2288
|
+
const line = pending.trimEnd();
|
|
2289
|
+
pending = "";
|
|
2290
|
+
if (line)
|
|
2291
|
+
this.appendLine(taskId, stream, line);
|
|
2292
|
+
};
|
|
2293
|
+
return { timer: interval, drain, flush };
|
|
2294
|
+
}
|
|
2295
|
+
/** Persist a registry entry for one running task (0214). */
|
|
2296
|
+
writeRegistryEntry(taskId, pid, workdir, branch, runId, kind, reviewKind) {
|
|
2297
|
+
const registry = readRegistry(this.cacheDir);
|
|
2298
|
+
const existing = registry.entries.findIndex((e) => e.taskId === taskId);
|
|
2299
|
+
const entry = { taskId, pid, workdir, branch, runId, kind, reviewKind };
|
|
2300
|
+
if (existing >= 0) {
|
|
2301
|
+
registry.entries[existing] = entry;
|
|
2302
|
+
}
|
|
2303
|
+
else {
|
|
2304
|
+
registry.entries.push(entry);
|
|
2305
|
+
}
|
|
2306
|
+
writeRegistry(this.cacheDir, registry);
|
|
2307
|
+
}
|
|
2308
|
+
/** Remove a registry entry for a task (0214). */
|
|
2309
|
+
removeRegistryEntry(taskId) {
|
|
2310
|
+
const registry = readRegistry(this.cacheDir);
|
|
2311
|
+
registry.entries = registry.entries.filter((e) => e.taskId !== taskId);
|
|
2312
|
+
writeRegistry(this.cacheDir, registry);
|
|
2313
|
+
}
|
|
2314
|
+
/** Clear the entire durable registry (0214). */
|
|
2315
|
+
clearRegistry() {
|
|
2316
|
+
try {
|
|
2317
|
+
const file = registryPath(this.cacheDir);
|
|
2318
|
+
if (existsSync(file))
|
|
2319
|
+
unlinkSync(file);
|
|
2320
|
+
}
|
|
2321
|
+
catch {
|
|
2322
|
+
/* best-effort */
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
// ---- Pending handoff persistence (#0235) ----
|
|
2326
|
+
/** Persist a handoff request for recovery across process boundaries. */
|
|
2327
|
+
persistPendingHandoff(request) {
|
|
2328
|
+
const store = readPendingHandoffs(this.cacheDir);
|
|
2329
|
+
// Newer run supersedes older one for the same task.
|
|
2330
|
+
store.requests = store.requests.filter((r) => r.taskId !== request.taskId);
|
|
2331
|
+
store.requests.push(request);
|
|
2332
|
+
writePendingHandoffs(this.cacheDir, store);
|
|
2333
|
+
}
|
|
2334
|
+
/** Remove a persisted handoff request (by task id). */
|
|
2335
|
+
clearPendingHandoff(taskId) {
|
|
2336
|
+
const store = readPendingHandoffs(this.cacheDir);
|
|
2337
|
+
const before = store.requests.length;
|
|
2338
|
+
store.requests = store.requests.filter((r) => r.taskId !== taskId);
|
|
2339
|
+
if (store.requests.length < before) {
|
|
2340
|
+
writePendingHandoffs(this.cacheDir, store);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Recover persisted handoff requests at server boot. Validates each request
|
|
2345
|
+
* (task still exists, still active, branch matches, not already finalizing)
|
|
2346
|
+
* and re-fires onHandoff for valid ones. Idempotent: a request already in
|
|
2347
|
+
* handoffsInFlight is skipped.
|
|
2348
|
+
*/
|
|
2349
|
+
recoverPendingHandoffs() {
|
|
2350
|
+
const store = readPendingHandoffs(this.cacheDir);
|
|
2351
|
+
if (store.requests.length === 0)
|
|
2352
|
+
return;
|
|
2353
|
+
for (const request of store.requests) {
|
|
2354
|
+
if (this.handoffsInFlight.has(request.taskId))
|
|
2355
|
+
continue;
|
|
2356
|
+
// Validate: task must still exist and be active, branch must match.
|
|
2357
|
+
const task = this.getTask?.(request.taskId) ?? null;
|
|
2358
|
+
if (!task) {
|
|
2359
|
+
this.clearPendingHandoff(request.taskId);
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
if (task.status !== "active") {
|
|
2363
|
+
this.clearPendingHandoff(request.taskId);
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
if (task.branch !== request.branch) {
|
|
2367
|
+
this.clearPendingHandoff(request.taskId);
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
// Admit to the authorized-capability map so the server's
|
|
2371
|
+
// consumeHandoff validation (server.ts onHandoff) succeeds. The normal
|
|
2372
|
+
// clean-exit path does the same in cleanup().
|
|
2373
|
+
this.authorizedHandoffs.set(request.runId, request);
|
|
2374
|
+
// Move to in-flight before firing so concurrent checks see it.
|
|
2375
|
+
this.handoffsInFlight.add(request.taskId);
|
|
2376
|
+
// Clear the persisted entry so a later boot does not re-fire the same
|
|
2377
|
+
// request. If finalization ultimately fails, the task stays active and
|
|
2378
|
+
// can be resumed manually.
|
|
2379
|
+
this.clearPendingHandoff(request.taskId);
|
|
2380
|
+
// Ensure the session is loaded so system()/appendLine() can write to
|
|
2381
|
+
// the transcript. For a dead-interrupted task, adoptRunningAgents only
|
|
2382
|
+
// pre-loads sessions for live PIDs — the interrupted-turn session is on
|
|
2383
|
+
// disk but not yet in memory (#0235 review fix).
|
|
2384
|
+
if (!this.sessions.has(request.taskId)) {
|
|
2385
|
+
const loaded = this.loadSession(request.taskId);
|
|
2386
|
+
if (loaded)
|
|
2387
|
+
this.sessions.set(request.taskId, loaded);
|
|
2388
|
+
}
|
|
2389
|
+
// Surface in the transcript so the human sees recovery.
|
|
2390
|
+
this.system(request.taskId, "Recovering pending handoff from interrupted turn — finalizing now");
|
|
2391
|
+
void Promise.resolve(this.onHandoff?.(request))
|
|
2392
|
+
.catch((err) => {
|
|
2393
|
+
this.appendLine(request.taskId, "sys", `✗ recovered handoff failed: ${err.message}`);
|
|
2394
|
+
})
|
|
2395
|
+
.finally(() => {
|
|
2396
|
+
this.handoffsInFlight.delete(request.taskId);
|
|
2397
|
+
// If the server handler rejected (consumeHandoff denied, or a
|
|
2398
|
+
// concurrent finalization was already in flight), the capability
|
|
2399
|
+
// was never consumed — remove it so it does not leak and confuse
|
|
2400
|
+
// a later validateHandoff check.
|
|
2401
|
+
if (this.authorizedHandoffs.has(request.runId)) {
|
|
2402
|
+
this.authorizedHandoffs.delete(request.runId);
|
|
2403
|
+
}
|
|
2404
|
+
// If the task is still active after recovery, record the outcome in
|
|
2405
|
+
// the Activity log so the watchdog's HANDOFF_RETAINED guard can
|
|
2406
|
+
// unstick it: without this, the retained line permanently blocks
|
|
2407
|
+
// auto-surface/escalation even when recovery has already been
|
|
2408
|
+
// attempted and failed (#0235 review fix).
|
|
2409
|
+
const task = this.getTask?.(request.taskId) ?? null;
|
|
2410
|
+
if (task && task.status === "active") {
|
|
2411
|
+
this.persistHandoffFailure(request.taskId, task, "handoff recovery attempted · finalization failed");
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
/** Append trusted server orchestration progress to the retained transcript. */
|
|
2417
|
+
system(taskId, text) {
|
|
2418
|
+
this.appendLine(taskId, "sys", text);
|
|
2419
|
+
}
|
|
2420
|
+
/** Validate that a handoff capability was minted by a successful active turn. */
|
|
2421
|
+
validateHandoff(request) {
|
|
2422
|
+
const issued = this.authorizedHandoffs.get(request.runId);
|
|
2423
|
+
return Boolean(issued &&
|
|
2424
|
+
issued.taskId === request.taskId &&
|
|
2425
|
+
issued.branch === request.branch &&
|
|
2426
|
+
this.samePath(issued.workdir, request.workdir) &&
|
|
2427
|
+
issued.sessionId === request.sessionId);
|
|
2428
|
+
}
|
|
2429
|
+
/** Validate and expire one runner-issued handoff capability atomically. */
|
|
2430
|
+
consumeHandoff(request) {
|
|
2431
|
+
if (!this.validateHandoff(request))
|
|
2432
|
+
return false;
|
|
2433
|
+
this.authorizedHandoffs.delete(request.runId);
|
|
2434
|
+
return true;
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Validate that a preview capability was minted by a real, current runner
|
|
2438
|
+
* turn (#0121). The capability carries the task id, the run id issued for
|
|
2439
|
+
* that turn, the registered branch, and the registered worktree — a forged,
|
|
2440
|
+
* expired (superseded by a newer run), cross-task, or path-substituted
|
|
2441
|
+
* request never matches all four.
|
|
2442
|
+
*/
|
|
2443
|
+
validatePreview(request) {
|
|
2444
|
+
const issued = this.authorizedPreviews.get(request.runId);
|
|
2445
|
+
return Boolean(issued &&
|
|
2446
|
+
issued.taskId === request.taskId &&
|
|
2447
|
+
issued.branch === request.branch &&
|
|
2448
|
+
this.samePath(issued.workdir, request.workdir) &&
|
|
2449
|
+
issued.sessionId === request.sessionId);
|
|
2450
|
+
}
|
|
2451
|
+
isRunning(taskId) {
|
|
2452
|
+
return this.entries.has(taskId);
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* The review turn kind ("run" | "chat") of a durable review session, if the
|
|
2456
|
+
* entry is one (0288). Used by ReviewManager to finalize an ADOPTED turn in
|
|
2457
|
+
* the right mode — the session's own `Run` record is not carried across a
|
|
2458
|
+
* reload, but its review kind is (persisted in the registry).
|
|
2459
|
+
*/
|
|
2460
|
+
reviewKindOf(sessionKey) {
|
|
2461
|
+
return this.entries.get(sessionKey)?.reviewKind;
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Record a deliberate human pause (0070): the task stays `active` but its
|
|
2465
|
+
* agent is intentionally stopped, so the watchdog must not touch it (#0180).
|
|
2466
|
+
* Cleared automatically the moment a new turn starts for the task.
|
|
2467
|
+
*/
|
|
2468
|
+
markPaused(taskId) {
|
|
2469
|
+
this.pausedTasks.add(taskId);
|
|
2470
|
+
}
|
|
2471
|
+
/** True when the task was explicitly paused and no new turn has started since. */
|
|
2472
|
+
isPaused(taskId) {
|
|
2473
|
+
return this.pausedTasks.has(taskId);
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* True while the server-side handoff for a task is still finalizing (check,
|
|
2477
|
+
* commit, review). The process is already gone, so `isRunning` is false, but
|
|
2478
|
+
* the task is not stuck — the close-out pipeline owns it for a few seconds.
|
|
2479
|
+
*/
|
|
2480
|
+
isHandoffInFlight(taskId) {
|
|
2481
|
+
return this.handoffsInFlight.has(taskId);
|
|
2482
|
+
}
|
|
2483
|
+
/** Live run telemetry for a task's session — zeros/nulls when none exists yet. */
|
|
2484
|
+
stats(taskId) {
|
|
2485
|
+
return this.snapshotStats(taskId);
|
|
2486
|
+
}
|
|
2487
|
+
running() {
|
|
2488
|
+
const out = [];
|
|
2489
|
+
for (const [id, e] of this.entries) {
|
|
2490
|
+
out.push({
|
|
2491
|
+
id,
|
|
2492
|
+
pid: e.adoptedPid ?? e.proc?.pid ?? -1,
|
|
2493
|
+
startedAt: e.startedAt,
|
|
2494
|
+
workdir: e.workdir,
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
return out;
|
|
2498
|
+
}
|
|
2499
|
+
/** Query historical stats for a task from the database. */
|
|
2500
|
+
taskStats(taskId) {
|
|
2501
|
+
return this.db?.getTaskStats(taskId) ?? null;
|
|
2502
|
+
}
|
|
2503
|
+
/** Query historical stats grouped by session type from the database. */
|
|
2504
|
+
sessionTypeStats() {
|
|
2505
|
+
return this.db?.getSessionTypeStats() ?? [];
|
|
2506
|
+
}
|
|
2507
|
+
/** Per-day usage totals (server's local time). */
|
|
2508
|
+
dailyTotals() {
|
|
2509
|
+
return this.db?.getDailyTotals() ?? [];
|
|
2510
|
+
}
|
|
2511
|
+
/** Query board-level summary stats from the database, optionally scoped to a usage range (0334). */
|
|
2512
|
+
boardStats(range = "all") {
|
|
2513
|
+
return this.db?.getBoardStats(range) ?? null;
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Spawn the coding agent on the task. Never blocks — the child runs
|
|
2517
|
+
* detached from the HTTP response. Returns a StartResult describing the
|
|
2518
|
+
* launch attempt; an async spawn failure is emitted as agent.exited.
|
|
2519
|
+
*
|
|
2520
|
+
* `opts.cwd` is the task's worktree directory when one was provisioned;
|
|
2521
|
+
* defaults to the repo root so launch still works (best-effort) when
|
|
2522
|
+
* worktree setup failed or the task's branch is the main checkout.
|
|
2523
|
+
*/
|
|
2524
|
+
start(task, branch, agent, opts = {}) {
|
|
2525
|
+
if (this.entries.has(task.id) ||
|
|
2526
|
+
this.handoffsInFlight.has(task.id) ||
|
|
2527
|
+
this.queuedIds.has(task.id)) {
|
|
2528
|
+
return { ok: false, reason: "task is already running or finalizing" };
|
|
2529
|
+
}
|
|
2530
|
+
const cwd = opts.cwd ?? this.config.root;
|
|
2531
|
+
const session = this.sessions.get(task.id) ?? this.loadSession(task.id) ?? this.emptySession();
|
|
2532
|
+
this.captureUsageBaseline(session);
|
|
2533
|
+
session.workdir = cwd;
|
|
2534
|
+
session.engine = engineForCli(agent.cli);
|
|
2535
|
+
session.task = task;
|
|
2536
|
+
session.branch = branch;
|
|
2537
|
+
session.agent = agent.name;
|
|
2538
|
+
session.model = agent.model;
|
|
2539
|
+
this.sessions.set(task.id, session);
|
|
2540
|
+
const selectedSkills = selectSkillsForRun(task, agent, this.config);
|
|
2541
|
+
// Only note it in the transcript when a skill was actually injected — a
|
|
2542
|
+
// "no skills selected" line on every single run is pure noise, and nothing
|
|
2543
|
+
// downstream keys off its presence.
|
|
2544
|
+
if (selectedSkills.length) {
|
|
2545
|
+
this.recordEntry(task.id, session, "sys", {
|
|
2546
|
+
s: "sys",
|
|
2547
|
+
d: `Skill routing: ${selectedSkills.map((skill) => skill.name).join(", ")}`,
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
const mission = missionFor(task, branch, cwd, agent, this.config, opts.contextPack, opts.resumePreamble, selectedSkills);
|
|
2551
|
+
const { cmd, args } = cliCommand(agent, mission, cwd);
|
|
2552
|
+
return this.spawnOrQueue(task.id, cmd, args, cwd, task, branch);
|
|
2553
|
+
}
|
|
2554
|
+
/** Start a persistent, non-task conversation with an explicit role mission. */
|
|
2555
|
+
startChat(sessionId, text, agent, repositoryContext, promptBuilder = repoGuidePrompt) {
|
|
2556
|
+
if (this.entries.has(sessionId) || this.queuedIds.has(sessionId)) {
|
|
2557
|
+
return {
|
|
2558
|
+
ok: false,
|
|
2559
|
+
busy: true,
|
|
2560
|
+
reason: "agent is busy — wait for the current turn to finish",
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
if (this.sessions.has(sessionId)) {
|
|
2564
|
+
return { ok: false, reason: "conversation already exists — send a follow-up instead" };
|
|
2565
|
+
}
|
|
2566
|
+
const human = { type: "human", text, at: new Date().toISOString() };
|
|
2567
|
+
const session = {
|
|
2568
|
+
lines: [human],
|
|
2569
|
+
pending: "",
|
|
2570
|
+
bytes: entryBytes(human),
|
|
2571
|
+
workdir: this.config.root,
|
|
2572
|
+
engine: engineForCli(agent.cli),
|
|
2573
|
+
agent: agent.name,
|
|
2574
|
+
model: agent.model,
|
|
2575
|
+
accumulatedMs: 0,
|
|
2576
|
+
stalledEmitted: false,
|
|
2577
|
+
};
|
|
2578
|
+
this.sessions.set(sessionId, session);
|
|
2579
|
+
const mission = agent.name === DEBUGGER_NAME
|
|
2580
|
+
? debuggerPrompt(text, repositoryContext, agent)
|
|
2581
|
+
: promptBuilder(text, repositoryContext, agent);
|
|
2582
|
+
const { cmd, args } = cliCommand(agent, mission, this.config.root);
|
|
2583
|
+
return this.spawnOrQueue(sessionId, cmd, args, this.config.root);
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Spawn a durable REVIEW turn under a synthetic session key (0288), so the
|
|
2587
|
+
* reviewer survives a server reload the same way engineer/PM turns do: the
|
|
2588
|
+
* child's stdout/stderr stream to durable log files and its PID is registered
|
|
2589
|
+
* so the next server's `adoptRunningAgents()` re-attaches and replays any
|
|
2590
|
+
* output written during the handoff. On completion — even post-reload —
|
|
2591
|
+
* `cleanup()` fires `onReviewDone` for the owning ReviewManager to finalize.
|
|
2592
|
+
*
|
|
2593
|
+
* Unlike `start`/`send`, the review agent is deliberately NOT given
|
|
2594
|
+
* `REPOOS_TASK_ID` / `REPOOS_API_URL`, preserving the read-only boundary that
|
|
2595
|
+
* keeps it from reaching the control plane's task endpoints (review.ts layer
|
|
2596
|
+
* 2). `kind: "review"` is persisted in the durable registry so adoption knows
|
|
2597
|
+
* to re-attach as a review.
|
|
2598
|
+
*
|
|
2599
|
+
* `reset: true` (a fresh run) clears any prior conversation; a chat turn
|
|
2600
|
+
* (`reset: false`, with `humanEntry`) continues the existing one.
|
|
2601
|
+
*/
|
|
2602
|
+
startReview(sessionKey, agent, mission, cwd, opts = {}) {
|
|
2603
|
+
if (this.entries.has(sessionKey)) {
|
|
2604
|
+
return { ok: false, busy: true, reason: "a review is already running for this task" };
|
|
2605
|
+
}
|
|
2606
|
+
let session = this.sessions.get(sessionKey) ?? this.loadSession(sessionKey);
|
|
2607
|
+
if (!session || opts.reset) {
|
|
2608
|
+
session = this.emptySession();
|
|
2609
|
+
}
|
|
2610
|
+
if (opts.reset) {
|
|
2611
|
+
session.lines = [];
|
|
2612
|
+
session.bytes = 0;
|
|
2613
|
+
}
|
|
2614
|
+
session.workdir = cwd;
|
|
2615
|
+
session.engine = engineForCli(agent.cli);
|
|
2616
|
+
session.agent = agent.name;
|
|
2617
|
+
session.model = agent.model;
|
|
2618
|
+
if (opts.humanEntry) {
|
|
2619
|
+
session.lines.push(opts.humanEntry);
|
|
2620
|
+
session.bytes += entryBytes(opts.humanEntry);
|
|
2621
|
+
while (session.bytes > OUTPUT_CAP_BYTES) {
|
|
2622
|
+
const dropped = session.lines.shift();
|
|
2623
|
+
if (!dropped)
|
|
2624
|
+
break;
|
|
2625
|
+
session.bytes -= entryBytes(dropped);
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
this.sessions.set(sessionKey, session);
|
|
2629
|
+
const { cmd, args } = reviewCommand(agent, mission, cwd);
|
|
2630
|
+
return this.spawnTurn(sessionKey, cmd, args, cwd, undefined, "", {
|
|
2631
|
+
review: true,
|
|
2632
|
+
reviewKind: opts.reviewKind ?? "run",
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
/** Release a durable review session from the in-memory map (its file stays). */
|
|
2636
|
+
discardReviewSession(sessionKey) {
|
|
2637
|
+
this.sessions.delete(sessionKey);
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Send a follow-up message to a task's session, resuming the same
|
|
2641
|
+
* conversation as a new turn. Rejected when the task has no session yet
|
|
2642
|
+
* (`ok: false`) or when a turn is already running (`ok: false, busy: true`).
|
|
2643
|
+
*/
|
|
2644
|
+
send(taskId, text, agent, opts = {}) {
|
|
2645
|
+
// Completed/non-task conversations are deliberately not preloaded at boot.
|
|
2646
|
+
// Hydrate one on demand so a persisted RepoOS Guide transcript can resume
|
|
2647
|
+
// after a server reload instead of being visible-but-unsendable.
|
|
2648
|
+
const session = this.sessions.get(taskId) ?? this.loadSession(taskId);
|
|
2649
|
+
if (!session) {
|
|
2650
|
+
return { ok: false, reason: "no session for this task — start work first" };
|
|
2651
|
+
}
|
|
2652
|
+
if (this.entries.has(taskId) ||
|
|
2653
|
+
this.handoffsInFlight.has(taskId) ||
|
|
2654
|
+
this.queuedIds.has(taskId)) {
|
|
2655
|
+
return {
|
|
2656
|
+
ok: false,
|
|
2657
|
+
busy: true,
|
|
2658
|
+
reason: "agent is busy — wait for the current turn or handoff to finish",
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
this.sessions.set(taskId, session);
|
|
2662
|
+
this.captureUsageBaseline(session);
|
|
2663
|
+
const entry = { type: "human", text, at: new Date().toISOString() };
|
|
2664
|
+
session.lines.push(entry);
|
|
2665
|
+
session.bytes += entryBytes(entry);
|
|
2666
|
+
while (session.bytes > OUTPUT_CAP_BYTES) {
|
|
2667
|
+
const dropped = session.lines.shift();
|
|
2668
|
+
if (!dropped)
|
|
2669
|
+
break;
|
|
2670
|
+
session.bytes -= entryBytes(dropped);
|
|
2671
|
+
}
|
|
2672
|
+
this.schedulePersist(taskId);
|
|
2673
|
+
// On resume turns, resolve the task from the index if not already set in the session.
|
|
2674
|
+
// This ensures task/branch are always available for handoff finalization.
|
|
2675
|
+
if (!session.task && this.getTask) {
|
|
2676
|
+
const resolvedTask = this.getTask(taskId);
|
|
2677
|
+
if (resolvedTask) {
|
|
2678
|
+
session.task = resolvedTask;
|
|
2679
|
+
session.branch = resolvedTask.branch;
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
const fullText = opts.resumePreamble ? `${opts.resumePreamble}\n\n${text}` : text;
|
|
2683
|
+
// session.sessionId is only meaningful to the CLI that produced it — if
|
|
2684
|
+
// agent.cli has since changed (override edited/cleared between turns),
|
|
2685
|
+
// reusing it would hand one CLI's session id to a different CLI's
|
|
2686
|
+
// --resume flag (e.g. an opencode `ses_...` id passed to `claude
|
|
2687
|
+
// --resume`, which requires a UUID and errors out). Drop it and let
|
|
2688
|
+
// resumeCommand fall back to a fresh/most-recent-session start instead.
|
|
2689
|
+
const sessionId = session.engine === engineForCli(agent.cli) ? session.sessionId : undefined;
|
|
2690
|
+
const { cmd, args } = resumeCommand(agent, fullText, sessionId, session.workdir ?? this.config.root);
|
|
2691
|
+
return this.spawnOrQueue(taskId, cmd, args, session.workdir ?? this.config.root, session.task, session.branch, { skipBoardDivergence: opts.skipBoardDivergence });
|
|
2692
|
+
}
|
|
2693
|
+
/** The retained transcript for a task, or null when no session exists. */
|
|
2694
|
+
output(taskId) {
|
|
2695
|
+
return this.sessions.get(taskId) ?? this.loadSession(taskId);
|
|
2696
|
+
}
|
|
2697
|
+
/** Whether a session exists for a key (in memory or persisted on disk). */
|
|
2698
|
+
hasSession(sessionKey) {
|
|
2699
|
+
return this.sessions.has(sessionKey) || this.readPersisted(sessionKey) !== null;
|
|
2700
|
+
}
|
|
2701
|
+
/** Drop a session both in memory and from disk (a fresh review run). */
|
|
2702
|
+
clearSession(sessionKey) {
|
|
2703
|
+
this.sessions.delete(sessionKey);
|
|
2704
|
+
const file = this.sessionFile(sessionKey);
|
|
2705
|
+
if (file && existsSync(file)) {
|
|
2706
|
+
try {
|
|
2707
|
+
unlinkSync(file);
|
|
2708
|
+
}
|
|
2709
|
+
catch {
|
|
2710
|
+
/* best-effort */
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Mark a task transcript complete and release its RAM. If its child is still
|
|
2716
|
+
* draining after a stop request, cleanup performs the final flush/eviction.
|
|
2717
|
+
*/
|
|
2718
|
+
complete(taskId) {
|
|
2719
|
+
// Task is leaving active — clear any persisted handoff (#0235).
|
|
2720
|
+
this.clearPendingHandoff(taskId);
|
|
2721
|
+
for (const [runId, req] of this.authorizedPreviews) {
|
|
2722
|
+
if (req.taskId === taskId)
|
|
2723
|
+
this.authorizedPreviews.delete(runId);
|
|
2724
|
+
}
|
|
2725
|
+
if (this.entries.has(taskId)) {
|
|
2726
|
+
this.pendingCompletion.add(taskId);
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
this.finishCompletion(taskId);
|
|
2730
|
+
}
|
|
2731
|
+
/** Flush all debounced transcript writes before shutdown/reload handover. */
|
|
2732
|
+
flushAll() {
|
|
2733
|
+
for (const taskId of this.writeTimers.keys())
|
|
2734
|
+
this.persist(taskId);
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Gate for every spawnTurn call site: at capacity, defer the spawn
|
|
2738
|
+
* instead of oversubscribing the machine. The caller still gets `ok: true`
|
|
2739
|
+
* immediately — `queued: true` distinguishes "will run shortly" from
|
|
2740
|
+
* "running now" without changing callers' happy-path handling. `id` is
|
|
2741
|
+
* marked busy via queuedIds so a second start/send for the same task/chat
|
|
2742
|
+
* while queued is rejected the same way an already-running one would be.
|
|
2743
|
+
*/
|
|
2744
|
+
spawnOrQueue(id, cmd, args, cwd, task, branch, opts = {}) {
|
|
2745
|
+
if (this.entries.size < this.maxConcurrentAgents) {
|
|
2746
|
+
return this.spawnTurn(id, cmd, args, cwd, task, branch, opts);
|
|
2747
|
+
}
|
|
2748
|
+
this.queuedIds.set(id, now());
|
|
2749
|
+
this.startQueue.push(() => {
|
|
2750
|
+
this.queuedIds.delete(id);
|
|
2751
|
+
this.emit({ type: "agent.dequeued", id, at: now() });
|
|
2752
|
+
this.spawnTurn(id, cmd, args, cwd, task, branch, opts);
|
|
2753
|
+
});
|
|
2754
|
+
this.logger?.agent(id, "info", `Queued — ${this.entries.size}/${this.maxConcurrentAgents} agent processes already running.`);
|
|
2755
|
+
this.emit({ type: "agent.queued", id, at: now() });
|
|
2756
|
+
return { ok: true, queued: true };
|
|
2757
|
+
}
|
|
2758
|
+
/** Tasks/chats currently waiting for a free maxConcurrentAgents slot. */
|
|
2759
|
+
queued() {
|
|
2760
|
+
return Array.from(this.queuedIds.entries()).map(([id, queuedAt]) => ({ id, queuedAt }));
|
|
2761
|
+
}
|
|
2762
|
+
/** Start the next queued spawn once a slot is free (called from cleanup() on every exit). */
|
|
2763
|
+
drainQueue() {
|
|
2764
|
+
while (this.startQueue.length > 0 && this.entries.size < this.maxConcurrentAgents) {
|
|
2765
|
+
const next = this.startQueue.shift();
|
|
2766
|
+
next?.();
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* Spawn one turn and attach streaming. Everything after the spawn is async;
|
|
2771
|
+
* failures surface as agent.exited via cleanup.
|
|
2772
|
+
*/
|
|
2773
|
+
spawnTurn(taskId, cmd, args, cwd, task, branch, opts = {}) {
|
|
2774
|
+
const runId = randomUUID();
|
|
2775
|
+
// A new turn means the task is active again — a human restarted a paused
|
|
2776
|
+
// task, or sent a follow-up — so the pause marker no longer applies.
|
|
2777
|
+
this.pausedTasks.delete(taskId);
|
|
2778
|
+
// A new turn supersedes any persisted handoff from a previous turn (#0235).
|
|
2779
|
+
this.clearPendingHandoff(taskId);
|
|
2780
|
+
// A new run supersedes the previous run's preview capability for this task
|
|
2781
|
+
// (#0121): a capability minted for an older run is expired the moment a
|
|
2782
|
+
// newer turn claims the task.
|
|
2783
|
+
for (const [prevRunId, req] of this.authorizedPreviews) {
|
|
2784
|
+
if (req.taskId === taskId)
|
|
2785
|
+
this.authorizedPreviews.delete(prevRunId);
|
|
2786
|
+
}
|
|
2787
|
+
const outLog = join(this.logDir, `${taskId}.out.log`);
|
|
2788
|
+
const errLog = join(this.logDir, `${taskId}.err.log`);
|
|
2789
|
+
let outFd;
|
|
2790
|
+
let errFd;
|
|
2791
|
+
let proc;
|
|
2792
|
+
try {
|
|
2793
|
+
// Give the child file descriptors, not pipes owned by this server. The
|
|
2794
|
+
// child keeps those descriptors when this process exits during reload,
|
|
2795
|
+
// so subsequent output cannot fail with EPIPE.
|
|
2796
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
2797
|
+
outFd = openSync(outLog, "w");
|
|
2798
|
+
errFd = openSync(errLog, "w");
|
|
2799
|
+
// REPOOS_AGENT=1 marks every managed agent process so the CLI's defense
|
|
2800
|
+
// in depth can reject an accidental direct `repoos serve` attempt, and
|
|
2801
|
+
// REPOOS_TASK_ID/REPOOS_RUN_ID let the runner bind any capability-request
|
|
2802
|
+
// signal to this exact turn. REPOOS_API_URL is kept as an informational
|
|
2803
|
+
// pointer at the control plane (ADR-0005: agents express intent, RepoOS
|
|
2804
|
+
// owns privileged process/network lifecycle) — managed previews are
|
|
2805
|
+
// requested by signal, never by a sandboxed localhost call (#0121).
|
|
2806
|
+
// A managed agent is, by definition, never a preview child or a reload
|
|
2807
|
+
// replacement. Scrub the control-plane's lifecycle markers and reload
|
|
2808
|
+
// secret before they can leak into the agent (and from it into the
|
|
2809
|
+
// #0096 agent-serve-guard test, where REPOOS_RELOAD=1 would exempt the
|
|
2810
|
+
// child from the direct-serve guard and hang the test).
|
|
2811
|
+
const agentEnv = { ...process.env };
|
|
2812
|
+
delete agentEnv.REPOOS_RELOAD;
|
|
2813
|
+
delete agentEnv.REPOOS_RELOAD_SECRET;
|
|
2814
|
+
delete agentEnv.REPOOS_PREVIEW_CHILD;
|
|
2815
|
+
agentEnv.REPOOS_AGENT = "1";
|
|
2816
|
+
// A REVIEW turn is deliberately read-only: unlike the engineer/PM, it is
|
|
2817
|
+
// NOT given REPOOS_TASK_ID / REPOOS_API_URL, so it has no pointer at the
|
|
2818
|
+
// control plane's task endpoints and cannot reach /done (review.ts layer
|
|
2819
|
+
// 2). REPOOS_RUN_ID is still bound so any capability-request signal (none
|
|
2820
|
+
// expected) would route to this exact run.
|
|
2821
|
+
if (!opts.review)
|
|
2822
|
+
agentEnv.REPOOS_TASK_ID = taskId;
|
|
2823
|
+
agentEnv.REPOOS_RUN_ID = runId;
|
|
2824
|
+
if (this.apiUrl && !opts.review)
|
|
2825
|
+
agentEnv.REPOOS_API_URL = this.apiUrl;
|
|
2826
|
+
proc = spawn(cmd, args, {
|
|
2827
|
+
cwd,
|
|
2828
|
+
stdio: ["ignore", outFd, errFd],
|
|
2829
|
+
env: agentEnv,
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
catch (err) {
|
|
2833
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2834
|
+
this.logger?.agent(taskId, "error", `Failed to spawn agent: ${reason}`, { cmd, args });
|
|
2835
|
+
this.emit({ type: "agent.exited", id: taskId, at: now() });
|
|
2836
|
+
return { ok: false, reason };
|
|
2837
|
+
}
|
|
2838
|
+
finally {
|
|
2839
|
+
// spawn duplicates the descriptors into the child. The parent's copies
|
|
2840
|
+
// are no longer needed and must not keep the files artificially open.
|
|
2841
|
+
if (outFd !== undefined)
|
|
2842
|
+
closeSync(outFd);
|
|
2843
|
+
if (errFd !== undefined)
|
|
2844
|
+
closeSync(errFd);
|
|
2845
|
+
}
|
|
2846
|
+
const tailers = [this.tailLog(taskId, outLog, "out"), this.tailLog(taskId, errLog, "err")];
|
|
2847
|
+
this.entries.set(taskId, {
|
|
2848
|
+
proc,
|
|
2849
|
+
startedAt: now(),
|
|
2850
|
+
workdir: cwd,
|
|
2851
|
+
task,
|
|
2852
|
+
branch: branch ?? task?.branch ?? "",
|
|
2853
|
+
runId,
|
|
2854
|
+
handoffRequested: false,
|
|
2855
|
+
previewRequested: false,
|
|
2856
|
+
skipBoardDivergence: opts.skipBoardDivergence,
|
|
2857
|
+
review: opts.review,
|
|
2858
|
+
reviewKind: opts.review ? opts.reviewKind : undefined,
|
|
2859
|
+
tailers,
|
|
2860
|
+
});
|
|
2861
|
+
// Turn-start bookkeeping for the live stats readout (0080): the silence
|
|
2862
|
+
// clock resets here too, not just on output, so a follow-up turn on a
|
|
2863
|
+
// session that's been idle in `review` for hours doesn't immediately read
|
|
2864
|
+
// as "stalled" before the freshly-spawned process has had a chance to
|
|
2865
|
+
// produce a single line.
|
|
2866
|
+
const session = this.sessions.get(taskId);
|
|
2867
|
+
if (session) {
|
|
2868
|
+
session.turnStartedAt = now();
|
|
2869
|
+
session.lastOutputAt = now();
|
|
2870
|
+
session.stalledEmitted = false;
|
|
2871
|
+
}
|
|
2872
|
+
// Persist the durable registry entry so a restart can re-attach (0214).
|
|
2873
|
+
if (proc.pid) {
|
|
2874
|
+
this.writeRegistryEntry(taskId, proc.pid, cwd, branch ?? task?.branch ?? "", runId, opts.review ? "review" : "engineer", opts.review ? opts.reviewKind : undefined);
|
|
2875
|
+
}
|
|
2876
|
+
// Either path means the run is over: natural exit, spawn error (e.g. the
|
|
2877
|
+
// CLI isn't installed), or our own SIGKILL after a graceful pause. `close`
|
|
2878
|
+
// (not `exit`) fires only after stdio has drained, so a trailing line with
|
|
2879
|
+
// no final newline is still in `pending` when cleanup flushes it.
|
|
2880
|
+
const finishTurn = (code) => {
|
|
2881
|
+
// The final write can arrive just before close, before the next polling
|
|
2882
|
+
// tick. Drain synchronously so no trailing output is lost.
|
|
2883
|
+
for (const tailer of this.entries.get(taskId)?.tailers ?? [])
|
|
2884
|
+
tailer.drain();
|
|
2885
|
+
this.cleanup(taskId, code === 0);
|
|
2886
|
+
};
|
|
2887
|
+
proc.on("close", finishTurn);
|
|
2888
|
+
proc.on("error", () => this.cleanup(taskId, false));
|
|
2889
|
+
// A very short-lived command can exit between `spawn()` and listener
|
|
2890
|
+
// registration. ChildProcess does not replay a missed `close` event, so
|
|
2891
|
+
// finalize it explicitly instead of leaving its last log line and runner
|
|
2892
|
+
// entry behind forever.
|
|
2893
|
+
if (proc.exitCode !== null)
|
|
2894
|
+
finishTurn(proc.exitCode);
|
|
2895
|
+
this.logger?.agent(taskId, "info", "Agent started", { pid: proc.pid, cwd, cmd });
|
|
2896
|
+
this.emit({
|
|
2897
|
+
type: "agent.running",
|
|
2898
|
+
id: taskId,
|
|
2899
|
+
at: this.entries.get(taskId)?.startedAt ?? now(),
|
|
2900
|
+
});
|
|
2901
|
+
this.emitStats(taskId);
|
|
2902
|
+
return { ok: true, pid: proc.pid };
|
|
2903
|
+
}
|
|
2904
|
+
/** Line-buffer a chunk into the session transcript and stream it out. */
|
|
2905
|
+
onData(taskId, stream, chunk) {
|
|
2906
|
+
const session = this.sessions.get(taskId);
|
|
2907
|
+
if (!session)
|
|
2908
|
+
return;
|
|
2909
|
+
session.pending += chunk.toString("utf8").replace(/\r/g, "\n");
|
|
2910
|
+
const parts = session.pending.split("\n");
|
|
2911
|
+
session.pending = parts.pop() ?? "";
|
|
2912
|
+
for (const raw of parts) {
|
|
2913
|
+
if (raw.length === 0)
|
|
2914
|
+
continue;
|
|
2915
|
+
this.appendLine(taskId, stream, raw);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
/** Turn one buffered line into a transcript entry and stream it out. */
|
|
2919
|
+
appendLine(taskId, stream, raw) {
|
|
2920
|
+
const session = this.sessions.get(taskId);
|
|
2921
|
+
if (!session)
|
|
2922
|
+
return;
|
|
2923
|
+
// claude's stream-json event shapes (nested under `message.content[]`)
|
|
2924
|
+
// differ from opencode's `part` shapes, so it gets its own parser branch
|
|
2925
|
+
// rather than a merged parser sniffing both (0109).
|
|
2926
|
+
if (stream === "out" && session.engine === "claude") {
|
|
2927
|
+
this.appendClaudeLine(taskId, session, raw);
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2930
|
+
if (stream === "out" && session.engine === "copilot") {
|
|
2931
|
+
this.appendCopilotLine(taskId, session, raw);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
if (stream === "out" && session.engine === "qwen") {
|
|
2935
|
+
this.appendClaudeLine(taskId, session, raw, parseQwenEvent);
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
if (stream === "out" && session.engine === "codex") {
|
|
2939
|
+
this.appendCodexLine(taskId, session, raw);
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
const parsed = stream === "out" && session.engine === "opencode" ? parseJsonEvent(raw) : null;
|
|
2943
|
+
let entry;
|
|
2944
|
+
if (parsed) {
|
|
2945
|
+
if (parsed.sessionID && !session.sessionId)
|
|
2946
|
+
session.sessionId = parsed.sessionID;
|
|
2947
|
+
entry = parsed.entry;
|
|
2948
|
+
}
|
|
2949
|
+
else {
|
|
2950
|
+
// Plain line: qwen / codex output, malformed JSON, anything on stderr,
|
|
2951
|
+
// or a `sys` notice. Keep the legacy `{s,d}` shape and the regex
|
|
2952
|
+
// session-id extraction (opencode's session-id event, unknown schemas).
|
|
2953
|
+
entry = { s: stream, d: raw };
|
|
2954
|
+
this.tryExtractSessionId(raw, session);
|
|
2955
|
+
}
|
|
2956
|
+
if (stream === "out") {
|
|
2957
|
+
entry = this.applySignals(taskId, raw, entry, session);
|
|
2958
|
+
}
|
|
2959
|
+
this.recordEntry(taskId, session, stream, entry);
|
|
2960
|
+
this.lineTouched(taskId, session, raw);
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Append a transcript entry, stream it out, and enforce the output cap.
|
|
2964
|
+
* `sys` entries (self-heal notices) flow on the "out" stream, matching how
|
|
2965
|
+
* parsed `error`/`file-update` events already surface as sys lines.
|
|
2966
|
+
*/
|
|
2967
|
+
recordEntry(taskId, session, stream, entry) {
|
|
2968
|
+
const stamped = entry.at ? entry : { ...entry, at: new Date().toISOString() };
|
|
2969
|
+
session.lines.push(stamped);
|
|
2970
|
+
session.bytes += entryBytes(stamped);
|
|
2971
|
+
this.emit({
|
|
2972
|
+
type: "agent.output",
|
|
2973
|
+
id: taskId,
|
|
2974
|
+
entry: stamped,
|
|
2975
|
+
stream: stream === "sys" ? "out" : stream,
|
|
2976
|
+
at: now(),
|
|
2977
|
+
});
|
|
2978
|
+
while (session.bytes > OUTPUT_CAP_BYTES) {
|
|
2979
|
+
const dropped = session.lines.shift();
|
|
2980
|
+
if (!dropped)
|
|
2981
|
+
break;
|
|
2982
|
+
session.bytes -= entryBytes(dropped);
|
|
2983
|
+
}
|
|
2984
|
+
this.schedulePersist(taskId);
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Per-line bookkeeping shared by every append path: fold any usage/cost the
|
|
2988
|
+
* line reported into the session and reset the stall clock. Any output is
|
|
2989
|
+
* evidence the process isn't hung, so it resets the silence clock and clears
|
|
2990
|
+
* a stall warning immediately — the periodic check only ever needs to raise
|
|
2991
|
+
* the flag, never lower it.
|
|
2992
|
+
*/
|
|
2993
|
+
lineTouched(taskId, session, raw) {
|
|
2994
|
+
const usageChanged = this.applyUsage(taskId, session, raw);
|
|
2995
|
+
session.lastOutputAt = now();
|
|
2996
|
+
const stallCleared = session.stalledEmitted;
|
|
2997
|
+
session.stalledEmitted = false;
|
|
2998
|
+
if (usageChanged || stallCleared)
|
|
2999
|
+
this.emitStats(taskId);
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* The claude stream-json branch of `appendLine` (0109). Recognized events
|
|
3003
|
+
* map onto structured entries, or are swallowed when they have no transcript
|
|
3004
|
+
* representation; anything else (a non-JSON `Warning: no stdin…` line, an
|
|
3005
|
+
* unknown schema) falls back to the plain-line path so nothing the CLI said
|
|
3006
|
+
* is lost.
|
|
3007
|
+
*/
|
|
3008
|
+
appendClaudeLine(taskId, session, raw, parser = parseClaudeEvent) {
|
|
3009
|
+
const parsed = parser(raw);
|
|
3010
|
+
if (!parsed) {
|
|
3011
|
+
const entry = { s: "out", d: raw };
|
|
3012
|
+
this.tryExtractSessionId(raw, session);
|
|
3013
|
+
this.recordEntry(taskId, session, "out", this.applySignals(taskId, raw, entry, session));
|
|
3014
|
+
this.lineTouched(taskId, session, raw);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
if (parsed.sessionID && !session.sessionId)
|
|
3018
|
+
session.sessionId = parsed.sessionID;
|
|
3019
|
+
if (parsed.pendingTool) {
|
|
3020
|
+
// A tool call with its result still to come: buffer it and emit the card
|
|
3021
|
+
// only when the matching tool_result line arrives, so it carries name +
|
|
3022
|
+
// input + output together. If a second call starts before the first
|
|
3023
|
+
// resolved, flush the stale one without its output so it isn't lost.
|
|
3024
|
+
if (session.pendingTool && session.pendingTool.id !== parsed.pendingTool.id) {
|
|
3025
|
+
this.recordEntry(taskId, session, "out", this.pendingToolEntry(session.pendingTool));
|
|
3026
|
+
}
|
|
3027
|
+
session.pendingTool = parsed.pendingTool;
|
|
3028
|
+
}
|
|
3029
|
+
else if (parsed.toolResult) {
|
|
3030
|
+
const pending = session.pendingTool && session.pendingTool.id === parsed.toolResult.id
|
|
3031
|
+
? session.pendingTool
|
|
3032
|
+
: undefined;
|
|
3033
|
+
if (pending) {
|
|
3034
|
+
session.pendingTool = undefined;
|
|
3035
|
+
this.recordEntry(taskId, session, "out", {
|
|
3036
|
+
type: "tool",
|
|
3037
|
+
tool: pending.name,
|
|
3038
|
+
...(pending.input ? { input: pending.input } : {}),
|
|
3039
|
+
...(parsed.toolResult.content ? { output: parsed.toolResult.content } : {}),
|
|
3040
|
+
...(parsed.toolResult.isError ? { state: "error" } : {}),
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
// An orphaned tool_result (an id we never saw a tool_use for) has
|
|
3044
|
+
// nothing to attach to — drop it rather than fabricate a tool card.
|
|
3045
|
+
}
|
|
3046
|
+
else if (parsed.entry) {
|
|
3047
|
+
this.recordEntry(taskId, session, "out", this.applySignals(taskId, raw, parsed.entry, session));
|
|
3048
|
+
}
|
|
3049
|
+
// Otherwise the line was a recognized-but-voiceless claude event (init,
|
|
3050
|
+
// rate_limit, thinking-only assistant message, terminal `result`) — it is
|
|
3051
|
+
// swallowed rather than dumped into the transcript as raw JSON.
|
|
3052
|
+
this.lineTouched(taskId, session, raw);
|
|
3053
|
+
}
|
|
3054
|
+
/** Copilot's JSONL branch; unlike Claude, tool start/finish events stand alone. */
|
|
3055
|
+
appendCopilotLine(taskId, session, raw) {
|
|
3056
|
+
const parsed = parseCopilotEvent(raw);
|
|
3057
|
+
if (!parsed) {
|
|
3058
|
+
const entry = { s: "out", d: raw };
|
|
3059
|
+
this.tryExtractSessionId(raw, session);
|
|
3060
|
+
this.recordEntry(taskId, session, "out", this.applySignals(taskId, raw, entry, session));
|
|
3061
|
+
this.lineTouched(taskId, session, raw);
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
if (parsed.sessionID && !session.sessionId)
|
|
3065
|
+
session.sessionId = parsed.sessionID;
|
|
3066
|
+
if (parsed.entry) {
|
|
3067
|
+
this.recordEntry(taskId, session, "out", this.applySignals(taskId, raw, parsed.entry, session));
|
|
3068
|
+
}
|
|
3069
|
+
this.lineTouched(taskId, session, raw);
|
|
3070
|
+
}
|
|
3071
|
+
/** Codex has one completed item per tool/message rather than Claude blocks. */
|
|
3072
|
+
appendCodexLine(taskId, session, raw) {
|
|
3073
|
+
const parsed = parseCodexEvent(raw);
|
|
3074
|
+
if (!parsed) {
|
|
3075
|
+
const entry = { s: "out", d: raw };
|
|
3076
|
+
this.tryExtractSessionId(raw, session);
|
|
3077
|
+
this.recordEntry(taskId, session, "out", this.applySignals(taskId, raw, entry, session));
|
|
3078
|
+
this.lineTouched(taskId, session, raw);
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
if (parsed.sessionID && !session.sessionId)
|
|
3082
|
+
session.sessionId = parsed.sessionID;
|
|
3083
|
+
if (parsed.entry) {
|
|
3084
|
+
this.recordEntry(taskId, session, "out", this.applySignals(taskId, raw, parsed.entry, session));
|
|
3085
|
+
}
|
|
3086
|
+
this.lineTouched(taskId, session, raw);
|
|
3087
|
+
}
|
|
3088
|
+
/** The transcript entry for a tool_use whose result never arrived. */
|
|
3089
|
+
pendingToolEntry(pending) {
|
|
3090
|
+
return {
|
|
3091
|
+
type: "tool",
|
|
3092
|
+
tool: pending.name,
|
|
3093
|
+
...(pending.input ? { input: pending.input } : {}),
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Whether a raw line carries the exact capability-request `signal` in plain
|
|
3098
|
+
* or structured output. Codex `--json` wraps the final assistant response in
|
|
3099
|
+
* an `item.completed` event, so the signal is matched inside `item.text` too,
|
|
3100
|
+
* not just as a standalone line.
|
|
3101
|
+
*/
|
|
3102
|
+
signalPresent(raw, entry, signal) {
|
|
3103
|
+
if (raw.trim() === signal)
|
|
3104
|
+
return true;
|
|
3105
|
+
if ("type" in entry &&
|
|
3106
|
+
entry.type === "text" &&
|
|
3107
|
+
entry.text.split("\n").some((line) => line.trim() === signal))
|
|
3108
|
+
return true;
|
|
3109
|
+
try {
|
|
3110
|
+
const event = JSON.parse(raw);
|
|
3111
|
+
return (event.type === "item.completed" &&
|
|
3112
|
+
event.item?.type === "agent_message" &&
|
|
3113
|
+
typeof event.item.text === "string" &&
|
|
3114
|
+
event.item.text.split("\n").some((line) => line.trim() === signal));
|
|
3115
|
+
}
|
|
3116
|
+
catch {
|
|
3117
|
+
return false;
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
/** Recognize the exact handoff-request signal in plain or structured output. */
|
|
3121
|
+
isHandoffSignal(raw, entry) {
|
|
3122
|
+
return this.signalPresent(raw, entry, HANDOFF_READY_SIGNAL);
|
|
3123
|
+
}
|
|
3124
|
+
/** Recognize the exact managed-preview request signal (#0121). */
|
|
3125
|
+
isPreviewRequestSignal(raw, entry) {
|
|
3126
|
+
return this.signalPresent(raw, entry, PREVIEW_REQUEST_SIGNAL);
|
|
3127
|
+
}
|
|
3128
|
+
/**
|
|
3129
|
+
* Detect the runner's capability-request signals in one output line and act:
|
|
3130
|
+
* the handoff signal is recorded for turn-end finalization, and the preview
|
|
3131
|
+
* request is recorded for server-side start+probe at turn exit (#0121). The
|
|
3132
|
+
* surfaced entry becomes a trusted system line so the raw signal text never
|
|
3133
|
+
* clutters the transcript.
|
|
3134
|
+
*
|
|
3135
|
+
* When a handoff signal is recognized (#0235), the request is persisted to
|
|
3136
|
+
* disk immediately so it survives an interrupted turn or server restart.
|
|
3137
|
+
*/
|
|
3138
|
+
applySignals(taskId, raw, entry, session) {
|
|
3139
|
+
let out = entry;
|
|
3140
|
+
if (this.isPreviewRequestSignal(raw, entry)) {
|
|
3141
|
+
const running = this.entries.get(taskId);
|
|
3142
|
+
if (running)
|
|
3143
|
+
running.previewRequested = true;
|
|
3144
|
+
out = { s: "sys", d: "✓ agent requested a managed preview" };
|
|
3145
|
+
}
|
|
3146
|
+
if (this.isHandoffSignal(raw, entry)) {
|
|
3147
|
+
const running = this.entries.get(taskId);
|
|
3148
|
+
if (running)
|
|
3149
|
+
running.handoffRequested = true;
|
|
3150
|
+
// Persist immediately so the request survives a crash before cleanup (#0235).
|
|
3151
|
+
if (running) {
|
|
3152
|
+
this.persistPendingHandoff({
|
|
3153
|
+
taskId,
|
|
3154
|
+
runId: running.runId,
|
|
3155
|
+
branch: running.branch,
|
|
3156
|
+
workdir: running.workdir ?? this.config.root,
|
|
3157
|
+
...(session?.sessionId ? { sessionId: session.sessionId } : {}),
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
out = { s: "sys", d: "✓ agent requested server-side handoff" };
|
|
3161
|
+
}
|
|
3162
|
+
return out;
|
|
3163
|
+
}
|
|
3164
|
+
/**
|
|
3165
|
+
* Fold any usage/cost found in a raw output line into the session. Returns
|
|
3166
|
+
* true when a stored value actually changed (so callers only emit stats when
|
|
3167
|
+
* there's something new to show). Also appends the raw event to
|
|
3168
|
+
* `session_usage_events` — the lossless ground truth this rollup derives from.
|
|
3169
|
+
*
|
|
3170
|
+
* Three fold modes, matching `foldUsage`:
|
|
3171
|
+
* - authoritative (claude `result`): the turn's real total FOR THIS PROCESS
|
|
3172
|
+
* — added onto `usageBaseline` (prior processes' totals for this same
|
|
3173
|
+
* session), not a bare overwrite, so a task resumed after review doesn't
|
|
3174
|
+
* see its cumulative cost/tokens drop back down.
|
|
3175
|
+
* - deltas (opencode `step_finish`): SUM — each event is one round-trip, and
|
|
3176
|
+
* `Math.max` would keep only the largest step (0109 undercounted a
|
|
3177
|
+
* 17-step reviewer run's cache-reads ~15x). Deltas accumulate directly on
|
|
3178
|
+
* `cur`, so they're baseline-safe across process boundaries already.
|
|
3179
|
+
* - otherwise (codex/claude running totals): Math.max against this
|
|
3180
|
+
* process's own total plus baseline — count up, never flicker down on a
|
|
3181
|
+
* transient mid-stream figure, and never dip below a prior process's
|
|
3182
|
+
* total either.
|
|
3183
|
+
*/
|
|
3184
|
+
applyUsage(taskKey, session, raw) {
|
|
3185
|
+
const found = extractUsage(raw);
|
|
3186
|
+
this.recordUsageEvent(taskKey, session, raw, found);
|
|
3187
|
+
// During a transcript replay the counters must not move — see `replayLog`.
|
|
3188
|
+
if (this.replayingUsage)
|
|
3189
|
+
return false;
|
|
3190
|
+
let changed = false;
|
|
3191
|
+
const set = (cur, v, baseline = 0) => found.authoritative
|
|
3192
|
+
? v + baseline
|
|
3193
|
+
: found.deltas
|
|
3194
|
+
? (cur ?? 0) + v
|
|
3195
|
+
: Math.max(cur ?? 0, v + baseline);
|
|
3196
|
+
const baseline = session.usageBaseline;
|
|
3197
|
+
if (found.inputTokens !== undefined) {
|
|
3198
|
+
const next = set(session.inputTokens, found.inputTokens, baseline?.inputTokens);
|
|
3199
|
+
if (next !== session.inputTokens) {
|
|
3200
|
+
session.inputTokens = next;
|
|
3201
|
+
changed = true;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
if (found.outputTokens !== undefined) {
|
|
3205
|
+
const next = set(session.outputTokens, found.outputTokens, baseline?.outputTokens);
|
|
3206
|
+
if (next !== session.outputTokens) {
|
|
3207
|
+
session.outputTokens = next;
|
|
3208
|
+
changed = true;
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
if (found.totalTokens !== undefined) {
|
|
3212
|
+
const next = set(session.tokens, found.totalTokens, baseline?.tokens);
|
|
3213
|
+
if (next !== session.tokens) {
|
|
3214
|
+
session.tokens = next;
|
|
3215
|
+
changed = true;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
if (found.costUsd !== undefined) {
|
|
3219
|
+
const next = set(session.costUsd, found.costUsd, baseline?.costUsd);
|
|
3220
|
+
if (next !== session.costUsd) {
|
|
3221
|
+
session.costUsd = next;
|
|
3222
|
+
changed = true;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
if (found.cacheReadTokens !== undefined) {
|
|
3226
|
+
const next = set(session.cacheReadTokens, found.cacheReadTokens, baseline?.cacheReadTokens);
|
|
3227
|
+
if (next !== session.cacheReadTokens) {
|
|
3228
|
+
session.cacheReadTokens = next;
|
|
3229
|
+
changed = true;
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
if (found.cacheCreationTokens !== undefined) {
|
|
3233
|
+
const next = set(session.cacheCreationTokens, found.cacheCreationTokens, baseline?.cacheCreationTokens);
|
|
3234
|
+
if (next !== session.cacheCreationTokens) {
|
|
3235
|
+
session.cacheCreationTokens = next;
|
|
3236
|
+
changed = true;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
if (found.turns !== undefined && found.turns > 0) {
|
|
3240
|
+
session.turns = (session.turns ?? 0) + found.turns; // delta — accumulate
|
|
3241
|
+
changed = true;
|
|
3242
|
+
}
|
|
3243
|
+
return changed;
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Append one raw usage-bearing line to `session_usage_events` before it is
|
|
3247
|
+
* folded. Skips lines that carried no usage figure at all (the vast majority
|
|
3248
|
+
* — text, tool calls, step_start). Best-effort; keyed by the CLI's own
|
|
3249
|
+
* session id when known, else the runner key, plus the resolved task id and a
|
|
3250
|
+
* timestamp so rows always correlate to a `sessions` row.
|
|
3251
|
+
*/
|
|
3252
|
+
recordUsageEvent(taskKey, session, raw, found) {
|
|
3253
|
+
if (!this.db)
|
|
3254
|
+
return;
|
|
3255
|
+
if (found.inputTokens === undefined &&
|
|
3256
|
+
found.outputTokens === undefined &&
|
|
3257
|
+
found.totalTokens === undefined &&
|
|
3258
|
+
found.cacheReadTokens === undefined &&
|
|
3259
|
+
found.cacheCreationTokens === undefined &&
|
|
3260
|
+
found.costUsd === undefined &&
|
|
3261
|
+
found.turns === undefined) {
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
let eventType;
|
|
3265
|
+
try {
|
|
3266
|
+
const parsed = JSON.parse(raw);
|
|
3267
|
+
if (typeof parsed.type === "string")
|
|
3268
|
+
eventType = parsed.type;
|
|
3269
|
+
}
|
|
3270
|
+
catch {
|
|
3271
|
+
/* non-JSON usage line (plain-text summary) — leave eventType null */
|
|
3272
|
+
}
|
|
3273
|
+
this.db.recordUsageEvent({
|
|
3274
|
+
sessionId: session.sessionId || taskKey,
|
|
3275
|
+
taskId: resolveSessionTaskId(taskKey),
|
|
3276
|
+
agent: session.agent ?? null,
|
|
3277
|
+
model: session.model ?? null,
|
|
3278
|
+
codingAgent: session.engine,
|
|
3279
|
+
eventType: eventType ?? null,
|
|
3280
|
+
inputTokens: found.inputTokens ?? null,
|
|
3281
|
+
outputTokens: found.outputTokens ?? null,
|
|
3282
|
+
totalTokens: found.totalTokens ?? null,
|
|
3283
|
+
cacheReadTokens: found.cacheReadTokens ?? null,
|
|
3284
|
+
cacheCreationTokens: found.cacheCreationTokens ?? null,
|
|
3285
|
+
costUsd: found.costUsd ?? null,
|
|
3286
|
+
turns: found.turns ?? null,
|
|
3287
|
+
raw,
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
/** Emit the current live-stats snapshot for a task. */
|
|
3291
|
+
emitStats(taskId) {
|
|
3292
|
+
this.emit({ type: "agent.stats", id: taskId, stats: this.snapshotStats(taskId), at: now() });
|
|
3293
|
+
}
|
|
3294
|
+
/** Compute a fresh stats snapshot — always safe to call, even with no session. */
|
|
3295
|
+
snapshotStats(taskId) {
|
|
3296
|
+
const session = this.sessions.get(taskId);
|
|
3297
|
+
const running = this.entries.has(taskId);
|
|
3298
|
+
const lastOutputAt = session?.lastOutputAt ?? null;
|
|
3299
|
+
const stalled = running && !!lastOutputAt && Date.now() - Date.parse(lastOutputAt) >= this.stallTimeoutMs;
|
|
3300
|
+
return {
|
|
3301
|
+
accumulatedMs: session?.accumulatedMs ?? 0,
|
|
3302
|
+
turnStartedAt: running ? (session?.turnStartedAt ?? null) : null,
|
|
3303
|
+
lastOutputAt,
|
|
3304
|
+
tokens: session?.tokens ?? null,
|
|
3305
|
+
costUsd: session?.costUsd ?? null,
|
|
3306
|
+
stalled,
|
|
3307
|
+
};
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Periodic sweep (every `stallCheckIntervalMs`) over currently-running turns:
|
|
3311
|
+
* raises the stall flag the moment silence crosses the threshold. Recovery
|
|
3312
|
+
* (new output, or the turn exiting) is handled inline where it happens, so
|
|
3313
|
+
* this only ever needs to turn the warning ON, never off.
|
|
3314
|
+
* Also polls adopted entries (0214) for PID liveness: if the adopted PID
|
|
3315
|
+
* died, clean up the entry so the task doesn't read as running forever.
|
|
3316
|
+
*/
|
|
3317
|
+
checkStalls() {
|
|
3318
|
+
for (const [taskId, entry] of this.entries) {
|
|
3319
|
+
if (entry.adoptedPid) {
|
|
3320
|
+
try {
|
|
3321
|
+
process.kill(entry.adoptedPid, 0);
|
|
3322
|
+
}
|
|
3323
|
+
catch {
|
|
3324
|
+
// PID died during adoption — clean up
|
|
3325
|
+
this.cleanup(taskId, false);
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
const session = this.sessions.get(taskId);
|
|
3330
|
+
if (!session || session.stalledEmitted)
|
|
3331
|
+
continue;
|
|
3332
|
+
if (this.snapshotStats(taskId).stalled) {
|
|
3333
|
+
session.stalledEmitted = true;
|
|
3334
|
+
this.emitStats(taskId);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
tryExtractSessionId(line, session) {
|
|
3339
|
+
if (session.sessionId)
|
|
3340
|
+
return;
|
|
3341
|
+
for (const re of SESSION_ID_PATTERNS) {
|
|
3342
|
+
const m = line.match(re);
|
|
3343
|
+
if (m?.[1]) {
|
|
3344
|
+
session.sessionId = m[1];
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* Signal a running agent to stop: graceful SIGTERM first, SIGKILL after a
|
|
3351
|
+
* short grace period. Returns immediately; the registry clears on exit.
|
|
3352
|
+
*/
|
|
3353
|
+
stop(taskId) {
|
|
3354
|
+
const entry = this.entries.get(taskId);
|
|
3355
|
+
if (!entry)
|
|
3356
|
+
return { stopped: false, reason: "task is not running" };
|
|
3357
|
+
for (const tailer of entry.tailers ?? []) {
|
|
3358
|
+
tailer.drain();
|
|
3359
|
+
tailer.flush();
|
|
3360
|
+
clearInterval(tailer.timer);
|
|
3361
|
+
}
|
|
3362
|
+
entry.tailers = undefined;
|
|
3363
|
+
if (entry.adoptedPid) {
|
|
3364
|
+
// For adopted entries (0214): kill by PID directly since proc is null.
|
|
3365
|
+
try {
|
|
3366
|
+
process.kill(entry.adoptedPid, "SIGTERM");
|
|
3367
|
+
}
|
|
3368
|
+
catch {
|
|
3369
|
+
/* already gone */
|
|
3370
|
+
}
|
|
3371
|
+
const adoptedPid = entry.adoptedPid;
|
|
3372
|
+
entry.killTimer = setTimeout(() => {
|
|
3373
|
+
try {
|
|
3374
|
+
process.kill(adoptedPid, "SIGKILL");
|
|
3375
|
+
}
|
|
3376
|
+
catch {
|
|
3377
|
+
/* already gone */
|
|
3378
|
+
}
|
|
3379
|
+
}, 3000);
|
|
3380
|
+
}
|
|
3381
|
+
else if (!entry.killTimer) {
|
|
3382
|
+
try {
|
|
3383
|
+
entry.proc?.kill("SIGTERM");
|
|
3384
|
+
}
|
|
3385
|
+
catch {
|
|
3386
|
+
/* already gone */
|
|
3387
|
+
}
|
|
3388
|
+
entry.killTimer = setTimeout(() => {
|
|
3389
|
+
try {
|
|
3390
|
+
entry.proc?.kill("SIGKILL");
|
|
3391
|
+
}
|
|
3392
|
+
catch {
|
|
3393
|
+
/* already gone */
|
|
3394
|
+
}
|
|
3395
|
+
}, 3000);
|
|
3396
|
+
}
|
|
3397
|
+
return { stopped: true };
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* Interrupt a running AI chat response (PM, guide, debugger, …). Stops the
|
|
3401
|
+
* in-flight agent process via {@link stop} and appends a persistent marker to
|
|
3402
|
+
* the chat's transcript so the user sees the response was user-stopped rather
|
|
3403
|
+
* than completed normally. Idempotent — safe to call when nothing is running.
|
|
3404
|
+
*
|
|
3405
|
+
* The marker is emitted server-side (so it survives a client hydrate, not just
|
|
3406
|
+
* the live SSE stream) as a `sys` entry on the session. The agent's own exit
|
|
3407
|
+
* also triggers the generic `agent.exited` "stopped" notice client-side.
|
|
3408
|
+
*/
|
|
3409
|
+
interrupt(taskId) {
|
|
3410
|
+
const wasRunning = this.entries.has(taskId);
|
|
3411
|
+
const result = this.stop(taskId);
|
|
3412
|
+
if (wasRunning && this.sessions.has(taskId)) {
|
|
3413
|
+
this.system(taskId, INTERRUPTED_MARKER);
|
|
3414
|
+
}
|
|
3415
|
+
return result;
|
|
3416
|
+
}
|
|
3417
|
+
/** Record a session to the database. Best-effort, never fails the server. */
|
|
3418
|
+
recordSessionToDb(sessionId, session, taskKey, exitedCleanly) {
|
|
3419
|
+
if (!this.db || !session)
|
|
3420
|
+
return;
|
|
3421
|
+
try {
|
|
3422
|
+
// Determine session type from agent name for better aggregations (0331):
|
|
3423
|
+
// exact built-in role names first, ordered substring probes second — so
|
|
3424
|
+
// a legacy "RepoOS Guide" is a "guide", never an "engineer".
|
|
3425
|
+
const sessionType = classifySessionType(session.agent, taskKey);
|
|
3426
|
+
// Attribute the session to the REAL task ID. The runner key (`taskKey`) is
|
|
3427
|
+
// the task id for engineer/review sessions, but chat-style sessions (PM)
|
|
3428
|
+
// are keyed by a synthetic id like `pm-task-v2:123`; stripping the prefix
|
|
3429
|
+
// restores the actual task so PM cost/tokens aggregate under the task
|
|
3430
|
+
// (0230). Non-task chats (guide) record no task id at all.
|
|
3431
|
+
const taskId = resolveSessionTaskId(taskKey);
|
|
3432
|
+
// Reuse existing session ID to accumulate multi-turn sessions into one record
|
|
3433
|
+
const finalSessionId = sessionId || `${taskKey}-${randomUUID()}`;
|
|
3434
|
+
const endedAt = new Date().toISOString();
|
|
3435
|
+
const elapsedMs = session.accumulatedMs;
|
|
3436
|
+
const agent = session.agent ?? "unknown";
|
|
3437
|
+
const model = session.model ?? "default";
|
|
3438
|
+
const codingAgent = session.engine;
|
|
3439
|
+
const inputTokens = session.inputTokens ?? undefined;
|
|
3440
|
+
const outputTokens = session.outputTokens ?? undefined;
|
|
3441
|
+
const totalTokens = session.tokens ?? undefined;
|
|
3442
|
+
const cacheReadTokens = session.cacheReadTokens ?? undefined;
|
|
3443
|
+
const cacheCreationTokens = session.cacheCreationTokens ?? undefined;
|
|
3444
|
+
const turns = session.turns ?? undefined;
|
|
3445
|
+
let costUsd = session.costUsd ?? undefined;
|
|
3446
|
+
let costSource = "none";
|
|
3447
|
+
const isKiro = session.engine === "kiro";
|
|
3448
|
+
if (totalTokens && !costUsd) {
|
|
3449
|
+
costUsd = estimateCostUsd(totalTokens);
|
|
3450
|
+
costSource = "estimate";
|
|
3451
|
+
}
|
|
3452
|
+
else if (session.costUsd) {
|
|
3453
|
+
// Kiro reports credits in its billing unit, not US dollars — flag the
|
|
3454
|
+
// source so aggregation/UI never present it as USD (0230).
|
|
3455
|
+
costSource = isKiro ? "kiro-credits" : "extractUsage";
|
|
3456
|
+
}
|
|
3457
|
+
const status = exitedCleanly ? "finished" : "errored";
|
|
3458
|
+
// Use session creation time (first time this session started), not current time
|
|
3459
|
+
const startedAt = session.createdAt ?? endedAt;
|
|
3460
|
+
this.db.upsertSession({
|
|
3461
|
+
sessionId: finalSessionId,
|
|
3462
|
+
sessionType,
|
|
3463
|
+
taskId: taskId || undefined,
|
|
3464
|
+
agent,
|
|
3465
|
+
model,
|
|
3466
|
+
codingAgent,
|
|
3467
|
+
startedAt,
|
|
3468
|
+
endedAt,
|
|
3469
|
+
elapsedMs,
|
|
3470
|
+
inputTokens,
|
|
3471
|
+
outputTokens,
|
|
3472
|
+
totalTokens,
|
|
3473
|
+
cacheReadTokens,
|
|
3474
|
+
cacheCreationTokens,
|
|
3475
|
+
turns,
|
|
3476
|
+
costUsd,
|
|
3477
|
+
costSource,
|
|
3478
|
+
status,
|
|
3479
|
+
lastActivityAt: session.lastOutputAt ?? endedAt,
|
|
3480
|
+
});
|
|
3481
|
+
}
|
|
3482
|
+
catch {
|
|
3483
|
+
// Database recording is best-effort and must never crash the server.
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
/** Drop the registry entry for a task (idempotent) and announce it. */
|
|
3487
|
+
cleanup(taskId, exitedCleanly) {
|
|
3488
|
+
const entry = this.entries.get(taskId);
|
|
3489
|
+
if (!entry)
|
|
3490
|
+
return;
|
|
3491
|
+
// Capture before the entry is deleted below so the review-completion hook
|
|
3492
|
+
// can still be fired and its DB recording skipped (0288).
|
|
3493
|
+
const wasReview = entry.review === true;
|
|
3494
|
+
// Drain and flush durable-log pollers before removing the registry entry.
|
|
3495
|
+
// The final output may not carry a newline, so it lives in the tailer's
|
|
3496
|
+
// local pending buffer until this point.
|
|
3497
|
+
for (const tailer of entry.tailers ?? []) {
|
|
3498
|
+
tailer.drain();
|
|
3499
|
+
tailer.flush();
|
|
3500
|
+
clearInterval(tailer.timer);
|
|
3501
|
+
}
|
|
3502
|
+
entry.tailers = undefined;
|
|
3503
|
+
this.removeRegistryEntry(taskId);
|
|
3504
|
+
this.logger?.agent(taskId, exitedCleanly ? "info" : "error", `Agent exited ${exitedCleanly ? "cleanly" : "with error"}`, { runId: entry.runId });
|
|
3505
|
+
const session = this.sessions.get(taskId);
|
|
3506
|
+
if (session && session.pending.trim()) {
|
|
3507
|
+
// Flush a trailing line with no final newline through the same parse
|
|
3508
|
+
// path: a complete JSON event becomes a structured entry, and a partial
|
|
3509
|
+
// one degrades to a plain line — either way nothing is lost or dropped.
|
|
3510
|
+
const line = session.pending.trimEnd();
|
|
3511
|
+
this.appendLine(taskId, "out", line);
|
|
3512
|
+
session.pending = "";
|
|
3513
|
+
}
|
|
3514
|
+
if (entry.killTimer)
|
|
3515
|
+
clearTimeout(entry.killTimer);
|
|
3516
|
+
// claude stream (0109): a tool_use whose result never arrived before the
|
|
3517
|
+
// turn ended still gets its card, so a call is never silently invisible.
|
|
3518
|
+
if ((session?.engine === "claude" || session?.engine === "qwen") && session.pendingTool) {
|
|
3519
|
+
this.recordEntry(taskId, session, "out", this.pendingToolEntry(session.pendingTool));
|
|
3520
|
+
session.pendingTool = undefined;
|
|
3521
|
+
}
|
|
3522
|
+
// Fold the finished turn's wall time into the running total (0080) — the
|
|
3523
|
+
// time-spent counter accumulates across turns rather than resetting each
|
|
3524
|
+
// time a follow-up message starts a fresh process.
|
|
3525
|
+
if (session?.turnStartedAt) {
|
|
3526
|
+
session.accumulatedMs += Date.now() - Date.parse(session.turnStartedAt);
|
|
3527
|
+
session.turnStartedAt = undefined;
|
|
3528
|
+
}
|
|
3529
|
+
if (session)
|
|
3530
|
+
session.stalledEmitted = false;
|
|
3531
|
+
this.entries.delete(taskId);
|
|
3532
|
+
this.drainQueue();
|
|
3533
|
+
// Kiro CLI does not print a session ID during the run. Capture it now by
|
|
3534
|
+
// querying the CLI's session list in the same cwd. Best-effort: if the
|
|
3535
|
+
// probe fails, sessionId stays undefined and the next turn falls back to
|
|
3536
|
+
// `-r` (most-recent resume). The lookup is synchronous-in-practice because
|
|
3537
|
+
// cleanup() is called from proc.on("close") — we fire-and-update so the
|
|
3538
|
+
// emit below is not delayed waiting for a CLI round-trip.
|
|
3539
|
+
if (session?.engine === "kiro" && !session.sessionId && entry.workdir) {
|
|
3540
|
+
void this.captureKiroSessionId(taskId, session, entry.workdir);
|
|
3541
|
+
}
|
|
3542
|
+
this.emit({ type: "agent.exited", id: taskId, at: now() });
|
|
3543
|
+
// A confirmed exit always clears any stall warning — silence is only
|
|
3544
|
+
// ambiguous while the process is still alive.
|
|
3545
|
+
if (session)
|
|
3546
|
+
this.emitStats(taskId);
|
|
3547
|
+
// Record session to database (best-effort). Review sessions are recorded by
|
|
3548
|
+
// ReviewManager's own completion handler instead, so they are skipped here
|
|
3549
|
+
// to avoid double-booking (0288).
|
|
3550
|
+
if (session && !wasReview) {
|
|
3551
|
+
this.recordSessionToDb(session.sessionId, session, taskId, exitedCleanly);
|
|
3552
|
+
}
|
|
3553
|
+
// Resolve task from index if not in entry (important for resume turns).
|
|
3554
|
+
const taskForHandoff = entry.task ?? (this.getTask ? this.getTask(taskId) : null);
|
|
3555
|
+
// Any non-clean exit that isn't a deliberate human pause means the task is
|
|
3556
|
+
// about to sit silently in its current status with nothing left running —
|
|
3557
|
+
// flag it the moment the process ends rather than waiting for
|
|
3558
|
+
// TaskWatchdog's staleness poll, which wouldn't even catch a fast
|
|
3559
|
+
// crash-on-exit (an expired CLI auth session, say): the process isn't
|
|
3560
|
+
// stalled, it's just done. Excludes `entry.handoffRequested`: that shape
|
|
3561
|
+
// already has its own recovery path below ("retained for recovery" on
|
|
3562
|
+
// next boot) and must not be double-flagged before that has a chance to
|
|
3563
|
+
// run.
|
|
3564
|
+
if (!exitedCleanly && !this.isPaused(taskId) && !entry.handoffRequested && taskForHandoff) {
|
|
3565
|
+
this.escalateFailedExit(taskId, taskForHandoff, session);
|
|
3566
|
+
}
|
|
3567
|
+
if (entry.handoffRequested &&
|
|
3568
|
+
exitedCleanly &&
|
|
3569
|
+
taskForHandoff &&
|
|
3570
|
+
entry.branch &&
|
|
3571
|
+
entry.workdir) {
|
|
3572
|
+
// Clean exit with handoff requested: clear the persisted request and fire finalization.
|
|
3573
|
+
this.clearPendingHandoff(taskId);
|
|
3574
|
+
const request = {
|
|
3575
|
+
taskId,
|
|
3576
|
+
runId: entry.runId,
|
|
3577
|
+
branch: entry.branch,
|
|
3578
|
+
workdir: entry.workdir,
|
|
3579
|
+
...(session?.sessionId ? { sessionId: session.sessionId } : {}),
|
|
3580
|
+
};
|
|
3581
|
+
this.authorizedHandoffs.set(request.runId, request);
|
|
3582
|
+
this.handoffsInFlight.add(taskId);
|
|
3583
|
+
void Promise.resolve(this.onHandoff?.(request))
|
|
3584
|
+
.catch((err) => {
|
|
3585
|
+
this.appendLine(taskId, "sys", `✗ server-side handoff failed: ${err.message}`);
|
|
3586
|
+
})
|
|
3587
|
+
.finally(() => this.handoffsInFlight.delete(taskId));
|
|
3588
|
+
}
|
|
3589
|
+
else if (entry.handoffRequested && exitedCleanly && !taskForHandoff) {
|
|
3590
|
+
this.appendLine(taskId, "sys", "✗ handoff was not started because the task could not be resolved");
|
|
3591
|
+
this.clearPendingHandoff(taskId);
|
|
3592
|
+
this.persistHandoffFailure(taskId, undefined, "task could not be resolved");
|
|
3593
|
+
}
|
|
3594
|
+
else if (entry.handoffRequested && !exitedCleanly) {
|
|
3595
|
+
// Interrupted turn: the persisted request survives for recovery on next boot (#0235).
|
|
3596
|
+
this.appendLine(taskId, "sys", "⚠ handoff retained for recovery — the request will be finalized on the next server start");
|
|
3597
|
+
this.persistHandoffFailure(taskId, entry.task, "agent turn was interrupted · handoff retained for recovery");
|
|
3598
|
+
}
|
|
3599
|
+
// A preview request is honored only after a clean turn (#0121): the runner
|
|
3600
|
+
// mints a capability bound to that run's task/branch/worktree and hands the
|
|
3601
|
+
// request to the server, which owns preview process and network lifecycle.
|
|
3602
|
+
if (entry.previewRequested &&
|
|
3603
|
+
exitedCleanly &&
|
|
3604
|
+
taskForHandoff &&
|
|
3605
|
+
entry.branch &&
|
|
3606
|
+
entry.workdir) {
|
|
3607
|
+
const request = {
|
|
3608
|
+
taskId,
|
|
3609
|
+
runId: entry.runId,
|
|
3610
|
+
branch: entry.branch,
|
|
3611
|
+
workdir: entry.workdir,
|
|
3612
|
+
...(session?.sessionId ? { sessionId: session.sessionId } : {}),
|
|
3613
|
+
};
|
|
3614
|
+
this.authorizedPreviews.set(request.runId, request);
|
|
3615
|
+
void Promise.resolve(this.onPreviewRequest?.(request)).catch((err) => {
|
|
3616
|
+
this.appendLine(taskId, "sys", `✗ managed preview failed: ${err.message}`);
|
|
3617
|
+
});
|
|
3618
|
+
}
|
|
3619
|
+
else if (entry.previewRequested && !exitedCleanly) {
|
|
3620
|
+
this.appendLine(taskId, "sys", "✗ managed preview was not started because the agent turn was interrupted");
|
|
3621
|
+
}
|
|
3622
|
+
// Defense-in-depth (0077): a turn that ended with the worktree copy at
|
|
3623
|
+
// `review`/`needs_input` while the main copy is still `active` means the
|
|
3624
|
+
// fail-safe checklist's main-copy sync silently failed (the #0068 shape).
|
|
3625
|
+
// Patch the main copy to match so the live board cannot drift silently.
|
|
3626
|
+
this.healBoardDivergence(taskId, entry, session);
|
|
3627
|
+
// Self-heal may have appended a final system line, so persist only after it.
|
|
3628
|
+
this.persist(taskId);
|
|
3629
|
+
if (this.pendingCompletion.delete(taskId))
|
|
3630
|
+
this.finishCompletion(taskId);
|
|
3631
|
+
// A review turn completed. Fire the hook so the owning ReviewManager can
|
|
3632
|
+
// finalize (write the report, log "review completed", auto-bounce) from the
|
|
3633
|
+
// durable session — including for an entry re-attached after a reload whose
|
|
3634
|
+
// completion is only observed here (0288).
|
|
3635
|
+
if (wasReview) {
|
|
3636
|
+
try {
|
|
3637
|
+
this.onReviewDone?.(taskId, exitedCleanly, entry.reviewKind);
|
|
3638
|
+
}
|
|
3639
|
+
catch (err) {
|
|
3640
|
+
this.logger?.agent(taskId, "error", `Review completion handler failed: ${err.message}`);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
/**
|
|
3645
|
+
* Post-run session-id capture for Kiro CLI (#0148). Kiro does not print its
|
|
3646
|
+
* session id during the run; after the process exits we query the CLI's local
|
|
3647
|
+
* session list for the cwd and take the most-recently-updated entry — that is
|
|
3648
|
+
* the turn that just finished. Best-effort and fail-soft: any failure leaves
|
|
3649
|
+
* sessionId undefined so the next turn falls back to `-r` (most-recent resume).
|
|
3650
|
+
*/
|
|
3651
|
+
captureKiroSessionId(taskId, session, cwd) {
|
|
3652
|
+
return new Promise((resolve) => {
|
|
3653
|
+
let proc;
|
|
3654
|
+
try {
|
|
3655
|
+
proc = spawn("kiro-cli", ["chat", "--list-sessions", "--format", "json"], {
|
|
3656
|
+
cwd,
|
|
3657
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
catch {
|
|
3661
|
+
resolve();
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
let out = "";
|
|
3665
|
+
const timer = setTimeout(() => {
|
|
3666
|
+
try {
|
|
3667
|
+
proc.kill("SIGKILL");
|
|
3668
|
+
}
|
|
3669
|
+
catch {
|
|
3670
|
+
/* already gone */
|
|
3671
|
+
}
|
|
3672
|
+
resolve();
|
|
3673
|
+
}, 5000);
|
|
3674
|
+
proc.stdout?.on("data", (c) => {
|
|
3675
|
+
out += c.toString("utf8");
|
|
3676
|
+
});
|
|
3677
|
+
proc.on("error", () => {
|
|
3678
|
+
clearTimeout(timer);
|
|
3679
|
+
resolve();
|
|
3680
|
+
});
|
|
3681
|
+
proc.on("close", () => {
|
|
3682
|
+
clearTimeout(timer);
|
|
3683
|
+
try {
|
|
3684
|
+
const rows = JSON.parse(out);
|
|
3685
|
+
const sessionId = rows[0]?.sessions?.[0]?.sessionId;
|
|
3686
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
3687
|
+
session.sessionId = sessionId;
|
|
3688
|
+
this.schedulePersist(taskId);
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
catch {
|
|
3692
|
+
/* malformed JSON — leave sessionId undefined */
|
|
3693
|
+
}
|
|
3694
|
+
resolve();
|
|
3695
|
+
});
|
|
3696
|
+
});
|
|
3697
|
+
}
|
|
3698
|
+
/**
|
|
3699
|
+
* Snapshot a session's cumulative usage into `usageBaseline` right before a
|
|
3700
|
+
* new CLI process is spawned for it (`start()` / `send()`), so an
|
|
3701
|
+
* authoritative or running-total figure from THAT process — which starts
|
|
3702
|
+
* counting at zero — is added onto prior processes' totals in `applyUsage`
|
|
3703
|
+
* instead of replacing them.
|
|
3704
|
+
*/
|
|
3705
|
+
captureUsageBaseline(session) {
|
|
3706
|
+
session.usageBaseline = {
|
|
3707
|
+
inputTokens: session.inputTokens ?? 0,
|
|
3708
|
+
outputTokens: session.outputTokens ?? 0,
|
|
3709
|
+
tokens: session.tokens ?? 0,
|
|
3710
|
+
costUsd: session.costUsd ?? 0,
|
|
3711
|
+
cacheReadTokens: session.cacheReadTokens ?? 0,
|
|
3712
|
+
cacheCreationTokens: session.cacheCreationTokens ?? 0,
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
emptySession() {
|
|
3716
|
+
return {
|
|
3717
|
+
lines: [],
|
|
3718
|
+
pending: "",
|
|
3719
|
+
bytes: 0,
|
|
3720
|
+
engine: "plain",
|
|
3721
|
+
accumulatedMs: 0,
|
|
3722
|
+
createdAt: now(),
|
|
3723
|
+
stalledEmitted: false,
|
|
3724
|
+
};
|
|
3725
|
+
}
|
|
3726
|
+
sessionFile(taskId) {
|
|
3727
|
+
// IDs can name both tasks and durable non-task conversations such as
|
|
3728
|
+
// `pm-task-v2:0209::alice@example.com` (per-user PM chat, 0248). Keep the
|
|
3729
|
+
// input strictly filename-safe, then escape the cross-platform-invalid
|
|
3730
|
+
// separators before constructing the path.
|
|
3731
|
+
if (!/^[A-Za-z0-9._:@-]+$/.test(taskId) || taskId === "." || taskId === "..")
|
|
3732
|
+
return null;
|
|
3733
|
+
return join(this.sessionsDir, `${taskId.replaceAll(":", "%3A").replaceAll("@", "%40")}.json`);
|
|
3734
|
+
}
|
|
3735
|
+
/** Read and validate one versioned file. Corruption/version drift fails soft. */
|
|
3736
|
+
readPersisted(taskId) {
|
|
3737
|
+
const file = this.sessionFile(taskId);
|
|
3738
|
+
if (!file || !existsSync(file))
|
|
3739
|
+
return null;
|
|
3740
|
+
try {
|
|
3741
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
3742
|
+
if (value.version !== SESSION_FILE_VERSION ||
|
|
3743
|
+
!Array.isArray(value.lines) ||
|
|
3744
|
+
!value.lines.every((line) => typeof line === "object" && line !== null) ||
|
|
3745
|
+
!["opencode", "claude", "copilot", "qwen", "codex", "kiro", "plain"].includes(value.engine) ||
|
|
3746
|
+
typeof value.updatedAt !== "string" ||
|
|
3747
|
+
(value.sessionId !== undefined && typeof value.sessionId !== "string") ||
|
|
3748
|
+
(value.workdir !== undefined && typeof value.workdir !== "string") ||
|
|
3749
|
+
(value.completedAt !== undefined && typeof value.completedAt !== "string") ||
|
|
3750
|
+
(value.agent !== undefined && typeof value.agent !== "string") ||
|
|
3751
|
+
(value.model !== undefined && typeof value.model !== "string"))
|
|
3752
|
+
return null;
|
|
3753
|
+
return value;
|
|
3754
|
+
}
|
|
3755
|
+
catch {
|
|
3756
|
+
return null;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
loadSession(taskId) {
|
|
3760
|
+
const saved = this.readPersisted(taskId);
|
|
3761
|
+
if (!saved)
|
|
3762
|
+
return null;
|
|
3763
|
+
const session = {
|
|
3764
|
+
lines: [...saved.lines],
|
|
3765
|
+
pending: "",
|
|
3766
|
+
bytes: saved.lines.reduce((sum, line) => sum + entryBytes(line), 0),
|
|
3767
|
+
engine: saved.engine,
|
|
3768
|
+
sessionId: saved.sessionId,
|
|
3769
|
+
workdir: saved.workdir,
|
|
3770
|
+
createdAt: saved.createdAt,
|
|
3771
|
+
agent: saved.agent,
|
|
3772
|
+
model: saved.model,
|
|
3773
|
+
accumulatedMs: 0,
|
|
3774
|
+
stalledEmitted: false,
|
|
3775
|
+
};
|
|
3776
|
+
while (session.bytes > OUTPUT_CAP_BYTES) {
|
|
3777
|
+
const dropped = session.lines.shift();
|
|
3778
|
+
if (!dropped)
|
|
3779
|
+
break;
|
|
3780
|
+
session.bytes -= entryBytes(dropped);
|
|
3781
|
+
}
|
|
3782
|
+
return session;
|
|
3783
|
+
}
|
|
3784
|
+
/** Boot only hot lifecycle states; completed transcripts remain cold. */
|
|
3785
|
+
loadHotSessions() {
|
|
3786
|
+
try {
|
|
3787
|
+
const hot = new Set(buildIndex(this.config)
|
|
3788
|
+
.tasks.filter((task) => task.status === "active" || task.status === "review")
|
|
3789
|
+
.map((task) => task.id));
|
|
3790
|
+
for (const taskId of hot) {
|
|
3791
|
+
const session = this.loadSession(taskId);
|
|
3792
|
+
if (session)
|
|
3793
|
+
this.sessions.set(taskId, session);
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
catch {
|
|
3797
|
+
// A bad task or unreadable cache must never prevent `repoos serve` boot.
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
schedulePersist(taskId) {
|
|
3801
|
+
const existing = this.writeTimers.get(taskId);
|
|
3802
|
+
if (existing)
|
|
3803
|
+
clearTimeout(existing);
|
|
3804
|
+
const timer = setTimeout(() => this.persist(taskId), this.writeDelayMs);
|
|
3805
|
+
timer.unref?.();
|
|
3806
|
+
this.writeTimers.set(taskId, timer);
|
|
3807
|
+
}
|
|
3808
|
+
/** Atomic temp-file + rename write; persistence remains best-effort. */
|
|
3809
|
+
persist(taskId, completedAt) {
|
|
3810
|
+
const timer = this.writeTimers.get(taskId);
|
|
3811
|
+
if (timer)
|
|
3812
|
+
clearTimeout(timer);
|
|
3813
|
+
this.writeTimers.delete(taskId);
|
|
3814
|
+
const session = this.sessions.get(taskId);
|
|
3815
|
+
const file = this.sessionFile(taskId);
|
|
3816
|
+
if (!session || !file)
|
|
3817
|
+
return;
|
|
3818
|
+
try {
|
|
3819
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
3820
|
+
const payload = {
|
|
3821
|
+
version: SESSION_FILE_VERSION,
|
|
3822
|
+
lines: session.lines,
|
|
3823
|
+
sessionId: session.sessionId,
|
|
3824
|
+
engine: session.engine,
|
|
3825
|
+
workdir: session.workdir,
|
|
3826
|
+
createdAt: session.createdAt,
|
|
3827
|
+
agent: session.agent,
|
|
3828
|
+
model: session.model,
|
|
3829
|
+
completedAt,
|
|
3830
|
+
updatedAt: this.clock().toISOString(),
|
|
3831
|
+
};
|
|
3832
|
+
const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
3833
|
+
try {
|
|
3834
|
+
writeFileSync(temp, JSON.stringify(payload, null, 2), "utf8");
|
|
3835
|
+
renameSync(temp, file);
|
|
3836
|
+
}
|
|
3837
|
+
finally {
|
|
3838
|
+
if (existsSync(temp))
|
|
3839
|
+
unlinkSync(temp);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
catch {
|
|
3843
|
+
// Session history is useful but must never take down the server/agent.
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
finishCompletion(taskId) {
|
|
3847
|
+
const hot = this.sessions.get(taskId);
|
|
3848
|
+
const saved = hot ? null : this.readPersisted(taskId);
|
|
3849
|
+
const session = hot ?? (saved?.completedAt ? null : this.loadSession(taskId));
|
|
3850
|
+
if (session) {
|
|
3851
|
+
this.sessions.set(taskId, session);
|
|
3852
|
+
this.persist(taskId, this.clock().toISOString());
|
|
3853
|
+
}
|
|
3854
|
+
this.sessions.delete(taskId);
|
|
3855
|
+
this.pendingCompletion.delete(taskId);
|
|
3856
|
+
this.pruneSessions();
|
|
3857
|
+
}
|
|
3858
|
+
/** Remove completed files older than the age bound or beyond the count cap. */
|
|
3859
|
+
pruneSessions() {
|
|
3860
|
+
let completed = [];
|
|
3861
|
+
try {
|
|
3862
|
+
completed = readdirSync(this.sessionsDir)
|
|
3863
|
+
.filter((name) => name.endsWith(".json"))
|
|
3864
|
+
.flatMap((name) => {
|
|
3865
|
+
const taskId = name.slice(0, -5);
|
|
3866
|
+
const saved = this.readPersisted(taskId);
|
|
3867
|
+
if (!saved?.completedAt)
|
|
3868
|
+
return [];
|
|
3869
|
+
const at = Date.parse(saved.completedAt);
|
|
3870
|
+
return Number.isFinite(at) ? [{ file: join(this.sessionsDir, name), at }] : [];
|
|
3871
|
+
})
|
|
3872
|
+
.sort((a, b) => b.at - a.at);
|
|
3873
|
+
}
|
|
3874
|
+
catch {
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
const cutoff = this.clock().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
|
3878
|
+
for (let i = 0; i < completed.length; i++) {
|
|
3879
|
+
if (completed[i].at >= cutoff && i < this.retentionCount)
|
|
3880
|
+
continue;
|
|
3881
|
+
try {
|
|
3882
|
+
// Re-check it is still a regular file before deletion.
|
|
3883
|
+
if (statSync(completed[i].file).isFile())
|
|
3884
|
+
unlinkSync(completed[i].file);
|
|
3885
|
+
}
|
|
3886
|
+
catch {
|
|
3887
|
+
/* pruning is best-effort */
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
/**
|
|
3892
|
+
* True when two absolute paths denote the same directory (realpath-normalized
|
|
3893
|
+
* when possible, so macOS /var vs /private/var aliases compare equal).
|
|
3894
|
+
*/
|
|
3895
|
+
samePath(a, b) {
|
|
3896
|
+
try {
|
|
3897
|
+
return realpathSync(a) === realpathSync(b);
|
|
3898
|
+
}
|
|
3899
|
+
catch {
|
|
3900
|
+
return resolve(a) === resolve(b);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
/**
|
|
3904
|
+
* Persist a handoff failure reason to the task file's Activity log.
|
|
3905
|
+
* Called when a handoff signal is expected but not detected, the process
|
|
3906
|
+
* exits uncleanly before handoff can be finalized, or (from handoff.ts)
|
|
3907
|
+
* server-side finalization itself fails. This persists the reason so it
|
|
3908
|
+
* survives server reloads and lets the task watchdog classify the failure
|
|
3909
|
+
* correctly instead of falling back to its generic guess (unlike
|
|
3910
|
+
* in-memory transcript entries, which the watchdog cannot see).
|
|
3911
|
+
*/
|
|
3912
|
+
persistHandoffFailure(taskId, task, reason) {
|
|
3913
|
+
if (!task)
|
|
3914
|
+
return;
|
|
3915
|
+
try {
|
|
3916
|
+
const current = parseTask({
|
|
3917
|
+
content: readFileSync(task.absPath, "utf8"),
|
|
3918
|
+
absPath: task.absPath,
|
|
3919
|
+
root: this.config.root,
|
|
3920
|
+
defaultStatus: this.config.defaultStatus,
|
|
3921
|
+
defaultAssignee: this.config.defaultAssignee,
|
|
3922
|
+
});
|
|
3923
|
+
recordChange(current, `handoff failed · ${reason}`);
|
|
3924
|
+
writeFileSync(task.absPath, serializeTask(current));
|
|
3925
|
+
}
|
|
3926
|
+
catch (err) {
|
|
3927
|
+
// Fail-soft: if we can't persist, just log — don't crash the runner
|
|
3928
|
+
console.error(`[repoos] failed to persist handoff failure for #${taskId}: ${err.message}`);
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
/**
|
|
3932
|
+
* Best-effort one-line summary of why a turn failed. Prefers the last
|
|
3933
|
+
* non-empty stderr line (where CLI-level failures like an expired OAuth
|
|
3934
|
+
* session land), falling back to the last `sys`/error line RepoOS itself
|
|
3935
|
+
* parsed out of the CLI's own JSON stream, then a generic fallback.
|
|
3936
|
+
*/
|
|
3937
|
+
lastFailureLine(session) {
|
|
3938
|
+
if (session) {
|
|
3939
|
+
for (let i = session.lines.length - 1; i >= 0; i--) {
|
|
3940
|
+
const line = session.lines[i];
|
|
3941
|
+
if ("s" in line && line.s === "err" && line.d.trim())
|
|
3942
|
+
return line.d.trim();
|
|
3943
|
+
}
|
|
3944
|
+
for (let i = session.lines.length - 1; i >= 0; i--) {
|
|
3945
|
+
const line = session.lines[i];
|
|
3946
|
+
const text = "type" in line && line.type === "sys"
|
|
3947
|
+
? line.d
|
|
3948
|
+
: "s" in line && line.s === "sys"
|
|
3949
|
+
? line.d
|
|
3950
|
+
: undefined;
|
|
3951
|
+
if (text?.trim())
|
|
3952
|
+
return text.trim();
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
return "the agent process exited with an error — open the task to see the full output";
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Flag a task for human attention the instant its agent turn ends non-
|
|
3959
|
+
* cleanly (and isn't a deliberate pause or an in-flight handoff recovery —
|
|
3960
|
+
* see the call site in cleanup()). Mirrors TaskWatchdog's own
|
|
3961
|
+
* `needsInput` escalation, just fired immediately instead of waiting for
|
|
3962
|
+
* the next staleness poll. Never overwrites an existing needsInput note.
|
|
3963
|
+
*
|
|
3964
|
+
* Also bumps `dev_error_count` — unlike the needsInput note, this always
|
|
3965
|
+
* increments, even on a repeat error before a human clears the flag,
|
|
3966
|
+
* because it counts real dev attempts, not distinct escalations (#0271
|
|
3967
|
+
* follow-up: confirmed live on #0291 — an engineer session that errors out
|
|
3968
|
+
* before ever reaching a review pass left `review_passes` at 0, so the
|
|
3969
|
+
* board's `D# · R#` badge showed nothing for a task that genuinely had one
|
|
3970
|
+
* failed dev round. TaskDrawer.vue's `taskRounds` folds this count into
|
|
3971
|
+
* `dev` so D can exceed R when a round errored without being reviewed.)
|
|
3972
|
+
*/
|
|
3973
|
+
escalateFailedExit(taskId, task, session) {
|
|
3974
|
+
try {
|
|
3975
|
+
const current = parseTask({
|
|
3976
|
+
content: readFileSync(task.absPath, "utf8"),
|
|
3977
|
+
absPath: task.absPath,
|
|
3978
|
+
root: this.config.root,
|
|
3979
|
+
defaultStatus: this.config.defaultStatus,
|
|
3980
|
+
defaultAssignee: this.config.defaultAssignee,
|
|
3981
|
+
});
|
|
3982
|
+
const errCount = current.extra?.dev_error_count;
|
|
3983
|
+
current.extra = {
|
|
3984
|
+
...current.extra,
|
|
3985
|
+
dev_error_count: (typeof errCount === "number" && Number.isFinite(errCount) ? errCount : 0) + 1,
|
|
3986
|
+
};
|
|
3987
|
+
if (current.needsInput) {
|
|
3988
|
+
writeFileSync(task.absPath, serializeTask(current));
|
|
3989
|
+
return;
|
|
3990
|
+
}
|
|
3991
|
+
current.needsInput = true;
|
|
3992
|
+
current.needsInputReason = "dev-error";
|
|
3993
|
+
const engine = session?.engine && session.engine !== "plain" ? ` (${session.engine})` : "";
|
|
3994
|
+
recordChange(current, `agent exited with an error${engine} · ${this.lastFailureLine(session)}`);
|
|
3995
|
+
writeFileSync(task.absPath, serializeTask(current));
|
|
3996
|
+
}
|
|
3997
|
+
catch (err) {
|
|
3998
|
+
console.error(`[repoos] failed to escalate failed exit for #${taskId}: ${err.message}`);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
/**
|
|
4002
|
+
* Self-heal the #0068 divergence. Narrowly scoped: ONLY the shape where the
|
|
4003
|
+
* main checkout's copy of the task is still `active` but the agent's
|
|
4004
|
+
* worktree copy shows `review` or `needs_input` backed by a real commit.
|
|
4005
|
+
* Any other state (task moved by a human, uncommitted worktree edit, no
|
|
4006
|
+
* separate worktree) is left untouched — this is not a general bidirectional
|
|
4007
|
+
* sync. The correction is surfaced (server log + a task.corrected event +
|
|
4008
|
+
* a transcript sys line) because it means the checklist itself failed.
|
|
4009
|
+
*/
|
|
4010
|
+
healBoardDivergence(taskId, entry, session) {
|
|
4011
|
+
if (entry.skipBoardDivergence)
|
|
4012
|
+
return;
|
|
4013
|
+
const task = entry.task ?? (this.getTask ? this.getTask(taskId) : null);
|
|
4014
|
+
if (!task || !entry.workdir)
|
|
4015
|
+
return;
|
|
4016
|
+
const worktreeCopy = join(entry.workdir, task.path);
|
|
4017
|
+
if (this.samePath(task.absPath, worktreeCopy))
|
|
4018
|
+
return; // same file — no divergence possible
|
|
4019
|
+
if (!existsSync(task.absPath) || !existsSync(worktreeCopy))
|
|
4020
|
+
return;
|
|
4021
|
+
const parse = (absPath) => {
|
|
4022
|
+
try {
|
|
4023
|
+
return parseTask({
|
|
4024
|
+
content: readFileSync(absPath, "utf8"),
|
|
4025
|
+
absPath,
|
|
4026
|
+
root: this.config.root,
|
|
4027
|
+
defaultStatus: this.config.defaultStatus,
|
|
4028
|
+
defaultAssignee: this.config.defaultAssignee,
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4031
|
+
catch {
|
|
4032
|
+
return null;
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
const main = parse(task.absPath);
|
|
4036
|
+
const wt = parse(worktreeCopy);
|
|
4037
|
+
if (!main || !wt)
|
|
4038
|
+
return;
|
|
4039
|
+
// Only the active-but-worktree-shows-review/needs_input divergence.
|
|
4040
|
+
if (main.status !== "active")
|
|
4041
|
+
return;
|
|
4042
|
+
const wantsReview = wt.status === "review";
|
|
4043
|
+
const wantsInput = wt.needsInput;
|
|
4044
|
+
if (!wantsReview && !wantsInput)
|
|
4045
|
+
return;
|
|
4046
|
+
// The worktree state must be backed by a commit, not a stray mid-edit.
|
|
4047
|
+
if (!fileCommittedClean(entry.workdir, task.path))
|
|
4048
|
+
return;
|
|
4049
|
+
const patch = {};
|
|
4050
|
+
if (wantsReview)
|
|
4051
|
+
patch.status = "review";
|
|
4052
|
+
if (wantsInput) {
|
|
4053
|
+
patch.needsInput = true;
|
|
4054
|
+
patch.needsInputReason = wt.needsInputReason;
|
|
4055
|
+
}
|
|
4056
|
+
try {
|
|
4057
|
+
patchTaskFile(this.config, task.absPath, patch);
|
|
4058
|
+
}
|
|
4059
|
+
catch (err) {
|
|
4060
|
+
console.error(`[repoos] self-heal failed for #${taskId}: ${err.message}`);
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
const note = `agent turn ended with the main copy still ${main.status} while the worktree copy was ` +
|
|
4064
|
+
`committed to ${wantsReview ? "review" : "needs_input"} — main copy patched to match`;
|
|
4065
|
+
if (session)
|
|
4066
|
+
this.appendLine(taskId, "sys", `⚠ board self-healed: ${note}`);
|
|
4067
|
+
this.emit({ type: "task.corrected", id: taskId, path: task.path, note, at: now() });
|
|
4068
|
+
console.error(`[repoos] self-healed task #${taskId} — ${note}`);
|
|
4069
|
+
}
|
|
4070
|
+
}
|