@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/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/audit.ts +255 -0
- package/check-client.mjs +273 -0
- package/client/src/index.js +2216 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/ext-icons-deploy.mjs +24 -0
- package/icon.svg +1 -0
- package/icons/terminal.svg +1 -0
- package/index.ts +544 -0
- package/package.json +34 -4
- package/pi-commands.ts +286 -0
- package/recognize.ts +508 -0
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/runner.ts +594 -0
- package/skills/command-create/SKILL.md +47 -0
- package/skills/command-fill/SKILL.md +46 -0
- package/skills/command-fix/SKILL.md +49 -0
- package/skills/command-run/SKILL.md +41 -0
- package/skills/index.json +6 -0
- package/store.ts +228 -0
- package/tools/build-client.mjs +52 -0
- package/tools/deploy.mjs +120 -0
- package/tools/exec-integration.test.mjs +222 -0
- package/tools/recognize.test.mjs +163 -0
- package/README.md +0 -3
package/index.ts
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-cmd · pi-web 命令管理器扩展(真 TTY 终端 + 常用命令 + 消息识别命令)
|
|
3
|
+
*
|
|
4
|
+
* 能力:
|
|
5
|
+
* - 执行引擎:node-pty 真 PTY(实时增量帧走 aiti-event-bus 主题 cmd-<id> 唯一下行;
|
|
6
|
+
* 初始回放/大消息走 REST 快照;POST 上行 input/resize/kill。旧 per-session SSE 已删——
|
|
7
|
+
* 订阅 topics 唯一,禁止兑底,2026-09-09 拍板)
|
|
8
|
+
* - 常用命令:CRUD + 参数化占位符 {xxx}
|
|
9
|
+
* - 消息识别:/commands/check 异步校验(与 /api/files 同模式)
|
|
10
|
+
* - 审计:所有执行 append-only JSONL 落盘(logs/<date>.jsonl)
|
|
11
|
+
*
|
|
12
|
+
* 用法:部署到 ~/.pi/web/extensions/aiti-cmd/(tools/deploy.mjs)后 reload。
|
|
13
|
+
*/
|
|
14
|
+
import { createAudit } from "./audit.js";
|
|
15
|
+
import { createStore, normSandbox } from "./store.js";
|
|
16
|
+
import { checkCommand, COMMAND_DICT, describeCommand, extractCommandsFromText, loadUserDict, saveUserDict } from "./recognize.js";
|
|
17
|
+
import { createRunner, type RunOptions } from "./runner.js";
|
|
18
|
+
import { createPiCommands } from "./pi-commands.js";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
let audit: ReturnType<typeof createAudit> | null = null;
|
|
22
|
+
let store: ReturnType<typeof createStore> | null = null;
|
|
23
|
+
let runner: ReturnType<typeof createRunner> | null = null;
|
|
24
|
+
let piCommands: ReturnType<typeof createPiCommands> | null = null;
|
|
25
|
+
// 数据目录(startup 捕获,api 处统一引用,不裸读 ctx.*)——新旧 core 双轨(过渡期):
|
|
26
|
+
// 新 core = configDir(持久)+ cacheDir(可重建);旧 core(如 prod 未同步)只有 dataDir。
|
|
27
|
+
// 取值序 configDir → dataDir:新 core 上 dataDir 属性「访问即抛」,configDir 在前短路绝不触它。
|
|
28
|
+
let dirCfg = "";
|
|
29
|
+
let dirCache = "";
|
|
30
|
+
|
|
31
|
+
// 终端流订阅保活(停推):type → 最后心跳时刻。前端 15s POST /alive;超 45s 未刷新的
|
|
32
|
+
// type 在 publish 包装里停推(页面/面板关闭后源头即断)。未报活过的 type 乐观推。
|
|
33
|
+
const aliveMap = new Map<string, number>();
|
|
34
|
+
const ALIVE_TIMEOUT_MS = 45_000;
|
|
35
|
+
|
|
36
|
+
// 词库分类展示(/commands/dict)
|
|
37
|
+
function COMMAND_DICT_GROUPS() {
|
|
38
|
+
return COMMAND_DICT.map((g) => ({ group: g.group, names: g.names, phrases: g.phrases.slice(0, 30) }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 危险命令一次性确认 token(用后即弃 + 3s 过期,防重放)
|
|
42
|
+
const confirmTokens = new Map<string, number>(); // token -> expires
|
|
43
|
+
export function issueConfirmToken(): string {
|
|
44
|
+
const t = "ct_" + randomBytes(16).toString("hex");
|
|
45
|
+
confirmTokens.set(t, Date.now() + 3000);
|
|
46
|
+
return t;
|
|
47
|
+
}
|
|
48
|
+
export function consumeConfirmToken(t: string): boolean {
|
|
49
|
+
const exp = confirmTokens.get(t);
|
|
50
|
+
if (!exp) return false;
|
|
51
|
+
confirmTokens.delete(t);
|
|
52
|
+
return Date.now() < exp;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
|
|
56
|
+
status,
|
|
57
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// source 规范收口(aiti-cmd-exec-integration-plan.md §3.1):枚举开放语义,这里只列基线值——
|
|
61
|
+
// POST /run 传基线外的新值照记(开放),传未知值归 "unknown"(防拼错不可归因);缺 source 兜底
|
|
62
|
+
// manual + 审计标 sourceDefaulted(不再静默吞)。
|
|
63
|
+
const KNOWN_SOURCES = new Set([
|
|
64
|
+
"manual", "message", "shell", "favorite", "asr-selfheal",
|
|
65
|
+
"schedule", "suggest", "ai-helper", "codebuddy", "service", "unknown",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// ---------- suggest 静态 provider(处理器:ai-input 进程内聚合优先;HTTP 端点同逻辑兑底) ----------
|
|
69
|
+
// 后端扩展点:本扩展启动时把查询处理器注册进 globalThis 注册表,ai-input /suggest 免 HTTP 自环直接调用
|
|
70
|
+
try {
|
|
71
|
+
const g = globalThis as Record<string, unknown>;
|
|
72
|
+
const reg = (g.__aitiSuggestHandlers ?? (g.__aitiSuggestHandlers = new Map())) as Map<
|
|
73
|
+
string, (q: string, limit: number) => Promise<unknown[]> | unknown[]>;
|
|
74
|
+
reg.set("aiti-cmd", (q: string, limit: number) => commandSuggest(store, q, limit));
|
|
75
|
+
} catch { /* 注册失败不影响主流程 */ }
|
|
76
|
+
|
|
77
|
+
// 命令 suggest 查询(api /commands/suggest 与进程内聚合共用)
|
|
78
|
+
// 排序:匹配度(name 前缀>name 子串>cmd/tags) > runCount > lastRunAt > 名称
|
|
79
|
+
function commandSuggest(store: ReturnType<typeof createStore> | null, q: string, limit: number): unknown[] {
|
|
80
|
+
if (!store) return [];
|
|
81
|
+
// 关闭了智能提示(suggest:false)的命令不参与联想(aiti-ai-input L0 本地层 / /suggest 聚合 / HTTP 端点同一函数全覆盖)
|
|
82
|
+
let items = store.list().filter((c) => c.suggest !== false);
|
|
83
|
+
const lq = q.trim().toLowerCase();
|
|
84
|
+
if (lq) items = items.filter((c) =>
|
|
85
|
+
c.name.toLowerCase().includes(lq) || c.cmd.toLowerCase().includes(lq) ||
|
|
86
|
+
(c.tags || []).some((t) => t.toLowerCase().includes(lq)));
|
|
87
|
+
const rank = (c: (typeof items)[number]): number => {
|
|
88
|
+
if (!lq) return 0;
|
|
89
|
+
if (c.name.toLowerCase().startsWith(lq)) return 3;
|
|
90
|
+
if (c.name.toLowerCase().includes(lq)) return 2;
|
|
91
|
+
if (c.cmd.toLowerCase().includes(lq) || (c.tags || []).some((t) => t.toLowerCase().includes(lq))) return 1;
|
|
92
|
+
return 0;
|
|
93
|
+
};
|
|
94
|
+
items.sort((a, b) => {
|
|
95
|
+
const d = rank(b) - rank(a); if (d) return d;
|
|
96
|
+
const du = (b.runCount || 0) - (a.runCount || 0); if (du) return du;
|
|
97
|
+
const dt = (b.lastRunAt || 0) - (a.lastRunAt || 0); if (dt) return dt;
|
|
98
|
+
return a.name.localeCompare(b.name, "zh");
|
|
99
|
+
});
|
|
100
|
+
return items.slice(0, Math.max(1, Math.min(limit || 6, 20))).map((c) => ({
|
|
101
|
+
id: c.id, kind: "cmd", name: c.name, cmd: c.cmd,
|
|
102
|
+
params: c.params || [], groupId: c.groupId || null, confirm: !!c.confirm,
|
|
103
|
+
runCount: c.runCount || 0, lastRunAt: c.lastRunAt || null,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default {
|
|
108
|
+
id: "aiti-cmd",
|
|
109
|
+
name: "aiti 命令管理器(真 TTY 终端 + 常用命令)",
|
|
110
|
+
version: "0.1.0",
|
|
111
|
+
icon: "icon.svg",
|
|
112
|
+
client: "dist/aiti-cmd-client.mjs",
|
|
113
|
+
|
|
114
|
+
// 声明本扩展是 suggest 静态 provider(ai-input 聚合;命中命令直接填/执行,不走 AI——docs/aiti-ai-helper-plan.md §3.5)
|
|
115
|
+
suggest: { label: "命令", rank: 0, path: "commands/suggest" },
|
|
116
|
+
|
|
117
|
+
// AI 自管理清单(docs/aiti-ai-helper-plan.md §3.2):告诉 AI 怎么管理本扩展
|
|
118
|
+
ai: {
|
|
119
|
+
manage: true,
|
|
120
|
+
intro: "AI 能帮我写命令(描述需求 → JSON → 校验 → 入库)",
|
|
121
|
+
skills: "skills",
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// 功能管理(extman 逐项启停):agent 工具门控——core per-tool 约定 feature.id = "tool.<注入后的工具名>"
|
|
125
|
+
features: [
|
|
126
|
+
{ id: "tool.aiti_cmd_run", label: "agent 工具:aiti_cmd_run(AI 会话内经后端 runner 执行命令;危险命令二次确认)", group: "agent-tools", default: true, description: "关闭后新 agent 会话不再注入该工具(已注入会话即时拒绝)。" },
|
|
127
|
+
],
|
|
128
|
+
|
|
129
|
+
ui: {
|
|
130
|
+
clientImmediately: false, // [懒加载 v2] M1 已同步生产,懒化重启用
|
|
131
|
+
// 懒加载 v2 M3批②:静态条目与 client 注册 id/icon 对齐
|
|
132
|
+
toolbar: [{ id: "aiti-cmd-open", label: "命令", title: "aiti-cmd:运行总览 / 常用命令 / 执行记录", icon: "icons/terminal.svg" }],
|
|
133
|
+
dialogs: [{ id: "aiti-cmd-panel", label: "aiti-cmd 命令面板" }, { id: "aiti-cmd-detail", label: "aiti-cmd 命令详情" }],
|
|
134
|
+
// terminalDock 槽位声明(懒加载适配修复 2026-09-09):declaredSlots 收集自 ui 的键——
|
|
135
|
+
// 缺此声明时 pendingForSlot("terminalDock") 恒空,TerminalPanelBridge 查槽不触发本扩展
|
|
136
|
+
// 物化 → fallback 再查 __aitiCmd 也无(client 未加载)→ 误报「未检测到 aiti-cmd 扩展」。
|
|
137
|
+
// id 与 client registerItems("terminalDock",[{id:"workspace-terminal",…}]) 对齐;
|
|
138
|
+
// 静态条目无 render,接管判定 hasExtRender 自动跳过,物化后同 id 覆盖(同 aiti-editor fileViewer 模式)。
|
|
139
|
+
terminalDock: [{ id: "workspace-terminal", label: "工作区终端", title: "aiti-cmd:工作区终端页签接管(xterm)" }],
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
server: {
|
|
143
|
+
startup(ctx) {
|
|
144
|
+
dirCfg = (ctx.configDir as string) || "";
|
|
145
|
+
dirCache = (ctx.cacheDir as string) || (dirCfg ? dirCfg + "/cache" : "");
|
|
146
|
+
audit = createAudit(dirCache);
|
|
147
|
+
store = createStore(dirCfg);
|
|
148
|
+
// aiti-event-bus 发布出口(aiti-cmd 自管理是否发送;惰性现取——event-bus 晚加载/缺席均安全)。
|
|
149
|
+
// 终端帧是高频大流量 → 走 publish-local(直连总线,不双喂 core route);
|
|
150
|
+
// local 缺席(旧版 event-bus)时退回全局 publish 出口。
|
|
151
|
+
const publish = (type: string, data: Record<string, unknown>) => {
|
|
152
|
+
try {
|
|
153
|
+
const last = aliveMap.get(type);
|
|
154
|
+
if (last !== undefined && Date.now() - last > ALIVE_TIMEOUT_MS) return; // 订阅已停止:停推
|
|
155
|
+
const svc = (ctx.services as { get?: (n: string) => { publish?: (t: string, d: Record<string, unknown>) => void } | undefined } | undefined);
|
|
156
|
+
(svc?.get?.("aiti-event-bus.publish-local") ?? svc?.get?.("aiti-event-bus.publish"))?.publish?.(type, data);
|
|
157
|
+
} catch {}
|
|
158
|
+
};
|
|
159
|
+
runner = createRunner(audit, store, dirCache, publish);
|
|
160
|
+
piCommands = createPiCommands(publish);
|
|
161
|
+
piCommands.lateCatchUp(); // 扩展加载晚于 wrapper 创建:扫 registry 补订阅存量会话
|
|
162
|
+
|
|
163
|
+
// 统一集成锚点(aiti-cmd-exec-integration-plan.md §3.2,对齐 codebuddy §9.3):进程内共享
|
|
164
|
+
// runner——跨扩展「后端建会话」的唯一出口(消费方:aiti-ai-input ASR 自愈 / aiti-codebuddy-agent
|
|
165
|
+
// 登录 PTY 等)。services 版返回纯数据(非 REST 的 Response);REST 端点保持原样作前端本体通道。
|
|
166
|
+
// 方法集 = runner.ts 返回值;惰性引模块级 runner(reload 后自然指向新实例)。
|
|
167
|
+
try {
|
|
168
|
+
const unwrap = async (r: unknown) => {
|
|
169
|
+
if (!(r instanceof Response)) return r;
|
|
170
|
+
const b = await r.json().catch(() => ({}));
|
|
171
|
+
return { status: r.status, ...b };
|
|
172
|
+
};
|
|
173
|
+
(ctx.services as { provide?: (n: string, s: unknown) => void } | undefined)?.provide?.("aiti-cmd.runner", {
|
|
174
|
+
// services 通道缺省 source="service"(§3.6:services 会话同进审计);消费方显式传值优先
|
|
175
|
+
run: (opts: Partial<RunOptions> = {}) => {
|
|
176
|
+
const r = runner!.run({ ...opts, source: opts.source || "service" });
|
|
177
|
+
// queued/position 透传(ASR 自愈等消费方依赖排队语义,行为等价)
|
|
178
|
+
return r.ok ? { ok: true, sessionId: r.sessionId, queued: r.queued === true, position: r.position } : r;
|
|
179
|
+
},
|
|
180
|
+
input: (id: string, data: string) => unwrap(runner!.input(id, data)),
|
|
181
|
+
resize: (id: string, cols: number, rows: number) => unwrap(runner!.resize(id, cols, rows)),
|
|
182
|
+
kill: (id: string) => unwrap(runner!.kill(id)),
|
|
183
|
+
list: () => runner!.list(),
|
|
184
|
+
getInfo: (id: string) => runner!.getInfo(id),
|
|
185
|
+
diag: () => runner!.diag(),
|
|
186
|
+
buffered: (id: string) => runner!.buffered(id),
|
|
187
|
+
sessions: () => runner!.list(), // 与 list 同源(codebuddy 清单二者并列,保持兼容)
|
|
188
|
+
});
|
|
189
|
+
console.log("[aiti-cmd] services 已注册:aiti-cmd.runner(跨扩展后端建会话统一出口)");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.warn("[aiti-cmd] services.provide(aiti-cmd.runner) 失败(旧 core 无 services?)", e);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 事件总线侧注册回放数据源?不需要——终端大消息(初始回放)不走总线,
|
|
195
|
+
// 客户端直接 REST 拉 /sessions/{id}/buffered 与 /pi-commands/{id}(见下方端点)。
|
|
196
|
+
|
|
197
|
+
// 保留策略:启动清理一次 + 每日清理(按天分片超期删除)
|
|
198
|
+
try { const removed = audit.cleanupRetention(); if (removed) console.log("[aiti-cmd] 清理超期日志 " + removed + " 个"); } catch {}
|
|
199
|
+
const daily = setInterval(() => { try { audit?.cleanupRetention(); } catch {} }, 24 * 3600 * 1000);
|
|
200
|
+
(daily as unknown as { unref?: () => void }).unref?.();
|
|
201
|
+
console.log("[aiti-cmd] 就绪:node-pty=" + (runner.diag().nodePty ? "可用" : "降级 script") + " · 保留 " + audit.retentionDays + " 天 · configDir=" + dirCfg);
|
|
202
|
+
},
|
|
203
|
+
shutdown() {
|
|
204
|
+
try { piCommands?.dispose(); } catch {}
|
|
205
|
+
audit = null; store = null; runner = null; piCommands = null;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// 方案 M agent 会话钩子:新会话即订阅(官方 subscribeEvent;旧 pi-web 自动兑底 globalThis)
|
|
209
|
+
agent: {
|
|
210
|
+
onAgentSession(info) {
|
|
211
|
+
try { piCommands?.attach(info as { sessionId: string; cwd?: string; subscribeEvent?: (l: (e: unknown) => void) => () => void; abortBash?: () => void }); } catch {}
|
|
212
|
+
},
|
|
213
|
+
onAgentSessionEnd({ sessionId }) {
|
|
214
|
+
try { piCommands?.detach(sessionId); } catch {}
|
|
215
|
+
},
|
|
216
|
+
// M4b「执行命令」agent 工具(aiti-cmd-exec-integration-plan.md §4-M4/§3.5):AI 会话内
|
|
217
|
+
// 构造的命令走 §3.2 services 同款进程内 runner 执行——命令不落前端(符合 §0 判据
|
|
218
|
+
// 「命令在后端不传前端」);危险命令(checkCommand 命中)二次确认:AI 必须先向用户展示
|
|
219
|
+
// 命令原文并获明确同意,才能 confirm:true 重调。门控:feature tool.aiti_cmd_run(默认开)。
|
|
220
|
+
agentTools(info) {
|
|
221
|
+
const sessionId = (info as { sessionId?: string } | undefined)?.sessionId;
|
|
222
|
+
return [{
|
|
223
|
+
name: "aiti_cmd_run",
|
|
224
|
+
label: "aiti_cmd_run",
|
|
225
|
+
description:
|
|
226
|
+
"在 aiti-cmd 后端 PTY runner 执行一条 shell 命令,创建常驻可交互会话(输出实时进 ⚡命令 面板,全程审计)。" +
|
|
227
|
+
"适合长驻/交互式/需要用户在面板围观的任务(如 dev server、watch、登录 PTY);需要立刻拿输出的一次性命令请用自带 bash。" +
|
|
228
|
+
"危险命令必须先向用户展示命令原文、获得明确同意后才可置 confirm:true 重调。",
|
|
229
|
+
parameters: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
command: { type: "string", description: "要执行的 shell 命令(必填)" },
|
|
233
|
+
cwd: { type: "string", description: "工作目录(可选)" },
|
|
234
|
+
name: { type: "string", description: "会话显示名(可选,默认取命令前缀)" },
|
|
235
|
+
ttlMs: { type: "number", description: "会话保留时长毫秒(可选,默认 1h;长驻任务可传 86400000)" },
|
|
236
|
+
confirm: { type: "boolean", description: "危险命令二次确认:仅在用户明确同意后置 true" },
|
|
237
|
+
},
|
|
238
|
+
required: ["command"],
|
|
239
|
+
},
|
|
240
|
+
async execute(_toolCallId: unknown, params: Record<string, unknown>) {
|
|
241
|
+
const p = params || {};
|
|
242
|
+
const command = String(p.command || "").trim();
|
|
243
|
+
if (!command) {
|
|
244
|
+
return { content: [{ type: "text", text: "缺少 command" }], isError: true } as never;
|
|
245
|
+
}
|
|
246
|
+
let danger: string[] = [];
|
|
247
|
+
try { danger = (await checkCommand(command)).danger || []; } catch { /* 词库异常按非危险放行 */ }
|
|
248
|
+
if (danger.length && p.confirm !== true) {
|
|
249
|
+
return {
|
|
250
|
+
content: [{ type: "text", text: "危险命令需二次确认:命中 " + danger.join(" / ") + "。请把命令原文完整展示给用户,获得明确同意后以 confirm:true 重调本次调用;未获同意则停止执行。" }],
|
|
251
|
+
} as never;
|
|
252
|
+
}
|
|
253
|
+
if (!runner) {
|
|
254
|
+
return { content: [{ type: "text", text: "runner 未初始化" }], isError: true } as never;
|
|
255
|
+
}
|
|
256
|
+
const r = runner.run({
|
|
257
|
+
command,
|
|
258
|
+
cwd: p.cwd ? String(p.cwd) : undefined,
|
|
259
|
+
name: p.name ? String(p.name) : undefined,
|
|
260
|
+
source: "ai-helper",
|
|
261
|
+
piSessionId: sessionId ? String(sessionId) : undefined,
|
|
262
|
+
danger: danger.length ? danger : undefined,
|
|
263
|
+
confirm: danger.length > 0,
|
|
264
|
+
ttlMs: Number.isFinite(Number(p.ttlMs)) && Number(p.ttlMs) > 0 ? Number(p.ttlMs) : undefined,
|
|
265
|
+
});
|
|
266
|
+
if (!r.ok) {
|
|
267
|
+
return { content: [{ type: "text", text: "执行失败:" + (r.error || "未知错误") }], isError: true } as never;
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text", text: "已创建会话 " + r.sessionId + (r.queued ? "(并发已满,排队中 位置 " + r.position + ")" : "") + "。输出实时进 ⚡命令 面板;快照接口 GET /api/extensions/aiti-cmd/sessions/" + r.sessionId + "/buffered 可拉取当前回放。" }],
|
|
271
|
+
} as never;
|
|
272
|
+
},
|
|
273
|
+
}];
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async api(req, ctx) {
|
|
278
|
+
const p = ctx.path || [];
|
|
279
|
+
if (!runner || !store || !audit) return json({ ok: false, error: "扩展未初始化" }, 500);
|
|
280
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204 });
|
|
281
|
+
|
|
282
|
+
// POST /alive {types:["cmd-s_x", ...]} —— 终端流订阅保活(前端 15s 一次;
|
|
283
|
+
// 45s 未刷新的 type 在发布侧停推)。全量语义:仅刷新所列 type,缺失项交给超时判定
|
|
284
|
+
//(多 tab 并存时各 tab 全量心跳互不干扰)。
|
|
285
|
+
if (p[0] === "alive" && req.method === "POST") {
|
|
286
|
+
const body = await req.json().catch(() => null);
|
|
287
|
+
if (!body || !Array.isArray(body.types)) return json({ ok: false, error: "types 必须是数组" }, 400);
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
let n = 0;
|
|
290
|
+
for (const t of body.types) {
|
|
291
|
+
if (typeof t !== "string" || t.length <= 4 || !t.startsWith("cmd-")) continue; // 拒绝非 cmd- 前缀与空 id
|
|
292
|
+
aliveMap.set(t, now);
|
|
293
|
+
n++;
|
|
294
|
+
}
|
|
295
|
+
// 顺带回收:超时 1h 的陈旧条目(会话早已结束/页面早已关闭)
|
|
296
|
+
for (const [t, ts] of aliveMap) if (now - ts > 3600_000) aliveMap.delete(t);
|
|
297
|
+
return json({ ok: true, count: n });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------- 执行引擎 ----------
|
|
301
|
+
// POST /run { command, cwd?, env?, name?, source?, piSessionId?, params?, danger?, confirm?, confirmToken?, commandId?, ttlMs? }
|
|
302
|
+
if (p[0] === "run" && req.method === "POST") {
|
|
303
|
+
const body = await req.json().catch(() => null);
|
|
304
|
+
if (!body || !body.command) return json({ ok: false, error: "缺少 command" }, 400);
|
|
305
|
+
const cmd = String(body.command);
|
|
306
|
+
// 危险命令强制确认(服务端兜底):check 命中危险模式 或 命令标记 confirm:true
|
|
307
|
+
const danger = (await checkCommand(cmd)).danger;
|
|
308
|
+
const isDanger = danger.length > 0 || body.confirm === true;
|
|
309
|
+
const confirmToken = body.confirmToken ? String(body.confirmToken) : null;
|
|
310
|
+
if (isDanger && !confirmToken) {
|
|
311
|
+
return json({ ok: false, needConfirm: true, confirmToken: issueConfirmToken(), danger, reason: "危险命令需确认" }, 403);
|
|
312
|
+
}
|
|
313
|
+
if (confirmToken && !consumeConfirmToken(confirmToken)) {
|
|
314
|
+
return json({ ok: false, error: "确认 token 无效或已过期,请重新确认" }, 403);
|
|
315
|
+
}
|
|
316
|
+
// source 收口(§3.1):未知归 unknown;缺省兜底 manual + sourceDefaulted 审计标记
|
|
317
|
+
const rawSource = body.source ? String(body.source) : "";
|
|
318
|
+
const source = rawSource ? (KNOWN_SOURCES.has(rawSource) ? rawSource : "unknown") : "manual";
|
|
319
|
+
const r = runner.run({
|
|
320
|
+
command: cmd,
|
|
321
|
+
cwd: body.cwd ? String(body.cwd) : undefined,
|
|
322
|
+
env: body.env && typeof body.env === "object" ? body.env : undefined,
|
|
323
|
+
name: body.name ? String(body.name) : undefined,
|
|
324
|
+
source,
|
|
325
|
+
sourceDefaulted: rawSource ? undefined : true,
|
|
326
|
+
piSessionId: body.piSessionId ? String(body.piSessionId) : undefined,
|
|
327
|
+
danger: danger.length ? danger : undefined,
|
|
328
|
+
confirm: isDanger,
|
|
329
|
+
commandId: body.commandId ? String(body.commandId) : undefined,
|
|
330
|
+
ttlMs: body.ttlMs ? Number(body.ttlMs) : undefined,
|
|
331
|
+
sandbox: normSandbox(body.sandbox),
|
|
332
|
+
});
|
|
333
|
+
if (!r.ok) return json(r, 400);
|
|
334
|
+
return json({ ok: true, sessionId: r.sessionId });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// POST /shell { cwd?, name?, source? } —— 后端建交互 shell 会话(§3.3 前端「打开终端」下沉:
|
|
338
|
+
// core 侧栏/工作区页签/folder·editor 查看窗/本体面板统一走这里拿 sid 渲染,命令文本不落前端;
|
|
339
|
+
// "$SHELL" 与本体面板「+ 新终端」同语义:win32 映射默认 shell 交互启动,POSIX 经 -c 内层交互)
|
|
340
|
+
if (p[0] === "shell" && req.method === "POST") {
|
|
341
|
+
const body = await req.json().catch(() => ({}));
|
|
342
|
+
const rawSource = body.source ? String(body.source) : "shell";
|
|
343
|
+
const ttl = Number(body.ttlMs);
|
|
344
|
+
const r = runner.run({
|
|
345
|
+
command: "$SHELL",
|
|
346
|
+
cwd: body.cwd ? String(body.cwd) : undefined,
|
|
347
|
+
name: body.name ? String(body.name) : "终端",
|
|
348
|
+
source: KNOWN_SOURCES.has(rawSource) ? rawSource : "unknown",
|
|
349
|
+
ttlMs: Number.isFinite(ttl) && ttl > 0 ? ttl : undefined,
|
|
350
|
+
sandbox: "none",
|
|
351
|
+
});
|
|
352
|
+
if (!r.ok) return json(r, 400);
|
|
353
|
+
return json({ ok: true, sessionId: r.sessionId, name: body.name ? String(body.name) : "终端" });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// GET /sessions
|
|
357
|
+
if (p[0] === "sessions" && p.length === 1 && req.method === "GET") {
|
|
358
|
+
return json({ ok: true, sessions: runner.list() });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// GET /sessions/{id}
|
|
362
|
+
if (p[0] === "sessions" && p.length === 2 && req.method === "GET") {
|
|
363
|
+
const info = runner.getInfo(p[1]);
|
|
364
|
+
if (!info) return json({ ok: false, error: "unknown session" }, 404);
|
|
365
|
+
return json({ ok: true, ...info });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// GET /sessions/{id}/buffered → 初始快照(大消息不走 event-bus:订阅方先订阅
|
|
369
|
+
// 总线 cmd-<id>,再拉这个;seq 去重衔接实时流)
|
|
370
|
+
if (p[0] === "sessions" && p.length === 3 && p[2] === "buffered" && req.method === "GET") {
|
|
371
|
+
const snap = runner.buffered(p[1]);
|
|
372
|
+
if (!snap) return json({ ok: false, error: "unknown session" }, 404);
|
|
373
|
+
return json({ ok: true, ...snap });
|
|
374
|
+
}
|
|
375
|
+
// GET /sessions/{id}/cwd → shell 实时目录(Linux/macOS 读交互 shell 进程当前 pwd;
|
|
376
|
+
// win32 不支持 / 会话未运行 → 404,前端降级会话创建时 cwd)。「+ 新建」/「新 pi-web 会话」用它
|
|
377
|
+
if (p[0] === "sessions" && p.length === 3 && p[2] === "cwd" && req.method === "GET") {
|
|
378
|
+
const cwd = runner.liveCwd(p[1]);
|
|
379
|
+
if (!cwd) return json({ ok: false, error: "cwd 不可用(会话未运行 / 平台不支持)" }, 404);
|
|
380
|
+
return json({ ok: true, cwd });
|
|
381
|
+
}
|
|
382
|
+
// POST /sessions/{id}/input { data }
|
|
383
|
+
if (p[0] === "sessions" && p.length === 3 && p[2] === "input" && req.method === "POST") {
|
|
384
|
+
const body = await req.json().catch(() => null);
|
|
385
|
+
if (!body || typeof body.data !== "string") return json({ ok: false, error: "缺少 data" }, 400);
|
|
386
|
+
return runner.input(p[1], body.data);
|
|
387
|
+
}
|
|
388
|
+
// POST /sessions/{id}/resize { cols, rows }
|
|
389
|
+
if (p[0] === "sessions" && p.length === 3 && p[2] === "resize" && req.method === "POST") {
|
|
390
|
+
const body = await req.json().catch(() => null);
|
|
391
|
+
if (!body) return json({ ok: false, error: "bad body" }, 400);
|
|
392
|
+
return runner.resize(p[1], Number(body.cols), Number(body.rows));
|
|
393
|
+
}
|
|
394
|
+
// POST /sessions/{id}/kill
|
|
395
|
+
if (p[0] === "sessions" && p.length === 3 && p[2] === "kill" && req.method === "POST") {
|
|
396
|
+
return runner.kill(p[1]);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------- agent 运行中命令(pi-web 会话 bash 实时视图) ----------
|
|
400
|
+
// GET /pi-commands → 列表(running + 近期 done)
|
|
401
|
+
if (p[0] === "pi-commands" && p.length === 1 && req.method === "GET") {
|
|
402
|
+
if (!piCommands) return json({ ok: false, error: "扩展未初始化" }, 500);
|
|
403
|
+
piCommands.lateCatchUp();
|
|
404
|
+
return json({ ok: true, commands: piCommands.list() });
|
|
405
|
+
}
|
|
406
|
+
// GET /pi-commands/{id} → 初始快照(大消息不走 event-bus:text=publishedText + seq);
|
|
407
|
+
// ?meta=1 → 轻量摘要(无 text)——状态定案用,已结束命令免拉全量文本
|
|
408
|
+
if (p[0] === "pi-commands" && p.length === 2 && req.method === "GET") {
|
|
409
|
+
const snap = piCommands.get(p[1], ctx.searchParams.get("meta") === "1");
|
|
410
|
+
if (!snap) return json({ ok: false, error: "unknown pi-command" }, 404);
|
|
411
|
+
return json({ ok: true, ...snap });
|
|
412
|
+
}
|
|
413
|
+
// POST /pi-commands/{id}/kill → abortBash(只杀命令,不 abort turn)
|
|
414
|
+
if (p[0] === "pi-commands" && p.length === 3 && p[2] === "kill" && req.method === "POST") {
|
|
415
|
+
if (!piCommands) return json({ ok: false, error: "扩展未初始化" }, 500);
|
|
416
|
+
return piCommands.kill(p[1]);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------- 常用命令 ----------
|
|
420
|
+
// GET /commands → { commands, groups }(含分组树)
|
|
421
|
+
if (p[0] === "commands" && p.length === 1 && req.method === "GET") {
|
|
422
|
+
return json({ ok: true, commands: store.list(), groups: store.listGroups() });
|
|
423
|
+
}
|
|
424
|
+
// GET /commands/suggest?q=<前缀>&limit=n → 输入联想静态 provider(契约:供 ai-helper 聚合;排序:匹配度 > 运行频次 > 最近运行 > 名称)
|
|
425
|
+
if (p[0] === "commands" && p[1] === "suggest" && p.length === 2 && req.method === "GET") {
|
|
426
|
+
return json({ ok: true, q: ctx.searchParams.get("q") || "", results: commandSuggest(store, ctx.searchParams.get("q") || "", parseInt(String(ctx.searchParams.get("limit") || "6"), 10)) });
|
|
427
|
+
}
|
|
428
|
+
// POST /commands
|
|
429
|
+
if (p[0] === "commands" && p.length === 1 && req.method === "POST") {
|
|
430
|
+
const body = await req.json().catch(() => null);
|
|
431
|
+
// name 可选(UI「名称(默认=命令)」;store.create 已支持缺省用 cmd)——只校验命令内容
|
|
432
|
+
if (!body || !body.cmd || !String(body.cmd).trim()) return json({ ok: false, error: "缺少命令内容(cmd)" }, 400);
|
|
433
|
+
return json({ ok: true, command: store.create({
|
|
434
|
+
name: body.name ? String(body.name) : undefined,
|
|
435
|
+
cmd: String(body.cmd),
|
|
436
|
+
cwd: body.cwd ? String(body.cwd) : undefined,
|
|
437
|
+
tags: Array.isArray(body.tags) ? body.tags.map(String) : [],
|
|
438
|
+
env: body.env && typeof body.env === "object" ? body.env : undefined,
|
|
439
|
+
confirm: body.confirm === true,
|
|
440
|
+
groupId: body.groupId ? String(body.groupId) : null,
|
|
441
|
+
sandbox: normSandbox(body.sandbox),
|
|
442
|
+
// suggest 缺省按 source 判定(store:manual 开 / ai-message·clipboard 关);显式传才覆盖
|
|
443
|
+
suggest: body.suggest === undefined ? undefined : (body.suggest === true || body.suggest === "true"),
|
|
444
|
+
source: body.source || "manual",
|
|
445
|
+
}) }, 201);
|
|
446
|
+
}
|
|
447
|
+
// POST /commands/check {text, cwd?} (消息识别:词库全文扫描 + 有效性校验 → 命中命令)
|
|
448
|
+
if (p[0] === "commands" && p[1] === "check" && req.method === "POST") {
|
|
449
|
+
const body = await req.json().catch(() => ({}));
|
|
450
|
+
const text = String(body.text || "");
|
|
451
|
+
const cwd = body.cwd ? String(body.cwd) : undefined;
|
|
452
|
+
if (!text.trim()) return json({ ok: false, error: "缺少 text" }, 400);
|
|
453
|
+
const commands = await extractCommandsFromText(dirCfg, text, cwd);
|
|
454
|
+
return json({ ok: true, commands, single: commands.length === 1 ? commands[0] : null });
|
|
455
|
+
}
|
|
456
|
+
// GET /commands/describe?text=… (命令管理面板:识别首命令 → 分类/描述/相关短语)
|
|
457
|
+
if (p[0] === "commands" && p[1] === "describe" && p.length === 2 && req.method === "GET") {
|
|
458
|
+
const text = ctx.searchParams.get("text") || "";
|
|
459
|
+
if (!text.trim()) return json({ ok: false, error: "缺少 text" }, 400);
|
|
460
|
+
return json({ ok: true, ...describeCommand(text) });
|
|
461
|
+
}
|
|
462
|
+
// GET /commands/dict → 查看词库(内置类别 + 用户自定义)
|
|
463
|
+
if (p[0] === "commands" && p[1] === "dict" && p.length === 2 && req.method === "GET") {
|
|
464
|
+
return json({ ok: true, groups: COMMAND_DICT_GROUPS(), user: loadUserDict(dirCfg) });
|
|
465
|
+
}
|
|
466
|
+
// PUT /commands/dict { extra: ["mytool", ...] } → 保存用户自定义词库
|
|
467
|
+
if (p[0] === "commands" && p[1] === "dict" && p.length === 2 && req.method === "PUT") {
|
|
468
|
+
const body = await req.json().catch(() => null);
|
|
469
|
+
if (!body || !Array.isArray(body.extra)) return json({ ok: false, error: "需要 extra 数组" }, 400);
|
|
470
|
+
return json({ ok: true, user: saveUserDict(dirCfg, body.extra.map(String)) });
|
|
471
|
+
}
|
|
472
|
+
// ---- 分组管理 ----
|
|
473
|
+
// POST /commands/groups { name, parentId? }
|
|
474
|
+
if (p[0] === "commands" && p[1] === "groups" && p.length === 2 && req.method === "POST") {
|
|
475
|
+
const body = await req.json().catch(() => null);
|
|
476
|
+
if (!body || !body.name) return json({ ok: false, error: "缺少 name" }, 400);
|
|
477
|
+
return json({ ok: true, group: store.createGroup(String(body.name), body.parentId ? String(body.parentId) : null) }, 201);
|
|
478
|
+
}
|
|
479
|
+
// PUT /commands/groups/{id} { name?, parentId? }
|
|
480
|
+
if (p[0] === "commands" && p[1] === "groups" && p.length === 3 && req.method === "PUT") {
|
|
481
|
+
const body = await req.json().catch(() => null);
|
|
482
|
+
if (!body) return json({ ok: false, error: "bad body" }, 400);
|
|
483
|
+
const updated = store.updateGroup(p[2], {
|
|
484
|
+
name: body.name !== undefined ? String(body.name) : undefined,
|
|
485
|
+
parentId: body.parentId !== undefined ? (body.parentId ? String(body.parentId) : null) : undefined,
|
|
486
|
+
});
|
|
487
|
+
if (!updated) return json({ ok: false, error: "unknown group" }, 404);
|
|
488
|
+
return json({ ok: true, group: updated });
|
|
489
|
+
}
|
|
490
|
+
// DELETE /commands/groups/{id}(级联删子孙,命令移入未分组)
|
|
491
|
+
if (p[0] === "commands" && p[1] === "groups" && p.length === 3 && req.method === "DELETE") {
|
|
492
|
+
return json({ ok: store.removeGroup(p[2]) });
|
|
493
|
+
}
|
|
494
|
+
// PUT/DELETE /commands/{id}
|
|
495
|
+
if (p[0] === "commands" && p.length === 2 && req.method === "PUT") {
|
|
496
|
+
const body = await req.json().catch(() => null);
|
|
497
|
+
if (!body) return json({ ok: false, error: "bad body" }, 400);
|
|
498
|
+
// suggest 规范化布尔(防字符串 "false" 穿透存储)
|
|
499
|
+
if (body.suggest !== undefined) body.suggest = body.suggest === true || body.suggest === "true";
|
|
500
|
+
const updated = store.update(p[1], body);
|
|
501
|
+
if (!updated) return json({ ok: false, error: "unknown command" }, 404);
|
|
502
|
+
return json({ ok: true, command: updated });
|
|
503
|
+
}
|
|
504
|
+
if (p[0] === "commands" && p.length === 2 && req.method === "DELETE") {
|
|
505
|
+
return json({ ok: store.remove(p[1]) });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------- 审计历史 ----------
|
|
509
|
+
// GET /history?from&to&source&exit&cmd&limit | groupBy=source(按来源汇总:数量/最近/失败率,§3.1)
|
|
510
|
+
if (p[0] === "history" && p.length === 1 && req.method === "GET") {
|
|
511
|
+
if (ctx.searchParams.get("groupBy") === "source") {
|
|
512
|
+
return json({ ok: true, groupBy: "source", groups: audit.summarizeBySource() });
|
|
513
|
+
}
|
|
514
|
+
return json({ ok: true, records: audit.query({
|
|
515
|
+
from: ctx.searchParams.get("from") ? Number(ctx.searchParams.get("from")) : undefined,
|
|
516
|
+
to: ctx.searchParams.get("to") ? Number(ctx.searchParams.get("to")) : undefined,
|
|
517
|
+
source: ctx.searchParams.get("source") || undefined,
|
|
518
|
+
exit: ctx.searchParams.get("exit") || undefined,
|
|
519
|
+
cmd: ctx.searchParams.get("cmd") || undefined,
|
|
520
|
+
limit: ctx.searchParams.get("limit") ? Number(ctx.searchParams.get("limit")) : undefined,
|
|
521
|
+
}) });
|
|
522
|
+
}
|
|
523
|
+
// GET /history/verify(哈希链校验——防篡改)——必须在 /{id} 之前
|
|
524
|
+
if (p[0] === "history" && p[1] === "verify" && p.length === 2 && req.method === "GET") {
|
|
525
|
+
return json({ ok: true, ...audit.verify() });
|
|
526
|
+
}
|
|
527
|
+
// GET /history/{id}(含输出)
|
|
528
|
+
if (p[0] === "history" && p.length === 2 && req.method === "GET") {
|
|
529
|
+
const rec = audit.getById(p[1]);
|
|
530
|
+
if (!rec) return json({ ok: false, error: "unknown record" }, 404);
|
|
531
|
+
const output = audit.readOutput(p[1]);
|
|
532
|
+
return json({ ok: true, record: rec, output });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------- 诊断 ----------
|
|
536
|
+
// GET /diag
|
|
537
|
+
if (p[0] === "diag") {
|
|
538
|
+
return json({ ok: true, ...runner.diag(), pi: piCommands?.diag() ?? null, retentionDays: audit.retentionDays, configDir: dirCfg });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return json({ ok: false, error: "not found: " + p.join("/") }, 404);
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiti-ai/aiti-cmd",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"type": "module",
|
|
4
|
+
"description": "命令管理器 —— 真 TTY 终端(node-pty,实时帧走事件总线主题)+ 常用命令收藏夹(CRUD 与 {参数} 占位符)+ 消息识别命令;执行全程 append-only 审计落盘",
|
|
5
|
+
"devDependencies": {
|
|
6
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
7
|
+
"@xterm/xterm": "^6.0.0",
|
|
8
|
+
"esbuild": "^0.24.2"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@aiti-ai/ext-shared": "^0.1.3",
|
|
12
|
+
"command-exists": "^1.2.9",
|
|
13
|
+
"node-pty": "^1.1.0",
|
|
14
|
+
"shell-quote": "^1.10.0"
|
|
15
|
+
},
|
|
16
|
+
"soul": "大脑留给思考,命令交给收藏夹。",
|
|
17
|
+
"version": "0.1.9",
|
|
18
|
+
"aitiConfigs": {
|
|
19
|
+
"configFiles": [
|
|
20
|
+
"commands.json",
|
|
21
|
+
"dict.json"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"aitiExtProtocol": "1.0",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"contentHash": "fbe85b2f45e9e7f7",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/aiti-ai/aiti-ai.git",
|
|
30
|
+
"directory": "extensions/aiti-cmd"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/aiti-ai/aiti-ai#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/aiti-ai/aiti-ai/issues"
|
|
35
|
+
}
|
|
36
|
+
}
|