@aiti-ai/aiti-cmd 0.0.0-stage → 0.1.9

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/runner.ts ADDED
@@ -0,0 +1,594 @@
1
+ /**
2
+ * aiti-cmd · 执行引擎 —— node-pty 真 PTY 会话管理 + SSE 流
3
+ *
4
+ * - run():spawn 真 PTY(node-pty,forkpty/ConPTY),每会话一个 session
5
+ * - input()/resize()/kill():POST 上行(真 TTY 交互)
6
+ * - 会话持久:session 独立于 SSE 连接,关面板不断;TTL 定期清理
7
+ * - 退出时刷审计:append-only JSONL + 输出落盘(去 ANSI,>1MB 截断)
8
+ */
9
+ import { createRequire } from "node:module";
10
+ import { whichBin } from "@aiti-ai/ext-shared/exec-async";
11
+ import { randomBytes } from "node:crypto";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import type { AuditRecord } from "./audit.js";
15
+ import type { CommandStore } from "./store.js";
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ // node-pty 原生模块:装于扩展目录 node_modules(jiti 相对 resolve);加载失败 → 降级标记
20
+ let ptyMod: any = null;
21
+ try {
22
+ ptyMod = require("node-pty");
23
+ if (!ptyMod || typeof ptyMod.spawn !== "function") ptyMod = null;
24
+ } catch (e) {
25
+ console.error("[aiti-cmd] node-pty 加载失败(将降级 script 伪 TTY):", String((e as Error)?.message || e));
26
+ }
27
+
28
+ // macOS 自愈:npm 源上 node-pty@1.1.0 tarball 的 prebuilds/darwin-*/spawn-helper 权限位是 0644
29
+ //(上游发布事故),helper 起不来 → 所有 pty spawn 报 "posix_spawnp failed."。加载时自动 chmod +x
30
+ //(扩展目录 / ~/node_modules 等任意解析路径一并覆盖),用户零感知;spawn 错误路径另有二次自愈
31
+ // + 一次重试(见 spawnSession)。非 darwin / 解析不到 node-pty 时静默空转。
32
+ function healPtyHelper(): void {
33
+ if (process.platform !== "darwin") return;
34
+ try {
35
+ const pkg = require.resolve("node-pty/package.json") as string;
36
+ const prebuilds = path.join(path.dirname(pkg), "prebuilds");
37
+ if (!fs.existsSync(prebuilds)) return;
38
+ for (const d of fs.readdirSync(prebuilds)) {
39
+ if (!d.startsWith("darwin-")) continue;
40
+ const helper = path.join(prebuilds, d, "spawn-helper");
41
+ try {
42
+ if (fs.existsSync(helper) && (fs.statSync(helper).mode & 0o100) === 0) {
43
+ fs.chmodSync(helper, 0o755);
44
+ console.warn("[aiti-cmd] 已自动修复 node-pty spawn-helper 执行权限: " + helper);
45
+ }
46
+ } catch { /* 单个失败不阻断 */ }
47
+ }
48
+ } catch { /* 静默:spawn 错误路径还有兜底 */ }
49
+ }
50
+ if (ptyMod) healPtyHelper();
51
+
52
+ const RING_MAX_BYTES = 1024 * 1024; // 环形缓冲(重放)1MB
53
+ const ALL_MAX_BYTES = 6 * 1024 * 1024; // 审计全量捕获上限 6MB(超出丢最旧 + 标记)
54
+ const SESSION_TTL = 60 * 60 * 1000; // 会话 TTL 1h
55
+ const MAX_CONCURRENT = 4;
56
+ const MAX_CMD_LEN = 20000;
57
+
58
+ // ---------------- 沙箱(bubblewrap 只读文件系统) ----------------
59
+ let bwrapCached: string | null | undefined;
60
+ function bwrapPath(): string | null {
61
+ if (bwrapCached !== undefined) return bwrapCached;
62
+ bwrapCached = null;
63
+ if (process.platform !== "win32") {
64
+ const p = whichBin("bwrap"); // 纯 fs PATH 探测(零 spawn)
65
+ if (p) bwrapCached = p;
66
+ }
67
+ return bwrapCached;
68
+ }
69
+ let sandboxRootDir = "";
70
+ function sandboxRoot(): string {
71
+ return sandboxRootDir;
72
+ }
73
+
74
+ // ---------------- Windows shell 解析(对齐 pi getShellConfig) ----------------
75
+ // 优先级:AITI_CMD_SHELL 显式覆盖 > pi settings.json shellPath(~/.pi/agent/settings.json)
76
+ // > ProgramFiles\Git\bin\bash.exe > where bash.exe 首个匹配。
77
+ // System32/Sysnative 的 bash.exe 是 WSL 启动器:Win→WSL 命令行转参易坏引号,与 pi 同款走 `-s` + 命令经 stdin;
78
+ // 其余 bash 用 -c;找不到任何 bash 时兜底 ComSpec(cmd.exe /d /s /c)。
79
+ interface WinShellPlan {
80
+ prog: string;
81
+ /** 交互式启动参数(终端页签) */
82
+ interactiveArgs: string[];
83
+ /** 一次性命令参数(带命令) */
84
+ cmdArgs: (cmd: string) => string[];
85
+ /** true = 命令经 stdin 喂(spawn 后 write,WSL bash -s) */
86
+ viaStdin: boolean;
87
+ }
88
+ /** shell 路径 → 执行计划(WSL 启动器特殊处理,其余按 bash -c) */
89
+ function winShellPlan(prog: string): WinShellPlan {
90
+ const norm = prog.replace(/\//g, "\\").toLowerCase();
91
+ if (/^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(norm)) {
92
+ return { prog, interactiveArgs: ["-s"], cmdArgs: () => ["-s"], viaStdin: true };
93
+ }
94
+ return { prog, interactiveArgs: [], cmdArgs: (x) => ["-c", x], viaStdin: false };
95
+ }
96
+ function resolveWinShell(): WinShellPlan {
97
+ const exists = (p: string) => { try { return fs.existsSync(p); } catch { return false; } };
98
+ const custom = process.env.AITI_CMD_SHELL;
99
+ if (custom) {
100
+ const base = (custom.toLowerCase().replace(/\.exe$/, "").split(/[\\/]/).pop()) || "";
101
+ const cmdArgs = base === "powershell" || base === "pwsh"
102
+ ? (c: string) => ["-NoLogo", "-Command", c]
103
+ : base === "cmd"
104
+ ? (c: string) => ["/d", "/s", "/c", c]
105
+ : (c: string) => ["-c", c];
106
+ return { prog: custom, interactiveArgs: [], cmdArgs, viaStdin: false };
107
+ }
108
+ // pi settings.json 的 shellPath(settings-manager.getShellPath 同源):用户显式指定的 shell 优先
109
+ try {
110
+ const home = process.env.USERPROFILE || process.env.HOME || "";
111
+ const settingsPath = home ? path.join(home, ".pi", "agent", "settings.json") : "";
112
+ if (settingsPath && exists(settingsPath)) {
113
+ const shellPath = String((JSON.parse(fs.readFileSync(settingsPath, "utf8")) as { shellPath?: string }).shellPath || "").trim();
114
+ if (shellPath && exists(shellPath)) return winShellPlan(shellPath);
115
+ }
116
+ } catch {}
117
+ const pf: string | undefined = process.env.ProgramFiles;
118
+ const pfx86: string | undefined = process.env["ProgramFiles(x86)"];
119
+ for (const c of [pf ? pf + "\\Git\\bin\\bash.exe" : "", pfx86 ? pfx86 + "\\Git\\bin\\bash.exe" : ""]) {
120
+ if (c && exists(c)) return winShellPlan(c);
121
+ }
122
+ {
123
+ const first = whichBin("bash.exe"); // 纯 fs PATH 探测(零 spawn;含 PATHEXT 解析)
124
+ if (first && exists(first)) return winShellPlan(first);
125
+ }
126
+ const comspec = process.env.ComSpec || "cmd.exe";
127
+ return { prog: comspec, interactiveArgs: [], cmdArgs: (x) => ["/d", "/s", "/c", x], viaStdin: false };
128
+ }
129
+
130
+ // node-pty onExit 的 signal 是原始信号编号(15=SIGTERM)→ 映射为名称
131
+ const SIGNAL_NAMES: Record<number, string> = {
132
+ 1: "SIGHUP", 2: "SIGINT", 3: "SIGQUIT", 4: "SIGILL", 6: "SIGABRT", 8: "SIGFPE", 9: "SIGKILL",
133
+ 11: "SIGSEGV", 13: "SIGPIPE", 14: "SIGALRM", 15: "SIGTERM", 17: "SIGCHLD", 18: "SIGCONT", 19: "SIGSTOP",
134
+ };
135
+ // 名称 → 编号反查(降级 script 路径的 exit signal 是名称字符串;审计 exit=128+n 同 PTY 语义)
136
+ const SIGNAL_NUM: Record<string, number> = Object.fromEntries(Object.entries(SIGNAL_NAMES).map(([n, name]) => [name, Number(n)]));
137
+ function signalName(n: number | null | undefined): string | null {
138
+ if (n == null || n === 0) return null;
139
+ return SIGNAL_NAMES[n] || "SIG" + n;
140
+ }
141
+ /** 退出码审计语义:被信号杀时 exit = 128 + 信号号(shell 惯例),exitCode 置 null */
142
+ function auditExit(exitCode: number | null, sigNum: number | null | undefined): number | null {
143
+ if (sigNum) return 128 + sigNum;
144
+ return exitCode;
145
+ }
146
+
147
+ export interface RunOptions {
148
+ command: string;
149
+ cwd?: string;
150
+ env?: Record<string, string>;
151
+ name?: string;
152
+ source?: AuditRecord["source"];
153
+ /** true = 入口未传 source 被兜底(审计标记用,§3.1 防静默吞成 manual) */
154
+ sourceDefaulted?: boolean;
155
+ piSessionId?: string;
156
+ params?: Record<string, string>;
157
+ danger?: string[];
158
+ confirm?: boolean;
159
+ commandId?: string;
160
+ /** 会话 TTL(默认 1h;终端类会话传大值如 24h) */
161
+ ttlMs?: number;
162
+ /** 沙箱:none(默认)/ bwrap-ro(只读+工作目录可写)/ bwrap-offline(只读+断网)/ bwrap-tmp(临时可写盘) */
163
+ sandbox?: "none" | "bwrap-ro" | "bwrap-offline" | "bwrap-tmp";
164
+ }
165
+
166
+ export interface RunSession {
167
+ id: string;
168
+ pty: any | null; // node-pty 实例(降级时 null → 用 script 伪 TTY 子进程)
169
+ child?: any; // 降级:script spawn 的子进程
170
+ cmd: string;
171
+ cwd?: string;
172
+ name?: string;
173
+ source: AuditRecord["source"];
174
+ /** true = 入口未传 source 被兜底(透传到审计记录) */
175
+ sourceDefaulted?: boolean;
176
+ piSessionId?: string;
177
+ startTs: number;
178
+ status: "running" | "exit" | "queued";
179
+ exitCode: number | null;
180
+ signal: string | null;
181
+ /** 原始信号号(审计 exit=128+n 用) */
182
+ sigNum: number | null;
183
+ pid: number | null;
184
+ /** 最近输出的重放缓冲(快照 REST 用);seq 单调递增,供客户端与实时流去重衔接 */
185
+ ring: { seq: number; data: string }[];
186
+ ringBytes: number;
187
+ /** 输出帧序号(feed 每帧 ++;实时总线帧与快照都携带,客户端 seq 去重) */
188
+ seq: number;
189
+ all: string; // 审计全量捕获(上限 ALL_MAX_BYTES)
190
+ allBytes: number;
191
+ allDroppedStart: boolean;
192
+ truncated: boolean;
193
+ /** 会话 TTL(默认 1h;终端类更长) */
194
+ ttlMs: number;
195
+ lastSeen: number;
196
+ /** 危险标记(审计用) */
197
+ danger: string[];
198
+ confirm: boolean;
199
+ /** 沙箱模式(审计用):none / bwrap-ro / bwrap-offline / bwrap-tmp */
200
+ sandbox?: "none" | "bwrap-ro" | "bwrap-offline" | "bwrap-tmp";
201
+ }
202
+
203
+ // 帧主题(aiti-event-bus v0.2 主题订阅):每个终端一个专属 type(cmd-<sessionId>),
204
+ // 列表快照用固定 type。终端大流量帧只进这些主题;订阅方按需声明,SSE 转发层过滤,
205
+ // 无关终端的帧不出连接。
206
+ const ptyTopic = (id: string) => "cmd-" + id;
207
+ const TOPIC_SESSIONS = "cmd-sessions";
208
+
209
+ export function createRunner(audit: { append: (r: AuditRecord) => void; writeOutput: (id: string, raw: string) => { outBytes: number; outputTruncated: boolean; outputFile: string }; logsDir: string }, store: CommandStore, cacheDir = "", publish?: (type: string, data: Record<string, unknown>) => void) {
210
+ sandboxRootDir = path.join(cacheDir || audit.logsDir, "sandbox");
211
+ const sessions = new Map<string, RunSession>();
212
+ const enc = new TextEncoder();
213
+ // aiti-event-bus 发布(aiti-cmd 自管理是否发送):缺席/异常静默(总线下线 = 无实时流,
214
+ // 订阅 topics 唯一,无降级通道——2026-09-09 拍板)。
215
+ const pub = (type: string, data: Record<string, unknown>) => { try { publish?.(type, data); } catch {} };
216
+ const publishSessions = () => { try { pub(TOPIC_SESSIONS, { evt: "sessions", sessions: list() }); } catch {} };
217
+
218
+ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
219
+ status,
220
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
221
+ });
222
+
223
+ function genId() { return "s_" + randomBytes(5).toString("hex"); }
224
+
225
+ /** 追加输出到会话缓冲(ring 重放 + all 审计捕获),并推给所有 SSE 监听 */
226
+ function feed(s: RunSession, data: string) {
227
+ if (!data) return;
228
+ const seq = ++s.seq;
229
+ // ring
230
+ s.ring.push({ seq, data });
231
+ s.ringBytes += Buffer.byteLength(data, "utf8");
232
+ while (s.ringBytes > RING_MAX_BYTES && s.ring.length > 1) {
233
+ const dropped = s.ring.shift()!;
234
+ s.ringBytes -= Buffer.byteLength(dropped.data, "utf8");
235
+ s.truncated = true;
236
+ }
237
+ // all(审计)
238
+ s.all += data;
239
+ s.allBytes += Buffer.byteLength(data, "utf8");
240
+ if (s.allBytes > ALL_MAX_BYTES) {
241
+ // 丢最旧一半
242
+ const cut = Math.floor(s.all.length / 2);
243
+ s.all = s.all.slice(cut);
244
+ s.allBytes = Buffer.byteLength(s.all, "utf8");
245
+ s.allDroppedStart = true;
246
+ }
247
+ s.lastSeen = Date.now();
248
+ // 总线实时增量(小帧走 event-bus;大回放不走——快照走 REST /sessions/{id}/buffered)
249
+ pub(ptyTopic(s.id), { evt: "data", seq, data });
250
+ }
251
+
252
+ /** 会话退出:刷审计 + 通知监听 + 启动队列下一个 */
253
+ function finish(s: RunSession) {
254
+ if (s.status === "exit") return;
255
+ s.status = "exit";
256
+ s.lastSeen = Date.now();
257
+ const durationMs = Date.now() - s.startTs;
258
+ const out = audit.writeOutput(s.id, s.all);
259
+ const rec: AuditRecord = {
260
+ ts: s.startTs,
261
+ id: s.id,
262
+ kind: "exec",
263
+ source: s.source,
264
+ sourceDefaulted: s.sourceDefaulted,
265
+ piSessionId: s.piSessionId,
266
+ cwd: s.cwd,
267
+ cmd: s.cmd,
268
+ danger: s.danger.length ? s.danger : undefined,
269
+ confirm: s.confirm || undefined,
270
+ sandbox: s.sandbox || undefined,
271
+ pid: s.pid || undefined,
272
+ exit: auditExit(s.exitCode, s.sigNum),
273
+ signal: s.signal || undefined,
274
+ durationMs,
275
+ outBytes: out.outBytes,
276
+ outputTruncated: out.outputTruncated || s.allDroppedStart,
277
+ outputFile: out.outputFile,
278
+ };
279
+ audit.append(rec);
280
+ if (s.commandId) { try { store.touchRun(s.commandId); } catch {} }
281
+ pub(ptyTopic(s.id), { evt: "exit", code: s.exitCode, signal: s.signal, durationMs, truncated: out.outputTruncated });
282
+ // 队列:有等待者 → 启动下一个
283
+ const next = queue.shift();
284
+ if (next) spawnSession(next);
285
+ publishSessions();
286
+ }
287
+
288
+ /** 排队 FIFO(超过并发上限时进入;finish 后自动启动下一个) */
289
+ const queue: RunSession[] = [];
290
+ const queuePosition = (id: string) => queue.findIndex((q) => q.id === id) + 1;
291
+
292
+ /** 真正 spawn(running 时调用;queued 已先行发布) */
293
+ function spawnSession(s: RunSession) {
294
+ s.status = "running";
295
+ s.startTs = Date.now();
296
+ s.lastSeen = Date.now();
297
+ pub(ptyTopic(s.id), { evt: "started", sessionId: s.id, pid: null, cmd: s.cmd, cwd: s.cwd, status: "running" });
298
+ try {
299
+ if (ptyMod) {
300
+ const isWin = process.platform === "win32";
301
+ let prog: string;
302
+ let args: string[];
303
+ let cwd = s.cwd || undefined;
304
+ let stdinCmd: string | null = null; // 非 null = spawn 后经 stdin 喂命令(WSL bash -s)
305
+ if (isWin) {
306
+ const ws = resolveWinShell();
307
+ const bare = s.cmd.trim().toLowerCase();
308
+ if (["$shell", "bash", "sh", "zsh", "fish"].includes(bare)) {
309
+ // 「+ 新终端」按钮发字面量 $SHELL、终端页签发 bash → 全部映射到解析出的默认 shell 交互启动
310
+ prog = ws.prog;
311
+ args = ws.interactiveArgs;
312
+ } else if (bare === "cmd" || bare === "cmd.exe") {
313
+ prog = process.env.ComSpec || "cmd.exe";
314
+ args = [];
315
+ } else if (bare === "powershell" || bare === "pwsh" || bare === "powershell.exe" || bare === "pwsh.exe") {
316
+ prog = bare.replace(/\.exe$/, "") + ".exe";
317
+ args = [];
318
+ } else {
319
+ prog = ws.prog;
320
+ args = ws.cmdArgs(s.cmd);
321
+ if (ws.viaStdin) stdinCmd = s.cmd;
322
+ }
323
+ if (s.sandbox && s.sandbox !== "none") {
324
+ feed(s, "\r\n[aiti-cmd] Windows 暂不支持 bubblewrap 沙箱,本次按无沙箱执行\r\n");
325
+ }
326
+ } else {
327
+ prog = process.env.SHELL || "/bin/bash";
328
+ args = ["-c", s.cmd];
329
+ }
330
+ if (!isWin && (s.sandbox === "bwrap-ro" || s.sandbox === "bwrap-offline" || s.sandbox === "bwrap-tmp")) {
331
+ // bubblewrap 沙箱:
332
+ // bwrap-ro 系统只读,仅工作目录真实可写(开发常用)
333
+ // bwrap-offline 同只读 + 断网(--unshare-net,最高隔离)
334
+ // bwrap-tmp 全系统临时可写盘(--tmp-overlay /:下层系统镜像 + 上层 tmpfs,退出即丢)
335
+ const bwrap = bwrapPath();
336
+ if (!bwrap) throw new Error("bwrap(bubblewrap)未安装,无法使用沙箱");
337
+ const workdir = cwd || path.join(sandboxRoot(), s.id);
338
+ fs.mkdirSync(workdir, { recursive: true });
339
+ const base = ["--die-with-parent", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"];
340
+ prog = bwrap;
341
+ if (s.sandbox === "bwrap-tmp") {
342
+ // 一次性环境:系统只读 + /tmp /var/tmp /run /root /$HOME 全 tmpfs(可写但退出即丢);工作目录只读可见
343
+ args = [...base, "--tmpfs", "/var/tmp", "--tmpfs", "/run", "--tmpfs", "/root", "--tmpfs", process.env.HOME || "/root", "--chdir", cwd || "/tmp", process.env.SHELL || "/bin/bash", "-c", s.cmd];
344
+ } else {
345
+ args = [...base, "--bind", workdir, workdir, "--chdir", workdir];
346
+ if (s.sandbox === "bwrap-offline") args.push("--unshare-net");
347
+ args.push(process.env.SHELL || "/bin/bash", "-c", s.cmd);
348
+ }
349
+ cwd = workdir;
350
+ }
351
+ const startPty = () => {
352
+ const p = ptyMod.spawn(prog, args, {
353
+ name: "xterm-256color",
354
+ cols: 100, rows: 30,
355
+ cwd,
356
+ env: { ...process.env, TERM: "xterm-256color" } as Record<string, string>,
357
+ });
358
+ s.pty = p;
359
+ s.pid = p.pid || null;
360
+ pub(ptyTopic(s.id), { evt: "pid", pid: s.pid });
361
+ if (stdinCmd) { try { p.write(stdinCmd + "\nexit\n"); } catch {} }
362
+ p.onData((d: string) => feed(s, d));
363
+ p.onExit(({ exitCode, signal }: { exitCode: number; signal: number }) => {
364
+ s.exitCode = exitCode;
365
+ s.signal = signalName(signal);
366
+ s.sigNum = signal || null;
367
+ finish(s);
368
+ });
369
+ };
370
+ try {
371
+ startPty();
372
+ } catch (spawnErr) {
373
+ // helper 权限丢失(如部署后 npm 又重装过 node-pty)→ 自愈后重试一次;再失败走外层报错
374
+ if (process.platform === "darwin" && String((spawnErr as Error)?.message || spawnErr).includes("posix_spawnp failed")) {
375
+ healPtyHelper();
376
+ startPty();
377
+ } else throw spawnErr;
378
+ }
379
+ } else {
380
+ spawnFallback(s.cmd, s.cwd, undefined, s);
381
+ }
382
+ } catch (e) {
383
+ feed(s, "\r\n[aiti-cmd] 启动失败: " + String((e as Error)?.message || e) + "\r\n");
384
+ s.exitCode = 127;
385
+ finish(s);
386
+ }
387
+ }
388
+
389
+
390
+ /** 降级路径:script -qec 伪 TTY(node-pty 不可用时) */
391
+ function spawnFallback(command: string, cwd: string | undefined, env: Record<string, string> | undefined, s: RunSession) {
392
+ if (process.platform === "win32") {
393
+ // script 降级仅限 Linux/macOS:Windows 上 node-pty 未加载时给出明确报错而非 script ENOENT
394
+ feed(s, "\r\n[aiti-cmd] Windows 需要 node-pty 原生模块(本次未加载成功),无法降级 script 伪 TTY\r\n");
395
+ s.exitCode = 127;
396
+ finish(s);
397
+ return null;
398
+ }
399
+ const { spawn } = require("node:child_process") as typeof import("node:child_process");
400
+ const args = process.platform === "darwin"
401
+ ? ["-q", "/dev/null", command]
402
+ : ["-qec", command, "/dev/null"];
403
+ const child = spawn("script", args, {
404
+ cwd: cwd || undefined,
405
+ env: { ...process.env, ...(env || {}), TERM: "xterm-256color" },
406
+ stdio: ["pipe", "pipe", "inherit"],
407
+ });
408
+ s.child = child;
409
+ s.pid = child.pid || null;
410
+ child.stdout.on("data", (d: Buffer) => feed(s, d.toString("utf8")));
411
+ child.on("error", (e: Error) => { feed(s, "\r\n[aiti-cmd] script 启动失败: " + e.message + "\r\n"); s.exitCode = 127; finish(s); });
412
+ child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
413
+ s.exitCode = code;
414
+ s.signal = signal ? String(signal) : null;
415
+ // 降级路径同样落 sigNum(审计 exit=128+n 语义与 PTY 模式一致;exitCode 置 null 同 auditExit)
416
+ s.sigNum = signal ? (SIGNAL_NUM[signal] ?? null) : null;
417
+ finish(s);
418
+ });
419
+ return child;
420
+ }
421
+
422
+ function run(opts: RunOptions): { ok: boolean; sessionId?: string; queued?: boolean; position?: number; error?: string } {
423
+ const cmd = String(opts.command || "").trim();
424
+ if (!cmd) return { ok: false, error: "命令为空" };
425
+ if (cmd.length > MAX_CMD_LEN) return { ok: false, error: "命令过长" };
426
+ // 惰性清理过期会话
427
+ const now = Date.now();
428
+ for (const [id, x] of sessions) {
429
+ if (x.status === "exit" && now - x.lastSeen > x.ttlMs) {
430
+ try { x.pty?.kill(); } catch {}
431
+ sessions.delete(id);
432
+ }
433
+ }
434
+ const id = genId();
435
+ const s: RunSession = {
436
+ id, pty: null, cmd, cwd: opts.cwd || undefined, name: opts.name,
437
+ source: opts.source || "manual", sourceDefaulted: opts.sourceDefaulted === true || undefined, piSessionId: opts.piSessionId,
438
+ startTs: Date.now(), status: "running", exitCode: null, signal: null, sigNum: null, pid: null,
439
+ ring: [], ringBytes: 0, all: "", allBytes: 0, seq: 0,
440
+ allDroppedStart: false, truncated: false,
441
+ ttlMs: opts.ttlMs || SESSION_TTL, lastSeen: Date.now(),
442
+ danger: opts.danger || [], confirm: opts.confirm === true,
443
+ sandbox: opts.sandbox === "bwrap-ro" || opts.sandbox === "bwrap-offline" || opts.sandbox === "bwrap-tmp" || opts.sandbox === "none" ? opts.sandbox : undefined,
444
+ };
445
+ sessions.set(id, s);
446
+ // 排队:超过并发上限 → FIFO 入队(不拒绝)
447
+ const running = [...sessions.values()].filter((x) => x.status === "running").length;
448
+ if (running >= MAX_CONCURRENT) {
449
+ s.status = "queued";
450
+ queue.push(s);
451
+ pub(ptyTopic(id), { evt: "queued", position: queue.length, maxConcurrent: MAX_CONCURRENT });
452
+ publishSessions();
453
+ return { ok: true, sessionId: id, queued: true, position: queue.length };
454
+ }
455
+ spawnSession(s);
456
+ publishSessions();
457
+ return { ok: true, sessionId: id };
458
+ }
459
+
460
+ function input(sessionId: string, data: string) {
461
+ const s = sessions.get(sessionId);
462
+ if (!s) return json({ ok: false, error: "unknown session" }, 404);
463
+ if (s.status !== "running") return json({ ok: false, error: "session 已退出" }, 409);
464
+ try {
465
+ if (s.pty) s.pty.write(data);
466
+ else s.child?.stdin.write(data);
467
+ } catch (e) { return json({ ok: false, error: String((e as Error)?.message || e) }); }
468
+ return json({ ok: true });
469
+ }
470
+
471
+ function resize(sessionId: string, cols: number, rows: number) {
472
+ const s = sessions.get(sessionId);
473
+ if (!s) return json({ ok: false, error: "unknown session" }, 404);
474
+ if (s.status !== "running") return json({ ok: false, error: "session 已退出" }, 409);
475
+ const c = Math.max(20, Math.min(500, parseInt(String(cols), 10) || 100));
476
+ const r = Math.max(5, Math.min(200, parseInt(String(rows), 10) || 30));
477
+ try {
478
+ if (s.pty) s.pty.resize(c, r);
479
+ } catch {}
480
+ return json({ ok: true, cols: c, rows: r });
481
+ }
482
+
483
+ function kill(sessionId: string) {
484
+ const s = sessions.get(sessionId);
485
+ if (!s) return json({ ok: false, error: "unknown session" }, 404);
486
+ if (s.status === "queued") {
487
+ // 排队中取消:出队 + 记一条未完成审计
488
+ const qi = queue.findIndex((q) => q.id === sessionId);
489
+ if (qi >= 0) queue.splice(qi, 1);
490
+ s.status = "exit";
491
+ s.lastSeen = Date.now();
492
+ audit.append({
493
+ ts: s.startTs, id: s.id, kind: "exec", source: s.source, piSessionId: s.piSessionId,
494
+ cwd: s.cwd, cmd: s.cmd, exit: null, durationMs: Date.now() - s.startTs, outBytes: 0,
495
+ });
496
+ pub(ptyTopic(sessionId), { evt: "exit", code: null, signal: "CANCELLED", durationMs: 0, truncated: false, cancelled: true });
497
+ publishSessions();
498
+ return json({ ok: true, cancelled: true });
499
+ }
500
+ if (s.status !== "running") return json({ ok: false, error: "session 已退出" }, 409);
501
+ try {
502
+ if (s.pty) {
503
+ if (process.platform === "win32") {
504
+ // node-pty Windows:kill(signal) 直接抛 "Signals not supported on windows.";
505
+ // 无参 kill 仅关 ConPTY,对 WSL bash 不保证生效 → taskkill 树杀兜底
506
+ try { s.pty.kill(); } catch {}
507
+ if (s.pid) {
508
+ try {
509
+ // 树杀兜底改异步派发(2026-09-12):fire-and-forget,不再同步等待 taskkill(≤5s 冻结)
510
+ const { spawn: _spawn } = require("node:child_process") as typeof import("node:child_process");
511
+ _spawn("taskkill", ["/PID", String(s.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).unref();
512
+ } catch {}
513
+ }
514
+ } else {
515
+ s.pty.kill("SIGTERM");
516
+ // 3s 后仍未退出 → 杀进程组(npm 孙进程树)
517
+ setTimeout(() => {
518
+ if (sessions.get(sessionId)?.status === "running" && s.pid) {
519
+ try { process.kill(-s.pid, "SIGKILL"); } catch {}
520
+ try { s.pty.kill("SIGKILL"); } catch {}
521
+ }
522
+ }, 3000);
523
+ }
524
+ } else if (s.child) {
525
+ try { s.child.kill("SIGTERM"); } catch {}
526
+ }
527
+ } catch (e) { return json({ ok: false, error: String((e as Error)?.message || e) }); }
528
+ return json({ ok: true, killing: true });
529
+ }
530
+
531
+ function list() {
532
+ return [...sessions.values()].map((s) => ({
533
+ id: s.id, cmd: s.cmd, name: s.name, cwd: s.cwd, source: s.source,
534
+ status: s.status, pid: s.pid, startTs: s.startTs,
535
+ exitCode: s.exitCode, signal: s.signal, durationMs: s.status === "exit" ? s.lastSeen - s.startTs : Date.now() - s.startTs,
536
+ ringBytes: s.ringBytes, truncated: s.truncated, sandbox: s.sandbox,
537
+ queuePosition: s.status === "queued" ? queuePosition(s.id) : undefined,
538
+ queueLength: queue.length,
539
+ }));
540
+ }
541
+
542
+ function getInfo(sessionId: string) {
543
+ const s = sessions.get(sessionId);
544
+ if (!s) return null;
545
+ return { id: s.id, cmd: s.cmd, cwd: s.cwd, status: s.status, exitCode: s.exitCode, ringBytes: s.ringBytes, sandbox: s.sandbox };
546
+ }
547
+
548
+ /** shell 实时目录(Linux 读 /proc/<pid>/cwd;macOS 用 lsof;win32 不支持返回 null)。
549
+ * PTY 会话 s.pid 即交互 shell 进程(bash -c 末命令 exec 后 pid 不变),内建 cd 同步其 cwd——
550
+ * 终端里 cd 过再「+ 新建」/「新 pi-web 会话」取的就是当前 pwd,与系统终端行为一致。 */
551
+ function liveCwd(sessionId: string): string | null {
552
+ const s = sessions.get(sessionId);
553
+ if (!s || !s.pid || s.status === "exit") return null;
554
+ if (process.platform === "linux") {
555
+ try { return fs.readlinkSync(`/proc/${s.pid}/cwd`); } catch { return null; }
556
+ }
557
+ if (process.platform === "darwin") {
558
+ try {
559
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
560
+ const out = execFileSync("lsof", ["-a", "-p", String(s.pid), "-d", "cwd", "-Fn"], { encoding: "utf8", timeout: 3000 });
561
+ const m = /^n(\/.+)$/m.exec(out);
562
+ return m ? m[1] : null;
563
+ } catch { return null; }
564
+ }
565
+ return null;
566
+ }
567
+
568
+ function diag() {
569
+ return { nodePty: !!ptyMod, sessions: sessions.size, running: [...sessions.values()].filter((x) => x.status === "running").length, queued: queue.length, maxConcurrent: MAX_CONCURRENT, bwrap: bwrapPath(), sandbox: "bwrap-ro" };
570
+ }
571
+
572
+ /**
573
+ * 初始快照(REST;终端大消息不走 event-bus,订阅方先订阅后拉这个)。
574
+ * 返回 started meta + ring 全量 + 当前 seq + exit 状态——客户端 reset 后重放、
575
+ * 用 seq 去重后续实时帧,与事件总线增量流无缝衔接(无缝无重复)。
576
+ */
577
+ function buffered(sessionId: string) {
578
+ const s = sessions.get(sessionId);
579
+ if (!s) return null;
580
+ return {
581
+ id: s.id, cmd: s.cmd, cwd: s.cwd, name: s.name, status: s.status,
582
+ pid: s.pid, startTs: s.startTs,
583
+ exitCode: s.exitCode, signal: s.signal,
584
+ durationMs: s.status === "exit" ? s.lastSeen - s.startTs : Date.now() - s.startTs,
585
+ truncated: s.truncated,
586
+ seq: s.seq,
587
+ chunks: s.ring,
588
+ };
589
+ }
590
+
591
+ return { run, input, resize, kill, list, getInfo, diag, buffered, liveCwd, sessions };
592
+ }
593
+
594
+ export type Runner = ReturnType<typeof createRunner>;
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: command-create
3
+ title: AI 帮我写命令
4
+ placeholder: 描述这条命令做什么(如:一键打包并推 GitHub)…
5
+ outputs:
6
+ format: json-obj
7
+ schema:
8
+ name: 命令名(简短、动宾,如 build-web)
9
+ cmd: shell 命令,参数用 {param} 占位(必填)
10
+ tags: 标签数组(可选)
11
+ confirm: 是否危险命令需确认(布尔,可选)
12
+ suggest: 是否作为智能提示来源(布尔,可选;默认 true=参与 ai-input 输入联想;隐私/敏感命令设 false)
13
+ action:
14
+ type: api
15
+ method: POST
16
+ path: /api/extensions/aiti-cmd/commands
17
+ mapping:
18
+ name: name
19
+ cmd: cmd
20
+ tags: tags
21
+ confirm: confirm
22
+ suggest: suggest
23
+ ---
24
+
25
+ # command-create · 写命令
26
+
27
+ 你是 aiti-cmd 命令助手。根据用户描述写一条 shell 命令,**只输出 JSON,不闲聊**。
28
+
29
+ ## 输出格式
30
+
31
+ ```json
32
+ {
33
+ "name": "简短命令名(动宾,如 build-web)",
34
+ "cmd": "shell 命令,参数用 {param} 占位符(必填)",
35
+ "tags": ["标签"],
36
+ "confirm": false,
37
+ "suggest": true
38
+ }
39
+ ```
40
+
41
+ - `confirm`: 是否危险命令需确认(布尔,可选;危险命令如 rm -rf、git push --force 等设 true)
42
+ - `suggest`: 是否作为 aiti-ai-input 智能提示(输入联想/补全)的来源(布尔,可选;默认 true。含隐私/敏感信息的命令设为 false,输入时不再被提示)
43
+ - 参考现有命令风格:可先 GET `{basePath}/api/extensions/aiti-cmd/commands` 看已有命令,保持命名/风格一致
44
+
45
+ ## 落地
46
+
47
+ 前端会把 JSON 通过 `POST {basePath}/api/extensions/aiti-cmd/commands` 入库(mapping:name/cmd/tags/confirm/suggest)。