@youweichen/pi-web-ui 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerminalManager — conversation-owned PTY sessions (node-pty) bridged over
|
|
3
|
+
* the WebSocket protocol, plus the user command list persisted in
|
|
4
|
+
* `<workspaceRoot>/.pi/commands.json`.
|
|
5
|
+
*
|
|
6
|
+
* Each conversation gets its own manager; terminals are shared across browser
|
|
7
|
+
* tabs through the session emit. A socket drop does not kill them: the
|
|
8
|
+
* conversation owns their lifecycle and releases them on disposal.
|
|
9
|
+
*
|
|
10
|
+
* Commands file format:
|
|
11
|
+
* { "commands": [ { "name": "dev", "command": "npm run dev", "cwd": "${pwd}" } ] }
|
|
12
|
+
* `${pwd}` inside cwd/command resolves to the agent session's current working
|
|
13
|
+
* directory (the same directory the agent operates in — see set_cwd).
|
|
14
|
+
*/
|
|
15
|
+
import { chmodSync, existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
// MUST run before node-pty is required: rewrites the installed node-pty copies
|
|
21
|
+
// so their worker/agent handlers tolerate Node `--watch`'s IPC traffic (see the
|
|
22
|
+
// module itself for details).
|
|
23
|
+
import "./patch-node-pty.js";
|
|
24
|
+
import { spawn } from "node-pty";
|
|
25
|
+
import { defineTool, } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { Type } from "typebox";
|
|
27
|
+
/** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
|
|
28
|
+
export function commandsFilePath(workspaceRoot) {
|
|
29
|
+
return join(workspaceRoot, ".pi", "commands.json");
|
|
30
|
+
}
|
|
31
|
+
/** Expand ${pwd} (and ~) in a cwd/command string against the session's cwd. */
|
|
32
|
+
export function expandPwd(input, pwd) {
|
|
33
|
+
let out = input.replace(/\$\{pwd\}/g, pwd);
|
|
34
|
+
if (out === "~")
|
|
35
|
+
return homedir();
|
|
36
|
+
if (out.startsWith("~/"))
|
|
37
|
+
out = join(homedir(), out.slice(2));
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Resolve a command's directory: default to the session cwd, expand ${pwd}/~, resolve relative paths. */
|
|
41
|
+
export function resolveCommandCwd(cwd, pwd) {
|
|
42
|
+
if (!cwd || cwd.trim() === "")
|
|
43
|
+
return pwd;
|
|
44
|
+
const expanded = expandPwd(cwd.trim(), pwd);
|
|
45
|
+
return isAbsolute(expanded) ? expanded : resolve(pwd, expanded);
|
|
46
|
+
}
|
|
47
|
+
/** Read the command list; missing file → empty list; malformed → empty list + warning text. */
|
|
48
|
+
export async function loadCommands(workspaceRoot) {
|
|
49
|
+
const path = commandsFilePath(workspaceRoot);
|
|
50
|
+
const { commands, warning } = await readCommandsFile(path);
|
|
51
|
+
return { commands, path, warning };
|
|
52
|
+
}
|
|
53
|
+
async function readCommandsFile(path) {
|
|
54
|
+
if (!existsSync(path))
|
|
55
|
+
return { commands: [] };
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(path, "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
return {
|
|
62
|
+
commands: [],
|
|
63
|
+
warning: `读取命令文件失败:${err.message}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { commands: [], warning: `命令文件不是有效 JSON:${path}` };
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(parsed)) {
|
|
74
|
+
// Tolerate a bare array: [{name, command, cwd}]
|
|
75
|
+
return {
|
|
76
|
+
commands: parsed
|
|
77
|
+
.filter((c) => typeof c === "object" &&
|
|
78
|
+
c !== null &&
|
|
79
|
+
typeof c.name === "string" &&
|
|
80
|
+
typeof c.command === "string")
|
|
81
|
+
.map((c) => ({ name: c.name, command: c.command, cwd: c.cwd })),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const obj = parsed;
|
|
85
|
+
if (obj && Array.isArray(obj.commands)) {
|
|
86
|
+
return {
|
|
87
|
+
commands: obj.commands
|
|
88
|
+
.filter((c) => typeof c === "object" &&
|
|
89
|
+
c !== null &&
|
|
90
|
+
typeof c.name === "string" &&
|
|
91
|
+
typeof c.command === "string")
|
|
92
|
+
.map((c) => ({ name: c.name, command: c.command, cwd: c.cwd })),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { commands: [], warning: `命令文件格式不正确:${path}` };
|
|
96
|
+
}
|
|
97
|
+
/** Persist the command list, creating .pi/ if needed. */
|
|
98
|
+
export async function saveCommandsFile(workspaceRoot, commands) {
|
|
99
|
+
const path = commandsFilePath(workspaceRoot);
|
|
100
|
+
try {
|
|
101
|
+
await mkdir(join(workspaceRoot, ".pi"), { recursive: true });
|
|
102
|
+
const payload = { commands };
|
|
103
|
+
await writeFile(path, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
104
|
+
return { path };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { path, error: `保存命令文件失败:${err.message}` };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const isWindows = process.platform === "win32";
|
|
111
|
+
const MAX_TERMINALS = 16;
|
|
112
|
+
/** Coalescing window for terminal_output WS messages (one animation frame). */
|
|
113
|
+
const OUTPUT_FLUSH_MS = 16;
|
|
114
|
+
const MAX_TERMINAL_HISTORY = 32;
|
|
115
|
+
const MAX_OUTPUT = 200_000;
|
|
116
|
+
const MAX_INPUT = 64 * 1024;
|
|
117
|
+
const MAX_ID = 80;
|
|
118
|
+
/**
|
|
119
|
+
* 终端活力检测阈值:agent 触碰过的终端连续静默这么久且该对话正在运行时,
|
|
120
|
+
* 通过 onAgentIdle 回调通知宿主(宿主注入一条 steer 消息唤醒 AI 去检查)。
|
|
121
|
+
* PI_WEB_TERMINAL_IDLE_MS 覆盖;0 = 关闭检测。每次调用时读取(测试可注入)。
|
|
122
|
+
*/
|
|
123
|
+
export function terminalIdleNotifyMs() {
|
|
124
|
+
const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_MS);
|
|
125
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 15_000;
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// 终端接管 bash(terminal-backed bash tool)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
/** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
|
|
131
|
+
* 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
|
|
132
|
+
const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
133
|
+
/**
|
|
134
|
+
* 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
|
|
135
|
+
*
|
|
136
|
+
* 单行很关键:整行先被 shell 完整解析再执行,命令中途读 stdin 也不会吃掉
|
|
137
|
+
* 后续哨兵;也避开交互 shell 的 bracketed-paste 对多行输入的特殊处理。
|
|
138
|
+
* 多行脚本用 `$'...'` ANSI-C 引号转义后交给 eval(bash/zsh/busybox ash 都支持)。
|
|
139
|
+
*/
|
|
140
|
+
export function buildTerminalBashLine(command) {
|
|
141
|
+
const trimmed = command.replace(/\s+$/, "");
|
|
142
|
+
let body = trimmed;
|
|
143
|
+
if (trimmed.includes("\n")) {
|
|
144
|
+
body = `eval $'${trimmed
|
|
145
|
+
.replace(/\\/g, "\\\\")
|
|
146
|
+
.replace(/'/g, "\\'")
|
|
147
|
+
.replace(/\r/g, "\\r")
|
|
148
|
+
.replace(/\n/g, "\\n")
|
|
149
|
+
.replace(/\t/g, "\\t")}'`;
|
|
150
|
+
}
|
|
151
|
+
return `${body}; __pi_rc=$?; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
|
|
152
|
+
}
|
|
153
|
+
/** 去掉 ANSI 转义序列(OSC/CSI/其余 ESC 序列)与孤立 CR(进度条重绘),
|
|
154
|
+
* 让 PTY 回显变成 bash 工具风格的纯文本。 */
|
|
155
|
+
export function stripAnsi(s) {
|
|
156
|
+
return s
|
|
157
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC(标题/超链接等)
|
|
158
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI(颜色/光标/清屏等)
|
|
159
|
+
.replace(/\x1b[@-_]/g, "") // 其余单字符 ESC 序列
|
|
160
|
+
.replace(/\r(?!\n)/g, ""); // 孤立 CR(进度条原地重绘)
|
|
161
|
+
}
|
|
162
|
+
/** 截断过长的工具结果:保留头尾,中间省略。 */
|
|
163
|
+
function truncateMiddle(text, max = 30_000) {
|
|
164
|
+
if (text.length <= max)
|
|
165
|
+
return text;
|
|
166
|
+
const head = Math.floor(max * 0.3);
|
|
167
|
+
const tail = max - head;
|
|
168
|
+
return `${text.slice(0, head)}\n…(中间省略 ${text.length - max} 字符)…\n${text.slice(-tail)}`;
|
|
169
|
+
}
|
|
170
|
+
/** `-i` makes bash interactive; cmd.exe / powershell.exe are interactive on their own. */
|
|
171
|
+
function bashArgs(shell) {
|
|
172
|
+
return /[\\/]bash(\.exe)?$/i.test(shell) ? ["-i"] : [];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Interactive shell for PTYs.
|
|
176
|
+
* - Windows: prefer bash — it matches the SDK's bash tool, so the agent and
|
|
177
|
+
* the terminal speak the same shell language (no more PowerShell/bash
|
|
178
|
+
* 混用 that leaves heredocs / `&&` / `<<` hanging or erroring). Order:
|
|
179
|
+
* 1. PI_WEB_SHELL (explicit override)
|
|
180
|
+
* 2. $SHELL when it exists on disk (user launched from a Git Bash session)
|
|
181
|
+
* 3. Git Bash install paths (ProgramFiles / ProgramFiles(x86))
|
|
182
|
+
* 4. busybox-w32 fallback in <home>/.pi-web/bin/bash.exe (ensure-bash.ts
|
|
183
|
+
* downloads it automatically when 2–3 are absent)
|
|
184
|
+
* 5. $COMSPEC (cmd.exe — always set)
|
|
185
|
+
* 6. powershell.exe (last resort)
|
|
186
|
+
* - POSIX: the user's login shell, falling back to bash.
|
|
187
|
+
* Resolved per terminal spawn (not at module load) so a busybox download that
|
|
188
|
+
* finishes after startup is picked up by the next terminal.
|
|
189
|
+
*/
|
|
190
|
+
function resolveShell() {
|
|
191
|
+
if (isWindows) {
|
|
192
|
+
const explicit = process.env.PI_WEB_SHELL;
|
|
193
|
+
if (explicit)
|
|
194
|
+
return { shell: explicit, args: bashArgs(explicit) };
|
|
195
|
+
const she = process.env.SHELL;
|
|
196
|
+
if (she && existsSync(she))
|
|
197
|
+
return { shell: she, args: bashArgs(she) };
|
|
198
|
+
const pf = process.env.ProgramFiles;
|
|
199
|
+
const pf86 = process.env["ProgramFiles(x86)"];
|
|
200
|
+
for (const cand of [
|
|
201
|
+
pf ? join(pf, "Git", "bin", "bash.exe") : "",
|
|
202
|
+
pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
|
|
203
|
+
]) {
|
|
204
|
+
if (cand && existsSync(cand))
|
|
205
|
+
return { shell: cand, args: ["-i"] };
|
|
206
|
+
}
|
|
207
|
+
const busybox = join(homedir(), ".pi-web", "bin", "bash.exe");
|
|
208
|
+
if (existsSync(busybox))
|
|
209
|
+
return { shell: busybox, args: ["-i"] };
|
|
210
|
+
return { shell: process.env.COMSPEC || "powershell.exe", args: [] };
|
|
211
|
+
}
|
|
212
|
+
return { shell: process.env.SHELL || "bash", args: ["-i"] };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Shell for the terminal-backed bash tool ('ai-bash'): ALWAYS bash, never the
|
|
216
|
+
* user's login shell (often zsh on macOS, whose `read -p` etc. diverge from
|
|
217
|
+
* the bash semantics models write). Windows already prefers Git Bash/busybox
|
|
218
|
+
* bash; on posix pick $SHELL when it is bash, else plain `bash`.
|
|
219
|
+
*/
|
|
220
|
+
function resolveBashShell() {
|
|
221
|
+
if (isWindows) {
|
|
222
|
+
const pf = process.env.ProgramFiles;
|
|
223
|
+
const pf86 = process.env["ProgramFiles(x86)"];
|
|
224
|
+
for (const cand of [
|
|
225
|
+
pf ? join(pf, "Git", "bin", "bash.exe") : "",
|
|
226
|
+
pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
|
|
227
|
+
]) {
|
|
228
|
+
if (cand && existsSync(cand))
|
|
229
|
+
return { shell: cand, args: ["-i"] };
|
|
230
|
+
}
|
|
231
|
+
const busybox = join(homedir(), ".pi-web", "bin", "bash.exe");
|
|
232
|
+
if (existsSync(busybox))
|
|
233
|
+
return { shell: busybox, args: ["-i"] };
|
|
234
|
+
}
|
|
235
|
+
const she = process.env.SHELL;
|
|
236
|
+
if (she && she.endsWith("bash") && existsSync(she)) {
|
|
237
|
+
return { shell: she, args: ["-i"] };
|
|
238
|
+
}
|
|
239
|
+
return { shell: "bash", args: ["-i"] };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Environment for spawned shells. System services (launchd/systemd) run with
|
|
243
|
+
* no locale variables, which puts the shell in the C locale: its line editor
|
|
244
|
+
* then renders UTF-8 continuation bytes 0x80–0x9F as C1 control characters
|
|
245
|
+
* (e.g. `�<0091><0098>` for 员), garbling Chinese input in the terminal.
|
|
246
|
+
* Default a UTF-8 locale so multibyte text round-trips.
|
|
247
|
+
*/
|
|
248
|
+
function shellEnv() {
|
|
249
|
+
const env = {
|
|
250
|
+
...process.env,
|
|
251
|
+
TERM: "xterm-256color",
|
|
252
|
+
};
|
|
253
|
+
if (!env.LANG && !env.LC_ALL)
|
|
254
|
+
env.LANG = "en_US.UTF-8";
|
|
255
|
+
return env;
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// node-pty ConoutConnection warning noise (Node --watch)
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// node-pty's ConoutConnection warns about every unknown message from its ConPTY
|
|
261
|
+
// worker thread. Under `node --watch` (the dev server: `node --watch --import
|
|
262
|
+
// tsx`), Node's watch mode pushes `watch:require` / `watch:import` messages over
|
|
263
|
+
// the worker's message channel to track module dependencies; node-pty doesn't
|
|
264
|
+
// recognize them and logs one `Unexpected ConoutWorkerMessage { … }` per message
|
|
265
|
+
// — hundreds of lines per terminal (the SCM panel's hidden query PTY triggers it
|
|
266
|
+
// on every git-view open). The messages are harmless: the worker only ever sends
|
|
267
|
+
// its READY sentinel, which the handler does process. Filter that exact warning
|
|
268
|
+
// at the console boundary so dev output stays readable. Production runs without
|
|
269
|
+
// --watch and never produces these.
|
|
270
|
+
const originalWarn = console.warn.bind(console);
|
|
271
|
+
console.warn = (...args) => {
|
|
272
|
+
if (args[0] === "Unexpected ConoutWorkerMessage")
|
|
273
|
+
return;
|
|
274
|
+
originalWarn(...args);
|
|
275
|
+
};
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// spawn-helper permission repair (node-pty macOS prebuilds)
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// node-pty 1.1.0 publishes its macOS prebuilds with `spawn-helper` lacking the
|
|
280
|
+
// execute bit (mode 0644 in the npm tarball), so posix_spawn fails with EACCES
|
|
281
|
+
// and node-pty throws the generic "posix_spawnp failed". Locally-built
|
|
282
|
+
// copies (build/Release) are fine; every `npm install` that picks the prebuild
|
|
283
|
+
// — e.g. `npm i -g pi-web-ui`, which is what system-service installs run — is
|
|
284
|
+
// broken until the bit is restored. Self-heal at startup AND lazily before
|
|
285
|
+
// every spawn (an `npm i -g` while the server is running replaces the helper
|
|
286
|
+
// under the running process, so the startup-only repair misses it).
|
|
287
|
+
// Best-effort: a read-only node_modules just keeps the old failure.
|
|
288
|
+
const require = createRequire(import.meta.url);
|
|
289
|
+
/** Absolute paths of every node-pty spawn-helper this install can exec. */
|
|
290
|
+
function spawnHelperPaths() {
|
|
291
|
+
try {
|
|
292
|
+
// require.resolve("node-pty") → <pkg>/lib/index.js → package root is two up.
|
|
293
|
+
const pkgDir = dirname(dirname(require.resolve("node-pty")));
|
|
294
|
+
const out = [];
|
|
295
|
+
const built = join(pkgDir, "build", "Release", "spawn-helper");
|
|
296
|
+
if (existsSync(built))
|
|
297
|
+
out.push(built);
|
|
298
|
+
const prebuildsDir = join(pkgDir, "prebuilds");
|
|
299
|
+
if (existsSync(prebuildsDir)) {
|
|
300
|
+
for (const entry of readdirSync(prebuildsDir)) {
|
|
301
|
+
const p = join(prebuildsDir, entry, "spawn-helper");
|
|
302
|
+
if (existsSync(p))
|
|
303
|
+
out.push(p);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Restore the +x bit on node-pty's spawn-helper binaries (idempotent). */
|
|
313
|
+
function repairSpawnHelperPermissions() {
|
|
314
|
+
if (isWindows)
|
|
315
|
+
return;
|
|
316
|
+
for (const p of spawnHelperPaths()) {
|
|
317
|
+
try {
|
|
318
|
+
if ((statSync(p).mode & 0o111) === 0)
|
|
319
|
+
chmodSync(p, 0o755);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// best-effort; a read-only node_modules just keeps the old failure
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
repairSpawnHelperPermissions();
|
|
327
|
+
/** Path of a still-broken helper, for the error hint ("" when none). */
|
|
328
|
+
function brokenSpawnHelper() {
|
|
329
|
+
if (isWindows)
|
|
330
|
+
return "";
|
|
331
|
+
for (const p of spawnHelperPaths()) {
|
|
332
|
+
try {
|
|
333
|
+
if ((statSync(p).mode & 0o111) === 0)
|
|
334
|
+
return p;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// ignore
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return "";
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// macOS TCC camera/mic warning (launchd-spawned servers)
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// TCC attributes camera/mic access to the process chain's "responsible
|
|
346
|
+
// process". When pi-web-ui runs as a launchd LaunchAgent (node ← launchd),
|
|
347
|
+
// the responsible process is node itself — a bare CLI binary with no app
|
|
348
|
+
// bundle / Info.plist / NSCameraUsageDescription — so TCC silently denies
|
|
349
|
+
// camera access (no prompt, nothing to tick in System Settings) and
|
|
350
|
+
// ffmpeg-style grabbers hang on frame capture. The identical command works
|
|
351
|
+
// from a terminal app that already holds the camera grant. Detect the
|
|
352
|
+
// "no GUI ancestor" case (ppid === 1 on macOS) and warn in the terminal.
|
|
353
|
+
const TCC_HINT = [
|
|
354
|
+
"\x1b[33m[提示] 本终端由后台服务(launchd)启动,macOS 隐私权限(相机/麦克风/屏幕录制等)对此类进程不可用。\x1b[0m",
|
|
355
|
+
"\x1b[90m · 需要隐私权限的命令会被系统静默拒绝:不弹授权窗,系统设置里也无法勾选,表现多为卡死或无输出。",
|
|
356
|
+
" · 这类任务请在你自己已授权的前台终端里运行。",
|
|
357
|
+
" · 本终端内可运行不需要隐私权限的命令(如文件处理、网络请求、远程设备流)。",
|
|
358
|
+
" · 若改在前台终端里运行 pi-web-ui,本提示即不再出现。\x1b[0m",
|
|
359
|
+
].join("\r\n") + "\r\n";
|
|
360
|
+
/** True when this server was spawned by launchd (or orphaned) on macOS — no GUI app in the ancestry, so camera/mic TCC grants are unavailable. */
|
|
361
|
+
function launchdSpawnedOnMac() {
|
|
362
|
+
return process.platform === "darwin" && process.ppid === 1;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Translate a logical key (named key or single character) plus modifiers into
|
|
366
|
+
* the exact byte sequence a PTY expects. Named keys are routed by NAME, so a
|
|
367
|
+
* Ctrl/Alt combo is NEVER derived from the key's first letter: Ctrl+ArrowUp
|
|
368
|
+
* must produce `ESC[1;5A`, not Ctrl+A, and Ctrl+Enter `ESC[13;5u`, not Ctrl+E.
|
|
369
|
+
* - arrows / F1–F4 / Home / End keep their plain form when unmodified and
|
|
370
|
+
* gain the xterm modifier parameter (`ESC[1;<m>X`) under Shift/Alt/Ctrl;
|
|
371
|
+
* - other named keys (Enter/Tab/Backspace/Escape/Insert/Delete/PageUp/PageDown)
|
|
372
|
+
* fall back to the CSI-u form (`ESC[<code>;<m>u`) once modified;
|
|
373
|
+
* - plain characters: Ctrl maps A–Z to 0x01–0x1A (error for non-letters),
|
|
374
|
+
* Shift uppercases, Alt prefixes with ESC.
|
|
375
|
+
*/
|
|
376
|
+
export function encodeTerminalKey(key, modifiers = {}) {
|
|
377
|
+
const named = {
|
|
378
|
+
Enter: "\r", Return: "\r", Tab: "\t", Backspace: "\x7f", Escape: "\x1b",
|
|
379
|
+
Up: "\x1b[A", ArrowUp: "\x1b[A", Down: "\x1b[B", ArrowDown: "\x1b[B",
|
|
380
|
+
Left: "\x1b[D", ArrowLeft: "\x1b[D", Right: "\x1b[C", ArrowRight: "\x1b[C",
|
|
381
|
+
Home: "\x1b[H", End: "\x1b[F", Delete: "\x1b[3~", Insert: "\x1b[2~",
|
|
382
|
+
PageUp: "\x1b[5~", PageDown: "\x1b[6~", F1: "\x1bOP", F2: "\x1bOQ", F3: "\x1bOR", F4: "\x1bOS",
|
|
383
|
+
};
|
|
384
|
+
let data = named[key] ?? (key.length === 1 ? key : "");
|
|
385
|
+
if (!data)
|
|
386
|
+
return { error: `不支持的终端按键:${key}` };
|
|
387
|
+
// xterm modifier encoding: 1=plain, 2=Shift, 3=Alt, 5=Ctrl,
|
|
388
|
+
// 6=Ctrl+Shift, 7=Ctrl+Alt, 8=Ctrl+Alt+Shift.
|
|
389
|
+
const modifier = 1 + (modifiers.shift ? 1 : 0) + (modifiers.alt ? 2 : 0) + (modifiers.ctrl ? 4 : 0);
|
|
390
|
+
const arrow = /^\x1b\[([A-DHF])$/.exec(data);
|
|
391
|
+
const functionKey = /^\x1bO([P-S])$/.exec(data);
|
|
392
|
+
const namedCode = {
|
|
393
|
+
Enter: 13, Return: 13, Tab: 9, Backspace: 127, Escape: 27,
|
|
394
|
+
Insert: 2, Delete: 3, Home: 1, End: 4, PageUp: 5, PageDown: 6,
|
|
395
|
+
};
|
|
396
|
+
if (arrow && modifier !== 1) {
|
|
397
|
+
data = `\x1b[1;${modifier}${arrow[1]}`;
|
|
398
|
+
}
|
|
399
|
+
else if (functionKey && modifier !== 1) {
|
|
400
|
+
data = `\x1b[1;${modifier}${functionKey[1]}`;
|
|
401
|
+
}
|
|
402
|
+
else if (namedCode[key] !== undefined && modifier !== 1) {
|
|
403
|
+
// CSI-u keeps named keys identifiable. In particular, Ctrl+Enter and
|
|
404
|
+
// Ctrl+Tab must not be derived from the first letter of "Enter"/"Tab".
|
|
405
|
+
data = `\x1b[${namedCode[key]};${modifier}u`;
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
if (modifiers.ctrl) {
|
|
409
|
+
if (key.length !== 1)
|
|
410
|
+
return { error: `Ctrl 组合键无效:${key}` };
|
|
411
|
+
const code = key.toUpperCase().charCodeAt(0);
|
|
412
|
+
if (code >= 64 && code <= 95)
|
|
413
|
+
data = String.fromCharCode(code - 64);
|
|
414
|
+
else
|
|
415
|
+
return { error: `Ctrl 组合键无效:${key}` };
|
|
416
|
+
}
|
|
417
|
+
else if (modifiers.shift && key.length === 1) {
|
|
418
|
+
data = key.toUpperCase();
|
|
419
|
+
}
|
|
420
|
+
if (modifiers.alt)
|
|
421
|
+
data = "\x1b" + data;
|
|
422
|
+
}
|
|
423
|
+
return { data };
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Owns one or more PTYs for a conversation. All output is forwarded as
|
|
427
|
+
* `terminal_output` messages through the provided emit (broadcast to every
|
|
428
|
+
* socket attached to the client session). Failed spawns emit an error notice and
|
|
429
|
+
* terminal_exit instead of throwing into the WebSocket dispatcher.
|
|
430
|
+
*/
|
|
431
|
+
export class TerminalManager {
|
|
432
|
+
emit;
|
|
433
|
+
workspaceRoot;
|
|
434
|
+
/** Live PTYs only. Exited entries move to history so they no longer consume
|
|
435
|
+
* the live-terminal limit while their output remains readable/replayable. */
|
|
436
|
+
terms = new Map();
|
|
437
|
+
history = new Map();
|
|
438
|
+
seq = 0;
|
|
439
|
+
tccHintShown = false;
|
|
440
|
+
/** 宿主回调:AI 触碰过的终端静默 ≥ 阈值时触发(一次性/纪元语义见
|
|
441
|
+
* noteAgentActivity)。宿主自行判断会话是否在运行并决定是否注入。 */
|
|
442
|
+
onAgentIdle = null;
|
|
443
|
+
constructor(emit, workspaceRoot) {
|
|
444
|
+
this.emit = emit;
|
|
445
|
+
this.workspaceRoot = workspaceRoot;
|
|
446
|
+
}
|
|
447
|
+
/** Start a plain interactive shell in the given directory. */
|
|
448
|
+
create(id, cwd, cols, rows, fallbackCwd, title, opts) {
|
|
449
|
+
const valid = this.validateId(id);
|
|
450
|
+
if (valid) {
|
|
451
|
+
this.fail(id, valid);
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (this.terms.has(id))
|
|
455
|
+
return this.info(this.terms.get(id));
|
|
456
|
+
// Every spawn path shares the same admission rule (ensureSpawnAllowed):
|
|
457
|
+
// a NEW live PTY needs a free slot under the cap. Reusing an exited name
|
|
458
|
+
// starts a fresh PTY and discards its old history — but only after the
|
|
459
|
+
// slot check, so a rejected request keeps its retained output.
|
|
460
|
+
if (!this.ensureSpawnAllowed(id))
|
|
461
|
+
return null;
|
|
462
|
+
this.history.delete(id);
|
|
463
|
+
const safeCwd = this.safeCwd(cwd || fallbackCwd);
|
|
464
|
+
if (!safeCwd) {
|
|
465
|
+
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash)) {
|
|
469
|
+
this.maybeEmitTccHint(id);
|
|
470
|
+
this.emitList();
|
|
471
|
+
return this.info(this.terms.get(id));
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
/** Warn about unavailable camera/mic TCC grants in a fresh terminal, once per client. */
|
|
476
|
+
maybeEmitTccHint(id) {
|
|
477
|
+
if (this.tccHintShown || !launchdSpawnedOnMac())
|
|
478
|
+
return;
|
|
479
|
+
this.tccHintShown = true;
|
|
480
|
+
this.writeOut(id, TCC_HINT);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Start a shell in the command's directory and run the command in it.
|
|
484
|
+
*
|
|
485
|
+
* If a terminal with this id already exists it is RESTARTED in place: the
|
|
486
|
+
* running process is killed and a fresh shell runs the command again in the
|
|
487
|
+
* same terminal (used when re-running a command by clicking its entry).
|
|
488
|
+
*/
|
|
489
|
+
runCommand(id, def, cols, rows, pwd) {
|
|
490
|
+
const invalidId = this.validateId(id);
|
|
491
|
+
if (invalidId) {
|
|
492
|
+
this.fail(id, invalidId);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const existing = this.terms.get(id);
|
|
496
|
+
// Same admission rule as create(): a live terminal may be restarted in
|
|
497
|
+
// place, but a NEW live PTY needs a free slot — an id sitting in history
|
|
498
|
+
// (exited) does NOT grant one, or re-running exited terminals while at
|
|
499
|
+
// the cap could push the live count past MAX_TERMINALS.
|
|
500
|
+
if (!existing && !this.ensureSpawnAllowed(id))
|
|
501
|
+
return;
|
|
502
|
+
const hasHistory = this.history.has(id);
|
|
503
|
+
const rawDir = resolveCommandCwd(def.cwd, pwd);
|
|
504
|
+
const dir = this.safeCwd(rawDir);
|
|
505
|
+
const command = expandPwd(def.command.trim(), pwd);
|
|
506
|
+
const title = def.name || command || `终端 ${++this.seq}`;
|
|
507
|
+
if (!dir) {
|
|
508
|
+
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (existing) {
|
|
512
|
+
// Re-run in place: interrupt the current process (kill the PTY's
|
|
513
|
+
// process group) and start a fresh shell with the same id. Keep the
|
|
514
|
+
// last known size so the replacement matches the xterm's dimensions.
|
|
515
|
+
if (!existing.exited) {
|
|
516
|
+
this.flushPending(existing);
|
|
517
|
+
existing.exited = true;
|
|
518
|
+
try {
|
|
519
|
+
existing.pty.kill();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
// already dead
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
cols = existing.cols || cols;
|
|
526
|
+
rows = existing.rows || rows;
|
|
527
|
+
this.terms.delete(id);
|
|
528
|
+
}
|
|
529
|
+
this.history.delete(id);
|
|
530
|
+
const ok = this.spawnShell(id, dir, cols, rows, title, def);
|
|
531
|
+
if (!ok)
|
|
532
|
+
return;
|
|
533
|
+
this.emitList();
|
|
534
|
+
// Clear the previous run's output, then show a banner and run the command
|
|
535
|
+
// (the PTY input buffer holds it until the shell is ready).
|
|
536
|
+
const banner = "\x1b[2J\x1b[3J\x1b[H" +
|
|
537
|
+
`\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
|
|
538
|
+
const fresh = this.terms.get(id);
|
|
539
|
+
if (fresh)
|
|
540
|
+
this.appendOutput(fresh, banner);
|
|
541
|
+
this.writeOut(id, banner);
|
|
542
|
+
this.maybeEmitTccHint(id);
|
|
543
|
+
if (command)
|
|
544
|
+
this.input(id, command + "\r");
|
|
545
|
+
}
|
|
546
|
+
/** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
|
|
547
|
+
spawnShell(id, cwd, cols, rows, title, command, forceBash) {
|
|
548
|
+
let abs = cwd;
|
|
549
|
+
if (!abs)
|
|
550
|
+
abs = homedir();
|
|
551
|
+
else if (!isAbsolute(abs))
|
|
552
|
+
abs = resolve(abs);
|
|
553
|
+
try {
|
|
554
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
555
|
+
this.fail(id, `目录不存在或不是目录:${abs}`);
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
this.fail(id, `无法访问终端目录:${abs}`);
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
// node-pty's spawn-helper may have lost its +x bit since the last repair
|
|
564
|
+
// (e.g. a global npm install replaced the helper while this server runs).
|
|
565
|
+
repairSpawnHelperPermissions();
|
|
566
|
+
let pty;
|
|
567
|
+
try {
|
|
568
|
+
const { shell, args } = forceBash ? resolveBashShell() : resolveShell();
|
|
569
|
+
pty = spawn(shell, args, {
|
|
570
|
+
name: "xterm-256color",
|
|
571
|
+
cols: Math.max(2, Math.floor(cols) || 80),
|
|
572
|
+
rows: Math.max(2, Math.floor(rows) || 24),
|
|
573
|
+
cwd: abs,
|
|
574
|
+
env: shellEnv(),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const helper = brokenSpawnHelper();
|
|
579
|
+
this.fail(id, helper
|
|
580
|
+
? `启动终端失败:${err.message}(node-pty 的 spawn-helper 缺少执行权限,请运行:chmod +x "${helper}")`
|
|
581
|
+
: `启动终端失败:${err.message}`);
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
const entry = {
|
|
585
|
+
id,
|
|
586
|
+
pty,
|
|
587
|
+
title,
|
|
588
|
+
cwd: abs,
|
|
589
|
+
cols: Math.max(2, Math.floor(cols) || 80),
|
|
590
|
+
rows: Math.max(2, Math.floor(rows) || 24),
|
|
591
|
+
exited: false,
|
|
592
|
+
exitCode: null,
|
|
593
|
+
command,
|
|
594
|
+
output: "",
|
|
595
|
+
outputOffset: 0,
|
|
596
|
+
waiters: new Set(),
|
|
597
|
+
pendingOut: "",
|
|
598
|
+
flushTimer: null,
|
|
599
|
+
agentTouched: false,
|
|
600
|
+
lastActivityAt: Date.now(),
|
|
601
|
+
idleTimer: null,
|
|
602
|
+
watches: [],
|
|
603
|
+
};
|
|
604
|
+
this.terms.set(id, entry);
|
|
605
|
+
// The closures capture `entry`: after a restart the map points at the
|
|
606
|
+
// replacement, so a late event from the OLD pty must be ignored.
|
|
607
|
+
pty.onData((data) => {
|
|
608
|
+
if (this.terms.get(id) !== entry)
|
|
609
|
+
return;
|
|
610
|
+
this.appendOutput(entry, data);
|
|
611
|
+
// Coalesce instead of emitting per chunk: node-pty onData fires per
|
|
612
|
+
// ConPTY read buffer (dozens of bytes to a few KB each), so a build or
|
|
613
|
+
// `cat` of a big file used to produce hundreds/thousands of WS frames
|
|
614
|
+
// per second — each paying stringify + frame + parse + dispatch cost.
|
|
615
|
+
// A one-frame micro-batch cuts the frame rate 10-50× at ≤16ms latency
|
|
616
|
+
// (imperceptible; xterm writes batch data more efficiently anyway).
|
|
617
|
+
this.queueOut(entry, data);
|
|
618
|
+
});
|
|
619
|
+
pty.onExit(({ exitCode }) => {
|
|
620
|
+
if (this.terms.get(id) !== entry)
|
|
621
|
+
return;
|
|
622
|
+
this.exit(id, exitCode);
|
|
623
|
+
});
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* 记录一次 agent 工具触碰并启动一个新的静默纪元(terminal_create /
|
|
628
|
+
* terminal_input / terminal_key 的工具包装层调用——浏览器路径绝不调用,
|
|
629
|
+
* 用户自己开的终端永远不会收到静默提醒)。
|
|
630
|
+
*
|
|
631
|
+
* 纪元语义(防骚扰):agentTouched 同时是「纪元武装」标志。看门狗触发
|
|
632
|
+
* 一次后即解除武装,之后无论静默多久都不再提醒,直到 agent 再次触碰
|
|
633
|
+
* (再发输入 = AI 又在等结果了)。纪元内的每一段输出都重置倒计时。
|
|
634
|
+
*/
|
|
635
|
+
noteAgentActivity(id) {
|
|
636
|
+
const entry = this.terms.get(id);
|
|
637
|
+
if (!entry || entry.exited)
|
|
638
|
+
return;
|
|
639
|
+
entry.agentTouched = true;
|
|
640
|
+
entry.lastActivityAt = Date.now();
|
|
641
|
+
this.armIdleWatch(entry);
|
|
642
|
+
}
|
|
643
|
+
/** 武装(或按当前 lastActivityAt 重置)静默看门狗。 */
|
|
644
|
+
armIdleWatch(entry) {
|
|
645
|
+
if (entry.idleTimer) {
|
|
646
|
+
clearTimeout(entry.idleTimer);
|
|
647
|
+
entry.idleTimer = null;
|
|
648
|
+
}
|
|
649
|
+
const idleMs = terminalIdleNotifyMs();
|
|
650
|
+
if (!entry.agentTouched || idleMs <= 0)
|
|
651
|
+
return;
|
|
652
|
+
const delay = Math.max(0, idleMs - (Date.now() - entry.lastActivityAt));
|
|
653
|
+
entry.idleTimer = setTimeout(() => {
|
|
654
|
+
entry.idleTimer = null;
|
|
655
|
+
// 原地重启/退出后旧 entry 的事件必须忽略(与 onData/onExit 同款守卫)。
|
|
656
|
+
if (this.terms.get(entry.id) !== entry || entry.exited)
|
|
657
|
+
return;
|
|
658
|
+
// 一次性:触发后解除武装,直到下次 agent 触碰。
|
|
659
|
+
entry.agentTouched = false;
|
|
660
|
+
this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title);
|
|
661
|
+
}, delay);
|
|
662
|
+
entry.idleTimer.unref?.();
|
|
663
|
+
}
|
|
664
|
+
/** Emit output immediately, bypassing the coalescing window (rare paths:
|
|
665
|
+
* one-shot hints/banners — not per-chunk data). */
|
|
666
|
+
writeOut(id, data) {
|
|
667
|
+
this.emit({ type: "terminal_output", terminalId: id, data });
|
|
668
|
+
}
|
|
669
|
+
/** Queue output for the coalescing window; flushes via flushPending. */
|
|
670
|
+
queueOut(entry, data) {
|
|
671
|
+
entry.pendingOut += data;
|
|
672
|
+
if (entry.flushTimer)
|
|
673
|
+
return;
|
|
674
|
+
entry.flushTimer = setTimeout(() => {
|
|
675
|
+
entry.flushTimer = null;
|
|
676
|
+
this.flushPending(entry);
|
|
677
|
+
}, OUTPUT_FLUSH_MS);
|
|
678
|
+
}
|
|
679
|
+
/** Emit everything queued for this terminal (no-op when nothing pending). */
|
|
680
|
+
flushPending(entry) {
|
|
681
|
+
if (entry.flushTimer) {
|
|
682
|
+
clearTimeout(entry.flushTimer);
|
|
683
|
+
entry.flushTimer = null;
|
|
684
|
+
}
|
|
685
|
+
if (!entry.pendingOut)
|
|
686
|
+
return;
|
|
687
|
+
const pending = entry.pendingOut;
|
|
688
|
+
entry.pendingOut = "";
|
|
689
|
+
this.emit({ type: "terminal_output", terminalId: entry.id, data: pending });
|
|
690
|
+
}
|
|
691
|
+
appendOutput(entry, data) {
|
|
692
|
+
entry.output += data;
|
|
693
|
+
if (entry.output.length > MAX_OUTPUT) {
|
|
694
|
+
const drop = entry.output.length - MAX_OUTPUT;
|
|
695
|
+
entry.output = entry.output.slice(drop);
|
|
696
|
+
entry.outputOffset += drop;
|
|
697
|
+
}
|
|
698
|
+
for (const wake of entry.waiters)
|
|
699
|
+
wake();
|
|
700
|
+
entry.waiters.clear();
|
|
701
|
+
// 纪元内的输出重置静默倒计时。
|
|
702
|
+
entry.lastActivityAt = Date.now();
|
|
703
|
+
if (entry.idleTimer)
|
|
704
|
+
this.armIdleWatch(entry);
|
|
705
|
+
// 输出观察器:累积匹配,命中一次即移除(终端接管 bash 的完成检测)。
|
|
706
|
+
if (entry.watches.length > 0) {
|
|
707
|
+
const remaining = [];
|
|
708
|
+
const hits = [];
|
|
709
|
+
for (const w of entry.watches) {
|
|
710
|
+
w.buf += data;
|
|
711
|
+
if (w.buf.length > 64_000)
|
|
712
|
+
w.buf = w.buf.slice(-32_000);
|
|
713
|
+
w.re.lastIndex = 0;
|
|
714
|
+
const m = w.re.exec(w.buf);
|
|
715
|
+
if (m)
|
|
716
|
+
hits.push({ w, m }); // 命中 → 移出(cb 在下面统一触发)
|
|
717
|
+
else
|
|
718
|
+
remaining.push(w);
|
|
719
|
+
}
|
|
720
|
+
entry.watches = remaining;
|
|
721
|
+
for (const { w, m } of hits)
|
|
722
|
+
w.cb(m);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
validateId(id) {
|
|
726
|
+
if (!id || id.length > MAX_ID || !/^[A-Za-z0-9._:-]+$/.test(id)) {
|
|
727
|
+
return "终端名称无效:只能使用字母、数字、.-、_ 或 :(最长 80 字符)";
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Admission control for EVERY spawn path (create / runCommand): spawning a
|
|
733
|
+
* NEW live PTY is only allowed while the live count is below MAX_TERMINALS.
|
|
734
|
+
* Restarting an id that is ALREADY live is always allowed (no extra slot).
|
|
735
|
+
* History entries (exited terminals) do not reserve a slot — re-spawning
|
|
736
|
+
* one while at the cap is rejected with the standard error feedback.
|
|
737
|
+
*/
|
|
738
|
+
ensureSpawnAllowed(id) {
|
|
739
|
+
if (this.terms.has(id))
|
|
740
|
+
return true;
|
|
741
|
+
if (this.terms.size >= MAX_TERMINALS) {
|
|
742
|
+
this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`);
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
safeCwd(raw) {
|
|
748
|
+
try {
|
|
749
|
+
const root = realpathSync(resolve(this.workspaceRoot));
|
|
750
|
+
const candidate = realpathSync(isAbsolute(raw) ? resolve(raw) : resolve(root, raw));
|
|
751
|
+
const rel = relative(root, candidate);
|
|
752
|
+
if (rel === "" || (!rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel))) {
|
|
753
|
+
return candidate;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// Missing directories and broken symlinks are rejected by the boundary.
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
info(entry) {
|
|
762
|
+
return {
|
|
763
|
+
id: entry.id,
|
|
764
|
+
title: entry.title,
|
|
765
|
+
cwd: entry.cwd,
|
|
766
|
+
cols: entry.cols,
|
|
767
|
+
rows: entry.rows,
|
|
768
|
+
running: !entry.exited,
|
|
769
|
+
exitCode: entry.exitCode,
|
|
770
|
+
command: entry.command,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
has(id) {
|
|
774
|
+
return this.terms.has(id) || this.history.has(id);
|
|
775
|
+
}
|
|
776
|
+
find(id) {
|
|
777
|
+
return this.terms.get(id) ?? this.history.get(id);
|
|
778
|
+
}
|
|
779
|
+
list() {
|
|
780
|
+
return [...this.terms.values(), ...this.history.values()].map((entry) => this.info(entry));
|
|
781
|
+
}
|
|
782
|
+
emitList() {
|
|
783
|
+
this.emit({ type: "terminal_list", terminals: this.list() });
|
|
784
|
+
}
|
|
785
|
+
/** Replay the retained output window after switching back to this conversation. */
|
|
786
|
+
replay() {
|
|
787
|
+
return [...this.terms.values(), ...this.history.values()]
|
|
788
|
+
.filter((entry) => entry.output.length > 0)
|
|
789
|
+
.map((entry) => ({ terminalId: entry.id, data: entry.output }));
|
|
790
|
+
}
|
|
791
|
+
/** Read output after an absolute cursor. */
|
|
792
|
+
read(id, cursor = 0, maxBytes = 20_000) {
|
|
793
|
+
const entry = this.find(id);
|
|
794
|
+
if (!entry)
|
|
795
|
+
return null;
|
|
796
|
+
const start = Math.max(entry.outputOffset, Math.min(cursor, entry.outputOffset + entry.output.length));
|
|
797
|
+
const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
|
|
798
|
+
return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
|
|
799
|
+
}
|
|
800
|
+
async waitForOutput(id, cursor, timeoutMs, signal) {
|
|
801
|
+
const current = this.read(id, cursor, 1);
|
|
802
|
+
if (!current || current.cursor > cursor || !current.running)
|
|
803
|
+
return;
|
|
804
|
+
await new Promise((resolvePromise) => {
|
|
805
|
+
const entry = this.find(id);
|
|
806
|
+
if (!entry)
|
|
807
|
+
return resolvePromise();
|
|
808
|
+
let timer;
|
|
809
|
+
const done = () => {
|
|
810
|
+
if (timer)
|
|
811
|
+
clearTimeout(timer);
|
|
812
|
+
entry.waiters.delete(done);
|
|
813
|
+
signal?.removeEventListener("abort", done);
|
|
814
|
+
resolvePromise();
|
|
815
|
+
};
|
|
816
|
+
entry.waiters.add(done);
|
|
817
|
+
timer = setTimeout(done, Math.max(0, Math.min(timeoutMs, 120_000)));
|
|
818
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
inputChecked(id, data) {
|
|
822
|
+
if (data.length > MAX_INPUT)
|
|
823
|
+
return `输入过长(上限 ${MAX_INPUT} 字符)`;
|
|
824
|
+
const entry = this.terms.get(id);
|
|
825
|
+
if (!entry || entry.exited)
|
|
826
|
+
return "终端不存在或进程已退出";
|
|
827
|
+
// 已武装的纪元里任何人(含用户手动敲键盘)写了输入都算新活动,重置倒计时。
|
|
828
|
+
entry.lastActivityAt = Date.now();
|
|
829
|
+
if (entry.idleTimer)
|
|
830
|
+
this.armIdleWatch(entry);
|
|
831
|
+
entry.pty.write(data);
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
key(id, key, modifiers = {}) {
|
|
835
|
+
const encoded = encodeTerminalKey(key, modifiers);
|
|
836
|
+
if ("error" in encoded)
|
|
837
|
+
return encoded.error;
|
|
838
|
+
return this.inputChecked(id, encoded.data);
|
|
839
|
+
}
|
|
840
|
+
/** 解除静默看门狗(退出/关闭/全部停止时)。 */
|
|
841
|
+
disarmIdleWatch(entry) {
|
|
842
|
+
if (entry.idleTimer) {
|
|
843
|
+
clearTimeout(entry.idleTimer);
|
|
844
|
+
entry.idleTimer = null;
|
|
845
|
+
}
|
|
846
|
+
entry.agentTouched = false;
|
|
847
|
+
}
|
|
848
|
+
/** 只拆钟不清标记(终端接管 bash 阻塞期间挂起活力提醒,避免双重通知)。 */
|
|
849
|
+
suspendIdleWatch(id) {
|
|
850
|
+
const entry = this.terms.get(id);
|
|
851
|
+
if (!entry || !entry.idleTimer)
|
|
852
|
+
return;
|
|
853
|
+
clearTimeout(entry.idleTimer);
|
|
854
|
+
entry.idleTimer = null;
|
|
855
|
+
}
|
|
856
|
+
/** 输出末尾的绝对 cursor(terminal-backed bash 的读取起点)。 */
|
|
857
|
+
endCursor(id) {
|
|
858
|
+
const entry = this.find(id);
|
|
859
|
+
if (!entry)
|
|
860
|
+
return null;
|
|
861
|
+
return entry.outputOffset + entry.output.length;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* 阻塞等待当前前台命令结束(哨兵行出现或终端退出)。terminal_wait 工具
|
|
865
|
+
* 用它在静默解阻后「重新加入等待」——AI 不必反复 terminal_read 轮询。
|
|
866
|
+
*
|
|
867
|
+
* @param afterCursor 只认该绝对偏移之后的哨兵(排除上一条命令残留的旧标记;
|
|
868
|
+
* 调用方传 endCursor() 即表示「等我调用之后才出现的结束」)
|
|
869
|
+
* @returns finished=false 表示超时/中止(命令仍在跑),可再次调用继续等
|
|
870
|
+
*/
|
|
871
|
+
async waitForCompletion(id, timeoutMs, signal, afterCursor = 0) {
|
|
872
|
+
const entry = this.find(id);
|
|
873
|
+
if (!entry)
|
|
874
|
+
return { finished: false, exitCode: null };
|
|
875
|
+
return new Promise((resolve) => {
|
|
876
|
+
// 命令可能在调用前就已结束:先扫 afterCursor 之后的存量缓冲。
|
|
877
|
+
const relStart = Math.max(0, afterCursor - entry.outputOffset);
|
|
878
|
+
const segment = entry.output.slice(relStart);
|
|
879
|
+
const scan = new RegExp(BASH_SENTINEL_RE.source, BASH_SENTINEL_RE.flags);
|
|
880
|
+
scan.lastIndex = 0;
|
|
881
|
+
const existing = [...segment.matchAll(scan)].pop();
|
|
882
|
+
if (existing) {
|
|
883
|
+
resolve({ finished: true, exitCode: Number(existing[1]) });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (entry.exited) {
|
|
887
|
+
resolve({ finished: true, exitCode: entry.exitCode });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
let timer;
|
|
891
|
+
let settled = false;
|
|
892
|
+
let unwatch = () => { };
|
|
893
|
+
const onAbort = () => done({ finished: false, exitCode: null });
|
|
894
|
+
const done = (r) => {
|
|
895
|
+
if (settled)
|
|
896
|
+
return;
|
|
897
|
+
settled = true;
|
|
898
|
+
if (timer)
|
|
899
|
+
clearTimeout(timer);
|
|
900
|
+
signal?.removeEventListener("abort", onAbort);
|
|
901
|
+
unwatch();
|
|
902
|
+
resolve(r);
|
|
903
|
+
};
|
|
904
|
+
unwatch = this.watchOutput(id, BASH_SENTINEL_RE, (m) => {
|
|
905
|
+
// m=null = 终端被关闭/退出 → 命令肯定结束了(退出码未知)。
|
|
906
|
+
done({ finished: true, exitCode: m ? Number(m[1]) : null });
|
|
907
|
+
});
|
|
908
|
+
timer = setTimeout(() => done({ finished: false, exitCode: null }), Math.max(1, Math.min(timeoutMs, 600_000)));
|
|
909
|
+
timer.unref?.();
|
|
910
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
/** 标记/清除「哨兵待决」状态(终端接管 bash 工具专用)。 */
|
|
914
|
+
setSentinelPending(id, pending) {
|
|
915
|
+
const entry = this.find(id);
|
|
916
|
+
if (entry)
|
|
917
|
+
entry.sentinelPending = pending;
|
|
918
|
+
}
|
|
919
|
+
/** 是否有带哨兵的命令尚未结束(terminal_wait 的适用性判断)。 */
|
|
920
|
+
isSentinelPending(id) {
|
|
921
|
+
return this.find(id)?.sentinelPending === true;
|
|
922
|
+
}
|
|
923
|
+
/** 注册一次性输出观察器:命中 re 或终端退出时回调一次。返回注销函数。 */
|
|
924
|
+
watchOutput(id, re, cb) {
|
|
925
|
+
const entry = this.find(id);
|
|
926
|
+
if (!entry) {
|
|
927
|
+
cb(null);
|
|
928
|
+
return () => { };
|
|
929
|
+
}
|
|
930
|
+
// 每个观察器独立 regex 实例(global 正则的 lastIndex 是共享可变状态)。
|
|
931
|
+
const own = new RegExp(re.source, re.flags);
|
|
932
|
+
const watch = { re: own, buf: "", cb };
|
|
933
|
+
entry.watches.push(watch);
|
|
934
|
+
return () => {
|
|
935
|
+
const cur = this.find(id);
|
|
936
|
+
if (!cur)
|
|
937
|
+
return;
|
|
938
|
+
cur.watches = cur.watches.filter((w) => w !== watch);
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
|
|
942
|
+
fail(id, text) {
|
|
943
|
+
this.emit({ type: "notice", level: "error", text });
|
|
944
|
+
this.emit({
|
|
945
|
+
type: "terminal_output",
|
|
946
|
+
terminalId: id,
|
|
947
|
+
data: `\x1b[91m${text}\x1b[0m\r\n`,
|
|
948
|
+
});
|
|
949
|
+
this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
|
|
950
|
+
}
|
|
951
|
+
exit(id, exitCode) {
|
|
952
|
+
const entry = this.terms.get(id);
|
|
953
|
+
if (!entry || entry.exited)
|
|
954
|
+
return;
|
|
955
|
+
this.disarmIdleWatch(entry);
|
|
956
|
+
// Flush queued output BEFORE the exit banner so ordering is preserved.
|
|
957
|
+
this.flushPending(entry);
|
|
958
|
+
const banner = `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
|
|
959
|
+
this.appendOutput(entry, banner);
|
|
960
|
+
this.emit({ type: "terminal_output", terminalId: id, data: banner });
|
|
961
|
+
entry.exited = true;
|
|
962
|
+
// 终端退出 → 未命中的输出观察器以 null 回调(宿主可据此通知「终端已关闭」)。
|
|
963
|
+
const pendingWatches = entry.watches;
|
|
964
|
+
entry.watches = [];
|
|
965
|
+
for (const w of pendingWatches)
|
|
966
|
+
w.cb(null);
|
|
967
|
+
entry.exitCode = exitCode;
|
|
968
|
+
this.terms.delete(id);
|
|
969
|
+
while (this.history.size >= MAX_TERMINAL_HISTORY) {
|
|
970
|
+
const oldest = this.history.keys().next().value;
|
|
971
|
+
if (typeof oldest !== "string")
|
|
972
|
+
break;
|
|
973
|
+
this.history.delete(oldest);
|
|
974
|
+
}
|
|
975
|
+
this.history.set(id, entry);
|
|
976
|
+
this.emit({ type: "terminal_exit", terminalId: id, exitCode });
|
|
977
|
+
this.emitList();
|
|
978
|
+
}
|
|
979
|
+
input(id, data) {
|
|
980
|
+
void this.inputChecked(id, data);
|
|
981
|
+
}
|
|
982
|
+
resize(id, cols, rows) {
|
|
983
|
+
const entry = this.terms.get(id);
|
|
984
|
+
if (!entry || entry.exited)
|
|
985
|
+
return;
|
|
986
|
+
try {
|
|
987
|
+
entry.pty.resize(Math.max(2, Math.floor(cols) || 80), Math.max(2, Math.floor(rows) || 24));
|
|
988
|
+
// Remember the size so an in-place restart spawns at the same dims.
|
|
989
|
+
entry.cols = Math.max(2, Math.floor(cols) || 80);
|
|
990
|
+
entry.rows = Math.max(2, Math.floor(rows) || 24);
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// PTY already gone — nothing to do.
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/** Kill one terminal (tab closed), including an exited terminal's retained history. */
|
|
997
|
+
kill(id) {
|
|
998
|
+
const entry = this.terms.get(id);
|
|
999
|
+
if (entry) {
|
|
1000
|
+
this.disarmIdleWatch(entry);
|
|
1001
|
+
const killedWatches = entry.watches;
|
|
1002
|
+
entry.watches = [];
|
|
1003
|
+
for (const w of killedWatches)
|
|
1004
|
+
w.cb(null);
|
|
1005
|
+
this.flushPending(entry);
|
|
1006
|
+
entry.exited = true;
|
|
1007
|
+
try {
|
|
1008
|
+
entry.pty.kill();
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
// already dead
|
|
1012
|
+
}
|
|
1013
|
+
this.terms.delete(id);
|
|
1014
|
+
this.emit({ type: "terminal_exit", terminalId: id, exitCode: null });
|
|
1015
|
+
this.emitList();
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (this.history.delete(id))
|
|
1019
|
+
this.emitList();
|
|
1020
|
+
}
|
|
1021
|
+
/** Kill every terminal owned by this conversation. */
|
|
1022
|
+
killAll() {
|
|
1023
|
+
for (const entry of this.terms.values()) {
|
|
1024
|
+
this.disarmIdleWatch(entry);
|
|
1025
|
+
if (entry.exited)
|
|
1026
|
+
continue;
|
|
1027
|
+
entry.exited = true;
|
|
1028
|
+
try {
|
|
1029
|
+
entry.pty.kill();
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
// already dead
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
for (const entry of this.terms.values()) {
|
|
1036
|
+
for (const wake of entry.waiters)
|
|
1037
|
+
wake();
|
|
1038
|
+
entry.waiters.clear();
|
|
1039
|
+
for (const w of entry.watches)
|
|
1040
|
+
w.cb(null);
|
|
1041
|
+
entry.watches = [];
|
|
1042
|
+
}
|
|
1043
|
+
this.terms.clear();
|
|
1044
|
+
this.history.clear();
|
|
1045
|
+
this.emitList();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
// 终端接管 bash:bash 风格工具跑在持久可见终端里
|
|
1050
|
+
// ---------------------------------------------------------------------------
|
|
1051
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1052
|
+
/** 取 collected 尾部里最后一个哨兵匹配(哨兵只可能出现在新输出的尾部)。 */
|
|
1053
|
+
function lastSentinel(collected) {
|
|
1054
|
+
const tail = collected.length > 8000 ? collected.slice(-8000) : collected;
|
|
1055
|
+
BASH_SENTINEL_RE.lastIndex = 0;
|
|
1056
|
+
return [...tail.matchAll(BASH_SENTINEL_RE)].pop() ?? null;
|
|
1057
|
+
}
|
|
1058
|
+
/** 去掉输入回显、哨兵及其后的 shell 提示符垃圾与 ANSI 序列,还原 bash 风格纯文本。 */
|
|
1059
|
+
function cleanBashOutput(raw) {
|
|
1060
|
+
let text = stripAnsi(raw).replace(/\r\n/g, "\n");
|
|
1061
|
+
// 回显的命令行可能被 readline 折行拆成多行,按行剥不可靠——改为锚定
|
|
1062
|
+
// printf 格式串字面量 [pi-exit:%s](真哨兵是数字版),连同其所在整行丢弃。
|
|
1063
|
+
// 注意:同一命令会被回显两次(PTY 输入回显 + readline 提示符回显),需循环。
|
|
1064
|
+
for (;;) {
|
|
1065
|
+
const fmtIdx = text.indexOf("[pi-exit:%s]");
|
|
1066
|
+
if (fmtIdx < 0)
|
|
1067
|
+
break;
|
|
1068
|
+
const nl = text.indexOf("\n", fmtIdx);
|
|
1069
|
+
text = nl >= 0 ? text.slice(nl + 1) : "";
|
|
1070
|
+
}
|
|
1071
|
+
// 最后一个哨兵之后的内容全是 shell 新提示符——整段截掉。
|
|
1072
|
+
BASH_SENTINEL_RE.lastIndex = 0;
|
|
1073
|
+
let last = null;
|
|
1074
|
+
for (let m = BASH_SENTINEL_RE.exec(text); m; m = BASH_SENTINEL_RE.exec(text)) {
|
|
1075
|
+
last = m;
|
|
1076
|
+
}
|
|
1077
|
+
if (last)
|
|
1078
|
+
text = text.slice(0, last.index);
|
|
1079
|
+
const lines = text.split("\n");
|
|
1080
|
+
while (lines.length > 0 && !lines[lines.length - 1].trim())
|
|
1081
|
+
lines.pop();
|
|
1082
|
+
return truncateMiddle(lines.join("\n").trim());
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* 终端接管的 bash 工具:模型看到的参数与 SDK bash 完全一致(command + 可选
|
|
1086
|
+
* timeout 秒),但执行体是往持久终端写命令并等哨兵行拿到真实退出码。
|
|
1087
|
+
*
|
|
1088
|
+
* 行为语义:
|
|
1089
|
+
* - 默认阻塞:等到命令结束才返回完整输出(ANSI 已清理)+ 真实退出码;
|
|
1090
|
+
* - 静默解阻:连续 idleMs 毫秒无新输出且未结束 → 立即返回「仍在运行」+ 已有
|
|
1091
|
+
* 输出,命令留在终端里继续跑,并注册完成观察器——结束后由宿主
|
|
1092
|
+
* notifyBackgroundDone 主动通知 AI(流式中 steer / 空闲时 nextTurn);
|
|
1093
|
+
* - abort_bash 支持:AbortController 注册进 kills 集合,abort 时向 PTY 发
|
|
1094
|
+
* Ctrl+C 杀前台进程,对话继续(与 makeKillableBashTool 同一套集合);
|
|
1095
|
+
* - shell 状态跨调用保留(cd / venv activate / ssh 会话)——这是原生 bash
|
|
1096
|
+
* 工具做不到的。
|
|
1097
|
+
*/
|
|
1098
|
+
export function makeTerminalBashTool(terminals, opts) {
|
|
1099
|
+
const TERM_ID = "ai-bash";
|
|
1100
|
+
return defineTool({
|
|
1101
|
+
name: "bash",
|
|
1102
|
+
label: "Run bash command",
|
|
1103
|
+
description: "Run a shell command and return its full output plus exit code. Commands execute in a PERSISTENT visible terminal ('ai-bash'): shell state such as cd, venv activation or ssh sessions is retained across calls. Run the bare command — do NOT pipe through tail/head/more/less (output is returned complete anyway, and pipes hide live progress in the visible terminal). If a command stays silent for a while it keeps running in the background and you get an automatic notice when it finishes; use terminal_wait to re-block until it finishes, or terminal_read / terminal_input / terminal_key on 'ai-bash' to observe or interact anytime.",
|
|
1104
|
+
promptSnippet: "run commands in the persistent visible terminal (state retained across calls)",
|
|
1105
|
+
parameters: Type.Object({
|
|
1106
|
+
command: Type.String({ description: "The shell command to run" }),
|
|
1107
|
+
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
1108
|
+
tail: Type.Optional(Type.Integer({
|
|
1109
|
+
minimum: 1,
|
|
1110
|
+
maximum: 5000,
|
|
1111
|
+
description: "Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail — the command keeps streaming live to the visible terminal while you only get the tail back.",
|
|
1112
|
+
})),
|
|
1113
|
+
}),
|
|
1114
|
+
execute: async (_id, p, signal) => {
|
|
1115
|
+
// create() 对已存活的同名终端原样返回、对已退出的原地重启。
|
|
1116
|
+
// forceBash:该终端永远跑 bash(而非用户登录 shell),模型写的
|
|
1117
|
+
// bash 语法(数组/read -p/process substitution…)不会踩 zsh 差异。
|
|
1118
|
+
if (terminals.create(TERM_ID, opts.cwd, 120, 40, opts.cwd, "AI bash", {
|
|
1119
|
+
forceBash: true,
|
|
1120
|
+
}) === null) {
|
|
1121
|
+
throw new Error(`无法打开 AI bash 终端(${TERM_ID})`);
|
|
1122
|
+
}
|
|
1123
|
+
// 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
|
|
1124
|
+
terminals.suspendIdleWatch(TERM_ID);
|
|
1125
|
+
const start = terminals.endCursor(TERM_ID);
|
|
1126
|
+
const ac = new AbortController();
|
|
1127
|
+
opts.kills.add(ac);
|
|
1128
|
+
const idleMs = Math.max(0, opts.idleMs());
|
|
1129
|
+
const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
|
|
1130
|
+
// tail 参数:只返回末尾 N 行(替代 `| tail -N` 管道——管道会缓冲输出、
|
|
1131
|
+
// 让可见终端全程哑火,还容易白白触发静默解阻)。
|
|
1132
|
+
const applyTail = (t) => {
|
|
1133
|
+
if (!p.tail || p.tail <= 0)
|
|
1134
|
+
return t;
|
|
1135
|
+
const lines = t.split("\n");
|
|
1136
|
+
return lines.length > p.tail
|
|
1137
|
+
? `…(前 ${lines.length - p.tail} 行已省略)\n${lines.slice(-p.tail).join("\n")}`
|
|
1138
|
+
: t;
|
|
1139
|
+
};
|
|
1140
|
+
try {
|
|
1141
|
+
let collected = "";
|
|
1142
|
+
let cursor = start;
|
|
1143
|
+
let lastDataAt = Date.now();
|
|
1144
|
+
// 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
|
|
1145
|
+
terminals.setSentinelPending(TERM_ID, true);
|
|
1146
|
+
const inputErr = terminals.inputChecked(TERM_ID, buildTerminalBashLine(p.command) + "\r");
|
|
1147
|
+
if (inputErr)
|
|
1148
|
+
throw new Error(inputErr);
|
|
1149
|
+
for (;;) {
|
|
1150
|
+
if (ac.signal.aborted || signal?.aborted) {
|
|
1151
|
+
// Ctrl+C 杀前台进程;终端本身保留(会话状态还在)。
|
|
1152
|
+
terminals.setSentinelPending(TERM_ID, false);
|
|
1153
|
+
terminals.inputChecked(TERM_ID, "\x03");
|
|
1154
|
+
throw new Error("Command aborted");
|
|
1155
|
+
}
|
|
1156
|
+
await sleep(60);
|
|
1157
|
+
const read = terminals.read(TERM_ID, cursor);
|
|
1158
|
+
if (read?.data) {
|
|
1159
|
+
collected += read.data;
|
|
1160
|
+
cursor = read.cursor;
|
|
1161
|
+
lastDataAt = Date.now();
|
|
1162
|
+
}
|
|
1163
|
+
const m = lastSentinel(collected);
|
|
1164
|
+
if (m) {
|
|
1165
|
+
terminals.setSentinelPending(TERM_ID, false);
|
|
1166
|
+
const text = applyTail(cleanBashOutput(collected));
|
|
1167
|
+
return {
|
|
1168
|
+
content: [
|
|
1169
|
+
{
|
|
1170
|
+
type: "text",
|
|
1171
|
+
text: `${text}${text ? "\n" : ""}[exit:${m[1]}]`,
|
|
1172
|
+
},
|
|
1173
|
+
],
|
|
1174
|
+
details: { exitCode: Number(m[1]), output: text },
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
if (deadline !== null && Date.now() > deadline) {
|
|
1178
|
+
terminals.setSentinelPending(TERM_ID, false);
|
|
1179
|
+
terminals.inputChecked(TERM_ID, "\x03");
|
|
1180
|
+
throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
|
|
1181
|
+
}
|
|
1182
|
+
// 静默解阻:转后台 + 注册完成观察器,立即把控制权还给模型。
|
|
1183
|
+
if (idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1184
|
+
return backgroundResult(terminals, opts, p.command, applyTail(cleanBashOutput(collected)), Math.round((Date.now() - lastDataAt) / 1000));
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
opts.kills.delete(ac);
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
/** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
|
|
1195
|
+
function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
|
|
1196
|
+
terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
|
|
1197
|
+
// 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
|
|
1198
|
+
terminals.setSentinelPending("ai-bash", false);
|
|
1199
|
+
opts.notifyBackgroundDone({
|
|
1200
|
+
terminalId: "ai-bash",
|
|
1201
|
+
command,
|
|
1202
|
+
exitCode: m ? Number(m[1]) : null,
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
// partialText 已在调用方做过 cleanBashOutput + applyTail。
|
|
1206
|
+
const partial = truncateMiddle(partialText, 6000);
|
|
1207
|
+
return {
|
|
1208
|
+
content: [
|
|
1209
|
+
{
|
|
1210
|
+
type: "text",
|
|
1211
|
+
text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
|
|
1212
|
+
`本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
|
|
1213
|
+
`已有输出:\n${partial || "(暂无输出)"}\n` +
|
|
1214
|
+
`要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
|
|
1215
|
+
},
|
|
1216
|
+
],
|
|
1217
|
+
details: { running: true, terminalId: "ai-bash", silentSeconds },
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
/** Names of the agent-facing persistent-terminal tools(设置开关门控用)。 */
|
|
1221
|
+
export const TERMINAL_TOOL_NAMES = [
|
|
1222
|
+
"terminal_create",
|
|
1223
|
+
"terminal_list",
|
|
1224
|
+
"terminal_close",
|
|
1225
|
+
"terminal_input",
|
|
1226
|
+
"terminal_key",
|
|
1227
|
+
"terminal_read",
|
|
1228
|
+
"terminal_wait",
|
|
1229
|
+
];
|
|
1230
|
+
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
1231
|
+
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
1232
|
+
* complete output in a single call, so it always wins on convenience. */
|
|
1233
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
1234
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1235
|
+
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1236
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
1237
|
+
Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
|
|
1238
|
+
Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
|
|
1239
|
+
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
1240
|
+
export function makePersistentTerminalTools(terminals, cwd) {
|
|
1241
|
+
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
1242
|
+
const failIf = (error) => {
|
|
1243
|
+
if (error)
|
|
1244
|
+
throw new Error(error);
|
|
1245
|
+
};
|
|
1246
|
+
return [
|
|
1247
|
+
defineTool({
|
|
1248
|
+
name: "terminal_create",
|
|
1249
|
+
label: "Create terminal",
|
|
1250
|
+
description: "Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output. Prefer this over bash when the program is interactive/TUI-based (REPLs, vim/htop, y/n prompts), when starting a long-running server you want to keep observing or interrupt, or when the user asks to work in the visible terminal. For simple one-shot commands use bash instead.",
|
|
1251
|
+
promptSnippet: "run interactive programs or long-running servers in a persistent visible PTY (multi-step: create → input/key → read)",
|
|
1252
|
+
parameters: Type.Object({
|
|
1253
|
+
terminalId: Type.String({ description: "Stable terminal name" }),
|
|
1254
|
+
cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
|
|
1255
|
+
cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
|
|
1256
|
+
rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
|
|
1257
|
+
}),
|
|
1258
|
+
execute: async (_id, p) => {
|
|
1259
|
+
const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
|
|
1260
|
+
if (!info)
|
|
1261
|
+
throw new Error(`创建终端失败:${p.terminalId}`);
|
|
1262
|
+
// AI 创建 → 启动活力检测纪元(静默提醒只针对 agent 触碰过的终端)。
|
|
1263
|
+
terminals.noteAgentActivity(p.terminalId);
|
|
1264
|
+
return result(`终端已创建:${JSON.stringify(info)}`, info);
|
|
1265
|
+
},
|
|
1266
|
+
}),
|
|
1267
|
+
defineTool({
|
|
1268
|
+
name: "terminal_list",
|
|
1269
|
+
label: "List terminals",
|
|
1270
|
+
description: "List all persistent PTY terminals owned by this conversation.",
|
|
1271
|
+
promptSnippet: "list persistent terminals",
|
|
1272
|
+
parameters: Type.Object({}),
|
|
1273
|
+
execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
|
|
1274
|
+
}),
|
|
1275
|
+
defineTool({
|
|
1276
|
+
name: "terminal_close",
|
|
1277
|
+
label: "Close terminal",
|
|
1278
|
+
description: "Close a persistent PTY and terminate its process tree.",
|
|
1279
|
+
parameters: Type.Object({ terminalId: Type.String() }),
|
|
1280
|
+
execute: async (_id, p) => {
|
|
1281
|
+
if (!terminals.has(p.terminalId))
|
|
1282
|
+
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1283
|
+
terminals.kill(p.terminalId);
|
|
1284
|
+
return result(`终端已关闭:${p.terminalId}`);
|
|
1285
|
+
},
|
|
1286
|
+
}),
|
|
1287
|
+
defineTool({
|
|
1288
|
+
name: "terminal_input",
|
|
1289
|
+
label: "Send terminal input",
|
|
1290
|
+
description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
|
|
1291
|
+
parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
|
|
1292
|
+
execute: async (_id, p) => {
|
|
1293
|
+
failIf(terminals.inputChecked(p.terminalId, p.data));
|
|
1294
|
+
// AI 发了输入 = 在等结果,重开一个静默纪元。
|
|
1295
|
+
terminals.noteAgentActivity(p.terminalId);
|
|
1296
|
+
return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
|
|
1297
|
+
},
|
|
1298
|
+
}),
|
|
1299
|
+
defineTool({
|
|
1300
|
+
name: "terminal_key",
|
|
1301
|
+
label: "Send terminal key",
|
|
1302
|
+
description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
|
|
1303
|
+
parameters: Type.Object({
|
|
1304
|
+
terminalId: Type.String(),
|
|
1305
|
+
key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
|
|
1306
|
+
modifiers: Type.Optional(Type.Object({
|
|
1307
|
+
ctrl: Type.Optional(Type.Boolean()),
|
|
1308
|
+
alt: Type.Optional(Type.Boolean()),
|
|
1309
|
+
shift: Type.Optional(Type.Boolean()),
|
|
1310
|
+
})),
|
|
1311
|
+
}),
|
|
1312
|
+
execute: async (_id, p) => {
|
|
1313
|
+
failIf(terminals.key(p.terminalId, p.key, p.modifiers));
|
|
1314
|
+
// 同 terminal_input:AI 主动交互后重新计时。
|
|
1315
|
+
terminals.noteAgentActivity(p.terminalId);
|
|
1316
|
+
return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
|
|
1317
|
+
},
|
|
1318
|
+
}),
|
|
1319
|
+
defineTool({
|
|
1320
|
+
name: "terminal_read",
|
|
1321
|
+
label: "Read terminal output",
|
|
1322
|
+
description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
|
|
1323
|
+
parameters: Type.Object({
|
|
1324
|
+
terminalId: Type.String(),
|
|
1325
|
+
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
1326
|
+
maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
|
|
1327
|
+
waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
|
|
1328
|
+
}),
|
|
1329
|
+
execute: async (_id, p, signal) => {
|
|
1330
|
+
const cursor = p.cursor ?? 0;
|
|
1331
|
+
if (p.waitMs)
|
|
1332
|
+
await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
|
|
1333
|
+
const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
|
|
1334
|
+
if (!read)
|
|
1335
|
+
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1336
|
+
return result(JSON.stringify(read), read);
|
|
1337
|
+
},
|
|
1338
|
+
}),
|
|
1339
|
+
defineTool({
|
|
1340
|
+
name: "terminal_wait",
|
|
1341
|
+
label: "Wait for terminal command",
|
|
1342
|
+
description: "Block until a command started THROUGH THE BASH TOOL finishes (its exit marker appears) or the timeout expires — no polling needed. Only applies to terminals with a pending bash-tool command; terminals driven manually via terminal_input (e.g. interactive programs) have no completion marker — use terminal_read(waitMs=…) to observe those instead. Returns {finished, exitCode} plus the output produced while waiting; finished=false means it is STILL running (call again to keep waiting).",
|
|
1343
|
+
promptSnippet: "block until a terminal's current command finishes (no polling)",
|
|
1344
|
+
parameters: Type.Object({
|
|
1345
|
+
terminalId: Type.String(),
|
|
1346
|
+
cursor: Type.Optional(Type.Integer({ minimum: 0, description: "Ignore exit markers before this absolute offset (default: now)" })),
|
|
1347
|
+
maxWaitMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 600000, description: "Max wait in ms (default 300000)" })),
|
|
1348
|
+
}),
|
|
1349
|
+
execute: async (_id, p, signal) => {
|
|
1350
|
+
if (!terminals.has(p.terminalId)) {
|
|
1351
|
+
throw new Error(`终端不存在:${p.terminalId}(可能已被关闭或会话重置,请先 terminal_create)`);
|
|
1352
|
+
}
|
|
1353
|
+
// 没有带哨兵的待决命令:shell 空闲在提示符,或该终端的命令是经
|
|
1354
|
+
// terminal_input 手动发的(无完成标记)——等哨兵永远等不到,直接
|
|
1355
|
+
// 说明并引导改用 terminal_read,避免 AI 无限重试。(显式传 cursor
|
|
1356
|
+
// 的调用是有目的的追溯查询,不拦。)
|
|
1357
|
+
if (p.cursor === undefined && !terminals.isSentinelPending(p.terminalId)) {
|
|
1358
|
+
const why = `终端 ${p.terminalId} 当前没有正在等待完成的 bash 工具命令(shell 空闲,或该命令是通过 terminal_input 发出的、没有完成标记)。terminal_wait 不适用;要观察输出请用 terminal_read(terminalId="${p.terminalId}", waitMs=…)。`;
|
|
1359
|
+
return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
|
|
1360
|
+
}
|
|
1361
|
+
const cursor = p.cursor ?? terminals.endCursor(p.terminalId) ?? 0;
|
|
1362
|
+
const wait = await terminals.waitForCompletion(p.terminalId, p.maxWaitMs ?? 300_000, signal, cursor);
|
|
1363
|
+
const read = terminals.read(p.terminalId, cursor, 20_000);
|
|
1364
|
+
const outputTail = read?.data ? stripAnsi(read.data).slice(-4000) : "";
|
|
1365
|
+
return result(JSON.stringify({ ...wait, outputTail }), {
|
|
1366
|
+
...wait,
|
|
1367
|
+
outputTail,
|
|
1368
|
+
});
|
|
1369
|
+
},
|
|
1370
|
+
}),
|
|
1371
|
+
];
|
|
1372
|
+
}
|