@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/pi-commands.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * aiti-cmd · pi-commands —— pi-web agent 会话运行中命令的实时视图
3
+ *
4
+ * 数据源:agent 会话事件(tool_execution_start / update / end)进程内直连订阅。
5
+ * - 订阅入口(按优先级):
6
+ * 1) 官方 AgentSessionInfo.subscribeEvent / abortBash(pi-web 方案 M agent 扩展点;
7
+ * lib/ext/types.ts + rpc-manager dispatch 传入)
8
+ * 2) 兜底 globalThis.__piSessions(rpc-manager 的 wrapper registry,globalThis 跨热重载
9
+ * 存活):wrapper.onEvent / wrapper.inner.abortBash——未打补丁的 pi-web(如 prod
10
+ * 旧构建)也立即可用
11
+ * - tool_execution_update 的 partialResult.content[].text 是「到目前为止的滚动输出」
12
+ * (pi-core bash 工具 100ms 节流)。agent events 的 SSE 出口过滤了该事件类型,
13
+ * 浏览器看不到;本模块在进程内原样拿到,按 per-连接 diff 增量下发:
14
+ * {type:"data"} = 追加增量,{type:"replace"} = 全量重写(截断/最终结果改写时)。
15
+ * - kill:abortBash() 只中止当前 bash 命令,不 abort 整个 turn——agent 收到
16
+ * "Command aborted" 错误结果后继续思考。注意:parallel 工具模式下同会话的
17
+ * 并发 bash 会被一起中止(AgentSession.abortBash 是会话级语义)。
18
+ * - RPC 直连的 executeBash(POST /api/agent/{id} {"type":"bash"})不产生
19
+ * tool_execution_* 事件,不在本视图内(那是 UI 直连通道,不经 agent 工具循环)。
20
+ */
21
+ import { randomBytes } from "node:crypto";
22
+
23
+ const TEXT_MAX_BYTES = 512 * 1024; // 单条命令输出保留上限(超出丢头部 + truncated 标记)
24
+ const DONE_TTL_MS = 5 * 60 * 1000; // 已结束条目保留时长(供事后查看)
25
+ const DONE_KEEP_MAX = 50; // 已结束条目数量上限(超出丢最旧)
26
+ const CMD_MAX_LEN = 20000;
27
+
28
+ export interface PiCmdEntry {
29
+ id: string; // toolCallId
30
+ sessionId: string;
31
+ toolName: string; // "bash"
32
+ cmd: string;
33
+ cwd?: string;
34
+ startedTs: number;
35
+ status: "running" | "done";
36
+ isError?: boolean;
37
+ endTs?: number;
38
+ /** 订阅前已开始(错过 start 事件,命令原文未知,靠 update 兜底建条目) */
39
+ lateJoin?: boolean;
40
+ /** 当前已知全量输出(滚动窗;update 替换式更新) */
41
+ text: string;
42
+ truncated: boolean;
43
+ /** 已发布到事件总线的全量基线(delta 衔接基准;快照 REST 返回它,配合 seq 无缝续流) */
44
+ publishedText: string;
45
+ /** 总线帧序号(每次发布 ++;客户端与快照去重用) */
46
+ seq: number;
47
+ }
48
+
49
+ /** 从 partialResult / result 提取文本(content: [{type:"text",text}]) */
50
+ function extractText(payload: unknown): string {
51
+ const content = (payload as { content?: unknown } | undefined | null)?.content;
52
+ if (!Array.isArray(content)) return "";
53
+ let out = "";
54
+ for (const b of content) {
55
+ if (b && typeof b === "object" && (b as { type?: string }).type === "text") {
56
+ const t = (b as { text?: unknown }).text;
57
+ if (typeof t === "string") out += t;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // 帧主题:每个 agent 命令视图一个专属 type(cmd-<toolCallId>),列表快照固定 type。
64
+ // 初始全量 text(≤512KB 大消息)不走总线——快照走 REST /pi-commands/{id};
65
+ // 总线只发实时增量(delta/replace 小帧)与状态。
66
+ const piTopic = (id: string) => "cmd-" + id;
67
+ const TOPIC_PILIST = "cmd-pilist";
68
+
69
+ export function createPiCommands(publish?: (type: string, data: Record<string, unknown>) => void) {
70
+ const sessions = new Map<string, { cwd?: string; unsubscribe?: () => void; abort?: () => void }>();
71
+ const entries = new Map<string, PiCmdEntry>();
72
+ // aiti-event-bus 发布(缺席/异常静默;客户端降级旧 per-entry SSE)
73
+ const pub = (type: string, data: Record<string, unknown>) => { try { publish?.(type, data); } catch {} };
74
+
75
+ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
76
+ status,
77
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
78
+ });
79
+
80
+ function summaryOf(e: PiCmdEntry) {
81
+ return {
82
+ id: e.id, sessionId: e.sessionId, toolName: e.toolName, cmd: e.cmd, cwd: e.cwd,
83
+ status: e.status, startedTs: e.startedTs,
84
+ durationMs: (e.status === "done" ? e.endTs ?? Date.now() : Date.now()) - e.startedTs,
85
+ bytes: Buffer.byteLength(e.text, "utf8"), truncated: e.truncated,
86
+ isError: e.isError, lateJoin: e.lateJoin || undefined,
87
+ };
88
+ }
89
+
90
+ /** 列表快照(不 sweep,防 publishList→list→sweep→publishList 递归) */
91
+ function snapshotList() {
92
+ return [...entries.values()].sort((a, b) => b.startedTs - a.startedTs).map(summaryOf);
93
+ }
94
+ const publishList = () => { try { pub(TOPIC_PILIST, { evt: "pilist", commands: snapshotList() }); } catch {} };
95
+
96
+ /** 更新条目输出:总线按「已发布基线」发增量/全量(aiti-cmd 自管理发送 + 订阅保活停推) */
97
+ function setText(e: PiCmdEntry, t: string) {
98
+ if (t.length > TEXT_MAX_BYTES) { t = t.slice(-TEXT_MAX_BYTES); e.truncated = true; }
99
+ e.text = t;
100
+ // 总线(aiti-cmd 自管理是否发送):基线 = publishedText;delta 基于 publishedText
101
+ // 单调推进,中途订阅者拿快照(publishedText@now + seq)后靠 seq 去重无缝衔接
102
+ if (t.startsWith(e.publishedText)) {
103
+ const delta = t.slice(e.publishedText.length);
104
+ if (delta) { e.seq += 1; pub(piTopic(e.id), { evt: "data", seq: e.seq, data: delta }); e.publishedText = t; }
105
+ } else {
106
+ e.seq += 1; pub(piTopic(e.id), { evt: "replace", seq: e.seq, data: t }); e.publishedText = t;
107
+ }
108
+ }
109
+
110
+ function finishEntry(e: PiCmdEntry, isError: boolean) {
111
+ if (e.status === "done") return;
112
+ e.status = "done";
113
+ e.endTs = Date.now();
114
+ e.isError = isError;
115
+ pub(piTopic(e.id), { evt: "exit", isError, durationMs: e.endTs - e.startedTs, truncated: e.truncated });
116
+ publishList();
117
+ }
118
+
119
+ function handleEvent(sessionId: string, ev: unknown) {
120
+ const e = ev as { type?: string; toolCallId?: unknown; toolName?: unknown; args?: unknown; partialResult?: unknown; result?: unknown; isError?: unknown } | null | undefined;
121
+ if (!e || typeof e.type !== "string") return;
122
+ const id = String(e.toolCallId ?? "");
123
+ if (e.type === "tool_execution_start" && e.toolName === "bash") {
124
+ if (!id || entries.has(id)) return;
125
+ const cmd = String((e.args as { command?: unknown } | undefined)?.command ?? "");
126
+ const entry: PiCmdEntry = {
127
+ id, sessionId, toolName: "bash", cmd: cmd.slice(0, CMD_MAX_LEN),
128
+ cwd: sessions.get(sessionId)?.cwd,
129
+ startedTs: Date.now(), status: "running", text: "", truncated: false,
130
+ publishedText: "", seq: 0,
131
+ };
132
+ entries.set(id, entry);
133
+ publishList();
134
+ } else if (e.type === "tool_execution_update") {
135
+ if (e.toolName !== "bash" || !id) return;
136
+ // 订阅晚于 start(扩展 reload / lateCatchUp 前已在跑)→ 建兜底条目,命令原文未知
137
+ const entry = entries.get(id) ?? ensureLateEntry(id, sessionId);
138
+ if (entry.status !== "running") return;
139
+ const text = extractText(e.partialResult);
140
+ if (!text) return;
141
+ setText(entry, text);
142
+ } else if (e.type === "tool_execution_end") {
143
+ if (e.toolName !== "bash" || !id) return;
144
+ const entry = entries.get(id) ?? ensureLateEntry(id, sessionId);
145
+ if (entry.status === "done") return;
146
+ // 最终结果(截断后的规范输出)可能与滚动窗不同 → replace 语义下发
147
+ const finalText = extractText(e.result);
148
+ if (finalText) setText(entry, finalText);
149
+ finishEntry(entry, !!e.isError);
150
+ }
151
+ }
152
+
153
+ /** update/end 到达但没有对应条目(订阅晚于 start)→ 建兜底条目,命令原文未知 */
154
+ function ensureLateEntry(id: string, sessionId: string): PiCmdEntry {
155
+ let entry = entries.get(id);
156
+ if (!entry) {
157
+ entry = {
158
+ id, sessionId, toolName: "bash", cmd: "", cwd: sessions.get(sessionId)?.cwd,
159
+ startedTs: Date.now(), status: "running", text: "", truncated: false,
160
+ publishedText: "", seq: 0,
161
+ lateJoin: true,
162
+ };
163
+ entries.set(id, entry);
164
+ publishList();
165
+ }
166
+ return entry;
167
+ }
168
+
169
+ /**
170
+ * 订阅一个 agent 会话(onAgentSession 回调 / lateCatchUp 扫描)。
171
+ * 优先官方 info.subscribeEvent / info.abortBash;缺则兜底 globalThis.__piSessions。
172
+ */
173
+ function attach(info: { sessionId: string; cwd?: string; subscribeEvent?: (l: (e: unknown) => void) => () => void; abortBash?: () => void }) {
174
+ const sid = String(info.sessionId || "");
175
+ if (!sid || sessions.has(sid)) return;
176
+ let unsubscribe: (() => void) | undefined;
177
+ let abort: (() => void) | undefined;
178
+ try {
179
+ if (typeof info.subscribeEvent === "function") unsubscribe = info.subscribeEvent((ev) => handleEvent(sid, ev));
180
+ if (typeof info.abortBash === "function") abort = () => { try { info.abortBash?.(); } catch {} };
181
+ } catch {}
182
+ if (!unsubscribe || !abort) {
183
+ try {
184
+ const w = (globalThis as unknown as { __piSessions?: Map<string, { onEvent?: (l: (e: unknown) => void) => () => void; cwd?: unknown; inner?: { abortBash?: () => void }; send?: (cmd: unknown) => Promise<unknown> } | undefined> }).__piSessions?.get?.(sid);
185
+ if (w) {
186
+ if (!unsubscribe && typeof w.onEvent === "function") unsubscribe = w.onEvent((ev) => handleEvent(sid, ev));
187
+ if (!abort) {
188
+ if (w.inner && typeof w.inner.abortBash === "function") abort = () => { try { w.inner?.abortBash(); } catch {} };
189
+ else if (typeof w.send === "function") abort = () => { void w.send?.({ type: "abort_bash" }).catch(() => {}); };
190
+ }
191
+ }
192
+ } catch {}
193
+ }
194
+ sessions.set(sid, { cwd: info.cwd, unsubscribe, abort });
195
+ }
196
+
197
+ /** 扩展加载/reload 晚于 wrapper 创建:扫描 registry 补订阅(幂等) */
198
+ function lateCatchUp() {
199
+ try {
200
+ const reg = (globalThis as unknown as { __piSessions?: Map<string, unknown> }).__piSessions;
201
+ if (!reg || typeof reg.forEach !== "function") return;
202
+ reg.forEach((w: unknown, sid: string) => {
203
+ if (sessions.has(sid)) return;
204
+ attach({ sessionId: sid, cwd: typeof (w as { cwd?: unknown })?.cwd === "string" ? (w as { cwd?: string }).cwd : undefined });
205
+ });
206
+ } catch {}
207
+ }
208
+
209
+ /** 会话销毁:退订 + 在跑条目收尾(wrapper 空闲 10min 回收 / 手动 shutdown) */
210
+ function detach(sessionId: string) {
211
+ const s = sessions.get(sessionId);
212
+ if (!s) return;
213
+ try { s.unsubscribe?.(); } catch {}
214
+ sessions.delete(sessionId);
215
+ for (const e of entries.values()) {
216
+ if (e.sessionId === sessionId && e.status === "running") finishEntry(e, true);
217
+ }
218
+ }
219
+
220
+ /** 惰性清理:过期/超量 done 条目 */
221
+ function sweep() {
222
+ const now = Date.now();
223
+ const done = [...entries.values()].filter((e) => e.status === "done").sort((a, b) => (a.endTs ?? 0) - (b.endTs ?? 0));
224
+ let removed = 0;
225
+ for (const e of done) {
226
+ const age = now - (e.endTs ?? 0);
227
+ if (age > DONE_TTL_MS) { entries.delete(e.id); removed++; }
228
+ }
229
+ while (done.length > DONE_KEEP_MAX) { const e = done.shift()!; entries.delete(e.id); removed++; }
230
+ if (removed) publishList(); // 列表快照推送(snapshotList 不 sweep,无递归)
231
+ }
232
+
233
+ // 定时清理:不等 list() 被调(否则无人开面板时 done 条目会持续堆积);
234
+ // unref 防阻拦进程退出;dispose() 时停表(防 reload 后旧闭包泄漏)
235
+ const sweepTimer = setInterval(() => { try { sweep(); } catch {} }, 60_000);
236
+ (sweepTimer as unknown as { unref?: () => void }).unref?.();
237
+
238
+ /** 扩展 shutdown:停定时器 + 全部退订收尾 */
239
+ function dispose() {
240
+ clearInterval(sweepTimer);
241
+ for (const sid of [...sessions.keys()]) detach(sid);
242
+ }
243
+
244
+ function list() {
245
+ sweep();
246
+ return snapshotList();
247
+ }
248
+
249
+ /**
250
+ * 初始快照(REST;大消息不走 event-bus,订阅方先订阅后拉这个)。
251
+ * text 返回已发布基线 publishedText(而非 e.text)——与后续 delta 帧严格衔接,
252
+ * 配合 seq 去重:客户端 reset 重写基线后,只收 seq 更大的帧。
253
+ */
254
+ function get(id: string, metaOnly = false) {
255
+ lateCatchUp();
256
+ const e = entries.get(id);
257
+ if (!e) return null;
258
+ // metaOnly:轻量摘要(无 text)——客户端状态定案用,死命令免拉全量文本
259
+ return { ...summaryOf(e), seq: e.seq, ...(metaOnly ? {} : { text: e.publishedText }) };
260
+ }
261
+
262
+ function kill(id: string) {
263
+ lateCatchUp();
264
+ const e = entries.get(id);
265
+ if (!e) return json({ ok: false, error: "unknown pi-command" }, 404);
266
+ if (e.status !== "running") return json({ ok: false, error: "命令已结束" }, 409);
267
+ const s = sessions.get(e.sessionId);
268
+ if (!s?.abort) return json({ ok: false, error: "会话不可达(未找到 wrapper/官方 abortBash)" }, 409);
269
+ try { s.abort(); } catch (err) { return json({ ok: false, error: String((err as Error)?.message || err) }); }
270
+ // 实际退出经 tool_execution_end → exit 事件下发(isError=true)
271
+ return json({ ok: true, aborting: true });
272
+ }
273
+
274
+ function diag() {
275
+ return {
276
+ attached: sessions.size,
277
+ subscribed: [...sessions.values()].filter((s) => s.unsubscribe).length,
278
+ running: [...entries.values()].filter((e) => e.status === "running").length,
279
+ done: [...entries.values()].filter((e) => e.status === "done").length,
280
+ };
281
+ }
282
+
283
+ return { attach, detach, lateCatchUp, handleEvent, ensureLateEntry, list, get, kill, diag, dispose };
284
+ }
285
+
286
+ export type PiCommands = ReturnType<typeof createPiCommands>;