pi-web-ui 0.59.0 → 0.60.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 -21
- package/README.md +497 -468
- package/README.zh-CN.md +422 -391
- package/bin/pi-web-ui.mjs +1881 -1848
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +54 -54
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +63 -33
- package/dist/server/attachments.js +3 -3
- package/dist/server/dsh/runtime/goal-rpc.mjs +662 -662
- package/dist/server/index.js +28 -11
- package/dist/server/terminals.js +135 -138
- package/extensions/webui.ts +192 -192
- package/package.json +1 -1
- package/web/dist/assets/TerminalPanel-CSzqnWV2.js +2 -0
- package/web/dist/assets/index-D9iD9G_p.css +10 -0
- package/web/dist/assets/{index-MeifpZzi.js → index-TZDcJFdi.js} +12 -12
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/TerminalPanel-_VfntAyG.js +0 -2
- package/web/dist/assets/index-DRsP4BO2.css +0 -10
package/dist/server/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* protocol defined in protocol.ts.
|
|
8
8
|
*
|
|
9
9
|
* Env:
|
|
10
|
-
* PI_WEB_PORT HTTP port (default 8787
|
|
10
|
+
* PI_WEB_PORT HTTP port (default 8787)
|
|
11
11
|
* PI_WEB_CWD workspace the agent operates in (default: process.cwd())
|
|
12
12
|
* PI_WEB_DATA_DIR where per-client UI state is stored (client-state.json,
|
|
13
13
|
* default: <home>/.pi-web). Chat sessions are NOT stored here — they live
|
|
@@ -37,13 +37,27 @@ import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
|
|
|
37
37
|
import { listThemes, resolveThemeFile } from "./themes.js";
|
|
38
38
|
import { PluginManager, resolvePluginClientFile } from "./plugins.js";
|
|
39
39
|
import { McpBridge } from "./mcp-bridge.js";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
|
|
41
|
+
* 让 `node dist/server/index.js --host 0.0.0.0 --port 9000` 这类直接启动也能生效,
|
|
42
|
+
* 而不只是经由 bin/pi-web-ui.mjs 的 env 转发。bin 仍是主入口,此处仅作兜底。 */
|
|
43
|
+
function cliFlag(name) {
|
|
44
|
+
const eq = `${name}=`;
|
|
45
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
46
|
+
const a = process.argv[i];
|
|
47
|
+
if (a === name && i + 1 < process.argv.length)
|
|
48
|
+
return process.argv[i + 1];
|
|
49
|
+
if (a.startsWith(eq))
|
|
50
|
+
return a.slice(eq.length);
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
|
|
55
|
+
const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
56
|
+
const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
43
57
|
/** Bind address. Default is loopback ONLY — the service is a local personal
|
|
44
58
|
* tool and should not be reachable from the network unless explicitly asked
|
|
45
59
|
* (e.g. PI_WEB_HOST=0.0.0.0 for LAN access / Docker port mapping). */
|
|
46
|
-
const HOST = process.env.PI_WEB_HOST ?? "127.0.0.1";
|
|
60
|
+
const HOST = cliFlag("--host") ?? process.env.PI_WEB_HOST ?? "127.0.0.1";
|
|
47
61
|
/** Optional strict hostname allowlist (comma-separated) — only used when set.
|
|
48
62
|
* Origin / Host same-authority matching happens regardless. */
|
|
49
63
|
const ALLOW_HOSTS = (process.env.PI_WEB_ALLOW_HOSTS ?? "")
|
|
@@ -105,13 +119,16 @@ function tokenOk(req) {
|
|
|
105
119
|
return requestTokens(req).includes(AUTH_TOKEN);
|
|
106
120
|
}
|
|
107
121
|
if (AUTH_TOKEN) {
|
|
108
|
-
// /api/health
|
|
122
|
+
// /api/health 保持开放:无敏感信息,容器/监控探针需要它。
|
|
123
|
+
// 但绝不能因命中 /api/health 就反射下发真实 token cookie(安全漏洞)。
|
|
109
124
|
app.use((req, res, next) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
125
|
+
const ok = tokenOk(req);
|
|
126
|
+
// 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数;
|
|
127
|
+
// 只有请求确实携带着有效 token 时才下发——匿名命中 /api/health 不触发。
|
|
128
|
+
if (ok && !req.headers.cookie?.includes("pi_web_token=")) {
|
|
129
|
+
res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
|
|
130
|
+
}
|
|
131
|
+
if (req.path === "/api/health" || ok) {
|
|
115
132
|
next();
|
|
116
133
|
return;
|
|
117
134
|
}
|
package/dist/server/terminals.js
CHANGED
|
@@ -124,8 +124,8 @@ export function terminalIdleNotifyMs() {
|
|
|
124
124
|
const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_MS);
|
|
125
125
|
return Number.isFinite(raw) && raw >= 0 ? raw : 15_000;
|
|
126
126
|
}
|
|
127
|
-
/**
|
|
128
|
-
*
|
|
127
|
+
/** 静默解阻时默认回送的最近输出行数(仅用于把已有输出截到可读量)。
|
|
128
|
+
* PI_WEB_TERMINAL_IDLE_LINES 覆盖;0 = 不截;限制在 1..500。每次调用时读取。 */
|
|
129
129
|
export function terminalIdleNotifyLines() {
|
|
130
130
|
const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_LINES);
|
|
131
131
|
const n = Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10;
|
|
@@ -137,8 +137,6 @@ export function terminalIdleNotifyLines() {
|
|
|
137
137
|
/** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
|
|
138
138
|
* 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
|
|
139
139
|
const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
140
|
-
/** 一次性 bash 终端的 id 计数器(全局单调递增,跨会话不重号)。 */
|
|
141
|
-
let bashCommandSeq = 0;
|
|
142
140
|
/**
|
|
143
141
|
* 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
|
|
144
142
|
*
|
|
@@ -159,11 +157,10 @@ export function buildTerminalBashLine(command, tailFile) {
|
|
|
159
157
|
}
|
|
160
158
|
// 退出码取【第一个】命令(真正干活的那个)而非管道末尾命令:`head`/`grep`/`tail`
|
|
161
159
|
// 在管道末尾会把退出码吞成自己的(head 恒 0、grep 无命恒 1)。`${PIPESTATUS:-$?}`
|
|
162
|
-
// 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// tailFile:`cmd > log 2>&1 | tail -N` —— 拆掉 tail 后 stdout 进文件、终端为空;
|
|
160
|
+
// 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为 `$?`。
|
|
161
|
+
// SIGPIPE(141) 归 0:`cmd | head` 里 head 读够 N 行就主动关管道,把首命令“截断杀掉”
|
|
162
|
+
// 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败照报。
|
|
163
|
+
// tailFile:`cmd > log 2>&1 | tail -N`——拆掉 tail 后 stdout 进文件、终端为空;
|
|
167
164
|
// 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
|
|
168
165
|
const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
|
|
169
166
|
const tailPart = tailFile
|
|
@@ -218,8 +215,7 @@ function parseTailLines(rest) {
|
|
|
218
215
|
* 末尾 N 行(tail)或全部输出。
|
|
219
216
|
*
|
|
220
217
|
* - **只拆末尾单个纯“限输出/透传”**;`| grep`/`| head`/`| awk`/`| sed`/`| sort`/`| uniq`
|
|
221
|
-
* (真过滤/变换,拆掉会丢语义或崩出海量未过滤输出)与 `| tee
|
|
222
|
-
* 交给 prompt 引导模型不用。`| head` 也因 `yes | head -5` 靠 SIGPIPE 早停而**不拆**。
|
|
218
|
+
* (真过滤/变换,拆掉会丢语义或崩出海量未过滤输出)与 `| tee`(写文件副作用)都**不拆**。
|
|
223
219
|
* - `tail -f` / `tail --follow`(长驻观察)也不拆。
|
|
224
220
|
*/
|
|
225
221
|
export function detectTrailingLimiter(command) {
|
|
@@ -407,10 +403,6 @@ function shellEnv() {
|
|
|
407
403
|
const env = {
|
|
408
404
|
...process.env,
|
|
409
405
|
TERM: "xterm-256color",
|
|
410
|
-
// 禁用分页器:stdout 是 tty 时 git log / git diff / man / … 会开 less 并挂住等按键。
|
|
411
|
-
// agent 工具需要完整输出而非分页,故强制 cat 透传(防挂死)。
|
|
412
|
-
GIT_PAGER: process.env.GIT_PAGER || "cat",
|
|
413
|
-
PAGER: process.env.PAGER || "cat",
|
|
414
406
|
};
|
|
415
407
|
if (!env.LANG && !env.LC_ALL)
|
|
416
408
|
env.LANG = "en_US.UTF-8";
|
|
@@ -619,7 +611,7 @@ export class TerminalManager {
|
|
|
619
611
|
// a NEW live PTY needs a free slot under the cap. Reusing an exited name
|
|
620
612
|
// starts a fresh PTY and discards its old history — but only after the
|
|
621
613
|
// slot check, so a rejected request keeps its retained output.
|
|
622
|
-
if (!this.ensureSpawnAllowed(id))
|
|
614
|
+
if (!this.ensureSpawnAllowed(id, opts?.agentBash))
|
|
623
615
|
return null;
|
|
624
616
|
this.history.delete(id);
|
|
625
617
|
const safeCwd = this.safeCwd(cwd || fallbackCwd);
|
|
@@ -627,7 +619,7 @@ export class TerminalManager {
|
|
|
627
619
|
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
628
620
|
return null;
|
|
629
621
|
}
|
|
630
|
-
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.
|
|
622
|
+
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash)) {
|
|
631
623
|
this.maybeEmitTccHint(id);
|
|
632
624
|
this.emitList();
|
|
633
625
|
return this.info(this.terms.get(id));
|
|
@@ -706,7 +698,7 @@ export class TerminalManager {
|
|
|
706
698
|
this.input(id, command + "\r");
|
|
707
699
|
}
|
|
708
700
|
/** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
|
|
709
|
-
spawnShell(id, cwd, cols, rows, title, command, forceBash,
|
|
701
|
+
spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false) {
|
|
710
702
|
let abs = cwd;
|
|
711
703
|
if (!abs)
|
|
712
704
|
abs = homedir();
|
|
@@ -728,12 +720,7 @@ export class TerminalManager {
|
|
|
728
720
|
let pty;
|
|
729
721
|
try {
|
|
730
722
|
const { shell, args } = forceBash ? resolveBashShell() : resolveShell();
|
|
731
|
-
|
|
732
|
-
// 退出后缓冲即干净输出(供一次性 bash 终端的事后查询/搜索)。
|
|
733
|
-
const spawnArgs = runLine !== undefined
|
|
734
|
-
? ["-c", runLine]
|
|
735
|
-
: args;
|
|
736
|
-
pty = spawn(shell, spawnArgs, {
|
|
723
|
+
pty = spawn(shell, args, {
|
|
737
724
|
name: "xterm-256color",
|
|
738
725
|
cols: Math.max(2, Math.floor(cols) || 80),
|
|
739
726
|
rows: Math.max(2, Math.floor(rows) || 24),
|
|
@@ -767,6 +754,7 @@ export class TerminalManager {
|
|
|
767
754
|
lastActivityAt: Date.now(),
|
|
768
755
|
idleTimer: null,
|
|
769
756
|
watches: [],
|
|
757
|
+
agentBash,
|
|
770
758
|
};
|
|
771
759
|
this.terms.set(id, entry);
|
|
772
760
|
// The closures capture `entry`: after a restart the map points at the
|
|
@@ -825,7 +813,7 @@ export class TerminalManager {
|
|
|
825
813
|
// 一次性:触发后解除武装,直到下次 agent 触碰。
|
|
826
814
|
entry.agentTouched = false;
|
|
827
815
|
// 附带最近 N 行输出(可配),便于 AI 直接看到终端当前状态。
|
|
828
|
-
const lastLines =
|
|
816
|
+
const lastLines = queryTerminalOutput(entry.output, { tail: terminalIdleNotifyLines() })?.text ?? "";
|
|
829
817
|
this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title, lastLines);
|
|
830
818
|
}, delay);
|
|
831
819
|
entry.idleTimer.unref?.();
|
|
@@ -903,11 +891,16 @@ export class TerminalManager {
|
|
|
903
891
|
* Restarting an id that is ALREADY live is always allowed (no extra slot).
|
|
904
892
|
* History entries (exited terminals) do not reserve a slot — re-spawning
|
|
905
893
|
* one while at the cap is rejected with the standard error feedback.
|
|
894
|
+
* 终端接管 bash(ai-bash,agentBash=true)永远放行:它由 API 自动创建且
|
|
895
|
+
* 常驻,不占用户的名额,也不受用户已开满上限影响。
|
|
906
896
|
*/
|
|
907
|
-
ensureSpawnAllowed(id) {
|
|
897
|
+
ensureSpawnAllowed(id, agentBash = false) {
|
|
908
898
|
if (this.terms.has(id))
|
|
909
899
|
return true;
|
|
910
|
-
if (
|
|
900
|
+
if (agentBash)
|
|
901
|
+
return true;
|
|
902
|
+
const liveUser = [...this.terms.values()].filter((t) => !t.agentBash).length;
|
|
903
|
+
if (liveUser >= MAX_TERMINALS) {
|
|
911
904
|
this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`);
|
|
912
905
|
return false;
|
|
913
906
|
}
|
|
@@ -937,6 +930,7 @@ export class TerminalManager {
|
|
|
937
930
|
running: !entry.exited,
|
|
938
931
|
exitCode: entry.exitCode,
|
|
939
932
|
command: entry.command,
|
|
933
|
+
agentBash: entry.agentBash,
|
|
940
934
|
};
|
|
941
935
|
}
|
|
942
936
|
has(id) {
|
|
@@ -966,13 +960,6 @@ export class TerminalManager {
|
|
|
966
960
|
const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
|
|
967
961
|
return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
|
|
968
962
|
}
|
|
969
|
-
/** 对终端(含已退出的 history 项)的输出缓冲做快照查询:head/tail/search+context。 */
|
|
970
|
-
query(id, q) {
|
|
971
|
-
const entry = this.find(id);
|
|
972
|
-
if (!entry)
|
|
973
|
-
return null;
|
|
974
|
-
return queryTerminalOutput(entry.output, q, !entry.exited, entry.exitCode);
|
|
975
|
-
}
|
|
976
963
|
async waitForOutput(id, cursor, timeoutMs, signal) {
|
|
977
964
|
const current = this.read(id, cursor, 1);
|
|
978
965
|
if (!current || current.cursor > cursor || !current.running)
|
|
@@ -1258,59 +1245,115 @@ function cleanBashOutput(raw) {
|
|
|
1258
1245
|
return truncateMiddle(lines.join("\n").trim());
|
|
1259
1246
|
}
|
|
1260
1247
|
/**
|
|
1261
|
-
* 终端接管的 bash
|
|
1262
|
-
* timeout
|
|
1248
|
+
* 终端接管的 bash 工具:覆盖 SDK 内置 bash,执行体把命令写进可见终端并等哨兵
|
|
1249
|
+
* 行拿到真实退出码。参数除 SDK 的 command + timeout 外,还多出 persist / head /
|
|
1250
|
+
* tail 三个可选参数。
|
|
1251
|
+
*
|
|
1252
|
+
* persist=false(默认,一次性):每次新建一个终端(agentBash、单独归「AI bash」
|
|
1253
|
+
* 分组、不占用户名额),命令结束 shell 用 exit 退场(进程结束),输出保留在
|
|
1254
|
+
* history 供查阅;阻塞到命令结束,不做静默解阻。
|
|
1263
1255
|
*
|
|
1264
|
-
*
|
|
1256
|
+
* persist=true(复用 'ai-bash' 持久终端):
|
|
1265
1257
|
* - 默认阻塞:等到命令结束才返回完整输出(ANSI 已清理)+ 真实退出码;
|
|
1266
1258
|
* - 静默解阻:连续 idleMs 毫秒无新输出且未结束 → 立即返回「仍在运行」+ 已有
|
|
1267
1259
|
* 输出,命令留在终端里继续跑,并注册完成观察器——结束后由宿主
|
|
1268
1260
|
* notifyBackgroundDone 主动通知 AI(流式中 steer / 空闲时 nextTurn);
|
|
1269
|
-
* -
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1261
|
+
* - shell 状态跨调用保留(cd / venv activate / ssh 会话)——这是原生一次性的
|
|
1262
|
+
* bash 工具做不到的。
|
|
1263
|
+
*
|
|
1264
|
+
* 两者都支持 abort(AbortController 注册进 kills 集合,abort 向 PTY 发 Ctrl+C)、
|
|
1265
|
+
* timeout,以及 head / tail 参数(只返回前/后 N 行,替代 `| head` / `| tail` 管道)。
|
|
1273
1266
|
*/
|
|
1267
|
+
/** 一次性 bash 终端序号:非持久调用每次新建一个终端,进程结束后退出、输出
|
|
1268
|
+
* 保留在 history 供查阅——故每调用独立 id,避免复用覆盖旧输出。 */
|
|
1269
|
+
let oneShotBashSeq = 0;
|
|
1270
|
+
/** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
|
|
1271
|
+
* 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
|
|
1272
|
+
* 截头再截尾。 */
|
|
1273
|
+
export function applyHeadTail(text, head, tail) {
|
|
1274
|
+
// 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
|
|
1275
|
+
// head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
|
|
1276
|
+
let data = text.split("\n");
|
|
1277
|
+
let headNote = null;
|
|
1278
|
+
let tailNote = null;
|
|
1279
|
+
if (head && head > 0 && data.length > head) {
|
|
1280
|
+
headNote = `…(后 ${data.length - head} 行已省略)`;
|
|
1281
|
+
data = data.slice(0, head);
|
|
1282
|
+
}
|
|
1283
|
+
if (tail && tail > 0 && data.length > tail) {
|
|
1284
|
+
tailNote = `…(前 ${data.length - tail} 行已省略)`;
|
|
1285
|
+
data = data.slice(-tail);
|
|
1286
|
+
}
|
|
1287
|
+
const parts = [];
|
|
1288
|
+
if (tailNote)
|
|
1289
|
+
parts.push(tailNote);
|
|
1290
|
+
parts.push(...data);
|
|
1291
|
+
if (headNote)
|
|
1292
|
+
parts.push(headNote);
|
|
1293
|
+
return parts.join("\n");
|
|
1294
|
+
}
|
|
1274
1295
|
export function makeTerminalBashTool(terminals, opts) {
|
|
1296
|
+
const PERSIST_ID = "ai-bash";
|
|
1275
1297
|
return defineTool({
|
|
1276
1298
|
name: "bash",
|
|
1277
1299
|
label: "Run bash command",
|
|
1278
|
-
description: "Run a shell command and return its full output plus exit code. Commands run in a
|
|
1279
|
-
|
|
1300
|
+
description: "Run a shell command and return its full output plus exit code. Commands run in a visible terminal.\n" +
|
|
1301
|
+
"persist=false (default, one-shot): a fresh terminal is created per call, run to completion, then the shell exits (the process ends) while its output stays in the terminal list for later review — like a normal bash call, but each command also leaves a viewable terminal record.\n" +
|
|
1302
|
+
"persist=true: commands run in the PERSISTENT visible terminal 'ai-bash' — shell state such as cd, venv activation or ssh sessions is retained across calls; you can use terminal_wait to re-block on a backgrounded command, or terminal_read / terminal_input / terminal_key on 'ai-bash' to observe or interact anytime.\n" +
|
|
1303
|
+
"Run the bare command — do NOT pipe through head/tail/more/less (output is returned complete anyway, and pipes hide live progress in the terminal). Use the head/tail parameters instead to trim the returned output. For interactive commands (REPLs, prompts, installers asking y/n) set persist=true and drive them with terminal_input / terminal_key.",
|
|
1304
|
+
promptSnippet: "run shell commands (persist=true keeps the terminal alive across calls)",
|
|
1280
1305
|
parameters: Type.Object({
|
|
1281
1306
|
command: Type.String({ description: "The shell command to run" }),
|
|
1282
1307
|
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
1283
|
-
|
|
1308
|
+
persist: Type.Optional(Type.Boolean({
|
|
1309
|
+
description: "Keep the terminal alive after the command (default: false → a one-shot terminal that exits when the command finishes while its output is retained for review). true runs in the persistent 'ai-bash' terminal so shell state (cd/venv/ssh) is retained across calls and the terminal stays interactive.",
|
|
1310
|
+
})),
|
|
1311
|
+
head: Type.Optional(Type.Integer({
|
|
1284
1312
|
minimum: 1,
|
|
1285
1313
|
maximum: 5000,
|
|
1286
|
-
description: "Only return the
|
|
1314
|
+
description: "Only return the FIRST N lines of output (like `| head -N`). Use this for verbose commands instead of piping through head.",
|
|
1287
1315
|
})),
|
|
1288
|
-
|
|
1289
|
-
|
|
1316
|
+
tail: Type.Optional(Type.Integer({
|
|
1317
|
+
minimum: 1,
|
|
1318
|
+
maximum: 5000,
|
|
1319
|
+
description: "Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail.",
|
|
1290
1320
|
})),
|
|
1291
1321
|
}),
|
|
1292
1322
|
execute: async (_id, p, signal) => {
|
|
1293
|
-
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1323
|
+
const persist = p.persist ?? opts.defaultPersist();
|
|
1324
|
+
// create() 对已存活的同名终端原样返回、对已退出的原地重启。
|
|
1325
|
+
// forceBash:该终端永远跑 bash(而非用户登录 shell),模型写的
|
|
1326
|
+
// bash 语法(数组/read -p/process substitution…)不会踩 zsh 差异。
|
|
1327
|
+
// agentBash:标记为终端接管 bash,前端单独归到「AI bash」分组,
|
|
1328
|
+
// 也不计入终端数量上限。
|
|
1329
|
+
const termId = persist ? PERSIST_ID : `ai-bash-${++oneShotBashSeq}`;
|
|
1330
|
+
const title = persist ? "AI bash" : `AI bash ${oneShotBashSeq}`;
|
|
1331
|
+
if (terminals.create(termId, opts.cwd, 120, 40, opts.cwd, title, {
|
|
1332
|
+
forceBash: true,
|
|
1333
|
+
agentBash: true,
|
|
1334
|
+
}) === null) {
|
|
1335
|
+
throw new Error(`无法打开 AI bash 终端(${termId})`);
|
|
1305
1336
|
}
|
|
1306
|
-
//
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1337
|
+
// 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
|
|
1338
|
+
terminals.suspendIdleWatch(termId);
|
|
1339
|
+
const start = terminals.endCursor(termId);
|
|
1340
|
+
const ac = new AbortController();
|
|
1341
|
+
opts.kills.add(ac);
|
|
1342
|
+
// 一键退出一次性终端:命令结束后 shell 用 exit 退场(进程结束),
|
|
1343
|
+
// 输出保留在 history 供查阅;持久终端则保留 shell 状态跨调用。
|
|
1344
|
+
const closeOneShot = () => {
|
|
1345
|
+
if (!persist)
|
|
1346
|
+
void terminals.inputChecked(termId, "exit\r");
|
|
1347
|
+
};
|
|
1348
|
+
const idleMs = Math.max(0, opts.idleMs());
|
|
1349
|
+
const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
|
|
1350
|
+
// 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat`):
|
|
1351
|
+
// 这类管道在终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;②吞掉真实退出码——
|
|
1352
|
+
// 退出码取管道最后一个命令(tail 恒 0),掩盖真实失败;③需 stdin 的管道出错后可能一直挂到超时。
|
|
1353
|
+
// 拆掉后底层命令直跑(实时可见 + 真实退出码),只在返回给模型时取末尾 N 行或全部。
|
|
1311
1354
|
const limiter = detectTrailingLimiter(p.command);
|
|
1312
1355
|
const stripped = limiter !== null;
|
|
1313
|
-
const effectiveTail = p.tail ?? (limiter?.kind === "tail" ? limiter.lines : undefined);
|
|
1356
|
+
const effectiveTail = p.tail ?? (limiter?.kind === "tail" ? (limiter.lines ?? undefined) : undefined);
|
|
1314
1357
|
const runCommand = stripped ? limiter.base : p.command;
|
|
1315
1358
|
// `cmd > log 2>&1 | tail -N`:拆掉 tail 后 stdout 进文件、终端为空 → 补一个
|
|
1316
1359
|
// tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
|
|
@@ -1319,49 +1362,23 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1319
1362
|
? { file: redirect.file, lines: limiter.lines ?? 10 }
|
|
1320
1363
|
: undefined;
|
|
1321
1364
|
const limiterNote = stripped
|
|
1322
|
-
?
|
|
1365
|
+
? "\n[注:检测到你带了「" + limiter.segment + "」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" + (limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") + " 后续直接用 bash(command, tail=N) 参数限输出。]"
|
|
1323
1366
|
: "";
|
|
1324
|
-
const bashLine = buildTerminalBashLine(runCommand, tailFile);
|
|
1325
|
-
const idLine = `[终端: ${termId}]`;
|
|
1326
|
-
// 一次性:以 `bash -c <line>` 非交互启动(无命令回显/提示符,缓冲=干净输出,
|
|
1327
|
-
// 便于事后按 id 查询/搜索),跑完自动退出。持久:交互 shell,命令经 stdin 写入
|
|
1328
|
-
//(回显/提示符属交互终端本身),保留 cd/venv/ssh 状态。forceBash:永远跑 bash。
|
|
1329
|
-
if (terminals.create(termId, opts.cwd, 120, 40, opts.cwd, persistent ? "AI bash" : `AI bash ${termId}`, persistent ? { forceBash: true } : { forceBash: true, runLine: bashLine }) === null) {
|
|
1330
|
-
throw new Error(`无法打开 AI bash 终端(${termId})`);
|
|
1331
|
-
}
|
|
1332
|
-
// 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
|
|
1333
|
-
terminals.suspendIdleWatch(termId);
|
|
1334
|
-
const start = terminals.endCursor(termId);
|
|
1335
|
-
const ac = new AbortController();
|
|
1336
|
-
opts.kills.add(ac);
|
|
1337
|
-
const idleMs = Math.max(0, opts.idleMs());
|
|
1338
|
-
const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
|
|
1339
|
-
// tail 参数(或检测到的行数):只返回末尾 N 行(替代 `| tail -N` 管道)。
|
|
1340
|
-
const applyTail = (t) => {
|
|
1341
|
-
if (!effectiveTail || effectiveTail <= 0)
|
|
1342
|
-
return t;
|
|
1343
|
-
const lines = t.split("\n");
|
|
1344
|
-
return lines.length > effectiveTail
|
|
1345
|
-
? `…(前 ${lines.length - effectiveTail} 行已省略)\n${lines.slice(-effectiveTail).join("\n")}`
|
|
1346
|
-
: t;
|
|
1347
|
-
};
|
|
1348
1367
|
try {
|
|
1349
1368
|
let collected = "";
|
|
1350
1369
|
let cursor = start;
|
|
1351
1370
|
let lastDataAt = Date.now();
|
|
1352
|
-
// 一次性:命令已在 spawn 时经 `bash -c` 跑起(无 stdin 回显);持久:经 stdin 写入。
|
|
1353
|
-
const inputErr = persistent
|
|
1354
|
-
? terminals.inputChecked(termId, bashLine + "\r")
|
|
1355
|
-
: null;
|
|
1356
|
-
if (inputErr)
|
|
1357
|
-
throw new Error(inputErr);
|
|
1358
1371
|
// 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
|
|
1359
1372
|
terminals.setSentinelPending(termId, true);
|
|
1373
|
+
const inputErr = terminals.inputChecked(termId, buildTerminalBashLine(runCommand, tailFile) + "\r");
|
|
1374
|
+
if (inputErr)
|
|
1375
|
+
throw new Error(inputErr);
|
|
1360
1376
|
for (;;) {
|
|
1361
1377
|
if (ac.signal.aborted || signal?.aborted) {
|
|
1362
|
-
// Ctrl+C
|
|
1378
|
+
// Ctrl+C 杀前台进程;一次性终端随之退出,持久终端保留(会话状态还在)。
|
|
1363
1379
|
terminals.setSentinelPending(termId, false);
|
|
1364
1380
|
terminals.inputChecked(termId, "\x03");
|
|
1381
|
+
closeOneShot();
|
|
1365
1382
|
throw new Error("Command aborted");
|
|
1366
1383
|
}
|
|
1367
1384
|
await sleep(60);
|
|
@@ -1374,29 +1391,27 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1374
1391
|
const m = lastSentinel(collected);
|
|
1375
1392
|
if (m) {
|
|
1376
1393
|
terminals.setSentinelPending(termId, false);
|
|
1377
|
-
|
|
1394
|
+
closeOneShot();
|
|
1395
|
+
const text = applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail);
|
|
1378
1396
|
return {
|
|
1379
1397
|
content: [
|
|
1380
1398
|
{
|
|
1381
1399
|
type: "text",
|
|
1382
|
-
text: `${
|
|
1400
|
+
text: `${text}${text ? "\n" : ""}${limiterNote}[exit:${m[1]}]`,
|
|
1383
1401
|
},
|
|
1384
1402
|
],
|
|
1385
|
-
details: {
|
|
1386
|
-
exitCode: Number(m[1]),
|
|
1387
|
-
output: text,
|
|
1388
|
-
terminalId: termId,
|
|
1389
|
-
},
|
|
1403
|
+
details: { exitCode: Number(m[1]), output: text },
|
|
1390
1404
|
};
|
|
1391
1405
|
}
|
|
1392
1406
|
if (deadline !== null && Date.now() > deadline) {
|
|
1393
1407
|
terminals.setSentinelPending(termId, false);
|
|
1394
1408
|
terminals.inputChecked(termId, "\x03");
|
|
1409
|
+
closeOneShot();
|
|
1395
1410
|
throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
|
|
1396
1411
|
}
|
|
1397
|
-
//
|
|
1398
|
-
if (idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1399
|
-
return backgroundResult(terminals, opts,
|
|
1412
|
+
// 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
|
|
1413
|
+
if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1414
|
+
return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000));
|
|
1400
1415
|
}
|
|
1401
1416
|
}
|
|
1402
1417
|
}
|
|
@@ -1407,12 +1422,12 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1407
1422
|
});
|
|
1408
1423
|
}
|
|
1409
1424
|
/** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
|
|
1410
|
-
function backgroundResult(terminals, opts,
|
|
1411
|
-
terminals.watchOutput(
|
|
1425
|
+
function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
|
|
1426
|
+
terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
|
|
1412
1427
|
// 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
|
|
1413
|
-
terminals.setSentinelPending(
|
|
1428
|
+
terminals.setSentinelPending("ai-bash", false);
|
|
1414
1429
|
opts.notifyBackgroundDone({
|
|
1415
|
-
terminalId,
|
|
1430
|
+
terminalId: "ai-bash",
|
|
1416
1431
|
command,
|
|
1417
1432
|
exitCode: m ? Number(m[1]) : null,
|
|
1418
1433
|
});
|
|
@@ -1423,14 +1438,13 @@ function backgroundResult(terminals, opts, terminalId, command, partialText, sil
|
|
|
1423
1438
|
content: [
|
|
1424
1439
|
{
|
|
1425
1440
|
type: "text",
|
|
1426
|
-
text:
|
|
1441
|
+
text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
|
|
1427
1442
|
`本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
|
|
1428
1443
|
`已有输出:\n${partial || "(暂无输出)"}\n` +
|
|
1429
|
-
`要重新阻塞等它结束就用 terminal_wait(terminalId="
|
|
1430
|
-
note,
|
|
1444
|
+
`要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
|
|
1431
1445
|
},
|
|
1432
1446
|
],
|
|
1433
|
-
details: { running: true, terminalId, silentSeconds },
|
|
1447
|
+
details: { running: true, terminalId: "ai-bash", silentSeconds },
|
|
1434
1448
|
};
|
|
1435
1449
|
}
|
|
1436
1450
|
/** Names of the agent-facing persistent-terminal tools(设置开关门控用)。 */
|
|
@@ -1446,12 +1460,11 @@ export const TERMINAL_TOOL_NAMES = [
|
|
|
1446
1460
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
1447
1461
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
1448
1462
|
* complete output in a single call, so it always wins on convenience. */
|
|
1449
|
-
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
|
|
1450
|
-
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
1451
|
-
- 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).
|
|
1452
|
-
- The user explicitly asks you to work in the visible terminal panel.
|
|
1453
|
-
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
|
|
1454
|
-
Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
|
|
1463
|
+
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 bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
|
|
1464
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
|
|
1465
|
+
- 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).
|
|
1466
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
1467
|
+
Use head/tail on bash to trim verbose output instead of piping through head/tail. 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.`;
|
|
1455
1468
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
1456
1469
|
export function makePersistentTerminalTools(terminals, cwd) {
|
|
1457
1470
|
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
@@ -1535,30 +1548,14 @@ export function makePersistentTerminalTools(terminals, cwd) {
|
|
|
1535
1548
|
defineTool({
|
|
1536
1549
|
name: "terminal_read",
|
|
1537
1550
|
label: "Read terminal output",
|
|
1538
|
-
description: "Read output from a persistent PTY
|
|
1551
|
+
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.",
|
|
1539
1552
|
parameters: Type.Object({
|
|
1540
1553
|
terminalId: Type.String(),
|
|
1541
1554
|
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
1542
1555
|
maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
|
|
1543
1556
|
waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
|
|
1544
|
-
head: Type.Optional(Type.Integer({ minimum: 1, maximum: 10000, description: "Return the first N lines (snapshot query)" })),
|
|
1545
|
-
tail: Type.Optional(Type.Integer({ minimum: 1, maximum: 10000, description: "Return the last N lines (snapshot query)" })),
|
|
1546
|
-
search: Type.Optional(Type.String({ description: "Search this keyword (case-insensitive) in the retained buffer (snapshot query)" })),
|
|
1547
|
-
context: Type.Optional(Type.Integer({ minimum: 0, maximum: 100, description: "Lines of context around each search match (default 3)" })),
|
|
1548
1557
|
}),
|
|
1549
1558
|
execute: async (_id, p, signal) => {
|
|
1550
|
-
// 快照查询模式:head / tail / search 任一给出即按行号返回整段缓冲。
|
|
1551
|
-
if (p.head !== undefined || p.tail !== undefined || p.search !== undefined) {
|
|
1552
|
-
const q = terminals.query(p.terminalId, {
|
|
1553
|
-
head: p.head,
|
|
1554
|
-
tail: p.tail,
|
|
1555
|
-
search: p.search,
|
|
1556
|
-
context: p.context,
|
|
1557
|
-
});
|
|
1558
|
-
if (!q)
|
|
1559
|
-
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1560
|
-
return result(JSON.stringify(q), q);
|
|
1561
|
-
}
|
|
1562
1559
|
const cursor = p.cursor ?? 0;
|
|
1563
1560
|
if (p.waitMs)
|
|
1564
1561
|
await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
|