@tea-agent/loop-agent 0.43.0-next.1 → 0.43.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 (207) hide show
  1. package/CHANGELOG.md +144 -9
  2. package/dist/application/dag/run-dag.js +5 -0
  3. package/dist/application/evaluation/budget.js +19 -1
  4. package/dist/application/task-lifecycle/advance.js +17 -4
  5. package/dist/application/task-lifecycle/observe.js +43 -29
  6. package/dist/application/task-lifecycle/plan-transitions.js +5 -4
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/executors/dag-pi-executor.js +1237 -586
  9. package/dist/executors/pi-executor.js +22 -1
  10. package/dist/executors/pi-sdk-executor.js +140 -39
  11. package/dist/executors/shell-executor.js +8 -4
  12. package/dist/shared/frontend-execution-policy.js +23 -0
  13. package/dist/task/config-types.js +4 -0
  14. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  15. package/dist/task/source-prepare/ledger-review.js +6 -9
  16. package/dist/task/source-prepare/semantic-intake.js +16 -26
  17. package/dist/task/source-prepare/source-fidelity-pi.js +28 -9
  18. package/dist/task/source-references.js +48 -23
  19. package/dist/worker/console/chat/assistant-content.js +23 -2
  20. package/dist/worker/console/chat/browser-policy.js +113 -0
  21. package/dist/worker/console/chat/browser-routes.js +140 -0
  22. package/dist/worker/console/chat/explore-tools.js +13 -0
  23. package/dist/worker/console/chat/pi-runtime/custom-tools/ask-user-question.js +44 -0
  24. package/dist/worker/console/chat/pi-runtime/custom-tools/explore-tools.js +202 -0
  25. package/dist/worker/console/chat/pi-runtime/custom-tools/goal-tools.js +79 -0
  26. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +86 -0
  27. package/dist/worker/console/chat/pi-runtime/custom-tools/todo-write.js +38 -0
  28. package/dist/worker/console/chat/pi-runtime/inspection-types.js +28 -0
  29. package/dist/worker/console/chat/pi-runtime/inspection.js +439 -0
  30. package/dist/worker/console/chat/pi-runtime/sdk-bindings.js +153 -0
  31. package/dist/worker/console/chat/pi-runtime/sdk-session.js +5 -0
  32. package/dist/worker/console/chat/pi-runtime.js +207 -1178
  33. package/dist/worker/console/chat/routes.js +208 -71
  34. package/dist/worker/console/chat/scm-routes.js +217 -0
  35. package/dist/worker/console/chat/scm-service.js +283 -0
  36. package/dist/worker/console/chat/scm-tools.js +111 -0
  37. package/dist/worker/console/chat/sdd-data-alignment.js +65 -9
  38. package/dist/worker/console/chat/session-catalog.js +32 -0
  39. package/dist/worker/console/chat/session-mode-view.js +49 -0
  40. package/dist/worker/console/chat/session-mode.js +219 -0
  41. package/dist/worker/console/chat/session-store.js +22 -6
  42. package/dist/worker/console/chat/shortcuts.js +6 -0
  43. package/dist/worker/console/chat/terminal-routes.js +216 -0
  44. package/dist/worker/console/chat/terminal-sessions.js +348 -0
  45. package/dist/worker/console/chat/terminal-tools.js +211 -0
  46. package/dist/worker/console/chat/tool-preview.js +11 -0
  47. package/dist/worker/console/chat/tools.js +2 -0
  48. package/dist/worker/console/server.js +2 -28
  49. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B0Q0nClf.js → abnfDiagram-N423BO3Z-CTSK0xbU.js} +1 -1
  50. package/dist/worker/console/static/assets/{arc-DCPjC19G.js → arc-_2MfctFP.js} +1 -1
  51. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP5n9jmG.js → architectureDiagram-T3A2C74G-DsDe2wTg.js} +1 -1
  52. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CmaWi_Wg.js → blockDiagram-VBNYF7ZC-Dtz_779U.js} +1 -1
  53. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Ct5tcMfZ.js → c4Diagram-5PPSVZJV-LaULi-gw.js} +1 -1
  54. package/dist/worker/console/static/assets/channel-CqS4yBWT.js +1 -0
  55. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-C0osm1Zf.js → chunk-2GRJ4B5K-DuHBqqX8.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CDWviED7.js → chunk-2Q5K7J3B-BZgZs1dn.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-mtSk1-j7.js → chunk-5RXB4S5H-tzwmlIO_.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D5J9PHC7.js → chunk-5VM5RSS4-CeYrLkSn.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Begg4WAa.js → chunk-6Q2QTUOP-C92cgilm.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-sF1AEy7T.js → chunk-GF5L2VYU-XOXpGC0F.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BqbsWXZb.js → chunk-JWPE2WC7-CGHbJyCH.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BKJrPcJ6.js → chunk-KBJHAD2P-Bs2-LTzf.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CcwNMRho.js → chunk-RYQCIY6F-rN7up7E4.js} +1 -1
  64. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BEJi3iIs.js → chunk-XXDRQBXY-B_fWjKpR.js} +1 -1
  65. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DVOxNOq0.js +1 -0
  66. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DVOxNOq0.js +1 -0
  67. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-DRLgTvVu.js → cose-bilkent-JH36ORCC-DE3SdJvB.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BoAVG3cs.js → cynefin-VYW2F7L2-B-4yuRAX.js} +1 -1
  69. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bzol06hR.js → cynefinDiagram-MW4NZA55-TiCPFuO8.js} +1 -1
  70. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UmliJM77.js → dagre-VZM6K2ZE-7crzLMzA.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BWLcJkFo.js → diagram-7IWD3JNH-Cvr-x6Fd.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CgApOm-O.js → diagram-B4RE2ZJO-BzcRHUjH.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Bh3RgTLs.js → diagram-LBJQPF4R-BjkAXilh.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Dsh-D5nE.js → diagram-Q27KOJAE-BkzyATK7.js} +1 -1
  75. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dkbbpcpb.js → diagram-UB23O5K3-rZuTarYP.js} +1 -1
  76. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-3EBaA3t3.js → ebnfDiagram-BXEA7PRR-CAYENaN1.js} +1 -1
  77. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bq2Dc5ok.js → erDiagram-JOGREHBK-DCEam0Uq.js} +1 -1
  78. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-De7y55ha.js → flowDiagram-UKHOOZJN-e_dH4wVO.js} +1 -1
  79. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-fLiDbQRh.js → ganttDiagram-PKOTCBZU-CvZa2zOK.js} +1 -1
  80. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Du617mp1.js → gitGraphDiagram-DS77QQ5N-CbT5TuKp.js} +1 -1
  81. package/dist/worker/console/static/assets/index-CUPeoE8W.js +468 -0
  82. package/dist/worker/console/static/assets/index-SAANIK8g.css +1 -0
  83. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BwmlF-XP.js → infoDiagram-6WML65LV-BWzHQa_Z.js} +1 -1
  84. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-zsGtS59u.js → ishikawaDiagram-WSZJBQD7-BTjlovtU.js} +1 -1
  85. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DTTPTe3f.js → journeyDiagram-NVQOT4AX-DkvQoCc1.js} +1 -1
  86. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BkXg9aP-.js → kanban-definition-27J2QSJJ-CHSzGi3W.js} +1 -1
  87. package/dist/worker/console/static/assets/{linear-7U2ue5IE.js → linear-DZMkTCWH.js} +1 -1
  88. package/dist/worker/console/static/assets/{mermaid.core-BUuGHmWO.js → mermaid.core-BIMhty7A.js} +5 -5
  89. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-7q6fX0Sv.js → mindmap-definition-FAOFIHXS-Dx-tyMl3.js} +1 -1
  90. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkQPJN55.js → pegDiagram-VL7TDLO6-C6G34rbY.js} +1 -1
  91. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CCal_tfX.js → pieDiagram-7S7Q4E2Y-CHK9XOfm.js} +1 -1
  92. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js → quadrantDiagram-CIZ2JOQS-CDARkI0w.js} +1 -1
  93. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CJmww7D-.js → railroadDiagram-AXF67PYL-DiuUe0ZQ.js} +1 -1
  94. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D6ldWJEv.js → requirementDiagram-LRYGKXZP-RE61UR56.js} +1 -1
  95. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-VGh33I9e.js → sankeyDiagram-W5VNT64P-KDNLHMEG.js} +1 -1
  96. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CCkDrIjU.js → sequenceDiagram-SI44F4Z6-CeVgXnD9.js} +1 -1
  97. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-NC8f5Otb.js → sizeCapture-X5ZJPWSS-CXwGyjjG.js} +1 -1
  98. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-_92ezZdF.js → stateDiagram-OKZ733FA-BjTfozab.js} +1 -1
  99. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BXoYsyr2.js +1 -0
  100. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BdUCGtbP.js → swimlanes-SLNWSIFB-BaXOB8GT.js} +2 -2
  101. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DfKliS4e.js +8 -0
  102. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CdD1H8ia.js → timeline-definition-Z64GVDOM-C3r2hqUU.js} +1 -1
  103. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-Dkdk3oFo.js → vennDiagram-T6HMQDX7-MWKgl1S9.js} +1 -1
  104. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CKu2uPPY.js → wardleyDiagram-T6FBY63Y-C9iFcZPk.js} +1 -1
  105. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-ClqAnuG8.js → xychartDiagram-ELKLHX3M-DqnM24Jp.js} +1 -1
  106. package/dist/worker/console/static/index.html +2 -2
  107. package/dist/worker/console/static-src/operator-chat/active-turn-tail.js +10 -0
  108. package/dist/worker/console/static-src/operator-chat/chat-link.js +94 -0
  109. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +122 -10
  110. package/dist/worker/console/static-src/operator-chat/compaction-message.js +7 -0
  111. package/dist/worker/console/static-src/operator-chat/pending-user-message.js +27 -15
  112. package/dist/worker/console/static-src/operator-chat/process-label.js +41 -0
  113. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +13 -0
  114. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +10 -0
  115. package/dist/worker/console/static-src/operator-chat/use-searchable-hidden.js +51 -0
  116. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +44 -24
  117. package/dist/worker/console/static-src/operator-chat/useChatStream.js +18 -2
  118. package/dist/worker/console/static-src/operator-chat/useChatThread.js +41 -8
  119. package/dist/worker/console/static-src/operator-chat/useComposer.js +26 -11
  120. package/dist/worker/console/static-src/operator-chat/user-turn-anchor-equality.js +21 -0
  121. package/dist/worker/console/workspace-context.js +15 -1
  122. package/dist/worker/observe/node-transparency.js +81 -72
  123. package/dist/worker/observe/routes.js +20 -1
  124. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  125. package/dist/worker/observe/static/dom.js +20 -1
  126. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  127. package/dist/worker/observe/static/format-pool.js +6 -0
  128. package/dist/worker/observe/static/format.js +7 -0
  129. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  130. package/dist/worker/observe/static/inspector-submission.js +32 -0
  131. package/dist/worker/observe/static/kpi.js +1 -0
  132. package/dist/worker/observe/static/relations.js +2 -0
  133. package/dist/worker/observe/static/router.js +13 -0
  134. package/dist/worker/observe/static/run-processing.js +2 -0
  135. package/dist/worker/observe/static/shell-chrome.js +36 -3
  136. package/dist/worker/observe/static/state.js +35 -2
  137. package/dist/worker/observe/static/styles.css +263 -39
  138. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  139. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  140. package/dist/worker/observe/static/task-history.js +12 -0
  141. package/dist/worker/observe/static/views/batch.js +6 -13
  142. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  143. package/dist/worker/observe/static/views/dag-inspector.js +742 -268
  144. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  145. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  146. package/dist/worker/observe/static/views/dag.js +49 -11
  147. package/dist/worker/observe/static/views/dags.js +2 -0
  148. package/dist/worker/observe/static/views/dashboard.js +21 -12
  149. package/dist/worker/observe/static/views/failures.js +21 -11
  150. package/dist/worker/observe/static/views/feature.js +11 -29
  151. package/dist/worker/observe/static/views/pool.js +37 -28
  152. package/dist/worker/observe/static/views/run.js +48 -5
  153. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  154. package/dist/worker/observe/static/views/task.js +81 -62
  155. package/dist/workflows/dag/budget-enforcement.js +53 -3
  156. package/dist/workflows/dag/frontend-capacity.js +9 -0
  157. package/dist/workflows/dag/frontend-closeout.js +1 -1
  158. package/dist/workflows/dag/frontend-design-policy.js +2 -1
  159. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  160. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  161. package/dist/workflows/dag/frontend-implementation-contract.js +13 -0
  162. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  163. package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
  164. package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
  165. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  166. package/dist/workflows/dag/frontend-prewrite-gate.js +3 -3
  167. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  168. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  169. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  170. package/dist/workflows/dag/frontend-review-context.js +2 -1
  171. package/dist/workflows/dag/frontend-review-scopes.js +139 -0
  172. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  173. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  174. package/dist/workflows/dag/frontend-shape-facts.js +12 -2
  175. package/dist/workflows/dag/frontend-shape.js +30 -1
  176. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  177. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  178. package/dist/workflows/dag/frontend-verification-trace.js +1 -1
  179. package/dist/workflows/dag/init-hybrid.js +65 -167
  180. package/dist/workflows/dag/node-execution.js +38 -155
  181. package/dist/workflows/dag/prompt.js +4 -0
  182. package/dist/workflows/dag/rerun-plan.js +7 -1
  183. package/dist/workflows/dag/runner.js +26 -1
  184. package/dist/workflows/dag/types.js +6 -0
  185. package/docs/operations/README.md +1 -0
  186. package/docs/templates/frontend-design-contract.md +4 -4
  187. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  188. package/docs/templates/frontend-implementation-dag.json +14 -13
  189. package/package.json +6 -3
  190. package/skills/frontend-bounded-implement/SKILL.md +3 -3
  191. package/skills/frontend-bounded-implement/references/code-standards.md +3 -3
  192. package/skills/frontend-contract/SKILL.md +2 -1
  193. package/skills/frontend-contract/references/contract-protocol.md +22 -4
  194. package/skills/frontend-design-review/SKILL.md +22 -16
  195. package/skills/frontend-design-review/references/review-checklist.md +7 -7
  196. package/skills/frontend-plan/SKILL.md +21 -2
  197. package/skills/frontend-plan/references/decision-contract.md +42 -23
  198. package/skills/frontend-review/SKILL.md +22 -29
  199. package/skills/frontend-review/references/review-findings.md +24 -43
  200. package/skills/frontend-scout/references/scout-evidence.md +4 -0
  201. package/dist/worker/console/static/assets/channel-DAS07MdS.js +0 -1
  202. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +0 -1
  203. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +0 -1
  204. package/dist/worker/console/static/assets/index-C0O48S_P.js +0 -449
  205. package/dist/worker/console/static/assets/index-CzKf4U8P.css +0 -1
  206. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +0 -1
  207. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B1cw_0Uy.js +0 -8
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Operator Chat — terminal management HTTP API (Console UI side).
3
+ *
4
+ * Endpoints (all under /api/operator/v1/chat):
5
+ * GET /terminals list terminal sessions (all owners)
6
+ * POST /terminals spawn { shell, name?, cwd?, ownerSessionId? }
7
+ * GET /terminals/:id session info + buffered output
8
+ * POST /terminals/:id/input { data } write to the PTY
9
+ * POST /terminals/:id/resize { cols, rows }
10
+ * POST /terminals/:id/interrupt send Ctrl+C
11
+ * POST /terminals/:id/kill stop and await full exit
12
+ * GET /terminals/:id/stream SSE: buffered replay + live chunks
13
+ *
14
+ * Mutating routes require the Console boot token (same gate as other chat
15
+ * mutations). The stream is read-only and uses the same origin checks as the
16
+ * chat event stream.
17
+ */
18
+ import { TerminalSessionError, availableShellKinds, } from "./terminal-sessions.js";
19
+ function sendJson(res, status, body) {
20
+ const payload = JSON.stringify(body);
21
+ res.writeHead(status, {
22
+ "content-type": "application/json; charset=utf-8",
23
+ "cache-control": "no-store",
24
+ });
25
+ res.end(payload);
26
+ }
27
+ function mapTerminalError(res, error) {
28
+ if (error instanceof TerminalSessionError) {
29
+ const status = error.code === "TERMINAL_NOT_FOUND"
30
+ ? 404
31
+ : error.code === "TERMINAL_EXITED" || error.code === "TERMINAL_SEND_ACTIVE"
32
+ ? 409
33
+ : 500;
34
+ sendJson(res, status, {
35
+ ok: false,
36
+ error: { code: error.code, message: error.message },
37
+ });
38
+ return;
39
+ }
40
+ sendJson(res, 500, {
41
+ ok: false,
42
+ error: {
43
+ code: "TERMINAL_INTERNAL",
44
+ message: error instanceof Error ? error.message : String(error),
45
+ },
46
+ });
47
+ }
48
+ async function readJsonBody(req, maxBytes = 512 * 1024) {
49
+ const chunks = [];
50
+ let total = 0;
51
+ for await (const chunk of req) {
52
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
53
+ total += buf.length;
54
+ if (total > maxBytes)
55
+ throw new Error("request body too large");
56
+ chunks.push(buf);
57
+ }
58
+ if (chunks.length === 0)
59
+ return {};
60
+ const raw = Buffer.concat(chunks).toString("utf8");
61
+ if (!raw.trim())
62
+ return {};
63
+ return JSON.parse(raw);
64
+ }
65
+ /** Route dispatcher; returns true when the request was handled. */
66
+ export async function handleTerminalRequest(req, res, deps, pathname) {
67
+ if (!pathname.startsWith("/api/operator/v1/chat/terminals"))
68
+ return false;
69
+ const method = (req.method ?? "GET").toUpperCase();
70
+ const rest = pathname.slice("/api/operator/v1/chat/terminals".length);
71
+ const parts = rest.split("/").filter(Boolean);
72
+ try {
73
+ // GET /terminals — list (+ available shell kinds for this platform)
74
+ if (method === "GET" && parts.length === 0) {
75
+ sendJson(res, 200, {
76
+ ok: true,
77
+ data: {
78
+ terminals: deps.terminals.list(),
79
+ availableShells: availableShellKinds().kinds,
80
+ },
81
+ });
82
+ return true;
83
+ }
84
+ // POST /terminals — spawn
85
+ if (method === "POST" && parts.length === 0) {
86
+ const gate = deps.gateMutation(req);
87
+ if (!gate.ok) {
88
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
89
+ return true;
90
+ }
91
+ const body = await readJsonBody(req);
92
+ const kinds = availableShellKinds().kinds;
93
+ const shell = kinds.includes(body.shell)
94
+ ? body.shell
95
+ : undefined;
96
+ if (!shell) {
97
+ sendJson(res, 400, {
98
+ ok: false,
99
+ error: {
100
+ code: "INVALID_INPUT",
101
+ message: `shell must be one of: ${kinds.join(", ")} (available on this platform)`,
102
+ },
103
+ });
104
+ return true;
105
+ }
106
+ const info = deps.terminals.spawn({
107
+ ownerSessionId: typeof body.ownerSessionId === "string" && body.ownerSessionId
108
+ ? body.ownerSessionId
109
+ : "console-ui",
110
+ name: typeof body.name === "string" && body.name ? body.name : `${shell}-ui`,
111
+ shell: shell,
112
+ cwd: typeof body.cwd === "string" && body.cwd
113
+ ? body.cwd
114
+ : (deps.defaultCwd ?? undefined),
115
+ cols: typeof body.cols === "number" ? body.cols : undefined,
116
+ rows: typeof body.rows === "number" ? body.rows : undefined,
117
+ });
118
+ sendJson(res, 201, { ok: true, data: info });
119
+ return true;
120
+ }
121
+ const sessionId = parts[0];
122
+ if (!sessionId)
123
+ return false;
124
+ const sub = parts[1] ?? "";
125
+ // GET /terminals/:id — info + buffered output
126
+ if (method === "GET" && parts.length === 1) {
127
+ const info = deps.terminals.get(sessionId);
128
+ const output = deps.terminals.readAll(sessionId);
129
+ sendJson(res, 200, { ok: true, data: { ...info, output } });
130
+ return true;
131
+ }
132
+ // GET /terminals/:id/stream — SSE
133
+ if (method === "GET" && sub === "stream") {
134
+ // Validate existence first (404 before opening the stream).
135
+ const info = deps.terminals.get(sessionId);
136
+ void info;
137
+ res.writeHead(200, {
138
+ "content-type": "text/event-stream; charset=utf-8",
139
+ "cache-control": "no-cache, no-store",
140
+ connection: "keep-alive",
141
+ "x-accel-buffering": "no",
142
+ });
143
+ const writeFrame = (event, data) => {
144
+ if (res.writableEnded)
145
+ return;
146
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
147
+ };
148
+ const buffered = deps.terminals.readAll(sessionId);
149
+ writeFrame("snapshot", {
150
+ truncated: buffered.truncated,
151
+ totalOutputChars: buffered.totalOutputChars,
152
+ text: buffered.text,
153
+ info: deps.terminals.get(sessionId),
154
+ });
155
+ const unsubscribe = deps.terminals.subscribe(sessionId, (chunk) => {
156
+ writeFrame("data", { chunk });
157
+ });
158
+ const heartbeat = setInterval(() => {
159
+ if (!res.writableEnded)
160
+ res.write(": heartbeat\n\n");
161
+ }, 15_000);
162
+ res.once("close", () => {
163
+ clearInterval(heartbeat);
164
+ unsubscribe();
165
+ });
166
+ return true;
167
+ }
168
+ // Everything else mutates → gate first.
169
+ const gate = deps.gateMutation(req);
170
+ if (!gate.ok) {
171
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
172
+ return true;
173
+ }
174
+ if (method === "POST" && sub === "input" && parts.length === 2) {
175
+ const body = await readJsonBody(req);
176
+ if (typeof body.data !== "string") {
177
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "data (string) is required" } });
178
+ return true;
179
+ }
180
+ deps.terminals.send(sessionId, body.data);
181
+ sendJson(res, 200, { ok: true });
182
+ return true;
183
+ }
184
+ if (method === "POST" && sub === "resize" && parts.length === 2) {
185
+ const body = await readJsonBody(req);
186
+ const cols = Number(body.cols);
187
+ const rows = Number(body.rows);
188
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 2 || rows < 2 || cols > 500 || rows > 200) {
189
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "cols and rows (integers 2..500/200) are required" } });
190
+ return true;
191
+ }
192
+ deps.terminals.resize(sessionId, cols, rows);
193
+ sendJson(res, 200, { ok: true });
194
+ return true;
195
+ }
196
+ if (method === "POST" && sub === "interrupt" && parts.length === 2) {
197
+ deps.terminals.interrupt(sessionId);
198
+ sendJson(res, 200, { ok: true });
199
+ return true;
200
+ }
201
+ if (method === "POST" && sub === "kill" && parts.length === 2) {
202
+ await deps.terminals.kill(sessionId);
203
+ sendJson(res, 200, { ok: true });
204
+ return true;
205
+ }
206
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `unknown terminal route: ${method} ${pathname}` } });
207
+ return true;
208
+ }
209
+ catch (error) {
210
+ if (!res.headersSent)
211
+ mapTerminalError(res, error);
212
+ else
213
+ res.end();
214
+ return true;
215
+ }
216
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Operator Chat — persistent PTY terminal sessions (DSH-style terminal seam).
3
+ *
4
+ * Process-local registry of owner-scoped persistent terminal sessions:
5
+ * - spawn: create a named PTY (shell) owned by a chat session
6
+ * - send: write bytes into the PTY (model tool or console UI input)
7
+ * - read: ring-buffered output history + live subscriber fan-out
8
+ * - signal/kill: interrupt or fully stop the process tree, awaited
9
+ * - dispose: stop everything owned by one chat session
10
+ *
11
+ * Design mirrors the DSH terminal seam contract (process-local, no sharing
12
+ * across agents, single active send, kill waits for full stop) but stays
13
+ * deliberately small: no backend abstraction (node-pty is the only backend),
14
+ * no cross-process persistence (sessions die with the Console process).
15
+ */
16
+ import { spawn as ptySpawn } from "node-pty";
17
+ import { existsSync } from "node:fs";
18
+ import { randomBytes } from "node:crypto";
19
+ import { release as osRelease } from "node:os";
20
+ /** Bounded ring buffer of raw output chunks (bytes preserved as utf8 text). */
21
+ const MAX_BUFFER_CHARS = 200_000;
22
+ export class TerminalSessionError extends Error {
23
+ code;
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.code = code;
27
+ this.name = "TerminalSessionError";
28
+ }
29
+ }
30
+ const IS_WINDOWS = process.platform === "win32";
31
+ function firstExisting(paths) {
32
+ return paths.find((p) => existsSync(p));
33
+ }
34
+ /** Windows PowerShell 5+ or pwsh 7; prefer pwsh 7 when installed. */
35
+ function resolvePowershell() {
36
+ if (IS_WINDOWS) {
37
+ const pwsh7 = firstExisting([
38
+ "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
39
+ "C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe",
40
+ ]);
41
+ if (pwsh7) {
42
+ return {
43
+ file: pwsh7,
44
+ args: [
45
+ "-NoLogo",
46
+ "-NoProfile",
47
+ "-ExecutionPolicy",
48
+ "Bypass",
49
+ "-NoExit",
50
+ "-Command",
51
+ "$PSDefaultParameterValues['Out-File:Encoding']='utf8'; [Console]::OutputEncoding=[Text.Encoding]::UTF8",
52
+ ],
53
+ };
54
+ }
55
+ const ps5 = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
56
+ if (existsSync(ps5)) {
57
+ return {
58
+ file: ps5,
59
+ args: [
60
+ "-NoProfile",
61
+ "-ExecutionPolicy",
62
+ "Bypass",
63
+ "-NoExit",
64
+ "-Command",
65
+ "$PSDefaultParameterValues['Out-File:Encoding']='utf8'; [Console]::OutputEncoding=[Text.Encoding]::UTF8",
66
+ ],
67
+ };
68
+ }
69
+ return undefined;
70
+ }
71
+ // macOS / Linux: only pwsh 7 if explicitly installed.
72
+ const pwsh = firstExisting([
73
+ "/usr/local/bin/pwsh",
74
+ "/opt/homebrew/bin/pwsh",
75
+ "/usr/bin/pwsh",
76
+ ]);
77
+ return pwsh
78
+ ? { file: pwsh, args: ["-NoLogo", "-NoProfile"] }
79
+ : undefined;
80
+ }
81
+ /** bash: Git Bash on Windows, /bin/bash (or Homebrew) on unix-likes. */
82
+ function resolveBash() {
83
+ if (IS_WINDOWS) {
84
+ const file = firstExisting([
85
+ "C:\\Program Files\\Git\\bin\\bash.exe",
86
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
87
+ "C:\\Program Files\\Git\\usr\\bin\\bash.exe",
88
+ ]);
89
+ return file ? { file, args: ["-i", "-l"] } : undefined;
90
+ }
91
+ const file = firstExisting(["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash", "/opt/homebrew/bin/bash"]);
92
+ return file ? { file, args: ["-i"] } : undefined;
93
+ }
94
+ /** zsh: macOS default shell; on Linux only when installed. */
95
+ function resolveZsh() {
96
+ if (IS_WINDOWS)
97
+ return undefined;
98
+ const file = firstExisting(["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh", "/opt/homebrew/bin/zsh"]);
99
+ return file ? { file, args: ["-i"] } : undefined;
100
+ }
101
+ /** Windows `cmd.exe`. Always present on Windows; offered alongside
102
+ * PowerShell so users can use native Windows tooling (`dir`, `where`,
103
+ * `mklink`, `net user`, etc.) without leaving the terminal panel.
104
+ * - `/Q` turns off command echo (otherwise cmd echoes every input line,
105
+ * doubling every prompt in PTY output).
106
+ * - `/D` disables AutoRun execution from the registry (avoid running the
107
+ * user's HKCU\...\RunOnce / autorun scripts on every fresh shell).
108
+ * - `/K "prompt $P$G"` keeps the shell alive across tool calls (cwd/env
109
+ * survive) and uses the classic cmd prompt `C:\path>` instead of the
110
+ * bare `>`, so the user can see the working directory at a glance —
111
+ * matching the default `cmd.exe` window. */
112
+ function resolveCmd() {
113
+ if (!IS_WINDOWS)
114
+ return undefined;
115
+ const file = firstExisting([
116
+ "C:\\Windows\\System32\\cmd.exe",
117
+ // Wine / uncommon layouts
118
+ "C:\\Windows\\Sysnative\\cmd.exe",
119
+ ]);
120
+ if (!file)
121
+ return undefined;
122
+ return {
123
+ file,
124
+ args: ["/Q", "/D", "/K", "prompt $P$G"],
125
+ };
126
+ }
127
+ /** Synthesize the cmd.exe startup banner that node-pty drops. cmd prints
128
+ * `Microsoft Windows [版本 10.0.26200]\n(c) Microsoft Corporation。保留所有权利。`
129
+ * via `WriteConsole` BEFORE ConPTY attaches to its stdio handles, so
130
+ * node-pty / ConPTY never sees those writes. We re-emit an equivalent
131
+ * banner using `os.release()` (kernel build, e.g. "10.0.26200") so the
132
+ * console terminal looks like a real cmd window. The leading \r\n avoids
133
+ * the banner colliding with whatever ANSI preamble ConPTY already sent.
134
+ *
135
+ * Only meaningful for the cmd shell — bash and PowerShell on Windows have
136
+ * their own startup output that we must NOT paper over with a Windows
137
+ * banner they don't print. */
138
+ function makeCmdBanner(shell) {
139
+ if (!IS_WINDOWS)
140
+ return undefined;
141
+ if (shell !== "cmd")
142
+ return undefined;
143
+ const build = osRelease();
144
+ return `\r\nMicrosoft Windows [版本 ${build}]\r\n(c) Microsoft Corporation。保留所有权利。\r\n\r\n`;
145
+ }
146
+ let cachedCatalog;
147
+ /** Platform shell catalog, computed once per process. Drives both the UI
148
+ * button set and the model tool's shell parameter enum. */
149
+ export function availableShellKinds() {
150
+ if (cachedCatalog)
151
+ return cachedCatalog;
152
+ const resolved = new Map();
153
+ const pwsh = resolvePowershell();
154
+ if (pwsh)
155
+ resolved.set("powershell", pwsh);
156
+ const bash = resolveBash();
157
+ if (bash)
158
+ resolved.set("bash", bash);
159
+ const zsh = resolveZsh();
160
+ if (zsh)
161
+ resolved.set("zsh", zsh);
162
+ const cmd = resolveCmd();
163
+ if (cmd)
164
+ resolved.set("cmd", cmd);
165
+ cachedCatalog = { kinds: [...resolved.keys()], resolved };
166
+ return cachedCatalog;
167
+ }
168
+ export function resolveShellCommand(shell) {
169
+ const resolved = availableShellKinds().resolved.get(shell);
170
+ if (!resolved) {
171
+ throw new TerminalSessionError("TERMINAL_SPAWN_FAILED", `shell "${shell}" is not available on this platform (${process.platform})`);
172
+ }
173
+ return resolved;
174
+ }
175
+ export class TerminalSessionRegistry {
176
+ entries = new Map();
177
+ nextSeq = 0;
178
+ spawn(options) {
179
+ let file;
180
+ let args;
181
+ try {
182
+ ({ file, args } = resolveShellCommand(options.shell));
183
+ }
184
+ catch (error) {
185
+ throw new TerminalSessionError("TERMINAL_SPAWN_FAILED", `failed to spawn ${options.shell}: ${error instanceof Error ? error.message : String(error)}`);
186
+ }
187
+ const sessionId = `term-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
188
+ let pty;
189
+ try {
190
+ pty = ptySpawn(file, args, {
191
+ name: "xterm-256color",
192
+ cwd: options.cwd,
193
+ cols: options.cols ?? 120,
194
+ rows: options.rows ?? 30,
195
+ env: process.env ?? {},
196
+ encoding: "utf8",
197
+ });
198
+ }
199
+ catch (error) {
200
+ throw new TerminalSessionError("TERMINAL_SPAWN_FAILED", `failed to spawn ${options.shell}: ${error instanceof Error ? error.message : String(error)}`);
201
+ }
202
+ const entry = {
203
+ info: {
204
+ sessionId,
205
+ ownerSessionId: options.ownerSessionId,
206
+ name: options.name,
207
+ shell: options.shell,
208
+ createdAt: new Date().toISOString(),
209
+ totalOutputChars: 0,
210
+ exited: false,
211
+ },
212
+ pty,
213
+ buffer: [],
214
+ bufferChars: 0,
215
+ subscribers: new Set(),
216
+ disposing: undefined,
217
+ pendingBanner: makeCmdBanner(options.shell) ?? undefined,
218
+ };
219
+ this.entries.set(sessionId, entry);
220
+ pty.onData((chunk) => {
221
+ entry.info.totalOutputChars += chunk.length;
222
+ // Inject the synthesized cmd banner into the FIRST chunk the
223
+ // subscribers see, so SSE/streaming attaches still see it even
224
+ // though node-pty already missed cmd's real banner.
225
+ if (entry.pendingBanner !== undefined) {
226
+ chunk = entry.pendingBanner + chunk;
227
+ entry.pendingBanner = undefined;
228
+ }
229
+ entry.buffer.push(chunk);
230
+ entry.bufferChars += chunk.length;
231
+ while (entry.bufferChars > MAX_BUFFER_CHARS && entry.buffer.length > 1) {
232
+ const dropped = entry.buffer.shift();
233
+ entry.bufferChars -= dropped?.length ?? 0;
234
+ }
235
+ for (const notify of entry.subscribers) {
236
+ try {
237
+ notify(chunk);
238
+ }
239
+ catch {
240
+ // subscriber callbacks must never break the pty stream
241
+ }
242
+ }
243
+ });
244
+ pty.onExit(({ exitCode }) => {
245
+ entry.info.exited = true;
246
+ entry.info.exitCode = exitCode;
247
+ for (const notify of entry.subscribers) {
248
+ try {
249
+ notify(`\r\n\u001b[90m[terminal exited: code ${exitCode}]\u001b[0m\r\n`);
250
+ }
251
+ catch {
252
+ // ignore
253
+ }
254
+ }
255
+ });
256
+ return { ...entry.info };
257
+ }
258
+ list(ownerSessionId) {
259
+ const all = [...this.entries.values()].map((e) => ({ ...e.info }));
260
+ return ownerSessionId
261
+ ? all.filter((i) => i.ownerSessionId === ownerSessionId)
262
+ : all;
263
+ }
264
+ get(sessionId) {
265
+ const entry = this.entries.get(sessionId);
266
+ if (!entry)
267
+ throw new TerminalSessionError("TERMINAL_NOT_FOUND", sessionId);
268
+ return { ...entry.info };
269
+ }
270
+ mustGet(sessionId) {
271
+ const entry = this.entries.get(sessionId);
272
+ if (!entry)
273
+ throw new TerminalSessionError("TERMINAL_NOT_FOUND", sessionId);
274
+ return entry;
275
+ }
276
+ /** Full buffered output (post-trim prefix may be incomplete). */
277
+ readAll(sessionId) {
278
+ const entry = this.mustGet(sessionId);
279
+ let text = entry.buffer.join("");
280
+ // If the caller reads before any real PTY output has arrived, the
281
+ // banner injection in onData hasn't fired yet — fold it in here.
282
+ if (entry.pendingBanner !== undefined) {
283
+ text = entry.pendingBanner + text;
284
+ entry.pendingBanner = undefined;
285
+ }
286
+ return {
287
+ text,
288
+ truncated: entry.info.totalOutputChars > entry.bufferChars,
289
+ totalOutputChars: entry.info.totalOutputChars,
290
+ };
291
+ }
292
+ /** Subscribe to live output chunks; returns unsubscribe. */
293
+ subscribe(sessionId, onChunk) {
294
+ const entry = this.mustGet(sessionId);
295
+ entry.subscribers.add(onChunk);
296
+ return () => entry.subscribers.delete(onChunk);
297
+ }
298
+ send(sessionId, data) {
299
+ const entry = this.mustGet(sessionId);
300
+ if (entry.info.exited)
301
+ throw new TerminalSessionError("TERMINAL_EXITED", "terminal already exited");
302
+ entry.pty.write(data);
303
+ }
304
+ /** Resize the PTY (console UI attach). */
305
+ resize(sessionId, cols, rows) {
306
+ const entry = this.mustGet(sessionId);
307
+ if (entry.info.exited)
308
+ return;
309
+ entry.pty.resize(cols, rows);
310
+ }
311
+ /** Send an interrupt character (Ctrl+C) to the foreground process. */
312
+ interrupt(sessionId) {
313
+ const entry = this.mustGet(sessionId);
314
+ if (entry.info.exited)
315
+ return;
316
+ entry.pty.write("\x03");
317
+ }
318
+ /** Kill and wait for full stop, then remove the registry entry so closed
319
+ * terminals disappear from the list (explicit close = forget). Idempotent. */
320
+ async kill(sessionId) {
321
+ const entry = this.entries.get(sessionId);
322
+ if (!entry)
323
+ throw new TerminalSessionError("TERMINAL_NOT_FOUND", sessionId);
324
+ if (!entry.info.exited) {
325
+ entry.disposing ??= new Promise((resolve) => {
326
+ const timer = setTimeout(() => resolve(), 5_000);
327
+ entry.pty.onExit(() => {
328
+ clearTimeout(timer);
329
+ resolve();
330
+ });
331
+ try {
332
+ entry.pty.kill();
333
+ }
334
+ catch {
335
+ clearTimeout(timer);
336
+ resolve();
337
+ }
338
+ });
339
+ await entry.disposing;
340
+ }
341
+ this.entries.delete(sessionId);
342
+ }
343
+ /** Kill every session owned by one chat session; awaited. */
344
+ async disposeOwner(ownerSessionId) {
345
+ const owned = this.list(ownerSessionId).map((i) => i.sessionId);
346
+ await Promise.all(owned.map((id) => this.kill(id).catch(() => undefined)));
347
+ }
348
+ }