@tunnelbox/core 0.1.27 → 0.1.29
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/dist/index.js +765 -8
- package/locales/de-DE.json +38 -1
- package/locales/en-US.json +38 -1
- package/locales/es-ES.json +38 -1
- package/locales/fr-FR.json +38 -1
- package/locales/ja-JP.json +38 -1
- package/locales/ko-KR.json +38 -1
- package/locales/zh-CN.json +38 -1
- package/locales/zh-TW.json +38 -1
- package/package.json +1 -1
- package/src/acp/client.ts +187 -0
- package/src/acp/index.ts +11 -0
- package/src/acp/map.ts +45 -0
- package/src/acp/mirror.ts +210 -0
- package/src/acp/permissions.ts +47 -0
- package/src/acp/protocol.ts +282 -0
- package/src/acp/supervisor.ts +158 -0
- package/src/index.ts +2 -1
- package/src/types.ts +7 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP 子进程的 stdio 客户端:JSON-RPC 2.0(NDJSON,每行一条消息)。
|
|
3
|
+
*
|
|
4
|
+
* 分发:
|
|
5
|
+
* - `method` + `id` → 服务端请求(需应答,交 `onServerRequest`)
|
|
6
|
+
* - `method`(无 id) → 通知(交 `onNotification`)
|
|
7
|
+
* - `id` + result/error → 我方请求的响应
|
|
8
|
+
*/
|
|
9
|
+
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
10
|
+
import { cmdLineFor, resolveCliBinary } from "../cli";
|
|
11
|
+
import type { JsonRpcError, RequestId } from "./protocol";
|
|
12
|
+
|
|
13
|
+
export interface AcpHandlers {
|
|
14
|
+
onNotification?: (method: string, params: unknown) => void;
|
|
15
|
+
onServerRequest?: (id: RequestId, method: string, params: unknown) => void;
|
|
16
|
+
onStderr?: (line: string) => void;
|
|
17
|
+
onExit?: (code: number | null, signal: string | null) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Pending {
|
|
21
|
+
resolve: (v: unknown) => void;
|
|
22
|
+
reject: (e: Error) => void;
|
|
23
|
+
timer: NodeJS.Timeout | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
export class AcpClient {
|
|
29
|
+
private child: ChildProcess | null = null;
|
|
30
|
+
private buf = "";
|
|
31
|
+
private seq = 0;
|
|
32
|
+
private pending = new Map<RequestId, Pending>();
|
|
33
|
+
private exited = false;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private bin: string,
|
|
37
|
+
private args: string[] = [],
|
|
38
|
+
private handlers: AcpHandlers = {},
|
|
39
|
+
private label = "acp",
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
get alive(): boolean {
|
|
43
|
+
return !!this.child && !this.exited;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 启动 ACP 子进程(stdio 传输)。 */
|
|
47
|
+
start(cwd?: string): Promise<void> {
|
|
48
|
+
return new Promise<void>((resolve, reject) => {
|
|
49
|
+
const resolved = resolveCliBinary(this.bin);
|
|
50
|
+
const opts: SpawnOptions = { cwd, env: process.env, stdio: ["pipe", "pipe", "pipe"] };
|
|
51
|
+
let child: ChildProcess;
|
|
52
|
+
try {
|
|
53
|
+
child = resolved?.viaShell
|
|
54
|
+
? spawn(cmdLineFor(resolved.path, this.args), [], { ...opts, shell: true })
|
|
55
|
+
: spawn(resolved?.path ?? this.bin, this.args, opts);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
reject(e as Error);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.child = child;
|
|
61
|
+
this.exited = false;
|
|
62
|
+
|
|
63
|
+
child.stdout?.setEncoding("utf8");
|
|
64
|
+
child.stdout?.on("data", (chunk: string) => this.onChunk(chunk));
|
|
65
|
+
child.stderr?.setEncoding("utf8");
|
|
66
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
67
|
+
for (const l of chunk.split("\n")) {
|
|
68
|
+
const s = l.trim();
|
|
69
|
+
if (s) this.handlers.onStderr?.(s);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
child.on("error", (e) => {
|
|
73
|
+
this.failAll(e as Error);
|
|
74
|
+
reject(e as Error);
|
|
75
|
+
});
|
|
76
|
+
child.on("close", (code, signal) => {
|
|
77
|
+
this.exited = true;
|
|
78
|
+
this.child = null;
|
|
79
|
+
this.failAll(new Error(`${this.label} exited (code=${code ?? "null"} signal=${signal ?? "null"})`));
|
|
80
|
+
this.handlers.onExit?.(code, signal);
|
|
81
|
+
});
|
|
82
|
+
resolve();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 发送请求并等待响应。`timeoutMs <= 0` 表示不设超时(如 `session/prompt` 持续整轮)。
|
|
88
|
+
*/
|
|
89
|
+
request<T = unknown>(method: string, params?: unknown, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): Promise<T> {
|
|
90
|
+
if (!this.alive) return Promise.reject(new Error(`${this.label} not running`));
|
|
91
|
+
const id = ++this.seq;
|
|
92
|
+
return new Promise<T>((resolve, reject) => {
|
|
93
|
+
const timer =
|
|
94
|
+
timeoutMs > 0
|
|
95
|
+
? setTimeout(() => {
|
|
96
|
+
this.pending.delete(id);
|
|
97
|
+
reject(new Error(`${this.label} request timeout: ${method}`));
|
|
98
|
+
}, timeoutMs)
|
|
99
|
+
: null;
|
|
100
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer });
|
|
101
|
+
this.write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 应答服务端请求(成功)。 */
|
|
106
|
+
respond(id: RequestId, result: unknown): void {
|
|
107
|
+
this.write({ jsonrpc: "2.0", id, result });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 应答服务端请求(失败)。 */
|
|
111
|
+
respondError(id: RequestId, code: number, message: string): void {
|
|
112
|
+
this.write({ jsonrpc: "2.0", id, error: { code, message } });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 发送通知。 */
|
|
116
|
+
notify(method: string, params?: unknown): void {
|
|
117
|
+
this.write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stop(): void {
|
|
121
|
+
this.exited = true;
|
|
122
|
+
this.failAll(new Error(`${this.label} client stopped`));
|
|
123
|
+
try {
|
|
124
|
+
this.child?.kill();
|
|
125
|
+
} catch {
|
|
126
|
+
/* ignore */
|
|
127
|
+
}
|
|
128
|
+
this.child = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private write(obj: unknown): void {
|
|
132
|
+
try {
|
|
133
|
+
this.child?.stdin?.write(JSON.stringify(obj) + "\n");
|
|
134
|
+
} catch {
|
|
135
|
+
/* ignore */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private onChunk(chunk: string): void {
|
|
140
|
+
this.buf += chunk;
|
|
141
|
+
let idx: number;
|
|
142
|
+
while ((idx = this.buf.indexOf("\n")) >= 0) {
|
|
143
|
+
const line = this.buf.slice(0, idx).trim();
|
|
144
|
+
this.buf = this.buf.slice(idx + 1);
|
|
145
|
+
if (line) this.onLine(line);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private onLine(line: string): void {
|
|
150
|
+
let msg: Record<string, unknown>;
|
|
151
|
+
try {
|
|
152
|
+
msg = JSON.parse(line) as Record<string, unknown>;
|
|
153
|
+
} catch {
|
|
154
|
+
this.handlers.onStderr?.(`non-json line: ${line.slice(0, 200)}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const method = typeof msg.method === "string" ? msg.method : "";
|
|
158
|
+
const id = msg.id as RequestId | undefined;
|
|
159
|
+
if (method && id !== undefined) {
|
|
160
|
+
this.handlers.onServerRequest?.(id, method, msg.params);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (method) {
|
|
164
|
+
this.handlers.onNotification?.(method, msg.params);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (id === undefined) return;
|
|
168
|
+
const p = this.pending.get(id);
|
|
169
|
+
if (!p) return;
|
|
170
|
+
this.pending.delete(id);
|
|
171
|
+
if (p.timer) clearTimeout(p.timer);
|
|
172
|
+
if (msg.error) {
|
|
173
|
+
const err = msg.error as JsonRpcError["error"];
|
|
174
|
+
p.reject(new Error(`${err?.message || `${this.label} error`}${err?.code !== undefined ? ` (${err.code})` : ""}`));
|
|
175
|
+
} else {
|
|
176
|
+
p.resolve(msg.result);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private failAll(e: Error): void {
|
|
181
|
+
for (const p of this.pending.values()) {
|
|
182
|
+
if (p.timer) clearTimeout(p.timer);
|
|
183
|
+
p.reject(e);
|
|
184
|
+
}
|
|
185
|
+
this.pending.clear();
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/acp/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tunnelbox/core —— ACP(Agent Client Protocol)共享层。
|
|
3
|
+
* 供各 ACP 适配器(cursor/codebuddy/kimi/trae…)复用:协议类型、stdio 客户端、
|
|
4
|
+
* 常驻进程监督器、session/update 映射、权限选择辅助、会话镜像工厂。
|
|
5
|
+
*/
|
|
6
|
+
export * from "./protocol";
|
|
7
|
+
export * from "./client";
|
|
8
|
+
export * from "./map";
|
|
9
|
+
export * from "./supervisor";
|
|
10
|
+
export * from "./permissions";
|
|
11
|
+
export * from "./mirror";
|
package/src/acp/map.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP `session/update` → tunnelbox 协议(`Part` / `ChatMessage`)的映射辅助。
|
|
3
|
+
*/
|
|
4
|
+
import type { ChatMessage, Part } from "../types";
|
|
5
|
+
import type { ContentBlock, PlanEntry, ToolCall } from "./protocol";
|
|
6
|
+
|
|
7
|
+
/** 取 content block 的文本(仅 text 块)。 */
|
|
8
|
+
export function textOf(content: ContentBlock | undefined): string {
|
|
9
|
+
if (content && typeof content === "object" && (content as { type?: string }).type === "text") {
|
|
10
|
+
return String((content as { text?: unknown }).text ?? "");
|
|
11
|
+
}
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function safeJson(v: unknown): string {
|
|
16
|
+
if (v === undefined || v === null) return "";
|
|
17
|
+
try {
|
|
18
|
+
return typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
19
|
+
} catch {
|
|
20
|
+
return String(v);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 工具调用 → `Part`(messageID/part.id 均用 toolCallId,保证同一工具卡的流式更新归并)。 */
|
|
25
|
+
export function toolPart(tc: ToolCall, complete: boolean): Part {
|
|
26
|
+
const name = tc.title || tc.kind || "tool";
|
|
27
|
+
return { id: tc.toolCallId, type: "tool", tool: name, args: safeJson(tc.rawInput), complete };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** plan 条目 → 可读文本(用于 text 部件展示)。 */
|
|
31
|
+
export function planText(entries: PlanEntry[] | undefined): string {
|
|
32
|
+
if (!Array.isArray(entries)) return "";
|
|
33
|
+
return entries
|
|
34
|
+
.map((e) => {
|
|
35
|
+
const mark = e.status === "completed" ? "x" : e.status === "in_progress" ? "~" : " ";
|
|
36
|
+
return `- [${mark}] ${e.content ?? ""}`.trim();
|
|
37
|
+
})
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 由 ACP 会话事件拼出的历史消息(本地镜像兜底时用)。 */
|
|
43
|
+
export function partMessage(id: string, role: "user" | "assistant", part: Part, created = Date.now()): ChatMessage {
|
|
44
|
+
return { id, role, parts: [part], created };
|
|
45
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话本地镜像存储工厂(各 ACP 适配器共享)。
|
|
3
|
+
*
|
|
4
|
+
* 会话内容以适配器为权威镜像(每次流式部件/用户消息都即时落一行),
|
|
5
|
+
* 供 session.list / session.messages / session.delete 使用;模型上下文续聊依赖采集到的
|
|
6
|
+
* ACP sessionId(meta.resumeId)经 `session/load` / `session/resume` 恢复。
|
|
7
|
+
*
|
|
8
|
+
* 每个适配器传入独立根目录(如 `~/.tunnelbox/kimi-sessions`),互不干扰。
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { ChatMessage, Part, SessionInfo } from "../types";
|
|
21
|
+
|
|
22
|
+
export interface SessionMeta {
|
|
23
|
+
title: string;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
resumeId?: string;
|
|
26
|
+
/** 标题是否为自动占位(新建时的本地化默认标题):为 true 时允许首条用户文本/宿主标题覆盖 */
|
|
27
|
+
titleAuto?: boolean;
|
|
28
|
+
created: number;
|
|
29
|
+
updated: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SessionMirror {
|
|
33
|
+
/** 读取会话元数据(title/cwd/resumeId 等)。 */
|
|
34
|
+
getMeta(sessionID: string): SessionMeta | null;
|
|
35
|
+
/** 生成新的稳定会话 id(UUID)。 */
|
|
36
|
+
newSessionId(): string;
|
|
37
|
+
/** 记录会话元数据变更(title/resumeId/cwd 等)。 */
|
|
38
|
+
saveMeta(sessionID: string, patch: Partial<SessionMeta>): SessionMeta;
|
|
39
|
+
/** 追加一条部件/消息(history 按 id 归并成 ChatMessage)。 */
|
|
40
|
+
appendPart(sessionID: string, msgId: string, role: "user" | "assistant", part: Part, created?: number): void;
|
|
41
|
+
/** 会话镜像是否存在。 */
|
|
42
|
+
exists(sessionID: string): boolean;
|
|
43
|
+
/** 会话列表(镜像目录),按最近更新倒序。 */
|
|
44
|
+
listSessions(): Promise<SessionInfo[]>;
|
|
45
|
+
/** 读取会话历史(按 append 行 id 归并 parts)。 */
|
|
46
|
+
readHistory(sessionID: string): Promise<ChatMessage[]>;
|
|
47
|
+
/** 删除会话目录。 */
|
|
48
|
+
removeSession(sessionID: string): Promise<void>;
|
|
49
|
+
/** 文件大小(粗略)。 */
|
|
50
|
+
dirSize(sessionID: string): number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const FILE = "session.jsonl";
|
|
54
|
+
const META = "meta.json";
|
|
55
|
+
|
|
56
|
+
/** 创建某个根目录下的会话镜像存储。 */
|
|
57
|
+
export function createSessionMirror(rootDir: string): SessionMirror {
|
|
58
|
+
const dirOf = (sessionID: string): string => join(rootDir, sessionID);
|
|
59
|
+
|
|
60
|
+
function readMeta(sessionID: string): SessionMeta | null {
|
|
61
|
+
try {
|
|
62
|
+
const p = join(dirOf(sessionID), META);
|
|
63
|
+
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")) as SessionMeta;
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeMeta(sessionID: string, meta: SessionMeta): void {
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(dirOf(sessionID), { recursive: true });
|
|
73
|
+
writeFileSync(join(dirOf(sessionID), META), JSON.stringify(meta, null, 2), "utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function saveMeta(sessionID: string, patch: Partial<SessionMeta>): SessionMeta {
|
|
80
|
+
const prev =
|
|
81
|
+
readMeta(sessionID) ||
|
|
82
|
+
({ title: "", titleAuto: true, created: Date.now(), updated: Date.now() } as SessionMeta);
|
|
83
|
+
const meta = { ...prev, ...patch, updated: Date.now() };
|
|
84
|
+
writeMeta(sessionID, meta);
|
|
85
|
+
return meta;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function appendPart(sessionID: string, msgId: string, role: "user" | "assistant", part: Part, created = Date.now()): void {
|
|
89
|
+
try {
|
|
90
|
+
const d = dirOf(sessionID);
|
|
91
|
+
mkdirSync(d, { recursive: true });
|
|
92
|
+
const line = JSON.stringify({ id: msgId, role, parts: [part], created });
|
|
93
|
+
writeFileSync(join(d, FILE), line + "\n", { encoding: "utf8", flag: "a" });
|
|
94
|
+
// 首条用户文本作为标题(仅当标题仍为自动占位;与语言无关)
|
|
95
|
+
if (role === "user" && part.type === "text" && part.text) {
|
|
96
|
+
const meta = readMeta(sessionID);
|
|
97
|
+
if (!meta || meta.titleAuto) {
|
|
98
|
+
const t = part.text.trim();
|
|
99
|
+
saveMeta(sessionID, { title: t.length > 30 ? `${t.slice(0, 30)}…` : t, titleAuto: false });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
saveMeta(sessionID, {});
|
|
103
|
+
} catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function exists(sessionID: string): boolean {
|
|
109
|
+
return existsSync(join(dirOf(sessionID), FILE));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function walkDirs(): string[] {
|
|
113
|
+
try {
|
|
114
|
+
return readdirSync(rootDir).filter((n) => existsSync(join(rootDir, n, FILE)));
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function listSessions(): Promise<SessionInfo[]> {
|
|
121
|
+
const out: SessionInfo[] = [];
|
|
122
|
+
for (const id of walkDirs()) {
|
|
123
|
+
const meta = readMeta(id);
|
|
124
|
+
if (!meta) continue;
|
|
125
|
+
out.push({ id, title: meta.title, created: meta.created, updated: meta.updated, status: "idle", workspace: meta.cwd });
|
|
126
|
+
}
|
|
127
|
+
return out.sort((a, b) => b.updated - a.updated);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readHistory(sessionID: string): Promise<ChatMessage[]> {
|
|
131
|
+
let lines: string[];
|
|
132
|
+
try {
|
|
133
|
+
lines = readFileSync(join(dirOf(sessionID), FILE), "utf8").split("\n");
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const byId = new Map<string, ChatMessage>();
|
|
138
|
+
const order: string[] = [];
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const s = line.trim();
|
|
141
|
+
if (!s) continue;
|
|
142
|
+
try {
|
|
143
|
+
const o = JSON.parse(s) as { id: string; role: "user" | "assistant"; parts: Part[]; created: number };
|
|
144
|
+
if (!o.id || !Array.isArray(o.parts)) continue;
|
|
145
|
+
const hit = byId.get(o.id);
|
|
146
|
+
if (hit) {
|
|
147
|
+
hit.parts.push(...o.parts);
|
|
148
|
+
} else {
|
|
149
|
+
order.push(o.id);
|
|
150
|
+
byId.set(o.id, { id: o.id, role: o.role, parts: [...o.parts], created: o.created ?? Date.now() });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
/* ignore */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return order.map((id) => byId.get(id)!).filter((m) => m.parts.length).map((m) => ({ ...m, parts: mergeParts(m.parts) }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function removeSession(sessionID: string): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
rmSync(dirOf(sessionID), { recursive: true, force: true });
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function dirSize(sessionID: string): number {
|
|
168
|
+
try {
|
|
169
|
+
const st = statSync(join(dirOf(sessionID), FILE));
|
|
170
|
+
return st.size;
|
|
171
|
+
} catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
getMeta: readMeta,
|
|
178
|
+
newSessionId,
|
|
179
|
+
saveMeta,
|
|
180
|
+
appendPart,
|
|
181
|
+
exists,
|
|
182
|
+
listSessions,
|
|
183
|
+
readHistory,
|
|
184
|
+
removeSession,
|
|
185
|
+
dirSize,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 生成新的稳定会话 id(UUID)。 */
|
|
190
|
+
export function newSessionId(): string {
|
|
191
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
192
|
+
return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 归并同一部件 id 的流式增量:delta 追加、text 替换(与手机端 mergePart 语义一致)。 */
|
|
196
|
+
function mergeParts(parts: Part[]): Part[] {
|
|
197
|
+
const byId = new Map<string, Part>();
|
|
198
|
+
const order: string[] = [];
|
|
199
|
+
for (const p of parts) {
|
|
200
|
+
const cur = byId.get(p.id);
|
|
201
|
+
if (!cur) {
|
|
202
|
+
order.push(p.id);
|
|
203
|
+
byId.set(p.id, { ...p });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const text = p.delta != null ? (cur.text || "") + p.delta : p.text != null ? p.text : cur.text;
|
|
207
|
+
byId.set(p.id, { ...cur, ...p, text });
|
|
208
|
+
}
|
|
209
|
+
return order.map((id) => byId.get(id)!);
|
|
210
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP 权限/选择应答辅助(各 ACP 适配器共享)。
|
|
3
|
+
*/
|
|
4
|
+
import type { PermissionScope } from "../types";
|
|
5
|
+
import type { PermissionOption } from "./protocol";
|
|
6
|
+
|
|
7
|
+
/** ACP 标准审批 kind(其余视为选项型 → 选择卡)。 */
|
|
8
|
+
export const STANDARD_PERMISSION_KINDS: ReadonlySet<string> = new Set([
|
|
9
|
+
"allow_once",
|
|
10
|
+
"allow_always",
|
|
11
|
+
"allow_session",
|
|
12
|
+
"reject_once",
|
|
13
|
+
"reject_always",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 是否为「选项型」权限请求(应渲染为选择卡而非审批卡):
|
|
18
|
+
* 存在非标准 kind,或出现多个 `allow_once`(如 AskUserQuestion 的选项列表)。
|
|
19
|
+
*/
|
|
20
|
+
export function isChoiceOptions(options: PermissionOption[]): boolean {
|
|
21
|
+
if (!options.length) return false;
|
|
22
|
+
const allowOnceCount = options.filter((o) => o.kind === "allow_once").length;
|
|
23
|
+
return options.some((o) => !STANDARD_PERMISSION_KINDS.has(o.kind)) || allowOnceCount > 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 依 ACP 选项 kind 选择与手机作答最匹配的 optionId(无匹配则退化为同类,最后退化为首项)。 */
|
|
27
|
+
export function chooseOption(
|
|
28
|
+
options: PermissionOption[],
|
|
29
|
+
status: "allow" | "deny",
|
|
30
|
+
scope: PermissionScope,
|
|
31
|
+
): PermissionOption | undefined {
|
|
32
|
+
if (!options.length) return undefined;
|
|
33
|
+
const prefer =
|
|
34
|
+
status === "deny"
|
|
35
|
+
? scope === "always"
|
|
36
|
+
? "reject_always"
|
|
37
|
+
: "reject_once"
|
|
38
|
+
: scope === "once"
|
|
39
|
+
? "allow_once"
|
|
40
|
+
: scope === "session"
|
|
41
|
+
? "allow_session"
|
|
42
|
+
: "allow_always";
|
|
43
|
+
const exact = options.find((o) => o.kind === prefer);
|
|
44
|
+
if (exact) return exact;
|
|
45
|
+
const prefix = status === "deny" ? "reject" : "allow";
|
|
46
|
+
return options.find((o) => String(o.kind ?? "").startsWith(prefix)) ?? options[0];
|
|
47
|
+
}
|