@tunnelbox/core 0.1.27 → 0.1.28
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 +733 -8
- package/locales/de-DE.json +34 -1
- package/locales/en-US.json +34 -1
- package/locales/es-ES.json +34 -1
- package/locales/fr-FR.json +34 -1
- package/locales/ja-JP.json +34 -1
- package/locales/ko-KR.json +34 -1
- package/locales/zh-CN.json +34 -1
- package/locales/zh-TW.json +34 -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 +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Client Protocol (ACP) v1 的最小通用类型(JSON-RPC 2.0,NDJSON 帧)。
|
|
3
|
+
*
|
|
4
|
+
* 权威定义见 https://agentclientprotocol.com/ 。各 ACP 适配器(cursor/codebuddy/kimi/trae…)
|
|
5
|
+
* 复用本模块;只声明用到的子集,解析保持宽容。适配器专有扩展(如 Cursor 的 `cursor/*`)
|
|
6
|
+
* 由各适配器自行声明。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type RequestId = string | number;
|
|
10
|
+
|
|
11
|
+
// ---- JSON-RPC 2.0 信封 ----
|
|
12
|
+
|
|
13
|
+
export interface JsonRpcRequest {
|
|
14
|
+
jsonrpc: "2.0";
|
|
15
|
+
id: RequestId;
|
|
16
|
+
method: string;
|
|
17
|
+
params?: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface JsonRpcNotification {
|
|
20
|
+
jsonrpc: "2.0";
|
|
21
|
+
method: string;
|
|
22
|
+
params?: unknown;
|
|
23
|
+
}
|
|
24
|
+
export interface JsonRpcResponse {
|
|
25
|
+
jsonrpc: "2.0";
|
|
26
|
+
id: RequestId;
|
|
27
|
+
result: unknown;
|
|
28
|
+
}
|
|
29
|
+
export interface JsonRpcError {
|
|
30
|
+
jsonrpc: "2.0";
|
|
31
|
+
id: RequestId;
|
|
32
|
+
error: { code: number; message: string; data?: unknown };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- Content ----
|
|
36
|
+
|
|
37
|
+
export type ContentBlock =
|
|
38
|
+
| { type: "text"; text: string }
|
|
39
|
+
| { type: "image"; data: string; mimeType: string; uri?: string }
|
|
40
|
+
| { type: "audio"; data: string; mimeType: string }
|
|
41
|
+
| { type: "resource_link"; uri: string; name: string; mimeType?: string; title?: string; description?: string; size?: number }
|
|
42
|
+
| { type: "resource"; resource: { uri: string; text?: string; blob?: string; mimeType?: string } };
|
|
43
|
+
|
|
44
|
+
// ---- initialize / authenticate ----
|
|
45
|
+
|
|
46
|
+
export interface ClientCapabilities {
|
|
47
|
+
fs?: { readTextFile?: boolean; writeTextFile?: boolean };
|
|
48
|
+
terminal?: boolean;
|
|
49
|
+
/** 声明 `form` 后支持 elicitation 的 agent 才会下发 `elicitation/create` */
|
|
50
|
+
elicitation?: { form?: unknown; url?: unknown };
|
|
51
|
+
/** 部分 agent(CodeBuddy 等)用于把 AskUserQuestion 委派给客户端的扩展能力位 */
|
|
52
|
+
askUserQuestion?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InitializeParams {
|
|
56
|
+
protocolVersion: number;
|
|
57
|
+
clientCapabilities?: ClientCapabilities;
|
|
58
|
+
clientInfo?: { name: string; title?: string; version: string };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AgentCapabilities {
|
|
62
|
+
loadSession?: boolean;
|
|
63
|
+
promptCapabilities?: { image?: boolean; audio?: boolean; embeddedContext?: boolean };
|
|
64
|
+
mcpCapabilities?: { http?: boolean; sse?: boolean };
|
|
65
|
+
/** CodeBuddy 扩展:multitaskSupport 等 */
|
|
66
|
+
multitaskSupport?: boolean;
|
|
67
|
+
sessionCapabilities?: {
|
|
68
|
+
list?: unknown;
|
|
69
|
+
resume?: unknown;
|
|
70
|
+
close?: unknown;
|
|
71
|
+
delete?: unknown;
|
|
72
|
+
fork?: unknown;
|
|
73
|
+
additionalDirectories?: unknown;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AuthMethod {
|
|
78
|
+
id: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
type?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface InitializeResult {
|
|
85
|
+
protocolVersion: number;
|
|
86
|
+
agentCapabilities?: AgentCapabilities;
|
|
87
|
+
agentInfo?: { name?: string; title?: string; version?: string };
|
|
88
|
+
authMethods?: AuthMethod[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AuthenticateParams {
|
|
92
|
+
methodId: string;
|
|
93
|
+
}
|
|
94
|
+
export interface AuthenticateResult {
|
|
95
|
+
_meta?: Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- 会话 ----
|
|
99
|
+
|
|
100
|
+
export interface SessionMode {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface SessionModeState {
|
|
106
|
+
currentModeId: string;
|
|
107
|
+
availableModes: SessionMode[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `session/new|load|resume` 返回的会话级配置项(mode/model/context_window/multitask…)。 */
|
|
111
|
+
export interface SessionConfigOption {
|
|
112
|
+
id: string;
|
|
113
|
+
name?: string;
|
|
114
|
+
type?: string;
|
|
115
|
+
description?: string;
|
|
116
|
+
currentValue?: unknown;
|
|
117
|
+
options?: { value: string; name?: string; description?: string }[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SessionNewParams {
|
|
121
|
+
cwd: string;
|
|
122
|
+
mcpServers?: unknown[];
|
|
123
|
+
additionalDirectories?: string[];
|
|
124
|
+
}
|
|
125
|
+
export interface SessionNewResult {
|
|
126
|
+
sessionId: string;
|
|
127
|
+
modes?: SessionModeState;
|
|
128
|
+
configOptions?: SessionConfigOption[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SessionLoadParams {
|
|
132
|
+
sessionId: string;
|
|
133
|
+
cwd: string;
|
|
134
|
+
mcpServers?: unknown[];
|
|
135
|
+
}
|
|
136
|
+
export interface SessionResumeParams {
|
|
137
|
+
sessionId: string;
|
|
138
|
+
cwd: string;
|
|
139
|
+
mcpServers?: unknown[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SessionPromptParams {
|
|
143
|
+
sessionId: string;
|
|
144
|
+
prompt: ContentBlock[];
|
|
145
|
+
/** 可选:本轮 conversationRequestId(部分 agent 支持) */
|
|
146
|
+
_meta?: Record<string, unknown>;
|
|
147
|
+
}
|
|
148
|
+
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled" | string;
|
|
149
|
+
export interface SessionPromptResult {
|
|
150
|
+
stopReason: StopReason;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface SessionSetModeParams {
|
|
154
|
+
sessionId: string;
|
|
155
|
+
modeId: string;
|
|
156
|
+
}
|
|
157
|
+
export interface SessionSetConfigOptionParams {
|
|
158
|
+
sessionId: string;
|
|
159
|
+
configId: string;
|
|
160
|
+
value: unknown;
|
|
161
|
+
type?: string;
|
|
162
|
+
}
|
|
163
|
+
export interface SessionCancelParams {
|
|
164
|
+
sessionId: string;
|
|
165
|
+
}
|
|
166
|
+
export interface SessionDeleteParams {
|
|
167
|
+
sessionId: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- session/update ----
|
|
171
|
+
|
|
172
|
+
export type ToolKind =
|
|
173
|
+
| "read"
|
|
174
|
+
| "edit"
|
|
175
|
+
| "delete"
|
|
176
|
+
| "move"
|
|
177
|
+
| "search"
|
|
178
|
+
| "execute"
|
|
179
|
+
| "think"
|
|
180
|
+
| "fetch"
|
|
181
|
+
| "switch_mode"
|
|
182
|
+
| "other";
|
|
183
|
+
export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
184
|
+
|
|
185
|
+
export interface ToolCall {
|
|
186
|
+
toolCallId: string;
|
|
187
|
+
title?: string;
|
|
188
|
+
kind?: ToolKind;
|
|
189
|
+
status?: ToolCallStatus;
|
|
190
|
+
content?: unknown[];
|
|
191
|
+
locations?: unknown[];
|
|
192
|
+
rawInput?: unknown;
|
|
193
|
+
rawOutput?: unknown;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface PlanEntry {
|
|
197
|
+
content?: string;
|
|
198
|
+
priority?: string;
|
|
199
|
+
status?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type SessionUpdate =
|
|
203
|
+
| { sessionUpdate: "user_message_chunk"; messageId?: string; content?: ContentBlock }
|
|
204
|
+
| { sessionUpdate: "agent_message_chunk"; messageId?: string; content?: ContentBlock }
|
|
205
|
+
| { sessionUpdate: "agent_thought_chunk"; messageId?: string; content?: ContentBlock }
|
|
206
|
+
| ({ sessionUpdate: "tool_call" } & ToolCall)
|
|
207
|
+
| ({ sessionUpdate: "tool_call_update" } & Partial<ToolCall>)
|
|
208
|
+
| { sessionUpdate: "plan"; entries?: PlanEntry[] }
|
|
209
|
+
| { sessionUpdate: "available_commands_update"; availableCommands?: { name: string; description?: string }[] }
|
|
210
|
+
| { sessionUpdate: "current_mode_update"; modeId?: string }
|
|
211
|
+
| { sessionUpdate: "config_option_update"; configOptions?: SessionConfigOption[] }
|
|
212
|
+
| { sessionUpdate: "usage_update"; used?: number; size?: number; cost?: unknown }
|
|
213
|
+
| { sessionUpdate: "session_info_update"; title?: string; _meta?: Record<string, unknown> }
|
|
214
|
+
| { sessionUpdate: string; [k: string]: unknown };
|
|
215
|
+
|
|
216
|
+
export interface SessionUpdateNotification {
|
|
217
|
+
sessionId: string;
|
|
218
|
+
update: SessionUpdate;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- session/request_permission ----
|
|
222
|
+
|
|
223
|
+
/** ACP 标准 kind;各 agent 可能扩展 `allow_session` 等,故用宽松字符串。 */
|
|
224
|
+
export type PermissionOptionKind = string;
|
|
225
|
+
|
|
226
|
+
export interface PermissionOption {
|
|
227
|
+
optionId: string;
|
|
228
|
+
name: string;
|
|
229
|
+
kind: PermissionOptionKind;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface RequestPermissionParams {
|
|
233
|
+
sessionId?: string;
|
|
234
|
+
toolCall: ToolCall;
|
|
235
|
+
options: PermissionOption[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type RequestPermissionOutcome = { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
|
|
239
|
+
export interface RequestPermissionResult {
|
|
240
|
+
outcome: RequestPermissionOutcome;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---- elicitation/create(agent → client 请求,需应答) ----
|
|
244
|
+
|
|
245
|
+
export interface ElicitationMeta {
|
|
246
|
+
[k: string]: unknown;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ElicitationPropertySchema {
|
|
250
|
+
type?: string;
|
|
251
|
+
title?: string;
|
|
252
|
+
description?: string;
|
|
253
|
+
enum?: unknown[];
|
|
254
|
+
enumNames?: unknown[];
|
|
255
|
+
items?: unknown;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface ElicitationRequestedSchema {
|
|
259
|
+
type?: string;
|
|
260
|
+
properties?: Record<string, ElicitationPropertySchema>;
|
|
261
|
+
required?: string[];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ElicitationCreateParams {
|
|
265
|
+
sessionId?: string;
|
|
266
|
+
toolCallId?: string;
|
|
267
|
+
mode?: string;
|
|
268
|
+
message?: string;
|
|
269
|
+
requestedSchema?: ElicitationRequestedSchema;
|
|
270
|
+
_meta?: ElicitationMeta;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type ElicitationCreateResult =
|
|
274
|
+
| { action: "accept"; content?: Record<string, unknown>; _meta?: Record<string, unknown> }
|
|
275
|
+
| { action: "decline"; _meta?: Record<string, unknown> }
|
|
276
|
+
| { action: "cancel"; _meta?: Record<string, unknown> };
|
|
277
|
+
|
|
278
|
+
/** `elicitation/complete`(agent → client 通知):撤卡,按 elicitationId 关联。 */
|
|
279
|
+
export interface ElicitationCompleteParams {
|
|
280
|
+
elicitationId?: string;
|
|
281
|
+
_meta?: ElicitationMeta;
|
|
282
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP 常驻子进程的生命周期管理:按需启动、`initialize` + 可选 `authenticate` 握手、
|
|
3
|
+
* 退出后清空句柄(下次请求自动重启并重新握手)。
|
|
4
|
+
*
|
|
5
|
+
* 全程只维护一个连接(stdio 单客户端);会话恢复由各适配器用 `session/load`(或 `session/resume`)完成。
|
|
6
|
+
* 由各 ACP 适配器(cursor/codebuddy/kimi/trae…)复用;spawn 参数、clientInfo、能力位、认证策略由适配器注入。
|
|
7
|
+
*/
|
|
8
|
+
import { AcpClient, type AcpHandlers } from "./client";
|
|
9
|
+
import type {
|
|
10
|
+
AgentCapabilities,
|
|
11
|
+
AuthMethod,
|
|
12
|
+
AuthenticateResult,
|
|
13
|
+
ClientCapabilities,
|
|
14
|
+
InitializeParams,
|
|
15
|
+
InitializeResult,
|
|
16
|
+
} from "./protocol";
|
|
17
|
+
|
|
18
|
+
const PROTOCOL_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export interface AcpSupervisorConfig {
|
|
21
|
+
/** ACP CLI 可执行名/路径 */
|
|
22
|
+
bin: string;
|
|
23
|
+
/** bin 之后的完整参数(如 `["--acp"]` 或 `["acp","serve"]`) */
|
|
24
|
+
args: string[];
|
|
25
|
+
/** 子进程 cwd */
|
|
26
|
+
cwd?: string;
|
|
27
|
+
/** 错误/日志标签(如 `codebuddy acp`) */
|
|
28
|
+
label: string;
|
|
29
|
+
/** initialize.clientInfo */
|
|
30
|
+
clientInfo: { name: string; title?: string; version: string };
|
|
31
|
+
/** initialize.clientCapabilities(缺省:不代理 fs/terminal、声明 elicitation.form) */
|
|
32
|
+
clientCapabilities?: ClientCapabilities;
|
|
33
|
+
/**
|
|
34
|
+
* 可选:ACP `authenticate` 的 methodId。缺省不调用(用于交互式登录的 agent);
|
|
35
|
+
* 非交互的 agent(如 Kimi 的 `login`)可传入以探测登录态。
|
|
36
|
+
*/
|
|
37
|
+
authMethod?: string;
|
|
38
|
+
/** 未配置 authMethod 时回调(由适配器输出本地化提示) */
|
|
39
|
+
onAuthSkipped?: () => void;
|
|
40
|
+
/** authenticate 结果回调(成功/失败) */
|
|
41
|
+
onAuthResult?: (r: { ok: boolean; result?: AuthenticateResult; error?: string }) => void;
|
|
42
|
+
/** 日志(已本地化) */
|
|
43
|
+
log: (msg: string) => void;
|
|
44
|
+
/** 子进程退出回调(供适配器做 fail-closed 处理) */
|
|
45
|
+
onExit?: (code: number | null, signal: string | null) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AcpSupervisor {
|
|
49
|
+
private client: AcpClient | null = null;
|
|
50
|
+
private starting: Promise<AcpClient> | null = null;
|
|
51
|
+
private caps: AgentCapabilities = {};
|
|
52
|
+
private auth: AuthenticateResult | null = null;
|
|
53
|
+
|
|
54
|
+
/** 由适配器注入:通知 / 服务端请求处理。 */
|
|
55
|
+
onNotification: (method: string, params: unknown) => void = () => {};
|
|
56
|
+
onServerRequest: (id: string | number, method: string, params: unknown) => void = () => {};
|
|
57
|
+
|
|
58
|
+
constructor(private cfg: AcpSupervisorConfig) {}
|
|
59
|
+
|
|
60
|
+
get running(): boolean {
|
|
61
|
+
return !!this.client?.alive;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 当前客户端(不触发启动);用于应答来自该连接的服务端请求。 */
|
|
65
|
+
get current(): AcpClient | null {
|
|
66
|
+
return this.client?.alive ? this.client : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** initialize 协商得到的 agent 能力位(loadSession / sessionCapabilities 等)。 */
|
|
70
|
+
get capabilities(): AgentCapabilities {
|
|
71
|
+
return this.caps;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `authenticate` 返回结果(未认证/未调用时为 null)。 */
|
|
75
|
+
get authResult(): AuthenticateResult | null {
|
|
76
|
+
return this.auth;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 取得可用客户端(必要时启动并握手)。 */
|
|
80
|
+
async ensure(): Promise<AcpClient> {
|
|
81
|
+
if (this.client?.alive) return this.client;
|
|
82
|
+
if (!this.starting) {
|
|
83
|
+
this.starting = this.launch().finally(() => {
|
|
84
|
+
this.starting = null;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return this.starting;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stop(): void {
|
|
91
|
+
this.client?.stop();
|
|
92
|
+
this.client = null;
|
|
93
|
+
this.caps = {};
|
|
94
|
+
this.auth = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async launch(): Promise<AcpClient> {
|
|
98
|
+
const handlers: AcpHandlers = {
|
|
99
|
+
onNotification: (method, params) => this.onNotification(method, params),
|
|
100
|
+
onServerRequest: (id, method, params) => this.onServerRequest(id, method, params),
|
|
101
|
+
onStderr: (line) => {
|
|
102
|
+
if (/\b(error|failed|panic|denied|unauthorized|auth|login|mcp|tunnelbox)\b/i.test(line)) {
|
|
103
|
+
this.cfg.log(`acp: ${line.slice(0, 300)}`);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
onExit: (code, signal) => {
|
|
107
|
+
this.client = null;
|
|
108
|
+
this.caps = {};
|
|
109
|
+
this.auth = null;
|
|
110
|
+
this.cfg.log(`${this.cfg.label} exited (code=${code ?? "null"} signal=${signal ?? "null"})`);
|
|
111
|
+
this.cfg.onExit?.(code, signal);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const client = new AcpClient(this.cfg.bin, this.cfg.args, handlers, this.cfg.label);
|
|
115
|
+
await client.start(this.cfg.cwd);
|
|
116
|
+
const params: InitializeParams = {
|
|
117
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
118
|
+
clientCapabilities: this.cfg.clientCapabilities ?? {
|
|
119
|
+
// 不代理文件/终端工具(适配器只做远程 UI;工具在本机执行)
|
|
120
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
121
|
+
terminal: false,
|
|
122
|
+
// 声明 form 后支持 elicitation 的 agent 才会下发 elicitation/create
|
|
123
|
+
elicitation: { form: {} },
|
|
124
|
+
},
|
|
125
|
+
clientInfo: this.cfg.clientInfo,
|
|
126
|
+
};
|
|
127
|
+
const res = await client.request<InitializeResult>("initialize", params, 20_000);
|
|
128
|
+
this.caps = res?.agentCapabilities ?? {};
|
|
129
|
+
await this.authenticate(client, res?.authMethods);
|
|
130
|
+
this.client = client;
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 完成登录握手。仅当配置了 `authMethod` 时调用(交互式登录的 agent 应缺省不调用,避免挂起/弹登录);
|
|
136
|
+
* 非交互的 agent(如 Kimi `login`)可传入以探测登录态,失败不阻断启动。
|
|
137
|
+
*/
|
|
138
|
+
private async authenticate(client: AcpClient, methods: AuthMethod[] | undefined): Promise<void> {
|
|
139
|
+
const wanted = (this.cfg.authMethod || "").trim();
|
|
140
|
+
if (!wanted) {
|
|
141
|
+
this.cfg.onAuthSkipped?.();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const list = Array.isArray(methods) ? methods : [];
|
|
145
|
+
const preferred = list.find((m) => m.id === wanted) ?? list[0];
|
|
146
|
+
if (!preferred) {
|
|
147
|
+
this.cfg.onAuthResult?.({ ok: false, error: "no auth methods" });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const r = await client.request<AuthenticateResult>("authenticate", { methodId: preferred.id }, 120_000);
|
|
152
|
+
this.auth = r ?? {};
|
|
153
|
+
this.cfg.onAuthResult?.({ ok: true, result: r ?? {} });
|
|
154
|
+
} catch (e) {
|
|
155
|
+
this.cfg.onAuthResult?.({ ok: false, error: (e as Error).message });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|