@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -9
- package/dist/application/dag/run-dag.js +5 -0
- package/dist/application/evaluation/budget.js +19 -1
- package/dist/application/task-lifecycle/advance.js +17 -4
- package/dist/application/task-lifecycle/observe.js +43 -29
- package/dist/application/task-lifecycle/plan-transitions.js +5 -4
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +1214 -584
- package/dist/executors/pi-executor.js +22 -1
- package/dist/executors/pi-sdk-executor.js +140 -39
- package/dist/executors/shell-executor.js +5 -2
- package/dist/shared/frontend-execution-policy.js +23 -0
- package/dist/task/config-types.js +4 -0
- package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
- package/dist/task/source-prepare/ledger-review.js +6 -9
- package/dist/task/source-prepare/semantic-intake.js +16 -26
- package/dist/task/source-prepare/source-fidelity-pi.js +28 -9
- package/dist/task/source-references.js +48 -23
- package/dist/worker/console/chat/assistant-content.js +23 -2
- package/dist/worker/console/chat/browser-policy.js +143 -0
- package/dist/worker/console/chat/browser-routes.js +148 -0
- package/dist/worker/console/chat/explore-tools.js +13 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/ask-user-question.js +44 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/explore-tools.js +202 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/goal-tools.js +78 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +86 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/todo-write.js +38 -0
- package/dist/worker/console/chat/pi-runtime/inspection-types.js +28 -0
- package/dist/worker/console/chat/pi-runtime/inspection.js +439 -0
- package/dist/worker/console/chat/pi-runtime/sdk-bindings.js +153 -0
- package/dist/worker/console/chat/pi-runtime/sdk-session.js +5 -0
- package/dist/worker/console/chat/pi-runtime.js +213 -1181
- package/dist/worker/console/chat/routes.js +212 -71
- package/dist/worker/console/chat/scm-routes.js +217 -0
- package/dist/worker/console/chat/scm-service.js +283 -0
- package/dist/worker/console/chat/scm-tools.js +111 -0
- package/dist/worker/console/chat/sdd-data-alignment.js +65 -9
- package/dist/worker/console/chat/session-catalog.js +32 -0
- package/dist/worker/console/chat/session-mode-view.js +48 -0
- package/dist/worker/console/chat/session-mode.js +218 -0
- package/dist/worker/console/chat/session-store.js +22 -6
- package/dist/worker/console/chat/shortcuts.js +6 -0
- package/dist/worker/console/chat/terminal-routes.js +216 -0
- package/dist/worker/console/chat/terminal-sessions.js +348 -0
- package/dist/worker/console/chat/terminal-tools.js +211 -0
- package/dist/worker/console/chat/tool-preview.js +11 -0
- package/dist/worker/console/chat/tools.js +2 -0
- package/dist/worker/console/server.js +2 -28
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B0Q0nClf.js → abnfDiagram-N423BO3Z-D_fNbWc_.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DCPjC19G.js → arc-_HuqLW6m.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP5n9jmG.js → architectureDiagram-T3A2C74G-Djy2qY1m.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CmaWi_Wg.js → blockDiagram-VBNYF7ZC-bB-1TSyY.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Ct5tcMfZ.js → c4Diagram-5PPSVZJV-C5Hmghrz.js} +1 -1
- package/dist/worker/console/static/assets/channel-C0A6UEIa.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-C0osm1Zf.js → chunk-2GRJ4B5K-Duyl39oy.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CDWviED7.js → chunk-2Q5K7J3B-x6mdnSyf.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-mtSk1-j7.js → chunk-5RXB4S5H-WlBuFj7W.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D5J9PHC7.js → chunk-5VM5RSS4-CTF429HX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Begg4WAa.js → chunk-6Q2QTUOP-Dwd6r-SF.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-sF1AEy7T.js → chunk-GF5L2VYU-C-Br82D1.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BqbsWXZb.js → chunk-JWPE2WC7-CuGp0DeE.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BKJrPcJ6.js → chunk-KBJHAD2P-CJhFX6Lh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CcwNMRho.js → chunk-RYQCIY6F-DpGOa_jT.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BEJi3iIs.js → chunk-XXDRQBXY-DXJaTynd.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DzguNppy.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DzguNppy.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-DRLgTvVu.js → cose-bilkent-JH36ORCC-CRBZWjE4.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BoAVG3cs.js → cynefin-VYW2F7L2-BdXcxtlb.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bzol06hR.js → cynefinDiagram-MW4NZA55-07UKsOts.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-UmliJM77.js → dagre-VZM6K2ZE-D38p9__S.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BWLcJkFo.js → diagram-7IWD3JNH-nSiBNNjH.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CgApOm-O.js → diagram-B4RE2ZJO-BFOTot5D.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Bh3RgTLs.js → diagram-LBJQPF4R-DypKaZ6k.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-Dsh-D5nE.js → diagram-Q27KOJAE-B6yoOQeW.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-Dkbbpcpb.js → diagram-UB23O5K3-BHYS6viX.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-3EBaA3t3.js → ebnfDiagram-BXEA7PRR-D2VD5Snj.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Bq2Dc5ok.js → erDiagram-JOGREHBK-E98mWxN1.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-De7y55ha.js → flowDiagram-UKHOOZJN-CT1zxhqP.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-fLiDbQRh.js → ganttDiagram-PKOTCBZU-6GcngNcy.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Du617mp1.js → gitGraphDiagram-DS77QQ5N-DCL6NvKe.js} +1 -1
- package/dist/worker/console/static/assets/index-24jp3aBh.js +468 -0
- package/dist/worker/console/static/assets/index-CY6y6I8M.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BwmlF-XP.js → infoDiagram-6WML65LV-C0627e-9.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-zsGtS59u.js → ishikawaDiagram-WSZJBQD7-C_u3P6Ik.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DTTPTe3f.js → journeyDiagram-NVQOT4AX-DPDPv9HL.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BkXg9aP-.js → kanban-definition-27J2QSJJ-D3OHdWiO.js} +1 -1
- package/dist/worker/console/static/assets/{linear-7U2ue5IE.js → linear-DI88zD7n.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BUuGHmWO.js → mermaid.core-p2l8IrWY.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-7q6fX0Sv.js → mindmap-definition-FAOFIHXS-B1bAX-oS.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkQPJN55.js → pegDiagram-VL7TDLO6-DwOnWxdD.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CCal_tfX.js → pieDiagram-7S7Q4E2Y-xRkTXnJC.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B7bpxyAZ.js → quadrantDiagram-CIZ2JOQS-_9UC5Zio.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CJmww7D-.js → railroadDiagram-AXF67PYL-CgEUirWJ.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D6ldWJEv.js → requirementDiagram-LRYGKXZP-Czbpwiyz.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-VGh33I9e.js → sankeyDiagram-W5VNT64P-_j7_KiqY.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CCkDrIjU.js → sequenceDiagram-SI44F4Z6-BNJa6Hyi.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-NC8f5Otb.js → sizeCapture-X5ZJPWSS-DedpKtOc.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-_92ezZdF.js → stateDiagram-OKZ733FA-BBvthr7c.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BABVQnfM.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BdUCGtbP.js → swimlanes-SLNWSIFB-Ds7umgEM.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D0qsLaxR.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CdD1H8ia.js → timeline-definition-Z64GVDOM-CKa-p1nF.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-Dkdk3oFo.js → vennDiagram-T6HMQDX7-LoUNR56o.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CKu2uPPY.js → wardleyDiagram-T6FBY63Y-Bqbl6ym7.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-ClqAnuG8.js → xychartDiagram-ELKLHX3M-B8dUg4lf.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/active-turn-tail.js +10 -0
- package/dist/worker/console/static-src/operator-chat/chat-link.js +94 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +122 -10
- package/dist/worker/console/static-src/operator-chat/compaction-message.js +7 -0
- package/dist/worker/console/static-src/operator-chat/pending-user-message.js +27 -15
- package/dist/worker/console/static-src/operator-chat/process-label.js +41 -0
- package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +13 -0
- package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +10 -0
- package/dist/worker/console/static-src/operator-chat/use-searchable-hidden.js +51 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +44 -24
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +18 -2
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +41 -8
- package/dist/worker/console/static-src/operator-chat/useComposer.js +26 -11
- package/dist/worker/console/static-src/operator-chat/user-turn-anchor-equality.js +21 -0
- package/dist/worker/console/workspace-context.js +15 -1
- package/dist/worker/observe/node-transparency.js +81 -72
- package/dist/worker/observe/routes.js +20 -1
- package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
- package/dist/worker/observe/static/dom.js +20 -1
- package/dist/worker/observe/static/format-pool.d.ts +2 -0
- package/dist/worker/observe/static/format-pool.js +6 -0
- package/dist/worker/observe/static/format.js +7 -0
- package/dist/worker/observe/static/inspect-workspace.js +34 -7
- package/dist/worker/observe/static/inspector-submission.js +32 -0
- package/dist/worker/observe/static/kpi.js +1 -0
- package/dist/worker/observe/static/relations.js +2 -0
- package/dist/worker/observe/static/router.js +13 -0
- package/dist/worker/observe/static/run-processing.js +2 -0
- package/dist/worker/observe/static/shell-chrome.js +36 -3
- package/dist/worker/observe/static/state.js +35 -2
- package/dist/worker/observe/static/styles.css +263 -39
- package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
- package/dist/worker/observe/static/task-failure-labels.js +67 -0
- package/dist/worker/observe/static/task-history.js +12 -0
- package/dist/worker/observe/static/views/batch.js +6 -13
- package/dist/worker/observe/static/views/dag-graph.js +50 -3
- package/dist/worker/observe/static/views/dag-inspector.js +742 -268
- package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
- package/dist/worker/observe/static/views/dag.d.ts +6 -0
- package/dist/worker/observe/static/views/dag.js +49 -11
- package/dist/worker/observe/static/views/dags.js +2 -0
- package/dist/worker/observe/static/views/dashboard.js +21 -12
- package/dist/worker/observe/static/views/failures.js +21 -11
- package/dist/worker/observe/static/views/feature.js +11 -29
- package/dist/worker/observe/static/views/pool.js +37 -28
- package/dist/worker/observe/static/views/run.js +48 -5
- package/dist/worker/observe/static/views/session-timeline.js +189 -240
- package/dist/worker/observe/static/views/task.js +81 -62
- package/dist/workflows/dag/budget-enforcement.js +53 -3
- package/dist/workflows/dag/frontend-capacity.js +9 -0
- package/dist/workflows/dag/frontend-design-policy.js +2 -1
- package/dist/workflows/dag/frontend-durable-tools.js +193 -0
- package/dist/workflows/dag/frontend-execution-groups.js +24 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +13 -0
- package/dist/workflows/dag/frontend-input-projection.js +76 -0
- package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
- package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
- package/dist/workflows/dag/frontend-plan-render.js +10 -3
- package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
- package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
- package/dist/workflows/dag/frontend-recovery-run.js +4 -0
- package/dist/workflows/dag/frontend-review-scopes.js +139 -0
- package/dist/workflows/dag/frontend-session-budget.js +249 -0
- package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
- package/dist/workflows/dag/frontend-shape-facts.js +12 -2
- package/dist/workflows/dag/frontend-shape.js +30 -1
- package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
- package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
- package/dist/workflows/dag/init-hybrid.js +16 -10
- package/dist/workflows/dag/node-execution.js +38 -155
- package/dist/workflows/dag/prompt.js +4 -0
- package/dist/workflows/dag/rerun-plan.js +7 -1
- package/dist/workflows/dag/runner.js +26 -1
- package/dist/workflows/dag/types.js +6 -0
- package/docs/operations/README.md +1 -0
- package/docs/templates/frontend-design-contract.md +4 -4
- package/docs/templates/frontend-implementation-contract.schema.json +34 -2
- package/docs/templates/frontend-implementation-dag.json +5 -5
- package/package.json +6 -3
- package/skills/frontend-contract/SKILL.md +2 -1
- package/skills/frontend-contract/references/contract-protocol.md +19 -3
- package/skills/frontend-design-review/SKILL.md +12 -11
- package/skills/frontend-plan/SKILL.md +9 -2
- package/skills/frontend-plan/references/decision-contract.md +18 -5
- package/skills/frontend-review/SKILL.md +10 -11
- package/skills/frontend-scout/references/scout-evidence.md +4 -0
- package/dist/worker/console/static/assets/channel-DAS07MdS.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Cwk-5jiW.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Cwk-5jiW.js +0 -1
- package/dist/worker/console/static/assets/index-C0O48S_P.js +0 -449
- package/dist/worker/console/static/assets/index-CzKf4U8P.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyJg5gzW.js +0 -1
- 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
|
+
}
|