@tea-agent/loop-agent 0.40.0-next.1 → 0.40.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +114 -109
  3. package/README.md +2 -2
  4. package/dist/application/dag/generate-task-dag.js +6 -2
  5. package/dist/application/task-lifecycle/advance.js +12 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/update/runtime-activity.js +1 -29
  8. package/dist/commands/dag-rerun.js +1 -1
  9. package/dist/executors/dag-pi-executor.js +69 -7
  10. package/dist/executors/pi-executor.js +4 -2
  11. package/dist/executors/pi-sdk-executor.js +46 -1
  12. package/dist/executors/pi-writer-tool-policy.js +57 -1
  13. package/dist/executors/shell-executor.js +107 -20
  14. package/dist/executors/workspace-write-snapshot.js +68 -0
  15. package/dist/governance/checks.js +1 -0
  16. package/dist/infrastructure/harness/atomic-write.js +23 -0
  17. package/dist/shared/dag-prompt-override.js +4 -5
  18. package/dist/shared/update/npm-client.js +33 -11
  19. package/dist/task/config-types.js +10 -0
  20. package/dist/task/contract/apply.js +11 -1
  21. package/dist/task/contract/constants.js +2 -0
  22. package/dist/task/contract/hash.js +3 -0
  23. package/dist/task/contract/observe.js +25 -4
  24. package/dist/task/contract/paths.js +2 -1
  25. package/dist/task/contract/project.js +15 -1
  26. package/dist/task/contract/schema.js +2 -0
  27. package/dist/task/contract/transaction.js +12 -0
  28. package/dist/task/source-prepare/completeness.js +188 -0
  29. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  30. package/dist/task/source-prepare/index.js +3 -0
  31. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  32. package/dist/task/source-prepare/ledger-review.js +214 -0
  33. package/dist/task/source-prepare/ledger.js +545 -0
  34. package/dist/task/source-prepare/prepare.js +262 -1
  35. package/dist/task/source-prepare/semantic-intake.js +19 -3
  36. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  37. package/dist/task/source-prepare/types.js +22 -0
  38. package/dist/worker/cli.js +12 -26
  39. package/dist/worker/console/chat/chat-event-store.js +75 -5
  40. package/dist/worker/console/chat/context-panel.js +7 -0
  41. package/dist/worker/console/chat/deferred-turn.js +12 -0
  42. package/dist/worker/console/chat/pi-runtime.js +331 -18
  43. package/dist/worker/console/chat/provider-error.js +98 -0
  44. package/dist/worker/console/chat/routes.js +389 -56
  45. package/dist/worker/console/chat/session-stats.js +179 -0
  46. package/dist/worker/console/chat/session-store.js +93 -63
  47. package/dist/worker/console/chat/todos.js +115 -0
  48. package/dist/worker/console/chat/tool-preview.js +42 -0
  49. package/dist/worker/console/chat/turn-process.js +20 -55
  50. package/dist/worker/console/chat/user-questions.js +266 -0
  51. package/dist/worker/console/chat/workspace-landing.js +79 -0
  52. package/dist/worker/console/console-handoff.js +82 -0
  53. package/dist/worker/console/console-update-and-init.js +115 -0
  54. package/dist/worker/console/console-update-runtime.js +84 -0
  55. package/dist/worker/console/dag-execution-receipt.js +20 -2
  56. package/dist/worker/console/draft-store.js +49 -0
  57. package/dist/worker/console/native-directory-picker.js +13 -0
  58. package/dist/worker/console/operation-store.js +1 -1
  59. package/dist/worker/console/operator-actions.js +133 -7
  60. package/dist/worker/console/operator-surface-health.js +1 -0
  61. package/dist/worker/console/prd-intake-bridge.js +13 -0
  62. package/dist/worker/console/routes.js +312 -1
  63. package/dist/worker/console/security.js +4 -4
  64. package/dist/worker/console/server.js +583 -172
  65. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Br92EGb7.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  66. package/dist/worker/console/static/assets/{arc-oulImtQq.js → arc-CAkA3We3.js} +1 -1
  67. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-tyHc6aqO.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  68. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CY86BSpT.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  69. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BKKsDmNs.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  70. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  71. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CXZdjl3B.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B-aIFDQ5.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BDgAW_B2.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DABa4UPR.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-aGbysVOx.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B8MSZDVs.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CpelV73g.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  78. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BE04ty3z.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  79. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BUxTkaaR.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  80. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CYYBVKfX.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  81. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  82. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  83. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CEQF_xmQ.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  84. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CqpxN5IT.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  85. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-B3PzanVi.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  86. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BSUGCNx1.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdLevU5T.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BjpF_Kok.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  89. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C4I6NJny.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  90. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C8hecCr5.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  91. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Daod6tOS.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  92. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BvvmazGv.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  93. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DeN_vfV4.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  94. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D6dq1t9z.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  95. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DziIdNY4.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  96. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  97. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  98. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  99. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czv5eYx2.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  100. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  101. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DxGmNj5K.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  102. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CYCFqkO_.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  103. package/dist/worker/console/static/assets/{linear-B0YzRwrd.js → linear-ByuxcHvp.js} +1 -1
  104. package/dist/worker/console/static/assets/{mermaid.core-t3EZtIGh.js → mermaid.core-DwPGvpCv.js} +5 -5
  105. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BTXpTv0T.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  106. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DU8UkKqA.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  107. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DP4y8ohp.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  108. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B07vNaYR.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  109. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-xDWktb2i.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  110. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BLNMKqeS.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  111. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-n0adalxH.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  112. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BbnS-2DL.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  113. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4uPpCLu.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  114. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA--MMbB0fX.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  115. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  116. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BXUgp2QY.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  118. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CsaMxki7.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  119. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C2IuG9iH.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  120. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CmEWOXCW.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  121. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D_9LE0MG.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  122. package/dist/worker/console/static/index.html +2 -2
  123. package/dist/worker/console/static-src/app/console-types.js +68 -4
  124. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  125. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  126. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  127. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  128. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  129. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  130. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  131. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  132. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  133. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  134. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  135. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  136. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  137. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  138. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  139. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  140. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  141. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  142. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  143. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  144. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  145. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  146. package/dist/worker/console/workspace-context.js +234 -0
  147. package/dist/worker/console/workspace-registry.js +214 -0
  148. package/dist/worker/observability/init-runtime-activity.js +26 -0
  149. package/dist/worker/observe/node-input.js +88 -2
  150. package/dist/worker/observe/routes.js +188 -0
  151. package/dist/worker/observe/server.js +4 -0
  152. package/dist/worker/observe/static/api.js +29 -9
  153. package/dist/worker/observe/static/app.js +18 -70
  154. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  155. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  156. package/dist/worker/observe/static/format.js +37 -0
  157. package/dist/worker/observe/static/index.html +6 -1
  158. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  159. package/dist/worker/observe/static/operator-chrome.css +145 -20
  160. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  161. package/dist/worker/observe/static/operator-chrome.js +330 -100
  162. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  163. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  164. package/dist/worker/observe/static/router.d.ts +46 -0
  165. package/dist/worker/observe/static/router.js +25 -1
  166. package/dist/worker/observe/static/state.d.ts +50 -0
  167. package/dist/worker/observe/static/state.js +174 -2
  168. package/dist/worker/observe/static/styles.css +357 -35
  169. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  170. package/dist/worker/observe/static/views/dag-inspector.js +533 -79
  171. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  172. package/dist/worker/observe/static/views/dag.js +6 -4
  173. package/dist/worker/observe/static/views/dashboard.js +6 -4
  174. package/dist/worker/observe/static/views/night.js +2 -2
  175. package/dist/worker/observe/static/views/pool.js +1 -0
  176. package/dist/worker/observe/static/views/run.js +3 -1
  177. package/dist/worker/observe/static/views/task.js +56 -1
  178. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +186 -8
  179. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  180. package/dist/workflows/dag/backend-test-pytest-collection.js +168 -16
  181. package/dist/workflows/dag/backend-test-result-contract.js +4 -0
  182. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  183. package/dist/workflows/dag/backend-test-writer-completeness.js +28 -17
  184. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  185. package/dist/workflows/dag/frontend-implementation-contract.js +88 -2
  186. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  187. package/dist/workflows/dag/frontend-prewrite-gate.js +197 -0
  188. package/dist/workflows/dag/frontend-review-context.js +92 -10
  189. package/dist/workflows/dag/init-hybrid.js +239 -52
  190. package/dist/workflows/dag/interrupt-request.js +3 -3
  191. package/dist/workflows/dag/lifecycle.js +3 -3
  192. package/dist/workflows/dag/node-execution.js +45 -0
  193. package/dist/workflows/dag/types.js +62 -12
  194. package/docs/README.md +2 -0
  195. package/docs/architecture/evolution.md +45 -0
  196. package/docs/init-surface.manifest.json +1 -0
  197. package/docs/operations/local-development-environment.md +21 -0
  198. package/docs/templates/README.md +1 -1
  199. package/docs/templates/agent-dag.schema.json +18 -0
  200. package/docs/templates/backend-test-dag.json +267 -238
  201. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  202. package/harness.json +3 -3
  203. package/package.json +3 -1
  204. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  205. package/dist/worker/console/static/assets/channel-TY36zt-i.js +0 -1
  206. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +0 -1
  207. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +0 -1
  208. package/dist/worker/console/static/assets/index-C3GgHg-g.css +0 -1
  209. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +0 -406
  210. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +0 -1
  211. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +0 -8
  212. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  213. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -12,12 +12,13 @@
12
12
  * Mutation routes go through the Console mutation gate (boot token + Origin).
13
13
  * Read routes (GET capabilities / sessions) are open to loopback.
14
14
  */
15
+ import { randomUUID } from "node:crypto";
15
16
  import { parseAssistantContent } from "./assistant-content.js";
16
17
  import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
17
18
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
18
19
  import { cleanChatTitle, acceptedOperationIdFromResult } from "./session-store.js";
19
20
  import { hashChatTurnPayload, projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
20
- import { projectDeferredTurnSummary, } from "./deferred-turn.js";
21
+ import { projectDeferredTurnSummary, projectDeferredTurnSummaries, } from "./deferred-turn.js";
21
22
  import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
22
23
  import { ResourcePreferencesError, setSkillAutoInvocationPreference, } from "./resource-preferences-store.js";
23
24
  import { ComposerDraftStore } from "./composer-draft-store.js";
@@ -33,9 +34,11 @@ import { dispatchOperatorAction } from "../operator-actions.js";
33
34
  import { makeHumanGateCard, } from "./human-gate-card.js";
34
35
  import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
35
36
  import { projectTaskContext } from "./context-panel.js";
37
+ import { deriveChatSessionTelemetry } from "./session-stats.js";
36
38
  import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
37
39
  import { sendJson } from "../routes.js";
38
40
  import { openSseResponse, writeSseEvent } from "../operation-sse.js";
41
+ import { buildTodoCompactionInstructions, projectCurrentTodos } from "./todos.js";
39
42
  function turnSummary(turn) {
40
43
  return {
41
44
  turnId: turn.turnId,
@@ -52,7 +55,6 @@ function turnSummary(turn) {
52
55
  function isTerminalTurnState(state) {
53
56
  return state === "settled" || state === "aborted" || state === "failed";
54
57
  }
55
- const DEFAULT_ABORT_WAIT_MS = 2_500;
56
58
  /** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
57
59
  * the same wire shape; its prefix intentionally fails the current process
58
60
  * epoch comparison and triggers safe snapshot recovery (M5.1). */
@@ -87,11 +89,38 @@ function turnEventToStorePartial(event) {
87
89
  case "message_update":
88
90
  case "message_end": {
89
91
  const content = parseAssistantContent(event.content);
92
+ const timingSource = event.type === "message_end" &&
93
+ event.timing &&
94
+ typeof event.timing === "object"
95
+ ? event.timing
96
+ : undefined;
97
+ const timing = timingSource &&
98
+ typeof timingSource.llmMs === "number" &&
99
+ Number.isFinite(timingSource.llmMs)
100
+ ? {
101
+ llmMs: Math.max(0, timingSource.llmMs),
102
+ ...(typeof timingSource.ttftMs === "number" &&
103
+ Number.isFinite(timingSource.ttftMs)
104
+ ? { ttftMs: Math.max(0, timingSource.ttftMs) }
105
+ : {}),
106
+ ...(typeof timingSource.decodeMs === "number" &&
107
+ Number.isFinite(timingSource.decodeMs)
108
+ ? { decodeMs: Math.max(0, timingSource.decodeMs) }
109
+ : {}),
110
+ ...(typeof timingSource.outputTokens === "number" &&
111
+ Number.isFinite(timingSource.outputTokens)
112
+ ? {
113
+ outputTokens: Math.max(0, timingSource.outputTokens),
114
+ }
115
+ : {}),
116
+ }
117
+ : undefined;
90
118
  return {
91
119
  kind: event.type,
92
120
  data: {
93
121
  text: String(event.text ?? ""),
94
122
  ...(content ? { content } : {}),
123
+ ...(timing ? { timing } : {}),
95
124
  },
96
125
  };
97
126
  }
@@ -123,6 +152,9 @@ function turnEventToStorePartial(event) {
123
152
  }),
124
153
  };
125
154
  }
155
+ case "todo_updated":
156
+ case "todo_cleared":
157
+ return { kind: event.type, data: { todos: Array.isArray(event.todos) ? event.todos : [], ...(event.toolCallId ? { toolCallId: String(event.toolCallId) } : {}) } };
126
158
  case "agent_end":
127
159
  return {
128
160
  kind: "agent_end",
@@ -130,8 +162,53 @@ function turnEventToStorePartial(event) {
130
162
  };
131
163
  case "agent_settled":
132
164
  return { kind: "agent_settled", data: {} };
133
- case "error":
134
- return { kind: "error", data: { message: String(event.message ?? "") } };
165
+ case "compaction_start":
166
+ case "compaction_end": {
167
+ const reason = event.reason === "threshold" || event.reason === "overflow"
168
+ ? event.reason
169
+ : "manual";
170
+ const status = event.type === "compaction_start"
171
+ ? "running"
172
+ : event.status === "aborted" || event.status === "failed"
173
+ ? event.status
174
+ : "completed";
175
+ const data = {
176
+ schemaVersion: 1,
177
+ compactionId: String(event.compactionId ?? ""),
178
+ reason,
179
+ status,
180
+ ...(typeof event.summary === "string"
181
+ ? { summary: event.summary }
182
+ : {}),
183
+ ...(typeof event.firstKeptEntryId === "string"
184
+ ? { firstKeptEntryId: event.firstKeptEntryId }
185
+ : {}),
186
+ ...(typeof event.tokensBefore === "number"
187
+ ? { tokensBefore: event.tokensBefore }
188
+ : {}),
189
+ ...(typeof event.estimatedTokensAfter === "number"
190
+ ? { estimatedTokensAfter: event.estimatedTokensAfter }
191
+ : {}),
192
+ willRetry: Boolean(event.willRetry),
193
+ ...(typeof event.errorMessage === "string"
194
+ ? { errorMessage: event.errorMessage }
195
+ : {}),
196
+ };
197
+ return { kind: event.type, data };
198
+ }
199
+ case "error": {
200
+ const content = parseAssistantContent(event.content);
201
+ return {
202
+ kind: "error",
203
+ data: {
204
+ message: String(event.message ?? ""),
205
+ ...(content ? { content } : {}),
206
+ ...(event.providerError && typeof event.providerError === "object"
207
+ ? { providerError: event.providerError }
208
+ : {}),
209
+ },
210
+ };
211
+ }
135
212
  default:
136
213
  return undefined;
137
214
  }
@@ -197,6 +274,13 @@ function ensureAgentEndEvent(deps, sessionId, turnId) {
197
274
  }
198
275
  }
199
276
  async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
277
+ // AC-R5-003: the multi-workspace dispatcher (server.ts readBodyOnce)
278
+ // pre-reads and JSON-parses the body for workspaceId resolution, stashing
279
+ // it on req.__preReadBody. Consume it first so the body is parsed exactly
280
+ // once per request; re-reading the consumed stream would yield {}.
281
+ const preRead = req.__preReadBody;
282
+ if (preRead !== undefined)
283
+ return preRead;
200
284
  const chunks = [];
201
285
  let total = 0;
202
286
  for await (const chunk of req) {
@@ -344,7 +428,10 @@ export async function handleChatCapabilities(_req, res, deps) {
344
428
  hasBash: true,
345
429
  });
346
430
  }
347
- export async function handleCreateChatSession(req, res, deps) {
431
+ export async function handleCreateChatSession(req, res, deps,
432
+ /** Explicit owning workspaceId from the multi-workspace dispatcher
433
+ * (AC-R7-002). The body still parses exactly once via req.__preReadBody. */
434
+ workspaceId) {
348
435
  const gate = gateMutation(req, deps);
349
436
  if (!gate.ok) {
350
437
  sendJson(res, gate.status, {
@@ -422,6 +509,10 @@ export async function handleCreateChatSession(req, res, deps) {
422
509
  sessionFile: handle.sessionFile,
423
510
  modelProvider: handle.model?.provider,
424
511
  modelId: handle.model?.modelId,
512
+ // AC-R7-002: explicit parameter from the dispatcher — the runtime
513
+ // store.create wrapper is gone; body workspaceId (if any) only acts as
514
+ // a fallback for direct (non-dispatcher) handler tests.
515
+ workspaceId: workspaceId ?? (typeof body.workspaceId === "string" && body.workspaceId ? body.workspaceId : undefined),
425
516
  });
426
517
  if (snapshot)
427
518
  deps.events.append(handle.sessionId, `${handle.sessionId}:snapshot`, {
@@ -646,8 +737,10 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
646
737
  await deps.operationLinker.recoverSession(sessionId);
647
738
  const composerDraft = await new ComposerDraftStore(deps.appData).get(sessionId);
648
739
  const snapshot = deps.events.snapshot(sessionId);
740
+ const telemetry = deriveChatSessionTelemetry(snapshot);
649
741
  const lastEvent = snapshot.at(-1);
650
742
  const activeTurn = deps.events.getActiveTurn(sessionId);
743
+ const pendingQuestion = await deps.questionRegistry?.get(sessionId);
651
744
  sendJson(res, 200, {
652
745
  ok: true,
653
746
  eventEpoch: deps.events.epoch(sessionId),
@@ -660,8 +753,22 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
660
753
  ? "stopping"
661
754
  : "running"
662
755
  : "idle",
756
+ pendingQuestion: pendingQuestion ?? null,
757
+ todos: projectCurrentTodos(snapshot) ?? [],
663
758
  },
664
759
  hasBash: true,
760
+ sessionStats: telemetry.stats,
761
+ usage: telemetry.usage,
762
+ compactions: snapshot
763
+ .filter((event) => event.kind === "compact" ||
764
+ event.kind === "compaction_start" ||
765
+ event.kind === "compaction_end")
766
+ .map((event) => ({
767
+ eventId: event.eventId,
768
+ kind: event.kind,
769
+ at: event.at,
770
+ data: event.data,
771
+ })),
665
772
  lastEventId: lastEvent?.eventId ?? null,
666
773
  lastEventSeq: lastEvent?.seq ?? 0,
667
774
  });
@@ -769,11 +876,12 @@ export async function handlePutDeferredTurn(req, res, deps, sessionId) {
769
876
  : "待发送消息无效或会话不存在",
770
877
  },
771
878
  deferredTurn: result.deferredTurn ?? null,
879
+ deferredTurns: projectDeferredTurnSummaries(await deps.store.listDeferredTurns(sessionId)),
772
880
  summary: projectDeferredTurnSummary(result.deferredTurn),
773
881
  });
774
882
  return;
775
883
  }
776
- emitDeferredTurnUpdate(deps, sessionId, blockedByTurnId, result.deferredTurn, result.deferredTurn.revision);
884
+ await emitDeferredTurnUpdate(deps, sessionId, blockedByTurnId, result.deferredTurn, result.deferredTurn.revision);
777
885
  sendJson(res, 200, {
778
886
  ok: true,
779
887
  accepted: true,
@@ -781,6 +889,7 @@ export async function handlePutDeferredTurn(req, res, deps, sessionId) {
781
889
  state: result.deferredTurn.state,
782
890
  revision: result.deferredTurn.revision,
783
891
  deferredTurn: result.deferredTurn,
892
+ deferredTurns: projectDeferredTurnSummaries(await deps.store.listDeferredTurns(sessionId)),
784
893
  summary: projectDeferredTurnSummary(result.deferredTurn),
785
894
  });
786
895
  void dispatchDeferredTurn(deps, sessionId);
@@ -826,18 +935,90 @@ export async function handleDeleteDeferredTurn(req, res, deps, sessionId) {
826
935
  : "待发送消息已变化,请刷新后重试",
827
936
  },
828
937
  deferredTurn: result.deferredTurn ?? null,
938
+ deferredTurns: projectDeferredTurnSummaries(await deps.store.listDeferredTurns(sessionId)),
829
939
  });
830
940
  return;
831
941
  }
832
942
  const clearedRevision = result.deferredTurn.revision + 1;
833
- emitDeferredTurnUpdate(deps, sessionId, result.deferredTurn.blockedByTurnId, undefined, clearedRevision);
943
+ await emitDeferredTurnUpdate(deps, sessionId, result.deferredTurn.blockedByTurnId, undefined, clearedRevision);
834
944
  sendJson(res, 200, {
835
945
  ok: true,
836
946
  cancelled: true,
837
947
  deferredTurn: result.deferredTurn,
948
+ deferredTurns: projectDeferredTurnSummaries(await deps.store.listDeferredTurns(sessionId)),
838
949
  revision: clearedRevision,
839
950
  });
840
951
  }
952
+ /** Move one addressed FIFO item into Pi's live steering queue. */
953
+ export async function handleSteerDeferredTurn(req, res, deps, sessionId, clientRequestId) {
954
+ const gate = gateMutation(req, deps);
955
+ if (!gate.ok) {
956
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
957
+ return;
958
+ }
959
+ let body = {};
960
+ try {
961
+ body = await readJsonBody(req);
962
+ }
963
+ catch (error) {
964
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
965
+ return;
966
+ }
967
+ const expectedRevision = typeof body.expectedRevision === "number" && Number.isSafeInteger(body.expectedRevision)
968
+ ? body.expectedRevision
969
+ : -1;
970
+ const active = deps.events.getActiveTurn(sessionId);
971
+ if (!active) {
972
+ sendJson(res, 409, { ok: false, error: { code: "TURN_NOT_ACTIVE", message: "当前没有可插话的运行中回合" } });
973
+ return;
974
+ }
975
+ const claim = await deps.store.claimDeferredTurn(sessionId, expectedRevision, clientRequestId);
976
+ if (!claim.ok) {
977
+ sendJson(res, claim.code === "NOT_FOUND" ? 404 : 409, {
978
+ ok: false,
979
+ error: { code: claim.code, message: "排队消息已变化,请刷新后重试" },
980
+ });
981
+ return;
982
+ }
983
+ const item = claim.deferredTurn;
984
+ try {
985
+ await deps.runtime.steer(sessionId, item.text, item.images);
986
+ const userMessage = await deps.store.appendMessage(sessionId, {
987
+ role: "user",
988
+ text: item.text,
989
+ });
990
+ deps.events.append(sessionId, active.turnId, {
991
+ kind: "user_message",
992
+ data: {
993
+ clientRequestId: item.clientRequestId,
994
+ message: { id: userMessage.id, text: userMessage.text, at: userMessage.at },
995
+ },
996
+ });
997
+ await deps.store.clearDeferredTurnForRequest(sessionId, item.clientRequestId);
998
+ await emitDeferredTurnUpdate(deps, sessionId, active.turnId, undefined, item.revision + 2);
999
+ sendJson(res, 200, {
1000
+ ok: true,
1001
+ accepted: true,
1002
+ deferredTurns: projectDeferredTurnSummaries(await deps.store.listDeferredTurns(sessionId)),
1003
+ });
1004
+ }
1005
+ catch (error) {
1006
+ const restored = await deps.store.transitionDeferredTurn(sessionId, {
1007
+ expectedRevision: item.revision,
1008
+ clientRequestId: item.clientRequestId,
1009
+ state: "queued",
1010
+ });
1011
+ if (restored)
1012
+ await emitDeferredTurnUpdate(deps, sessionId, active.turnId, restored, restored.revision);
1013
+ sendJson(res, 409, {
1014
+ ok: false,
1015
+ error: {
1016
+ code: "STEER_FAILED",
1017
+ message: error instanceof Error ? error.message : "插话发送失败",
1018
+ },
1019
+ });
1020
+ }
1021
+ }
841
1022
  /** Lightweight busy-truth endpoint for the client reconcile loop (pi-web
842
1023
  * get_state equivalent): exposes the active turn + the latest turn's durable
843
1024
  * outcome WITHOUT the full message payload, so the UI can poll it on an
@@ -865,6 +1046,7 @@ export async function handleGetChatSessionState(_req, res, deps, sessionId) {
865
1046
  isBashRunning: false,
866
1047
  };
867
1048
  const runtimeBusy = Object.values(runtimeFacts).some((value) => value === true);
1049
+ const pendingQuestion = await deps.questionRegistry?.get(sessionId);
868
1050
  const turnPhase = active
869
1051
  ? active.abortRequestedAt
870
1052
  ? "stopping"
@@ -884,10 +1066,60 @@ export async function handleGetChatSessionState(_req, res, deps, sessionId) {
884
1066
  latestTurn: latest ? turnSummary(latest) : null,
885
1067
  runtime: runtimeFacts,
886
1068
  turnPhase,
887
- deferredTurn: projectDeferredTurnSummary(record.deferredTurn),
1069
+ deferredTurn: projectDeferredTurnSummary(record.deferredTurns?.[0]),
1070
+ deferredTurns: projectDeferredTurnSummaries(record.deferredTurns ?? []),
1071
+ pendingQuestion: pendingQuestion ?? null,
1072
+ todos: projectCurrentTodos(deps.events.snapshot(sessionId)) ?? [],
888
1073
  },
889
1074
  });
890
1075
  }
1076
+ export async function handleAnswerUserQuestion(req, res, deps, sessionId, requestId) {
1077
+ const gate = gateMutation(req, deps);
1078
+ if (!gate.ok) {
1079
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1080
+ return;
1081
+ }
1082
+ if (!(await requireChatSession(res, deps, sessionId)))
1083
+ return;
1084
+ if (!deps.questionRegistry) {
1085
+ sendJson(res, 503, { ok: false, error: { code: "USER_QUESTIONS_UNAVAILABLE", message: "user question capability is unavailable" } });
1086
+ return;
1087
+ }
1088
+ try {
1089
+ const body = await readJsonBody(req);
1090
+ const question = await deps.questionRegistry.answer(sessionId, requestId, body);
1091
+ sendJson(res, 200, { ok: true, resolved: true, question });
1092
+ }
1093
+ catch (error) {
1094
+ const message = error instanceof Error ? error.message : String(error);
1095
+ const status = message === "USER_QUESTION_NOT_FOUND" ? 404 : message === "USER_QUESTION_STALE" ? 409 : 400;
1096
+ sendJson(res, status, { ok: false, error: { code: message, message } });
1097
+ }
1098
+ }
1099
+ export async function handleCancelUserQuestion(req, res, deps, sessionId, requestId) {
1100
+ const gate = gateMutation(req, deps);
1101
+ if (!gate.ok) {
1102
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1103
+ return;
1104
+ }
1105
+ if (!(await requireChatSession(res, deps, sessionId)))
1106
+ return;
1107
+ if (!deps.questionRegistry) {
1108
+ sendJson(res, 503, { ok: false, error: { code: "USER_QUESTIONS_UNAVAILABLE", message: "user question capability is unavailable" } });
1109
+ return;
1110
+ }
1111
+ try {
1112
+ const body = await readJsonBody(req).catch(() => ({}));
1113
+ const reason = typeof body.reason === "string" && body.reason.trim() ? body.reason.trim().slice(0, 120) : "USER_CANCELLED";
1114
+ const question = await deps.questionRegistry.cancel(sessionId, requestId, reason);
1115
+ sendJson(res, 200, { ok: true, cancelled: true, question });
1116
+ }
1117
+ catch (error) {
1118
+ const message = error instanceof Error ? error.message : String(error);
1119
+ const status = message === "USER_QUESTION_NOT_FOUND" ? 404 : 409;
1120
+ sendJson(res, status, { ok: false, error: { code: message, message } });
1121
+ }
1122
+ }
891
1123
  export async function handleListChatSessions(_req, res, deps) {
892
1124
  const sessions = await deps.store.list({ includeArchived: true });
893
1125
  sendJson(res, 200, {
@@ -903,7 +1135,8 @@ export async function handleListChatSessions(_req, res, deps) {
903
1135
  : active
904
1136
  ? "running"
905
1137
  : "idle",
906
- deferredTurn: projectDeferredTurnSummary(session.deferredTurn),
1138
+ deferredTurn: projectDeferredTurnSummary(session.deferredTurns?.[0]),
1139
+ deferredTurns: projectDeferredTurnSummaries(session.deferredTurns ?? []),
907
1140
  };
908
1141
  }),
909
1142
  hasBash: true,
@@ -1150,6 +1383,7 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
1150
1383
  store: deps.store,
1151
1384
  actionContext,
1152
1385
  sessionId,
1386
+ turnId,
1153
1387
  text,
1154
1388
  images,
1155
1389
  signal: ac.signal,
@@ -1445,6 +1679,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId, trustedPay
1445
1679
  store: deps.store,
1446
1680
  actionContext,
1447
1681
  sessionId,
1682
+ turnId: turn.turnId,
1448
1683
  text,
1449
1684
  images,
1450
1685
  signal: controller.signal,
@@ -1530,12 +1765,16 @@ export async function handleCreateChatTurn(req, res, deps, sessionId, trustedPay
1530
1765
  }
1531
1766
  })();
1532
1767
  }
1533
- function emitDeferredTurnUpdate(deps, sessionId, turnId, deferredTurn, revision) {
1768
+ async function emitDeferredTurnUpdate(deps, sessionId, turnId, _deferredTurn, _revision) {
1769
+ const record = await deps.store.get(sessionId);
1770
+ if (!record)
1771
+ return;
1534
1772
  deps.events.append(sessionId, turnId, {
1535
1773
  kind: "queue_update",
1536
1774
  data: {
1537
- deferredTurn: projectDeferredTurnSummary(deferredTurn),
1538
- revision,
1775
+ deferredTurn: projectDeferredTurnSummary(record.deferredTurns?.[0]),
1776
+ deferredTurns: projectDeferredTurnSummaries(record.deferredTurns ?? []),
1777
+ revision: record.deferredQueueRevision ?? 0,
1539
1778
  },
1540
1779
  });
1541
1780
  }
@@ -1571,7 +1810,7 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1571
1810
  deferredDispatchingSessions.add(sessionId);
1572
1811
  try {
1573
1812
  let record = await deps.store.get(sessionId);
1574
- let deferred = record?.deferredTurn;
1813
+ let deferred = record?.deferredTurns?.find((item) => item.state === "dispatching" || item.state === "queued");
1575
1814
  if (!deferred)
1576
1815
  return;
1577
1816
  if (deferred.state === "accepted")
@@ -1583,17 +1822,19 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1583
1822
  if (existing) {
1584
1823
  const accepted = await deps.store.transitionDeferredTurn(sessionId, {
1585
1824
  expectedRevision: deferred.revision,
1825
+ clientRequestId: deferred.clientRequestId,
1586
1826
  state: "accepted",
1587
1827
  acceptedTurnId: existing.turnId,
1588
1828
  });
1589
1829
  if (accepted)
1590
- emitDeferredTurnUpdate(deps, sessionId, existing.turnId, accepted, accepted.revision);
1830
+ await emitDeferredTurnUpdate(deps, sessionId, existing.turnId, accepted, accepted.revision);
1591
1831
  return;
1592
1832
  }
1593
1833
  if (deps.events.getActiveTurn(sessionId))
1594
1834
  return;
1595
1835
  const requeued = await deps.store.transitionDeferredTurn(sessionId, {
1596
1836
  expectedRevision: deferred.revision,
1837
+ clientRequestId: deferred.clientRequestId,
1597
1838
  state: "queued",
1598
1839
  });
1599
1840
  if (!requeued)
@@ -1604,11 +1845,11 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1604
1845
  const runtimeBusy = Boolean(runtimeFacts && Object.values(runtimeFacts).some((value) => value === true));
1605
1846
  if (deps.events.getActiveTurn(sessionId) || runtimeBusy)
1606
1847
  return;
1607
- const claim = await deps.store.claimDeferredTurn(sessionId, deferred.revision);
1848
+ const claim = await deps.store.claimDeferredTurn(sessionId, deferred.revision, deferred.clientRequestId);
1608
1849
  if (!claim.ok)
1609
1850
  return;
1610
1851
  deferred = claim.deferredTurn;
1611
- emitDeferredTurnUpdate(deps, sessionId, deferred.blockedByTurnId, deferred, deferred.revision);
1852
+ await emitDeferredTurnUpdate(deps, sessionId, deferred.blockedByTurnId, deferred, deferred.revision);
1612
1853
  const started = await startTrustedChatTurn(deps, sessionId, {
1613
1854
  clientRequestId: deferred.clientRequestId,
1614
1855
  text: deferred.text,
@@ -1627,7 +1868,7 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1627
1868
  });
1628
1869
  const cleared = await deps.store.clearDeferredTurnForRequest(sessionId, deferred.clientRequestId);
1629
1870
  if (cleared)
1630
- emitDeferredTurnUpdate(deps, sessionId, turnId, undefined, cleared.clearedRevision);
1871
+ await emitDeferredTurnUpdate(deps, sessionId, turnId, undefined, cleared.clearedRevision);
1631
1872
  },
1632
1873
  });
1633
1874
  const accepted = started.body.accepted === true;
@@ -1637,11 +1878,12 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1637
1878
  if (accepted && turn?.turnId) {
1638
1879
  const next = await deps.store.transitionDeferredTurn(sessionId, {
1639
1880
  expectedRevision: deferred.revision,
1881
+ clientRequestId: deferred.clientRequestId,
1640
1882
  state: "accepted",
1641
1883
  acceptedTurnId: turn.turnId,
1642
1884
  });
1643
1885
  if (next)
1644
- emitDeferredTurnUpdate(deps, sessionId, turn.turnId, next, next.revision);
1886
+ await emitDeferredTurnUpdate(deps, sessionId, turn.turnId, next, next.revision);
1645
1887
  return;
1646
1888
  }
1647
1889
  const error = started.body.error && typeof started.body.error === "object"
@@ -1650,14 +1892,16 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1650
1892
  if (error.code === "TURN_ACTIVE") {
1651
1893
  const next = await deps.store.transitionDeferredTurn(sessionId, {
1652
1894
  expectedRevision: deferred.revision,
1895
+ clientRequestId: deferred.clientRequestId,
1653
1896
  state: "queued",
1654
1897
  });
1655
1898
  if (next)
1656
- emitDeferredTurnUpdate(deps, sessionId, deferred.blockedByTurnId, next, next.revision);
1899
+ await emitDeferredTurnUpdate(deps, sessionId, deferred.blockedByTurnId, next, next.revision);
1657
1900
  return;
1658
1901
  }
1659
1902
  const failed = await deps.store.transitionDeferredTurn(sessionId, {
1660
1903
  expectedRevision: deferred.revision,
1904
+ clientRequestId: deferred.clientRequestId,
1661
1905
  state: "failed",
1662
1906
  lastError: {
1663
1907
  code: error.code ?? `HTTP_${started.status}`,
@@ -1665,17 +1909,18 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1665
1909
  },
1666
1910
  });
1667
1911
  if (failed)
1668
- emitDeferredTurnUpdate(deps, sessionId, deferred.blockedByTurnId, failed, failed.revision);
1912
+ await emitDeferredTurnUpdate(deps, sessionId, deferred.blockedByTurnId, failed, failed.revision);
1669
1913
  }
1670
1914
  catch (error) {
1671
1915
  // Keep fire-and-forget callers safe and make a dispatch failure visible and
1672
1916
  // recoverable from the composer instead of leaving the slot indefinitely
1673
1917
  // in an ambiguous queued/dispatching state.
1674
1918
  try {
1675
- const current = (await deps.store.get(sessionId))?.deferredTurn;
1919
+ const current = (await deps.store.get(sessionId))?.deferredTurns?.find((item) => item.state === "queued" || item.state === "dispatching");
1676
1920
  if (current && (current.state === "queued" || current.state === "dispatching")) {
1677
1921
  const failed = await deps.store.transitionDeferredTurn(sessionId, {
1678
1922
  expectedRevision: current.revision,
1923
+ clientRequestId: current.clientRequestId,
1679
1924
  state: "failed",
1680
1925
  lastError: {
1681
1926
  code: "DEFERRED_TURN_DISPATCH_FAILED",
@@ -1683,7 +1928,7 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1683
1928
  },
1684
1929
  });
1685
1930
  if (failed)
1686
- emitDeferredTurnUpdate(deps, sessionId, failed.blockedByTurnId, failed, failed.revision);
1931
+ await emitDeferredTurnUpdate(deps, sessionId, failed.blockedByTurnId, failed, failed.revision);
1687
1932
  }
1688
1933
  }
1689
1934
  catch {
@@ -1698,12 +1943,9 @@ export async function dispatchDeferredTurn(deps, sessionId) {
1698
1943
  export async function recoverDeferredTurns(deps) {
1699
1944
  const sessions = await deps.store.list({ includeArchived: false });
1700
1945
  await Promise.all(sessions
1701
- .filter((session) => session.deferredTurn
1702
- ? session.deferredTurn.state === "queued" ||
1703
- session.deferredTurn.state === "dispatching"
1704
- : false)
1946
+ .filter((session) => (session.deferredTurns ?? []).some((item) => item.state === "queued" || item.state === "dispatching"))
1705
1947
  .map(async (session) => {
1706
- const deferred = session.deferredTurn;
1948
+ const deferred = session.deferredTurns.find((item) => item.state === "dispatching" || item.state === "queued");
1707
1949
  const active = deps.events.getActiveTurn(session.sessionId);
1708
1950
  // A process restart cannot retain AbortController ownership. When the
1709
1951
  // durable blocker was already stopping and no execution owns it now,
@@ -1770,34 +2012,24 @@ export async function handleAbortChatTurn(req, res, deps, sessionId, turnId) {
1770
2012
  });
1771
2013
  return;
1772
2014
  }
1773
- deps.events.markAbortRequested?.(sessionId, turnId);
2015
+ const stopping = deps.events.markAbortRequested?.(sessionId, turnId) ?? turn;
2016
+ // DSH-style acknowledgement: deliver the AbortSignal synchronously, then let
2017
+ // the detached Turn closeout own SDK/process cleanup and terminal persistence.
2018
+ // The HTTP request reports that Stop was accepted; it does not wait for Pi to
2019
+ // become idle. This keeps cancellation latency independent of slow providers,
2020
+ // tools and process-tree cleanup while preserving the active-Turn lease.
1774
2021
  if (!execution.controller.signal.aborted)
1775
2022
  execution.controller.abort();
1776
- const waited = await (deps.turnExecutions?.waitForCompletion(sessionId, turnId, DEFAULT_ABORT_WAIT_MS) ?? Promise.resolve({ status: "missing" }));
1777
- if (waited.status === "completed") {
1778
- const finalTurn = deps.events.getTurn(sessionId, turnId) ??
1779
- {
1780
- ...turn,
1781
- state: waited.outcome.state,
1782
- };
1783
- sendJson(res, 200, {
1784
- ok: true,
1785
- accepted: true,
1786
- idempotent: false,
1787
- turn: turnSummary(finalTurn),
1788
- });
1789
- return;
1790
- }
1791
- // Timeout or missing completion: keep non-terminal; client reconciles via /state.
1792
- const pending = deps.events.getTurn(sessionId, turnId) ?? turn;
2023
+ const questionCancellation = deps.questionRegistry?.cancelForSession(sessionId, "TURN_ABORTED");
2024
+ if (questionCancellation)
2025
+ void questionCancellation.catch(() => undefined);
2026
+ const acceptedTurn = deps.events.getTurn(sessionId, turnId) ?? stopping;
1793
2027
  sendJson(res, 202, {
1794
- ok: false,
1795
- accepted: false,
1796
- error: {
1797
- code: "TURN_ABORT_PENDING",
1798
- message: "abort requested; turn has not yet reached a terminal state",
1799
- },
1800
- turn: turnSummary(pending),
2028
+ ok: true,
2029
+ accepted: true,
2030
+ idempotent: false,
2031
+ cleanupPending: !isTerminalTurnState(acceptedTurn.state),
2032
+ turn: turnSummary(acceptedTurn),
1801
2033
  });
1802
2034
  }
1803
2035
  export async function handleGetChatTurn(_req, res, deps, sessionId, turnId) {
@@ -1921,6 +2153,8 @@ export async function handleDeleteChatSession(req, res, deps, sessionId) {
1921
2153
  });
1922
2154
  return;
1923
2155
  }
2156
+ await deps.questionRegistry?.cancelForSession(sessionId, "SESSION_DELETED").catch(() => undefined);
2157
+ deps.todoRegistry?.clear(sessionId);
1924
2158
  deps.runtime.dispose(sessionId);
1925
2159
  deps.operationLinker.clearSession(sessionId);
1926
2160
  deps.events.clear(sessionId);
@@ -2595,6 +2829,36 @@ async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
2595
2829
  });
2596
2830
  }
2597
2831
  }
2832
+ async function handleUnlinkWorkspaceOperation(req, res, deps, sessionId, operationId) {
2833
+ const gate = gateMutation(req, deps);
2834
+ if (!gate.ok) {
2835
+ sendJson(res, gate.status, {
2836
+ ok: false,
2837
+ error: { code: gate.code, message: gate.message },
2838
+ });
2839
+ return;
2840
+ }
2841
+ if (!(await requireChatSession(res, deps, sessionId)))
2842
+ return;
2843
+ // AC-R7-001 (2026-08-23): removing a workspace-linked DAG association is
2844
+ // idempotent — a missing link is already-removed (200), never an error.
2845
+ try {
2846
+ const removed = await deps.operationLinker.unlink({
2847
+ sessionId,
2848
+ operationId,
2849
+ });
2850
+ sendJson(res, 200, { ok: true, removed });
2851
+ }
2852
+ catch (error) {
2853
+ sendJson(res, 400, {
2854
+ ok: false,
2855
+ error: {
2856
+ code: "INVALID_INPUT",
2857
+ message: error instanceof Error ? error.message : String(error),
2858
+ },
2859
+ });
2860
+ }
2861
+ }
2598
2862
  async function handleStartInterview(req, res, deps, sessionId) {
2599
2863
  const gate = gateMutation(req, deps);
2600
2864
  if (!gate.ok) {
@@ -3247,14 +3511,59 @@ export async function handleChatCompact(req, res, deps, sessionId) {
3247
3511
  return;
3248
3512
  }
3249
3513
  const rawInstructions = typeof body.instructions === "string" ? body.instructions : undefined;
3250
- const customInstructions = rawInstructions?.trim() || undefined;
3514
+ const currentTodos = projectCurrentTodos(deps.events.snapshot(sessionId));
3515
+ const customInstructions = buildTodoCompactionInstructions(rawInstructions, currentTodos);
3516
+ const turnId = deps.events.latestTurnId(sessionId) ?? `${sessionId}:compact`;
3517
+ const compactionId = `${sessionId}:compaction:${randomUUID()}`;
3518
+ deps.events.append(sessionId, turnId, {
3519
+ kind: "compaction_start",
3520
+ data: {
3521
+ schemaVersion: 1,
3522
+ compactionId,
3523
+ reason: "manual",
3524
+ status: "running",
3525
+ willRetry: false,
3526
+ },
3527
+ });
3251
3528
  try {
3252
3529
  const snapshot = await deps.runtime.compact(sessionId, customInstructions);
3253
- const event = deps.events.append(sessionId, deps.events.latestTurnId(sessionId) ?? `${sessionId}:compact`, { kind: "compact", data: snapshot });
3530
+ const event = deps.events.append(sessionId, turnId, {
3531
+ kind: "compaction_end",
3532
+ data: {
3533
+ schemaVersion: 1,
3534
+ compactionId,
3535
+ reason: "manual",
3536
+ status: "completed",
3537
+ summary: snapshot.summary,
3538
+ ...(snapshot.firstKeptEntryId
3539
+ ? { firstKeptEntryId: snapshot.firstKeptEntryId }
3540
+ : {}),
3541
+ ...(snapshot.tokensBefore === undefined
3542
+ ? {}
3543
+ : { tokensBefore: snapshot.tokensBefore }),
3544
+ ...(snapshot.estimatedTokensAfter === undefined
3545
+ ? {}
3546
+ : { estimatedTokensAfter: snapshot.estimatedTokensAfter }),
3547
+ willRetry: false,
3548
+ },
3549
+ });
3254
3550
  sendJson(res, 200, { ok: true, snapshot, eventId: event.eventId });
3255
3551
  }
3256
3552
  catch (error) {
3257
3553
  const classified = classifyChatCompactionFailure(error);
3554
+ const aborted = error instanceof Error &&
3555
+ (error.name === "AbortError" || /cancel(?:led|ed)|aborted/i.test(error.message));
3556
+ deps.events.append(sessionId, turnId, {
3557
+ kind: "compaction_end",
3558
+ data: {
3559
+ schemaVersion: 1,
3560
+ compactionId,
3561
+ reason: "manual",
3562
+ status: aborted ? "aborted" : "failed",
3563
+ willRetry: false,
3564
+ ...(aborted ? {} : { errorMessage: classified.message }),
3565
+ },
3566
+ });
3258
3567
  sendJson(res, classified.status, {
3259
3568
  ok: false,
3260
3569
  error: { code: classified.code, message: classified.message },
@@ -3283,8 +3592,10 @@ export async function handleChatMessages(res, deps, sessionId, url) {
3283
3592
  /**
3284
3593
  * Dispatch a chat HTTP request. Returns true if handled.
3285
3594
  * Mounted under /api/operator/v1/chat/*.
3595
+ * workspaceScope (AC-R7-002): the explicit owning workspaceId for session
3596
+ * create, forwarded to handleCreateChatSession → store.create.
3286
3597
  */
3287
- export async function handleChatRequest(req, res, deps, pathname) {
3598
+ export async function handleChatRequest(req, res, deps, pathname, workspaceScope) {
3288
3599
  if (!deps)
3289
3600
  return false;
3290
3601
  if (!pathname.startsWith("/api/operator/v1/chat/") &&
@@ -3362,6 +3673,16 @@ export async function handleChatRequest(req, res, deps, pathname) {
3362
3673
  await handleStartInterview(req, res, deps, decodeURIComponent(interviewStart[1]));
3363
3674
  return true;
3364
3675
  }
3676
+ const userQuestionAnswer = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/answer$/);
3677
+ if (method === "POST" && userQuestionAnswer) {
3678
+ await handleAnswerUserQuestion(req, res, deps, decodeURIComponent(userQuestionAnswer[1]), decodeURIComponent(userQuestionAnswer[2]));
3679
+ return true;
3680
+ }
3681
+ const userQuestionCancel = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/questions\/([^/]+)\/cancel$/);
3682
+ if (method === "POST" && userQuestionCancel) {
3683
+ await handleCancelUserQuestion(req, res, deps, decodeURIComponent(userQuestionCancel[1]), decodeURIComponent(userQuestionCancel[2]));
3684
+ return true;
3685
+ }
3365
3686
  const interviewAnswer = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/answers$/);
3366
3687
  if (method === "POST" && interviewAnswer) {
3367
3688
  await handleAnswerInterview(req, res, deps, decodeURIComponent(interviewAnswer[1]), decodeURIComponent(interviewAnswer[2]));
@@ -3468,6 +3789,11 @@ export async function handleChatRequest(req, res, deps, pathname) {
3468
3789
  await handleDeleteDeferredTurn(req, res, deps, decodeURIComponent(deferredTurn[1]));
3469
3790
  return true;
3470
3791
  }
3792
+ const deferredTurnSteer = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/deferred-turn\/([^/]+)\/steer$/);
3793
+ if (method === "POST" && deferredTurnSteer) {
3794
+ await handleSteerDeferredTurn(req, res, deps, decodeURIComponent(deferredTurnSteer[1]), decodeURIComponent(deferredTurnSteer[2]));
3795
+ return true;
3796
+ }
3471
3797
  const sessionTurnAbort = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)\/abort$/);
3472
3798
  if (method === "POST" && sessionTurnAbort) {
3473
3799
  await handleAbortChatTurn(req, res, deps, decodeURIComponent(sessionTurnAbort[1]), decodeURIComponent(sessionTurnAbort[2]));
@@ -3483,6 +3809,13 @@ export async function handleChatRequest(req, res, deps, pathname) {
3483
3809
  await handleLinkWorkspaceOperation(req, res, deps, decodeURIComponent(operationLink[1]));
3484
3810
  return true;
3485
3811
  }
3812
+ const operationLinkItem = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/operation-links\/([^/]+)$/);
3813
+ if (method === "DELETE" && operationLinkItem) {
3814
+ // AC-R7-001: Details rail DAG remove — real DELETE of the workspace
3815
+ // operation-link association (idempotent).
3816
+ await handleUnlinkWorkspaceOperation(req, res, deps, decodeURIComponent(operationLinkItem[1]), decodeURIComponent(operationLinkItem[2]));
3817
+ return true;
3818
+ }
3486
3819
  const sessionCompact = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/compact$/);
3487
3820
  if (method === "POST" && sessionCompact) {
3488
3821
  await handleChatCompact(req, res, deps, decodeURIComponent(sessionCompact[1]));
@@ -3530,7 +3863,7 @@ export async function handleChatRequest(req, res, deps, pathname) {
3530
3863
  }
3531
3864
  }
3532
3865
  if (method === "POST" && pathname === "/api/operator/v1/chat/sessions") {
3533
- await handleCreateChatSession(req, res, deps);
3866
+ await handleCreateChatSession(req, res, deps, workspaceScope);
3534
3867
  return true;
3535
3868
  }
3536
3869
  if (isMutationMethod(method)) {