@aiti-ai/aiti-claude-code-agent 0.0.0-stage → 0.1.8
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 +32 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/claude-handle.test.mjs +236 -0
- package/claude-quota.test.mjs +88 -0
- package/claude-quota.ts +147 -0
- package/client/src/controlled-login-client.mjs +22 -0
- package/client/src/controlled-login-dialog.mjs +270 -0
- package/dev-pack.mjs +246 -0
- package/dist/controlled-login-client.mjs +22 -0
- package/dist/controlled-login-dialog.mjs +270 -0
- package/external-governance.ts +138 -0
- package/index.ts +1431 -0
- package/package.json +28 -4
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/tools/deploy.mjs +45 -0
- package/tools/turn-ack-probe.mjs +106 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-claude-code-agent — 会话后端扩展:驱动 Claude Code CLI 会话。
|
|
3
|
+
*
|
|
4
|
+
* 打通 all-sessions 的跨 agent 链路:面板里的 CC 历史行("claude:<sid>")
|
|
5
|
+
* 经 openExternalSession → 本扩展 spawn `claude -p --resume <sid>` 变成 live
|
|
6
|
+
* 会话;全新会话则不带 --resume 启动,从 system/init 帧捕获真实 session_id。
|
|
7
|
+
*
|
|
8
|
+
* 协议(本机 claude 2.x 实测):
|
|
9
|
+
* stdin ← {"type":"user","message":{...}} 用户消息
|
|
10
|
+
* stdin ← {"type":"control_request","request_id","request":{subtype:"interrupt"}}
|
|
11
|
+
* stdout → {"type":"system","subtype":"init","session_id","model",...}
|
|
12
|
+
* stdout → {"type":"stream_event","event":{"type":"content_block_delta",
|
|
13
|
+
* "delta":{"type":"text_delta","text":...}}} 流式增量
|
|
14
|
+
* stdout → {"type":"assistant","message":{content:[...]}} 完整消息
|
|
15
|
+
* stdout → {"type":"user","message":{content:[{type:"tool_result",…}]}} 工具结果
|
|
16
|
+
* stdout → {"type":"result","subtype":"success|error_during_execution",
|
|
17
|
+
* "is_error", "result"} 轮次终结
|
|
18
|
+
*
|
|
19
|
+
* 能力面(fail-loud):prompt/followUp(本地队列,D11 探针证据)/abort/getTools/getCommands/
|
|
20
|
+
* queueManage ✅;steer/fork/rename/compact 等 ❌。
|
|
21
|
+
*
|
|
22
|
+
* 权限提示:print 模式无交互审批。需要自动批准时由调用方经 opts.args 传
|
|
23
|
+
* ["--permission-mode","acceptEdits"] 或 ["--dangerously-skip-permissions"]。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import { fetchClaudeQuota, readClaudeCreds } from "./claude-quota.ts";
|
|
29
|
+
import { whichBin } from "@aiti-ai/ext-shared/exec-async";
|
|
30
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
31
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { governanceEnv } from "./external-governance.ts";
|
|
35
|
+
import { agentSpawnEnv, spawnEnvFeature, spawnCommandFeature, spawnArgsPrefixFeature, spawnEnvList, spawnCommand, spawnArgsPrefix, parseArgsLines, resolveCliLaunch, captureSpawnEnvFeatures } from "@aiti-ai/ext-shared/agent-spawn-env";
|
|
36
|
+
import type { LaunchOverride } from "@aiti-ai/ext-shared/agent-spawn-env";
|
|
37
|
+
// 队列共享实现层(权威源 pi-web-ext/_shared/backend-queue.ts,副本随扩展部署——改动须双向同步)
|
|
38
|
+
import { createBackendQueueSupport, createQueueFilePersistence, migrateQueueSidecar, type BackendQueue } from "@aiti-ai/ext-shared/backend-queue";
|
|
39
|
+
import {
|
|
40
|
+
readExtModelState, readAgentLastUsedModel, readPiModels, renameExtModelState, writeExtModelState,
|
|
41
|
+
mergeModelCatalogs, withCurrentFirst, withLastUsedFirst, type ExtModelEntry,
|
|
42
|
+
} from "@aiti-ai/ext-shared/ext-model-state";
|
|
43
|
+
// 受控登录共享层(权威源 pi-web-ext/_shared/controlled-login.mjs,副本随扩展部署——改动须双向同步)
|
|
44
|
+
import { cleanEnv, createControlledLogin, mountControlledLoginApi, tailLine } from "@aiti-ai/ext-shared/controlled-login";
|
|
45
|
+
|
|
46
|
+
/** 队列崩溃恢复 sidecar 根(startup 捕获 <stateDir>;undefined/空 = 不持久化)。 */
|
|
47
|
+
let queueStateDir: string | undefined;
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// 结构化契约 + 线协议工具(out-of-tree,无宿主导入)
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
type Listener = (event: Record<string, unknown>) => void;
|
|
54
|
+
interface SessionCapabilitiesLike extends Record<string, boolean> {}
|
|
55
|
+
|
|
56
|
+
function lineReader(
|
|
57
|
+
stream: NodeJS.ReadableStream | null,
|
|
58
|
+
onLine: (line: string) => void,
|
|
59
|
+
opts?: { maxLineBytes?: number; onOverflow?: (size: number) => void },
|
|
60
|
+
): () => void {
|
|
61
|
+
if (!stream) return () => {};
|
|
62
|
+
// 字节累积、按 \n 切 Buffer 后整行 utf8 解码(加固 A1)——逐 chunk toString 会把
|
|
63
|
+
// 跨 chunk 的多字节字符(中文)解码成 U+FFFD。
|
|
64
|
+
const maxLineBytes = opts?.maxLineBytes ?? 10 * 1024 * 1024;
|
|
65
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
66
|
+
let overflowed = false;
|
|
67
|
+
const onData = (chunk: Buffer | string) => {
|
|
68
|
+
if (overflowed) return;
|
|
69
|
+
const c = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
70
|
+
buffer = buffer.length === 0 ? c : Buffer.concat([buffer, c]);
|
|
71
|
+
for (;;) {
|
|
72
|
+
const idx = buffer.indexOf(0x0a);
|
|
73
|
+
if (idx === -1) break;
|
|
74
|
+
const line = buffer.subarray(0, idx).toString("utf8");
|
|
75
|
+
buffer = buffer.subarray(idx + 1);
|
|
76
|
+
if (line.trim()) onLine(line);
|
|
77
|
+
}
|
|
78
|
+
if (buffer.length > maxLineBytes) {
|
|
79
|
+
overflowed = true;
|
|
80
|
+
opts?.onOverflow?.(buffer.length);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
stream.on("data", onData);
|
|
84
|
+
return () => {
|
|
85
|
+
stream.off("data", onData);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 工具入参单行摘要(权限/工具卡用;优选 command/path/url 等关键字段,超长截断)。 */
|
|
90
|
+
function summarizeToolInput(input: unknown): string {
|
|
91
|
+
if (input == null) return "";
|
|
92
|
+
if (typeof input === "string") return input.length > 120 ? `${input.slice(0, 120)}…` : input;
|
|
93
|
+
try {
|
|
94
|
+
const rec = input as Record<string, unknown>;
|
|
95
|
+
const preferred = ["command", "path", "file_path", "url", "pattern", "query", "description"]
|
|
96
|
+
.filter((k) => typeof rec[k] === "string" && rec[k])
|
|
97
|
+
.map((k) => `${k}=${String(rec[k])}`);
|
|
98
|
+
const s = preferred.length ? preferred.join(" ") : JSON.stringify(input);
|
|
99
|
+
return s.length > 120 ? `${s.slice(0, 120)}…` : s;
|
|
100
|
+
} catch {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 模型目录核心(handle 方法与注册级静态 listModels 共用,防实现漂移):
|
|
107
|
+
* CC 内置清单 + pi 目录(models.json 实时扁平化,选择器独立 "pi" 分组),
|
|
108
|
+
* 当前模型置顶。pi 模型能否被 CC 实际服务取决于其自身 provider 配置(fail-loud)。
|
|
109
|
+
*/
|
|
110
|
+
async function claudeCodeCatalog(current?: { id: string; provider: string }): Promise<Array<ExtModelEntry & { alias?: string }>> {
|
|
111
|
+
const builtin: ExtModelEntry[] = [
|
|
112
|
+
{ id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5", alias: "sonnet", provider: "claude-code", source: "ext" },
|
|
113
|
+
{ id: "claude-opus-4-1", label: "Claude Opus 4.1", alias: "opus", provider: "claude-code", source: "ext" },
|
|
114
|
+
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5", alias: "haiku", provider: "claude-code", source: "ext" },
|
|
115
|
+
{ id: "glm-5.2", label: "GLM 5.2", provider: "claude-code", source: "ext" },
|
|
116
|
+
{ id: "glm-5.3", label: "GLM 5.3", provider: "claude-code", source: "ext" },
|
|
117
|
+
];
|
|
118
|
+
const piModels = await readPiModels();
|
|
119
|
+
return withCurrentFirst(
|
|
120
|
+
mergeModelCatalogs(builtin, piModels),
|
|
121
|
+
current,
|
|
122
|
+
) as Array<ExtModelEntry & { alias?: string }>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* claude 族上下文窗口(get_context_usage 换算用):模型 id 带 `[1m]` 后缀(如
|
|
127
|
+
* claude-sonnet-4-5[1m],CC 的 1M 上下文 beta 变体在模型名里体现)→ 1M;其余
|
|
128
|
+
* claude 模型(含 haiku/sonnet/opus 全系)一律 200K。未知 id 也回 200K(宁近似
|
|
129
|
+
* 不空——tooltip 会展示窗口数值可核对)。
|
|
130
|
+
*/
|
|
131
|
+
export function claudeContextWindow(modelId?: string): number {
|
|
132
|
+
if (typeof modelId === "string" && modelId.includes("[1m]")) return 1_000_000;
|
|
133
|
+
return 200_000;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const CC_CAPABILITIES: SessionCapabilitiesLike = { steer: false,
|
|
137
|
+
// D11 探针(2026-09-13,claude 2.1.201,带 AITI_BRIDGE_URL 桥连通 + result 后 0ms 写入):
|
|
138
|
+
// 第二轮正常受理(新 init + 新 result,非复读)——无 codebuddy 同款吞输入病。followUp 经
|
|
139
|
+
// backend-queue 共享层本地队列实现(settle= result 帧,自动续轮,可撤回)。
|
|
140
|
+
followUp: true,
|
|
141
|
+
getMessages: true,
|
|
142
|
+
respondPermission: true,
|
|
143
|
+
getUsage: true,
|
|
144
|
+
listModels: true,
|
|
145
|
+
fileAttachments: true,
|
|
146
|
+
// 上下文用量(2026-09-19):result 帧 usage 三项 input 侧相加即当前上下文占用,
|
|
147
|
+
// 窗口按模型族 map(claudeContextWindow)——percent 供 ctx-meter/底栏 chip。
|
|
148
|
+
contextUsage: true,
|
|
149
|
+
// 2026-08 对 claude 2.1.201 实证:control_request 支持 subtype "set_model"(同
|
|
150
|
+
// set_permission_mode 通道,响应经 control_response 关联)。
|
|
151
|
+
setModel: true,
|
|
152
|
+
setThinkingLevel: false,
|
|
153
|
+
fork: false,
|
|
154
|
+
renameSession: false,
|
|
155
|
+
compact: false,
|
|
156
|
+
executeBash: false,
|
|
157
|
+
listTools: true, // system/init 携带 tools 清单
|
|
158
|
+
setActiveTools: false,
|
|
159
|
+
navigateTree: false,
|
|
160
|
+
deleteSession: false,
|
|
161
|
+
getCommands: true, // system/init 携带 slash_commands
|
|
162
|
+
queueManage: true, // backend-queue 共享层全 CRUD(followUp 组;无 steer 组——协议无注入面)
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// ClaudeCodeHandle
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
/** 导出仅供单测直接驱动(宿主 loader 只消费 default 导出)。 */
|
|
170
|
+
export class ClaudeCodeHandle {
|
|
171
|
+
readonly source = "aiti-claude-code-agent/claude-code";
|
|
172
|
+
readonly capabilities: SessionCapabilitiesLike = CC_CAPABILITIES;
|
|
173
|
+
id: string;
|
|
174
|
+
|
|
175
|
+
private child: ChildProcess;
|
|
176
|
+
private detachReader: (() => void) | null = null;
|
|
177
|
+
private listeners = new Set<Listener>();
|
|
178
|
+
private aliveFlag = true;
|
|
179
|
+
private running = false;
|
|
180
|
+
private model: { id: string; provider: string } | undefined;
|
|
181
|
+
private tools: Array<{ name: string; description: string }> = [];
|
|
182
|
+
private commands: Array<{ name: string; description?: string; source: string }> = [];
|
|
183
|
+
/** abort 的 control_request 关联(request_id → resolver) */
|
|
184
|
+
private interruptWaiters: Array<(ok: boolean) => void> = [];
|
|
185
|
+
/** 待决 set_permission_mode 请求:request_id → resolver */
|
|
186
|
+
private pmWaiters = new Map<string, (ok: boolean) => void>();
|
|
187
|
+
/** 待决 set_model 请求:request_id → resolver(2.1.201 实证支持) */
|
|
188
|
+
private smWaiters = new Map<string, (ok: boolean) => void>();
|
|
189
|
+
/** 临时模型状态文件的目录键(agentKey,如 claude) */
|
|
190
|
+
private readonly stateAgentKey: string;
|
|
191
|
+
/** 待决工具授权请求(can_use_tool):request_id → resolver */
|
|
192
|
+
private permissionPending = new Map<string, (allowed: boolean) => void>();
|
|
193
|
+
/** 待决授权元数据(requestId → 工具/入参)——决策/过期记录进会话消息流用(D4)。 */
|
|
194
|
+
private permissionMeta = new Map<string, { toolName?: string; input?: unknown }>();
|
|
195
|
+
/** 直播期工具调用账本:toolCallId → 工具/入参(user 帧 tool_result 回填 toolName,方案 X)。 */
|
|
196
|
+
private toolCallOpen = new Map<string, { name: string; input: unknown }>();
|
|
197
|
+
/** 最近一轮用量/成本(result 帧提取) */
|
|
198
|
+
private lastUsage: {
|
|
199
|
+
model?: string; inputTokens?: number; outputTokens?: number;
|
|
200
|
+
cacheReadTokens?: number; cacheWriteTokens?: number; costUsd?: number; numTurns?: number;
|
|
201
|
+
} | undefined;
|
|
202
|
+
/** 会话累计用量(本 handle 进程存活期逐轮累加;getUsageTotals 数据源,重启归零) */
|
|
203
|
+
private usageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0, turns: 0 };
|
|
204
|
+
private seq = 0;
|
|
205
|
+
/** 本进程内累积的消息(user prompt + assistant 完整帧)——print 模式 jsonl
|
|
206
|
+
* 只在进程退出时落盘,活会话的历史回放靠这份内存日志。 */
|
|
207
|
+
private liveHistory: unknown[] = [];
|
|
208
|
+
/** resume 场景:进程启动后首次 getMessages 时从 jsonl 读既往轮次(只读一次)。 */
|
|
209
|
+
private prelude: unknown[] | null = null;
|
|
210
|
+
/** --resume 请求的原始 sid:CC resume 后 init 可能报告新 id,历史文件仍以旧 sid 命名 */
|
|
211
|
+
private resumeSourceSid: string | undefined;
|
|
212
|
+
/** 历史回放目录(会话真实原 directory;可能与 spawn cwd 不同——原目录已删时 spawn 落兜底 cwd,jsonl 仍按原目录找)。 */
|
|
213
|
+
historyCwd: string | undefined;
|
|
214
|
+
/** 当前权限模式(init 后由 spawn 参数/运行时切换更新)。 */
|
|
215
|
+
private permissionMode: string | undefined;
|
|
216
|
+
|
|
217
|
+
readonly cwd: string;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 队列支持(共享层,D3/D11 同款):本地 followUp 队列 + settle 自动续轮。D11 探针
|
|
221
|
+
* 证明 claude result 后 0ms 写入被正常受理(无 codebuddy 吞输入病),无需 turn-ack 门控。
|
|
222
|
+
* dispatch 闭包回 prompt() 全簿记;无原生 steer → 运行中输入一律入队(可撤回)。
|
|
223
|
+
*/
|
|
224
|
+
private q = createBackendQueueSupport({
|
|
225
|
+
isRunning: () => this.running || !this.aliveFlag,
|
|
226
|
+
alive: () => this.aliveFlag,
|
|
227
|
+
dispatch: (text, images) => this.prompt(text, { images: images as never }),
|
|
228
|
+
onEvent: (ev) => this.emit(ev),
|
|
229
|
+
log: (m) => console.warn(`[aiti-claude-code-agent] ${m}`),
|
|
230
|
+
// 队列崩溃恢复 sidecar(docs/aiti-remote-queue-and-sidecar-plan.md §3)
|
|
231
|
+
persistence: createQueueFilePersistence(() => (queueStateDir ? join(queueStateDir, "queues", `${this.id}.json`) : undefined), (m) => console.warn(m)),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
constructor(id: string, child: ChildProcess, cwd = "", capabilities?: SessionCapabilitiesLike, stateAgentKey = "claude") {
|
|
235
|
+
this.id = id;
|
|
236
|
+
this.child = child;
|
|
237
|
+
this.cwd = cwd;
|
|
238
|
+
this.stateAgentKey = stateAgentKey;
|
|
239
|
+
if (capabilities) (this as { capabilities: SessionCapabilitiesLike }).capabilities = capabilities;
|
|
240
|
+
|
|
241
|
+
this.detachReader = lineReader(child.stdout, (line) => this.onLine(line), {
|
|
242
|
+
onOverflow: (size) => this.markDead(`stdout line overflow (${size} bytes)`),
|
|
243
|
+
});
|
|
244
|
+
// 三个流必须挂 error 监听(加固 A2):子进程被杀/管道断裂时流上抛 ECONNRESET/EPIPE,
|
|
245
|
+
// 没有监听器 = unhandled 'error' event = 崩宿主进程。(on?. 防御:测试桩的流可能缺 on)
|
|
246
|
+
child.stdout?.on?.("error", (err) => this.markDead(`stdout error: ${err.message}`));
|
|
247
|
+
child.stderr?.on?.("error", (err) => console.log(`[claude-code:${this.id.slice(0, 20)}] stderr error: ${err.message}`));
|
|
248
|
+
child.stdin?.on?.("error", (err) => console.log(`[claude-code:${this.id.slice(0, 20)}] stdin error: ${err.message}`));
|
|
249
|
+
child.stderr?.on("data", (c: Buffer | string) => {
|
|
250
|
+
const text = String(c).trim();
|
|
251
|
+
if (text) console.log(`[claude-code:${this.id.slice(0, 20)}] ${text.slice(0, 300)}`);
|
|
252
|
+
});
|
|
253
|
+
child.once("exit", (code) => {
|
|
254
|
+
this.running = false;
|
|
255
|
+
this.markDead(`process exited (${code ?? "signal"})`);
|
|
256
|
+
});
|
|
257
|
+
child.once("error", (err) => this.markDead(`process error: ${err.message}`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private markDead(reason: string): void {
|
|
261
|
+
if (!this.aliveFlag) return;
|
|
262
|
+
this.aliveFlag = false;
|
|
263
|
+
this.detachReader?.();
|
|
264
|
+
this.detachReader = null;
|
|
265
|
+
for (const w of this.interruptWaiters) w(false);
|
|
266
|
+
this.interruptWaiters = [];
|
|
267
|
+
// 未决 control 往返一并落定(waiter 自带 clearTimeout),权限表清空
|
|
268
|
+
for (const w of this.pmWaiters.values()) w(false);
|
|
269
|
+
this.pmWaiters.clear();
|
|
270
|
+
for (const w of this.smWaiters.values()) w(false);
|
|
271
|
+
this.smWaiters.clear();
|
|
272
|
+
this.permissionPending.clear();
|
|
273
|
+
this.permissionMeta.clear();
|
|
274
|
+
this.q.dispose(); // 队列清空 + 停未决续轮(进程死了队列没有未来)
|
|
275
|
+
this.emit({ type: "session_backend_dead", reason });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private emit(event: Record<string, unknown>): void {
|
|
279
|
+
// 逐监听器隔离(加固 A3):一个订阅者抛错不得漏帧给其余订阅者、不得沿流回调裸抛
|
|
280
|
+
for (const l of this.listeners) {
|
|
281
|
+
try {
|
|
282
|
+
l(event);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error(`[aiti-claude-code-agent] listener error: ${e instanceof Error ? e.message : String(e)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 会话消息流记录(custom 消息,规范 §6 范式 / ACP 样板):liveHistory 恒记录(刷新可回放);
|
|
291
|
+
* liveOnly=true 只发 message_end 直播卡(历史里已有原生 toolCall 块,不双记)。
|
|
292
|
+
*/
|
|
293
|
+
private pushOrEmitCustom(opts: { customType: string; content: string; details?: unknown; liveOnly?: boolean }): void {
|
|
294
|
+
const message = {
|
|
295
|
+
role: "custom",
|
|
296
|
+
customType: opts.customType,
|
|
297
|
+
content: opts.content,
|
|
298
|
+
display: true,
|
|
299
|
+
...(opts.details !== undefined ? { details: opts.details } : {}),
|
|
300
|
+
timestamp: Date.now(),
|
|
301
|
+
};
|
|
302
|
+
if (!opts.liveOnly) this.liveHistory.push(message);
|
|
303
|
+
this.emit({ type: "message_end", message });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 权限请求/决策写进会话消息流(规范 §6:ask 模式有卡有记录;allow 静默放行也留痕)。 */
|
|
307
|
+
private recordPermissionCustom(
|
|
308
|
+
toolName: string | undefined,
|
|
309
|
+
input: unknown,
|
|
310
|
+
decision: "pending" | "allow" | "deny" | "cancelled",
|
|
311
|
+
requestId: string,
|
|
312
|
+
note?: string,
|
|
313
|
+
): void {
|
|
314
|
+
const label = decision === "pending" ? "⏳ 等待授权"
|
|
315
|
+
: decision === "allow" ? `✅ 已允许${note ? `(${note})` : ""}`
|
|
316
|
+
: decision === "deny" ? `⛔ 已拒绝${note ? `(${note})` : ""}`
|
|
317
|
+
: "🚫 已取消(回合结束)";
|
|
318
|
+
const inputStr = summarizeToolInput(input);
|
|
319
|
+
this.pushOrEmitCustom({
|
|
320
|
+
customType: "🔐 工具授权",
|
|
321
|
+
content: `${toolName || "tool"}${inputStr ? ` — ${inputStr}` : ""}\n\n${label}`,
|
|
322
|
+
details: { requestId, toolName, input, decision },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private onLine(line: string): void {
|
|
327
|
+
let msg: Record<string, unknown>;
|
|
328
|
+
try {
|
|
329
|
+
msg = JSON.parse(line) as Record<string, unknown>;
|
|
330
|
+
} catch {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const type = String(msg.type ?? "");
|
|
334
|
+
|
|
335
|
+
if (type === "system" && msg.subtype === "init") {
|
|
336
|
+
const sid = String(msg.session_id ?? "");
|
|
337
|
+
if (sid && !this.id.includes(sid)) {
|
|
338
|
+
const oldId = this.id;
|
|
339
|
+
this.id = `claude:${sid}`;
|
|
340
|
+
// 宿主据此把 store 键从占位 id 换成真实复合 id(旧键保留兼容已发出的引用)
|
|
341
|
+
this.emit({ type: "session_backend_ready", oldId, id: this.id });
|
|
342
|
+
// 状态文件跟随换绑(模型选择持久化到新 id 名下)
|
|
343
|
+
renameExtModelState(this.stateAgentKey, oldId, this.id).then(() => this.persistModelState());
|
|
344
|
+
// 队列 sidecar 随 id 换绑(占位 id 随机,不迁移则 init 前排队的条目崩溃后不可恢复)
|
|
345
|
+
migrateQueueSidecar(queueStateDir, oldId, this.id, (m) => console.warn(`[aiti-claude-code-agent] ${m}`));
|
|
346
|
+
}
|
|
347
|
+
const m = typeof msg.model === "string" ? msg.model : undefined;
|
|
348
|
+
if (m) this.trackModel(m, "restore");
|
|
349
|
+
if (Array.isArray(msg.tools)) {
|
|
350
|
+
this.tools = (msg.tools as string[]).map((name) => ({ name, description: "" }));
|
|
351
|
+
}
|
|
352
|
+
if (Array.isArray(msg.slash_commands)) {
|
|
353
|
+
this.commands = (msg.slash_commands as string[]).map((name) => ({
|
|
354
|
+
name,
|
|
355
|
+
source: "skill" as const,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
this.emit({ type: "agent_start", sessionId: this.id, model: this.model });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (type === "stream_event") {
|
|
363
|
+
const ev = (msg.event ?? {}) as Record<string, unknown>;
|
|
364
|
+
const evType = String(ev.type ?? "");
|
|
365
|
+
if (evType === "message_start") {
|
|
366
|
+
this.running = true;
|
|
367
|
+
this.emit({ type: "message_start", role: "assistant" });
|
|
368
|
+
} else if (evType === "content_block_delta") {
|
|
369
|
+
const delta = (ev.delta ?? {}) as Record<string, unknown>;
|
|
370
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
371
|
+
this.emit({ type: "message_update", delta: delta.text });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (type === "assistant") {
|
|
378
|
+
const message = (msg.message ?? {}) as Record<string, unknown>;
|
|
379
|
+
this.recordAssistant(msg);
|
|
380
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
381
|
+
// 方案 X:工具调用直播走原生 assistant toolCall 消息(与 liveHistory 原生块同形;
|
|
382
|
+
// 宿主 ToolCallBlock 按 toolCallId 折叠后续 toolResult——直播/refetch 同形无双记)。
|
|
383
|
+
const toolUseBlocks = (content as Array<Record<string, unknown>>)
|
|
384
|
+
.filter((b) => b && b.type === "tool_use" && typeof b.name === "string");
|
|
385
|
+
if (toolUseBlocks.length > 0) {
|
|
386
|
+
for (const b of toolUseBlocks) this.toolCallOpen.set(String(b.id ?? ""), { name: String(b.name), input: b.input ?? {} });
|
|
387
|
+
this.emit({ type: "message_end", message: {
|
|
388
|
+
role: "assistant",
|
|
389
|
+
content: toolUseBlocks.map((b) => ({
|
|
390
|
+
type: "toolCall",
|
|
391
|
+
toolCallId: String(b.id ?? ""),
|
|
392
|
+
toolName: String(b.name),
|
|
393
|
+
input: (b.input ?? {}) as Record<string, unknown>,
|
|
394
|
+
})),
|
|
395
|
+
model: typeof message.model === "string" ? message.model : this.model?.id ?? "",
|
|
396
|
+
provider: "aiti-claude-code-agent",
|
|
397
|
+
timestamp: Date.now(),
|
|
398
|
+
} });
|
|
399
|
+
}
|
|
400
|
+
const text = content
|
|
401
|
+
.map((b) => (b as Record<string, unknown>).text)
|
|
402
|
+
.filter((t): t is string => typeof t === "string")
|
|
403
|
+
.join("");
|
|
404
|
+
if (text) this.emit({ type: "message_end", role: "assistant", text });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (type === "user") {
|
|
409
|
+
// 方案 X:工具结果 → 原生 toolResult 条目(liveHistory 折叠配对 + message_end 直播即时折叠)。
|
|
410
|
+
// 历史回放:本进程轮次走 liveHistory;进程外既往轮次走 jsonl prelude(首次读一次)。
|
|
411
|
+
const message = (msg.message ?? {}) as Record<string, unknown>;
|
|
412
|
+
const content = Array.isArray(message.content) ? (message.content as Array<Record<string, unknown>>) : [];
|
|
413
|
+
for (const b of content) {
|
|
414
|
+
if (b.type === "tool_result") {
|
|
415
|
+
const id = String(b.tool_use_id ?? "");
|
|
416
|
+
const open = this.toolCallOpen.get(id);
|
|
417
|
+
this.toolCallOpen.delete(id);
|
|
418
|
+
const rc = Array.isArray(b.content) ? (b.content as Array<Record<string, unknown>>) : [];
|
|
419
|
+
const outText = rc.map((x) => (typeof x.text === "string" ? x.text : "")).join("").trim();
|
|
420
|
+
const isError = b.is_error === true;
|
|
421
|
+
const resultMsg = {
|
|
422
|
+
role: "toolResult",
|
|
423
|
+
...(id ? { toolCallId: id } : {}),
|
|
424
|
+
...(open?.name ? { toolName: open.name } : {}),
|
|
425
|
+
content: [{ type: "text", text: outText.slice(0, 4000) }],
|
|
426
|
+
...(isError ? { isError: true } : {}),
|
|
427
|
+
timestamp: Date.now(),
|
|
428
|
+
};
|
|
429
|
+
this.liveHistory.push(resultMsg);
|
|
430
|
+
this.emit({ type: "message_end", message: resultMsg });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (type === "control_request" && String((msg.request as Record<string, unknown>)?.subtype ?? "") === "can_use_tool") {
|
|
437
|
+
const reqId = String(msg.request_id ?? "");
|
|
438
|
+
const request = msg.request as Record<string, unknown>;
|
|
439
|
+
// 挂起并广播给客户端(Web 端可渲染批准对话框;超时/断连由 dispose 清理)
|
|
440
|
+
this.permissionPending.set(reqId, () => {});
|
|
441
|
+
this.permissionMeta.set(reqId, { toolName: request.tool_name, input: request.input });
|
|
442
|
+
this.emit({
|
|
443
|
+
type: "permission_request",
|
|
444
|
+
requestId: reqId,
|
|
445
|
+
toolName: request.tool_name,
|
|
446
|
+
input: request.input,
|
|
447
|
+
});
|
|
448
|
+
// D4:请求写进会话消息流(ask 模式有卡有记录,刷新可回放;规范 §6)
|
|
449
|
+
this.recordPermissionCustom(request.tool_name, request.input, "pending", reqId);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (type === "control_response") {
|
|
454
|
+
const resp = (msg.response ?? {}) as Record<string, unknown>;
|
|
455
|
+
const ok = resp.subtype === "success";
|
|
456
|
+
// request_id 关联分发:interrupt / set_permission_mode / set_model 共用 control_response 通道
|
|
457
|
+
if (ok && typeof resp.mode === "string") {
|
|
458
|
+
// set_permission_mode 成功 → 记录当前模式并通知
|
|
459
|
+
this.emit({ type: "permission_mode_changed", mode: resp.mode });
|
|
460
|
+
}
|
|
461
|
+
for (const w of this.interruptWaiters.splice(0)) w(ok);
|
|
462
|
+
for (const [rid, waiter] of this.pmWaiters) {
|
|
463
|
+
if (rid === String(resp.request_id)) { waiter(ok); this.pmWaiters.delete(rid); }
|
|
464
|
+
}
|
|
465
|
+
for (const [rid, waiter] of this.smWaiters) {
|
|
466
|
+
if (rid === String(resp.request_id)) { waiter(ok); this.smWaiters.delete(rid); }
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (type === "result") {
|
|
472
|
+
this.running = false;
|
|
473
|
+
const usage = (msg.usage ?? {}) as Record<string, number>;
|
|
474
|
+
const modelUsage = (msg.modelUsage ?? {}) as Record<string, Record<string, number>>;
|
|
475
|
+
const firstModel = Object.entries(modelUsage)[0];
|
|
476
|
+
this.lastUsage = {
|
|
477
|
+
...(this.model ? { model: this.model.id } : {}),
|
|
478
|
+
inputTokens: usage.input_tokens,
|
|
479
|
+
outputTokens: usage.output_tokens,
|
|
480
|
+
cacheReadTokens: usage.cache_read_input_tokens,
|
|
481
|
+
cacheWriteTokens: usage.cache_creation_input_tokens,
|
|
482
|
+
costUsd: typeof msg.total_cost_usd === "number" ? msg.total_cost_usd : undefined,
|
|
483
|
+
numTurns: typeof msg.num_turns === "number" ? msg.num_turns : undefined,
|
|
484
|
+
...(firstModel ? { inputTokens: firstModel[1].inputTokens, outputTokens: firstModel[1].outputTokens, cacheReadTokens: firstModel[1].cacheReadInputTokens, costUsd: firstModel[1].costUSD } : {}),
|
|
485
|
+
};
|
|
486
|
+
this.accumulateUsage(this.lastUsage);
|
|
487
|
+
// 结果帧终结所有未决授权(轮次已结束)——D4:取消记录进会话消息流
|
|
488
|
+
for (const [rid, meta] of this.permissionPending) {
|
|
489
|
+
const m = this.permissionMeta.get(rid);
|
|
490
|
+
this.recordPermissionCustom(m?.toolName, m?.input, "cancelled", rid);
|
|
491
|
+
this.emit({ type: "permission_expired", requestId: rid });
|
|
492
|
+
}
|
|
493
|
+
this.permissionPending.clear();
|
|
494
|
+
this.permissionMeta.clear();
|
|
495
|
+
const isError = msg.is_error === true;
|
|
496
|
+
this.emit({
|
|
497
|
+
type: "agent_settled",
|
|
498
|
+
...(isError ? { error: String(msg.result ?? "error_during_execution") } : {}),
|
|
499
|
+
result: typeof msg.result === "string" ? msg.result : undefined,
|
|
500
|
+
});
|
|
501
|
+
// settle(result 帧)后自动续轮(共享层幂等;微任务让 agent_settled 先到达前端)
|
|
502
|
+
this.q.scheduleDrain();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private writeLine(obj: Record<string, unknown>): boolean {
|
|
508
|
+
// 背压修正(加固 A4):write() 返回 false 仅表示帧已入缓冲(单帧 >16KB 高水位必返 false),
|
|
509
|
+
// 不是失败——大图片/长文本 prompt 曾被误判 "stdin closed" 并卡死 running。真失败=不可写或 throw。
|
|
510
|
+
const stdin = this.child.stdin;
|
|
511
|
+
if (!stdin?.writable) return false;
|
|
512
|
+
try {
|
|
513
|
+
stdin.write(JSON.stringify(obj) + "\n");
|
|
514
|
+
return true;
|
|
515
|
+
} catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
static async start(opts: {
|
|
521
|
+
sessionId?: string;
|
|
522
|
+
cwd?: string;
|
|
523
|
+
model?: { provider: string; id: string };
|
|
524
|
+
/** cc-switch 档案名(~/.claude/<name>.json)→ --settings 注入 */
|
|
525
|
+
profile?: string;
|
|
526
|
+
extraArgs?: string[];
|
|
527
|
+
env?: Record<string, string>;
|
|
528
|
+
/** 协议族成员二进制(PATH 名或绝对路径);缺省 claude。 */
|
|
529
|
+
bin?: string;
|
|
530
|
+
permissionMode?: string;
|
|
531
|
+
caps?: SessionCapabilitiesLike;
|
|
532
|
+
/** P2 启动包装覆盖(aiti-custom-agent 委托路径):成员级 command/argsPrefix/env/bridge,
|
|
533
|
+
* 优先级高于 extman spawn.command/spawn.argsPrefix(缺省全空 = 用 extman/默认)。 */
|
|
534
|
+
launchOverride?: LaunchOverride;
|
|
535
|
+
/** 会话真实原 directory(冷恢复 meta.cwd;仅用于历史 jsonl 定位,spawn cwd 仍走 opts.cwd)。 */
|
|
536
|
+
historyCwd?: string;
|
|
537
|
+
}): Promise<ClaudeCodeHandle> {
|
|
538
|
+
const cli = opts.bin ?? process.env.CLAUDE_CLI_PATH ?? "claude";
|
|
539
|
+
const settingsPath = opts.profile
|
|
540
|
+
? join(homedir(), ".claude", `${opts.profile}.json`)
|
|
541
|
+
: undefined;
|
|
542
|
+
if (opts.profile && !existsSync(settingsPath!)) {
|
|
543
|
+
throw new Error(`[aiti-claude-code-agent] profile not found: ${opts.profile}`);
|
|
544
|
+
}
|
|
545
|
+
const args = [
|
|
546
|
+
cli,
|
|
547
|
+
"-p",
|
|
548
|
+
"--output-format", "stream-json",
|
|
549
|
+
"--input-format", "stream-json",
|
|
550
|
+
// 无条件必带(回归修复 2026-09-15):print 模式下 stream-json 必须 --verbose,
|
|
551
|
+
// --include-partial-messages 提供 message_update 增量——曾误当重复 flag 条件化,
|
|
552
|
+
// 直驱 start()(无 member flags)即缺参被 CLI 拒启(冒烟实证)
|
|
553
|
+
"--verbose",
|
|
554
|
+
"--include-partial-messages",
|
|
555
|
+
...(settingsPath ? ["--settings", settingsPath] : []),
|
|
556
|
+
// cc-switch 语义:模型在会话创建时确定(profile 文件里的 ANTHROPIC_MODEL
|
|
557
|
+
// 未显式指定 model 时由 CLI 从 settings env 解析)
|
|
558
|
+
...(opts.model?.id ? ["--model", opts.model.id] : []),
|
|
559
|
+
// 默认最大权限:headless 下权限询问无法应答=任务直接失败,未显式指定时全绕过
|
|
560
|
+
...(opts.permissionMode
|
|
561
|
+
? ["--permission-mode", opts.permissionMode]
|
|
562
|
+
: ["--dangerously-skip-permissions"]),
|
|
563
|
+
...(opts.sessionId ? ["--resume", opts.sessionId] : []),
|
|
564
|
+
...(opts.extraArgs ?? []),
|
|
565
|
+
];
|
|
566
|
+
// P1' 启动包装(docs/aiti-custom-agent-plan.md):extman spawn.command/spawn.argsPrefix
|
|
567
|
+
// —— 缺省(两者皆空)= 行为不变;配置后 command 覆盖 CLI、argsPrefix 拼在内置参数前(docker 化等)
|
|
568
|
+
// P2:launchOverride(custom-agent 委托路径)优先于 extman feature
|
|
569
|
+
const launch = resolveCliLaunch({
|
|
570
|
+
defaultCommand: cli,
|
|
571
|
+
command: opts.launchOverride?.command?.trim() || spawnCommand("aiti-claude-code-agent") || undefined,
|
|
572
|
+
argsPrefix: opts.launchOverride?.argsPrefix?.length
|
|
573
|
+
? opts.launchOverride.argsPrefix
|
|
574
|
+
: parseArgsLines(spawnArgsPrefix("aiti-claude-code-agent")).args,
|
|
575
|
+
driverArgs: args.slice(1),
|
|
576
|
+
env: agentSpawnEnv({ cli: "claude-code", governance: governanceEnv, extra: opts.env, extId: "aiti-claude-code-agent", envList: spawnEnvList("aiti-claude-code-agent") }),
|
|
577
|
+
memberEnv: opts.launchOverride?.env,
|
|
578
|
+
bridge: opts.launchOverride?.bridge,
|
|
579
|
+
cwd: opts.cwd || process.cwd(),
|
|
580
|
+
warn: (m) => console.log(`[aiti-claude-code-agent] ${m}`),
|
|
581
|
+
});
|
|
582
|
+
const child = spawn(launch.command, launch.args, {
|
|
583
|
+
shell: launch.shell,
|
|
584
|
+
cwd: opts.cwd || process.cwd(),
|
|
585
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
586
|
+
env: launch.env,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// 占位 id:fresh 会话的真实 session_id 在首条消息后的 system/init 帧才出现
|
|
590
|
+
// (CLI 懒初始化)——立即返回;init 到达时发 session_backend_ready,
|
|
591
|
+
// 宿主(createSessionHandle)监听该事件换绑真实复合 id。
|
|
592
|
+
// randomUUID 防并发建会话同毫秒同 id 撞 store 键(加固 P2)
|
|
593
|
+
const provisionalId = opts.sessionId ? `claude:${opts.sessionId}` : `claude:new-${randomUUID()}`;
|
|
594
|
+
const handle = new ClaudeCodeHandle(provisionalId, child, opts.cwd || process.cwd(), opts.caps);
|
|
595
|
+
handle.resumeSourceSid = opts.sessionId;
|
|
596
|
+
handle.historyCwd = opts.historyCwd;
|
|
597
|
+
// 种子模型:显式指定 > 状态文件恢复(重开会话不丢上次切换)> init 帧回报
|
|
598
|
+
if (opts.model?.id) {
|
|
599
|
+
handle.model = { id: opts.model.id, provider: "claude-code" };
|
|
600
|
+
} else if (opts.sessionId) {
|
|
601
|
+
const restored = await readExtModelState("claude", `claude:${opts.sessionId}`);
|
|
602
|
+
if (restored?.selected?.id) handle.model = { id: restored.selected.id, provider: "claude-code" };
|
|
603
|
+
}
|
|
604
|
+
// 新会话(无显式模型/无同会话恢复)→ 该 agent 上次使用的模型(per-agent last used)
|
|
605
|
+
// ⚠ 仅新会话:resume 会话的模型以 CLI/会话自身为准,不得用 agent 级默认覆盖
|
|
606
|
+
if (!handle.model && !opts.sessionId) {
|
|
607
|
+
const lastUsed = await readAgentLastUsedModel("claude");
|
|
608
|
+
if (lastUsed?.id) handle.model = { id: lastUsed.id, provider: "claude-code" };
|
|
609
|
+
}
|
|
610
|
+
// 启动即写状态文件(selected + pi 目录快照):引导外部 agent + 选择持久化
|
|
611
|
+
handle.persistModelState();
|
|
612
|
+
handle.permissionMode = opts.permissionMode ?? "bypassPermissions";
|
|
613
|
+
return handle;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---- SessionHandle mandatory core ----------------------------------------
|
|
617
|
+
|
|
618
|
+
async prompt(
|
|
619
|
+
text: string,
|
|
620
|
+
options?: {
|
|
621
|
+
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
|
622
|
+
files?: Array<{ data: string; mimeType: string; filename?: string }>;
|
|
623
|
+
/** 运行中排队语义(core 透传):steer/缺省/followUp 同入本地队列(协议无注入面,settle 后自动续轮)。 */
|
|
624
|
+
streamingBehavior?: "steer" | "followUp";
|
|
625
|
+
},
|
|
626
|
+
): Promise<void> {
|
|
627
|
+
if (!this.aliveFlag) throw new Error("[aiti-claude-code-agent] stdin closed");
|
|
628
|
+
if (this.running) {
|
|
629
|
+
// 分流归共享层:运行中输入入本地 followUp 队列(可撤回,result 后自动续轮)。
|
|
630
|
+
// running 中 routePrompt 不会 dispatch(dispatch 回本方法,空闲时走下方执行路径)。
|
|
631
|
+
await this.q.routePrompt(text, {
|
|
632
|
+
images: options?.images as never,
|
|
633
|
+
streamingBehavior: options?.streamingBehavior,
|
|
634
|
+
});
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// 组装内容块:文本 + 图片 + 文件(CC 原生支持 image/document base64 块,2026-08 实测)
|
|
638
|
+
const content: Array<Record<string, unknown>> = [];
|
|
639
|
+
for (const img of options?.images ?? []) {
|
|
640
|
+
content.push({ type: "image", source: { type: "base64", media_type: img.mimeType, data: img.data } });
|
|
641
|
+
}
|
|
642
|
+
for (const f of options?.files ?? []) {
|
|
643
|
+
if (f.mimeType === "application/pdf") {
|
|
644
|
+
content.push({ type: "document", source: { type: "base64", media_type: "application/pdf", data: f.data } });
|
|
645
|
+
} else if (f.mimeType.startsWith("image/")) {
|
|
646
|
+
content.push({ type: "image", source: { type: "base64", media_type: f.mimeType, data: f.data } });
|
|
647
|
+
} else {
|
|
648
|
+
console.warn(`[aiti-claude-code-agent] unsupported file mime "${f.mimeType}" (${f.filename ?? "?"}) — skipped`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (text || content.length === 0) content.push({ type: "text", text });
|
|
652
|
+
const ok = this.writeLine({
|
|
653
|
+
type: "user",
|
|
654
|
+
message: { role: "user", content },
|
|
655
|
+
});
|
|
656
|
+
if (!ok) throw new Error("[aiti-claude-code-agent] stdin closed");
|
|
657
|
+
// 写成功后才置 running(加固 A4):写失败抛错时 running 保持 false,队列不会被永久卡住
|
|
658
|
+
this.running = true;
|
|
659
|
+
this.liveHistory.push({ role: "user", content: text });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async abort(): Promise<void> {
|
|
663
|
+
if (!this.running) return;
|
|
664
|
+
const requestId = `int-${++this.seq}`;
|
|
665
|
+
// 15s 超时(加固:对齐 set_model/set_permission_mode;CLI 忙时不让宿主 abort 永挂)
|
|
666
|
+
const interrupted = await new Promise<boolean>((resolve) => {
|
|
667
|
+
let settled = false;
|
|
668
|
+
const timer = setTimeout(() => {
|
|
669
|
+
if (!settled) { settled = true; resolve(false); }
|
|
670
|
+
}, 15_000);
|
|
671
|
+
timer.unref?.();
|
|
672
|
+
this.interruptWaiters.push((ok) => {
|
|
673
|
+
if (!settled) { settled = true; clearTimeout(timer); resolve(ok); }
|
|
674
|
+
});
|
|
675
|
+
const wrote = this.writeLine({
|
|
676
|
+
type: "control_request",
|
|
677
|
+
request_id: requestId,
|
|
678
|
+
request: { subtype: "interrupt" },
|
|
679
|
+
});
|
|
680
|
+
if (!wrote && !settled) { settled = true; clearTimeout(timer); resolve(false); }
|
|
681
|
+
});
|
|
682
|
+
if (!interrupted) console.warn("[aiti-claude-code-agent] interrupt not acknowledged");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
subscribe(listener: Listener): () => void {
|
|
686
|
+
this.listeners.add(listener);
|
|
687
|
+
return () => {
|
|
688
|
+
this.listeners.delete(listener);
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async dispose(): Promise<void> {
|
|
693
|
+
if (!this.aliveFlag) return;
|
|
694
|
+
this.child.kill("SIGTERM");
|
|
695
|
+
await new Promise<void>((resolve) => {
|
|
696
|
+
const t = setTimeout(() => {
|
|
697
|
+
this.child.kill("SIGKILL");
|
|
698
|
+
resolve();
|
|
699
|
+
}, 1000);
|
|
700
|
+
this.child.once("exit", () => {
|
|
701
|
+
clearTimeout(t);
|
|
702
|
+
resolve();
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
this.markDead("disposed");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
alive(): boolean {
|
|
709
|
+
return this.aliveFlag && this.child.exitCode === null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
isRunning(): boolean {
|
|
713
|
+
return this.running;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
getModel(): { id: string; provider: string } | undefined {
|
|
717
|
+
return this.model;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* 模型感知(后台切了我们能感知到):CLI 侧模型变化(init/assistant 帧)时更新
|
|
722
|
+
* 并广播 model_select(pi 原生 ModelSelectEvent 形状:source "set"|"cycle"|"restore")。
|
|
723
|
+
* handle-adapter 消费该事件回写 lastModel → context.model / get_state 跟随。
|
|
724
|
+
*/
|
|
725
|
+
private trackModel(id: string, source: "set" | "restore"): void {
|
|
726
|
+
const next = { id, provider: "claude-code" };
|
|
727
|
+
const prev = this.model;
|
|
728
|
+
if (prev && prev.id === id) return;
|
|
729
|
+
this.model = next;
|
|
730
|
+
if (prev) {
|
|
731
|
+
// 从一个已知模型切到另一个 —— 后台主动切换,广播给前端
|
|
732
|
+
this.emit({ type: "model_select", model: next, previousModel: prev, source });
|
|
733
|
+
this.persistModelState();
|
|
734
|
+
}
|
|
735
|
+
// prev 为空 = 会话首帧初始化(restore),不广播(选择器直接展示即可)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 运行时切换模型:set_model control_request → control_response。
|
|
740
|
+
* 2026-08 对 claude 2.1.201 二进制取证确认该 subtype 存在(同 set_permission_mode 通道)。
|
|
741
|
+
*/
|
|
742
|
+
async setModel(model: { id: string; provider: string }): Promise<boolean> {
|
|
743
|
+
if (!this.aliveFlag) throw new Error("[aiti-claude-code-agent] stdin closed");
|
|
744
|
+
const requestId = `sm-${++this.seq}`;
|
|
745
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
746
|
+
const timer = setTimeout(() => {
|
|
747
|
+
if (this.smWaiters.has(requestId)) {
|
|
748
|
+
this.smWaiters.delete(requestId);
|
|
749
|
+
console.warn(`[aiti-claude-code-agent] set_model(${model.id}) timed out — CLI may not have initialized yet (send a message first)`);
|
|
750
|
+
resolve(false);
|
|
751
|
+
}
|
|
752
|
+
}, 15_000);
|
|
753
|
+
this.smWaiters.set(requestId, (v) => { clearTimeout(timer); resolve(v); });
|
|
754
|
+
const wrote = this.writeLine({
|
|
755
|
+
type: "control_request",
|
|
756
|
+
request_id: requestId,
|
|
757
|
+
request: { subtype: "set_model", model: model.id },
|
|
758
|
+
});
|
|
759
|
+
if (!wrote) { clearTimeout(timer); this.smWaiters.delete(requestId); resolve(false); }
|
|
760
|
+
});
|
|
761
|
+
if (ok) {
|
|
762
|
+
const prev = this.model;
|
|
763
|
+
this.model = { id: model.id, provider: "claude-code" };
|
|
764
|
+
this.emit({ type: "model_select", model: this.model, ...(prev ? { previousModel: prev } : {}), source: "set" });
|
|
765
|
+
this.persistModelState();
|
|
766
|
+
}
|
|
767
|
+
return ok;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** 临时模型状态文件(需求:启动/切换时把模型与 pi 目录存盘,引导外部 agent + 持久化)。 */
|
|
771
|
+
persistModelState(): void {
|
|
772
|
+
void this.getAvailableModels().then((models) =>
|
|
773
|
+
writeExtModelState({
|
|
774
|
+
agentKey: this.stateAgentKey,
|
|
775
|
+
sessionId: this.id,
|
|
776
|
+
...(this.model ? { selected: this.model } : {}),
|
|
777
|
+
models,
|
|
778
|
+
updatedAt: "",
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* 模型目录:CC 内置清单 + pi 目录(models.json 实时扁平化,选择器独立 "pi" 分组)。
|
|
784
|
+
* 当前模型置顶;pi 模型能否被 CC 实际服务取决于其自身 provider 配置(fail-loud)。
|
|
785
|
+
*/
|
|
786
|
+
async getAvailableModels(): Promise<Array<ExtModelEntry & { alias?: string }>> {
|
|
787
|
+
return claudeCodeCatalog(this.model);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async getCommands(): Promise<Array<{ name: string; description?: string; source: string }>> {
|
|
791
|
+
return this.commands;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async getAllTools(): Promise<Array<{ name: string; description: string }>> {
|
|
795
|
+
return this.tools;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** 从 onLine 的 assistant 完整帧构造消息并记入内存历史。 */
|
|
799
|
+
private recordAssistant(msg: Record<string, unknown>): void {
|
|
800
|
+
const message = (msg.message ?? {}) as Record<string, unknown>;
|
|
801
|
+
const blocks: Array<Record<string, unknown>> = [];
|
|
802
|
+
for (const b of (Array.isArray(message.content) ? message.content : []) as Array<Record<string, unknown>>) {
|
|
803
|
+
if (b.type === "text" && typeof b.text === "string" && b.text) {
|
|
804
|
+
blocks.push({ type: "text", text: b.text });
|
|
805
|
+
} else if (b.type === "tool_use") {
|
|
806
|
+
blocks.push({
|
|
807
|
+
type: "toolCall",
|
|
808
|
+
toolCallId: String(b.id ?? ""),
|
|
809
|
+
toolName: String(b.name ?? "tool"),
|
|
810
|
+
input: (b.input ?? {}) as Record<string, unknown>,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (blocks.length > 0) {
|
|
815
|
+
this.liveHistory.push({
|
|
816
|
+
role: "assistant",
|
|
817
|
+
content: blocks,
|
|
818
|
+
model: typeof message.model === "string" ? message.model : this.model?.id ?? "",
|
|
819
|
+
provider: "aiti-claude-code-agent",
|
|
820
|
+
});
|
|
821
|
+
// 后台模型感知:assistant 帧自带实际服务的 model(CLI 降级/切换会变)
|
|
822
|
+
if (typeof message.model === "string" && message.model) this.trackModel(message.model, "set");
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* 历史回放:内存日志(本进程轮次)+ resume 场景的 jsonl 既往轮次(首次调用时读一次)。
|
|
828
|
+
*/
|
|
829
|
+
async getMessages(): Promise<unknown[]> {
|
|
830
|
+
// 内存日志优先:活会话期间它是权威(jsonl 可能尚未/同时落盘,避免双计);
|
|
831
|
+
// 内存为空时读 jsonl 拿既往轮次。
|
|
832
|
+
if (this.liveHistory.length > 0) return [...this.liveHistory];
|
|
833
|
+
if (this.prelude === null) {
|
|
834
|
+
this.prelude = [];
|
|
835
|
+
const sid = this.id.startsWith("claude:") ? this.id.slice(7) : this.id;
|
|
836
|
+
// resume 场景:init 可能换发新 id,而历史 jsonl 仍以请求时的旧 sid 命名——两个都试
|
|
837
|
+
const candidates = [sid, this.resumeSourceSid].filter((v): v is string => Boolean(v));
|
|
838
|
+
// 目录候选:spawn cwd + 会话真实原 directory(冷恢复原目录已删时二者不同)
|
|
839
|
+
const projectDirs = [...new Set([this.cwd, this.historyCwd]
|
|
840
|
+
.filter((v): v is string => Boolean(v))
|
|
841
|
+
.map((c) => c.replaceAll("/", "-")))];
|
|
842
|
+
if (candidates.length > 0 && projectDirs.length > 0) {
|
|
843
|
+
let saved: string | null = null;
|
|
844
|
+
for (const projectDir of projectDirs) {
|
|
845
|
+
for (const cand of candidates) {
|
|
846
|
+
const file = join(homedir(), ".claude", "projects", projectDir, `${cand}.jsonl`);
|
|
847
|
+
try {
|
|
848
|
+
saved = await readFile(file, "utf8");
|
|
849
|
+
break;
|
|
850
|
+
} catch (e) {
|
|
851
|
+
continue; // 该候选无落盘文件
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (saved !== null) break;
|
|
855
|
+
}
|
|
856
|
+
if (saved !== null) {
|
|
857
|
+
for (const line of saved.split("\n")) {
|
|
858
|
+
if (!line.trim()) continue;
|
|
859
|
+
let rec: Record<string, unknown>;
|
|
860
|
+
try { rec = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
861
|
+
if (rec.isSidechain === true || rec.isMeta === true) continue;
|
|
862
|
+
const type = String(rec.type ?? "");
|
|
863
|
+
if (type !== "user" && type !== "assistant") continue;
|
|
864
|
+
const message = (rec.message ?? {}) as Record<string, unknown>;
|
|
865
|
+
const ts = typeof rec.timestamp === "string" ? Date.parse(rec.timestamp) || undefined : undefined;
|
|
866
|
+
if (type === "user") {
|
|
867
|
+
const c = message.content;
|
|
868
|
+
if (Array.isArray(c)) {
|
|
869
|
+
for (const b of c as Array<Record<string, unknown>>) {
|
|
870
|
+
if (b.type === "tool_result") {
|
|
871
|
+
const rc = Array.isArray(b.content) ? (b.content as Array<Record<string, unknown>>) : [];
|
|
872
|
+
const text = rc.map((x) => (typeof x.text === "string" ? x.text : "")).join("").trim();
|
|
873
|
+
this.prelude.push({ role: "toolResult", toolCallId: String(b.tool_use_id ?? ""), content: [{ type: "text", text }], isError: b.is_error === true, timestamp: ts });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const userText = (c as Array<Record<string, unknown>>).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("");
|
|
877
|
+
if (userText) this.prelude.push({ role: "user", content: userText, timestamp: ts });
|
|
878
|
+
} else if (typeof c === "string" && c.trim()) {
|
|
879
|
+
this.prelude.push({ role: "user", content: c, timestamp: ts });
|
|
880
|
+
}
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const blocks: Array<Record<string, unknown>> = [];
|
|
884
|
+
for (const b of (Array.isArray(message.content) ? message.content : []) as Array<Record<string, unknown>>) {
|
|
885
|
+
if (b.type === "text" && typeof b.text === "string" && b.text) blocks.push({ type: "text", text: b.text });
|
|
886
|
+
else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking) blocks.push({ type: "thinking", thinking: b.thinking });
|
|
887
|
+
else if (b.type === "tool_use") blocks.push({ type: "toolCall", toolCallId: String(b.id ?? ""), toolName: String(b.name ?? "tool"), input: (b.input ?? {}) as Record<string, unknown> });
|
|
888
|
+
}
|
|
889
|
+
if (blocks.length > 0) this.prelude.push({ role: "assistant", content: blocks, model: typeof message.model === "string" ? message.model : "", provider: "aiti-claude-code-agent", timestamp: ts });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return [...this.prelude, ...this.liveHistory];
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async respondPermission(requestId: string, decision: { behavior: "allow" | "deny"; updatedInput?: Record<string, unknown>; message?: string }): Promise<boolean> {
|
|
898
|
+
if (!this.aliveFlag) throw new Error("[aiti-claude-code-agent] stdin closed");
|
|
899
|
+
const response: Record<string, unknown> = decision.behavior === "allow"
|
|
900
|
+
? { behavior: "allow", ...(decision.updatedInput ? { updatedInput: decision.updatedInput } : {}) }
|
|
901
|
+
: { behavior: "deny", message: decision.message ?? "denied by user" };
|
|
902
|
+
const ok = this.writeLine({
|
|
903
|
+
type: "control_response",
|
|
904
|
+
response: { subtype: "success", request_id: requestId, response },
|
|
905
|
+
});
|
|
906
|
+
// D4:决策写进会话消息流(deny 理由 / allow 备注;写失败也记录人工操作轨迹)
|
|
907
|
+
const meta = this.permissionMeta.get(requestId);
|
|
908
|
+
this.recordPermissionCustom(meta?.toolName, meta?.input, decision.behavior, requestId,
|
|
909
|
+
decision.behavior === "deny" ? (decision.message ?? undefined) : (decision.updatedInput ? "入参已修正" : undefined));
|
|
910
|
+
this.permissionPending.delete(requestId);
|
|
911
|
+
this.permissionMeta.delete(requestId);
|
|
912
|
+
return ok;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
getUsage() {
|
|
916
|
+
return this.lastUsage;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** result 帧累计记账(getUsageTotals 数据源;字段缺失按 0 计)。 */
|
|
920
|
+
private accumulateUsage(u: {
|
|
921
|
+
inputTokens?: number; outputTokens?: number; cacheReadTokens?: number;
|
|
922
|
+
cacheWriteTokens?: number; costUsd?: number;
|
|
923
|
+
}): void {
|
|
924
|
+
this.usageTotals.inputTokens += u.inputTokens ?? 0;
|
|
925
|
+
this.usageTotals.outputTokens += u.outputTokens ?? 0;
|
|
926
|
+
this.usageTotals.cacheReadTokens += u.cacheReadTokens ?? 0;
|
|
927
|
+
this.usageTotals.cacheWriteTokens += u.cacheWriteTokens ?? 0;
|
|
928
|
+
this.usageTotals.costUsd += u.costUsd ?? 0;
|
|
929
|
+
this.usageTotals.turns += 1;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* @capability contextUsage —— 当前上下文占用(最近 result 帧的 input 侧三项相加:
|
|
934
|
+
* 非缓存 input + cache 读 + cache 写;CC 协议三者互斥、相加即发往模型的全量 prompt)。
|
|
935
|
+
* 首轮 result 未到返回 undefined(前端「暂无数据」空轨态)。
|
|
936
|
+
*/
|
|
937
|
+
getContextUsage(): { percent: number; contextWindow: number; tokens: number } | undefined {
|
|
938
|
+
const u = this.lastUsage;
|
|
939
|
+
if (!u) return undefined;
|
|
940
|
+
const tokens = (u.inputTokens ?? 0) + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0);
|
|
941
|
+
if (!(tokens > 0)) return undefined;
|
|
942
|
+
const contextWindow = claudeContextWindow(this.model?.id ?? u.model);
|
|
943
|
+
return { percent: Math.min(100, (tokens / contextWindow) * 100), contextWindow, tokens };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** @capability getUsageTotals —— 会话累计(宿主 get_session_stats 真实口径)。 */
|
|
947
|
+
getUsageTotals() {
|
|
948
|
+
return { ...this.usageTotals };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* 运行时切换权限模式:set_permission_mode control_request → control_response。
|
|
953
|
+
* 2026-08 对 claude 2.1.201 实测成功(响应回传 {"mode":"acceptEdits"})。
|
|
954
|
+
*/
|
|
955
|
+
async setPermissionMode(mode: string): Promise<boolean> {
|
|
956
|
+
if (!this.aliveFlag) throw new Error("[aiti-claude-code-agent] stdin closed");
|
|
957
|
+
const requestId = `pm-${++this.seq}`;
|
|
958
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
959
|
+
const timer = setTimeout(() => {
|
|
960
|
+
if (this.pmWaiters.has(requestId)) {
|
|
961
|
+
this.pmWaiters.delete(requestId);
|
|
962
|
+
console.warn(`[aiti-claude-code-agent] set_permission_mode(${mode}) timed out — CLI may not have initialized yet (send a message first)`);
|
|
963
|
+
resolve(false);
|
|
964
|
+
}
|
|
965
|
+
}, 15_000);
|
|
966
|
+
this.pmWaiters.set(requestId, (v) => { clearTimeout(timer); resolve(v); });
|
|
967
|
+
const wrote = this.writeLine({
|
|
968
|
+
type: "control_request",
|
|
969
|
+
request_id: requestId,
|
|
970
|
+
request: { subtype: "set_permission_mode", mode },
|
|
971
|
+
});
|
|
972
|
+
if (!wrote) { clearTimeout(timer); this.pmWaiters.delete(requestId); resolve(false); }
|
|
973
|
+
});
|
|
974
|
+
if (ok) this.permissionMode = mode;
|
|
975
|
+
return ok;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** 当前权限模式(spawn 参数或运行时切换后的最新值)。 */
|
|
979
|
+
getPermissionMode(): string | undefined {
|
|
980
|
+
return this.permissionMode;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
// fail-loud stubs
|
|
985
|
+
steer(): never {
|
|
986
|
+
throw new Error('Capability "steer" is not supported by session backend "aiti-claude-code-agent/claude-code"');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** @capability followUp —— 运行中入本地队列(result 后自动续轮,可撤回);空闲等同普通 prompt。 */
|
|
990
|
+
async followUp(text: string): Promise<void> {
|
|
991
|
+
if (!this.aliveFlag) throw new Error("[aiti-claude-code-agent] stdin closed");
|
|
992
|
+
await this.q.routePrompt(text, { streamingBehavior: "followUp" });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* @capability queueManage —— queue keeper 队列操作原语转发共享层。
|
|
997
|
+
* followUp 组全 CRUD;steer 组恒空(stream-json 协议无运行中注入面)。
|
|
998
|
+
*/
|
|
999
|
+
queueManage = (req: {
|
|
1000
|
+
action: "list" | "clear" | "move" | "convert" | "remove" | "recall" | "requeue";
|
|
1001
|
+
kind?: "steer" | "followUp";
|
|
1002
|
+
from?: number;
|
|
1003
|
+
to?: number;
|
|
1004
|
+
index?: number;
|
|
1005
|
+
targetKind?: "steer" | "followUp";
|
|
1006
|
+
text?: string;
|
|
1007
|
+
}) => this.q.queueManage(req);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ============================================================================
|
|
1011
|
+
// Extension entry —— 协议族注册表:探测 PATH 上的同族 CLI,逐个注册 provider
|
|
1012
|
+
// ============================================================================
|
|
1013
|
+
|
|
1014
|
+
/** claude-stream-json 协议族已知成员。flags 记录各家与 CC 原版的差异。 */
|
|
1015
|
+
const FAMILY: Array<{
|
|
1016
|
+
name: string;
|
|
1017
|
+
bin: string;
|
|
1018
|
+
agentKey: string;
|
|
1019
|
+
label: string;
|
|
1020
|
+
/** cc-switch 风格档案目录(相对 home;如 .claude/<name>.json → --settings 注入) */
|
|
1021
|
+
profilesDir?: string;
|
|
1022
|
+
/** 运行时 set_permission_mode 已实测支持(未实测成员不开——fail-loud) */
|
|
1023
|
+
runtimePermissionMode?: boolean;
|
|
1024
|
+
}> = [
|
|
1025
|
+
{ name: "claude-code", bin: "claude", agentKey: "claude", label: "Claude Code CLI", runtimePermissionMode: true, profilesDir: ".claude" },
|
|
1026
|
+
// qoder/qoder-cn 已移交专属扩展 aiti-qoder-agent(credits/listModels 能力 + 区域路由),
|
|
1027
|
+
// 此处不再重复注册(同 agentKey 双注册会在 UI 出现重复后端)。
|
|
1028
|
+
];
|
|
1029
|
+
|
|
1030
|
+
/** 每成员能力:共享基线 + 按实测标记开启运行时权限切换 */
|
|
1031
|
+
function capsFor(member: (typeof FAMILY)[number]): SessionCapabilitiesLike {
|
|
1032
|
+
return { ...CC_CAPABILITIES, setPermissionMode: member.runtimePermissionMode === true };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function which(bin: string): string | null {
|
|
1036
|
+
// 纯 fs PATH 探测(2026-09-12:原 execFileSync("which") 移除——探针不阻塞事件循环)
|
|
1037
|
+
return whichBin(bin);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
let registered: string[] = [];
|
|
1041
|
+
|
|
1042
|
+
/** configDir 兑底(宿主 serverContext 恒带 configDir;按 demo 约定自拼,仅防御性)。 */
|
|
1043
|
+
function extDataDir(): string {
|
|
1044
|
+
const env = process.env.PI_WEB_DATA_DIR;
|
|
1045
|
+
const root = env || join(process.env.HOME || process.env.USERPROFILE || "", ".pi", "web", "data");
|
|
1046
|
+
return join(root, "aiti-claude-code-agent", "config");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ============================================================================
|
|
1050
|
+
// 受控登录 driver(外部 Agent 受控登录标准,共享层 _shared/controlled-login.mjs)
|
|
1051
|
+
// 模式 B paste-relay:spawn `claude auth login`,授权完成页显示一次性 code —— 用户粘回
|
|
1052
|
+
// 前端 → submit 原样喂 login 进程 stdin(`Paste code here if prompted >`;兼容粘完整回调 URL)。
|
|
1053
|
+
// ============================================================================
|
|
1054
|
+
|
|
1055
|
+
/** login URL 抓取(官方打印格式:stdout `…https://claude.com|claude.ai/cai/oauth/authorize?…`)。 */
|
|
1056
|
+
export function extractClaudeLoginUrl(text: string): string | null {
|
|
1057
|
+
const m = /https?:\/\/[^\s"'`]*oauth\/authorize\?[^\s"'`]+/.exec(text);
|
|
1058
|
+
return m ? m[0] : null;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** 结果判定:退出码 0 → success;其余 failed(尾行透出;code 错误/超时都是该路径)。 */
|
|
1062
|
+
export function interpretClaudeLogin(code: number | null, stdout: string, stderr: string): { state: "success" | "failed"; error?: string } {
|
|
1063
|
+
if (code === 0) return { state: "success" };
|
|
1064
|
+
return { state: "failed", error: tailLine(stderr) || tailLine(stdout) || `claude auth login 异常退出(code=${code ?? "signal"})` };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export const claudeLoginDriver = {
|
|
1068
|
+
agentId: "claude",
|
|
1069
|
+
mode: "paste" as const,
|
|
1070
|
+
loginTimeoutMs: 330_000,
|
|
1071
|
+
buildLogin: ({ runtime, consoleMode }: { runtime: string; consoleMode?: boolean }) => ({
|
|
1072
|
+
cmd: runtime || "claude",
|
|
1073
|
+
args: ["auth", "login", ...(consoleMode ? ["--console"] : [])],
|
|
1074
|
+
env: cleanEnv(),
|
|
1075
|
+
}),
|
|
1076
|
+
extractUrl: extractClaudeLoginUrl,
|
|
1077
|
+
interpret: interpretClaudeLogin,
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
/** 受控登录 API(startup 里挂载;/login/* 命中返回 Response,其余 null 放行)。 */
|
|
1081
|
+
let loginApi: ((req: Request, ctx: { path: string[]; method: string; searchParams: URLSearchParams }) => Promise<Response | null>) | null = null;
|
|
1082
|
+
/** 受控登录控制器引用(shutdown 时终止进行中的登录进程)。 */
|
|
1083
|
+
let loginCtl: { dispose(): void } | null = null;
|
|
1084
|
+
/** DI:startup 捕获 ctx.services(session-manager.sessions 冷恢复 cwd 消费)。 */
|
|
1085
|
+
let hostSvc: any = null;
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* 冷恢复 cwd 修复(2026-09-16):resume 场景 options.cwd 常缺省 → 曾落 process.cwd()
|
|
1089
|
+
* (= pi-web 服务端目录),而 claude 历史 jsonl 按会话真实目录存于
|
|
1090
|
+
* ~/.claude/projects/<cwd-encoded>/<sid>.jsonl —— 路径错 → getMessages 的 prelude
|
|
1091
|
+
* 回放读不到文件,打开历史会话恒空白。经 session-manager parseSession 取 meta.cwd
|
|
1092
|
+
* (同 aiti-zcode-agent 恢复语义);缺席/读失败 → null(调用方走旧兜底)。
|
|
1093
|
+
*/
|
|
1094
|
+
async function restoredCwdFor(sessionId?: string): Promise<string | null> {
|
|
1095
|
+
if (!sessionId) return null;
|
|
1096
|
+
try {
|
|
1097
|
+
const svc = hostSvc?.get?.("session-manager.sessions");
|
|
1098
|
+
if (typeof svc?.parseSession !== "function") return null;
|
|
1099
|
+
const parsed = await svc.parseSession(`claude:${sessionId}`);
|
|
1100
|
+
const cwd = typeof parsed?.meta?.cwd === "string" ? parsed.meta.cwd.trim() : "";
|
|
1101
|
+
return cwd || null;
|
|
1102
|
+
} catch {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ============================================================================
|
|
1108
|
+
// 会话 recycle API(zcode/codebuddy 同款,D8):存量 handle 升级到当前扩展代码,无需重启 pi-web。
|
|
1109
|
+
// GET /session/recycle → 存量 claude-code 会话清单
|
|
1110
|
+
// POST /session/recycle {id, force?} → dispose 旧 handle → loopback 重建(当前代码)
|
|
1111
|
+
// ============================================================================
|
|
1112
|
+
|
|
1113
|
+
type RecycleHandleLike = { id: string; isRunning(): boolean; alive(): boolean; dispose(): unknown; cwd?: string };
|
|
1114
|
+
|
|
1115
|
+
function recycleSelfBase(req: Request): string {
|
|
1116
|
+
try {
|
|
1117
|
+
const u = new URL(req.url);
|
|
1118
|
+
if (u.hostname === "127.0.0.1" || u.hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(u.hostname)) {
|
|
1119
|
+
return u.origin;
|
|
1120
|
+
}
|
|
1121
|
+
} catch { /* fallthrough */ }
|
|
1122
|
+
const i = process.argv.indexOf("--port");
|
|
1123
|
+
const port = i >= 0 ? Number(process.argv[i + 1]) : NaN;
|
|
1124
|
+
return `http://127.0.0.1:${Number.isFinite(port) ? port : 3000}`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function recycleApi(req: Request, ctx: { path: string[]; method: string }): Promise<Response | null> {
|
|
1128
|
+
if (ctx.path[0] !== "session" || ctx.path[1] !== "recycle") return null;
|
|
1129
|
+
const store = (globalThis as { __sessionHandles?: Map<string, RecycleHandleLike> }).__sessionHandles;
|
|
1130
|
+
if (ctx.method === "GET") {
|
|
1131
|
+
const sessions = [...(store?.values() ?? [])]
|
|
1132
|
+
.filter((h) => h.id.startsWith("claude:"))
|
|
1133
|
+
.map((h) => ({ id: h.id, running: h.isRunning(), alive: h.alive() }));
|
|
1134
|
+
return Response.json({ sessions });
|
|
1135
|
+
}
|
|
1136
|
+
if (req.method !== "POST") return Response.json({ error: "method not allowed" }, { status: 405 });
|
|
1137
|
+
let body: { id?: unknown; force?: unknown } = {};
|
|
1138
|
+
try { body = await req.json() as typeof body; } catch { /* 空体按缺省 */ }
|
|
1139
|
+
const id = typeof body.id === "string" ? body.id : "";
|
|
1140
|
+
if (!id.startsWith("claude:")) return Response.json({ error: "id 必须是 claude: 复合 id" }, { status: 400 });
|
|
1141
|
+
const old = store?.get(id);
|
|
1142
|
+
if (!old) return Response.json({ error: `无存活的 handle:${id}` }, { status: 404 });
|
|
1143
|
+
if (old.isRunning() && !body.force) {
|
|
1144
|
+
return Response.json(
|
|
1145
|
+
{ error: "会话正在运行中——等本轮收口后重试;确认要打断请带 force:true(当前轮作废,已排队消息随之清空)", running: true },
|
|
1146
|
+
{ status: 409 },
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const realSid = id.startsWith("claude:sess_") || /^claude:[0-9a-f-]{36}$/.test(id) ? id.slice("claude:".length) : "";
|
|
1150
|
+
const cwd = typeof old.cwd === "string" ? old.cwd : "";
|
|
1151
|
+
try { await old.dispose(); } catch { /* 已死容错 */ }
|
|
1152
|
+
if (!realSid) {
|
|
1153
|
+
return Response.json({ recycled: true, recreated: false, id, note: "占位会话已释放(未发过消息的空会话),前端下次访问按需重建" });
|
|
1154
|
+
}
|
|
1155
|
+
const base = recycleSelfBase(req);
|
|
1156
|
+
try {
|
|
1157
|
+
const res = await fetch(`${base}/api/session-backends`, {
|
|
1158
|
+
method: "POST",
|
|
1159
|
+
headers: { "content-type": "application/json" },
|
|
1160
|
+
body: JSON.stringify({ providerType: "aiti-claude-code-agent/claude-code", sessionId: realSid, ...(cwd ? { cwd } : {}) }),
|
|
1161
|
+
});
|
|
1162
|
+
const data = await res.json().catch(() => ({}));
|
|
1163
|
+
if (!res.ok) {
|
|
1164
|
+
return Response.json({
|
|
1165
|
+
recycled: true, recreated: false, id,
|
|
1166
|
+
error: `重建请求 HTTP ${res.status}: ${JSON.stringify(data).slice(0, 200)}`,
|
|
1167
|
+
note: "旧 handle 已释放——前端刷新该会话即走复活路径用当前代码重建",
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
return Response.json({ recycled: true, recreated: true, id, session: data });
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
return Response.json({
|
|
1173
|
+
recycled: true, recreated: false, id,
|
|
1174
|
+
error: `重建请求失败: ${e instanceof Error ? e.message : String(e)}`,
|
|
1175
|
+
note: "旧 handle 已释放——前端刷新该会话即走复活路径用当前代码重建",
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ============================================================================
|
|
1181
|
+
// 账号额度(agent-manager 统一「账号额度」行;令牌仅服务端使用)
|
|
1182
|
+
// ============================================================================
|
|
1183
|
+
|
|
1184
|
+
/** 额度缓存(成功 60s / 失败 10s;?force=1 绕过)。 */
|
|
1185
|
+
let quotaCache: { at: number; ttl: number; value: Record<string, unknown> } | null = null;
|
|
1186
|
+
const QUOTA_TTL_MS = 60_000;
|
|
1187
|
+
const QUOTA_ERROR_TTL_MS = 10_000;
|
|
1188
|
+
|
|
1189
|
+
/** GET /login/quota[?force=1] → 归一化额度视图(凭据缺失/接口失败回落 available:false)。 */
|
|
1190
|
+
async function quotaView(force: boolean): Promise<Response> {
|
|
1191
|
+
if (!force && quotaCache && Date.now() - quotaCache.at < quotaCache.ttl) return Response.json(quotaCache.value);
|
|
1192
|
+
const fail = (reason: string, note?: string): Response => {
|
|
1193
|
+
const v = { available: false, reason, ...(note ? { note } : {}), fetchedAt: Date.now() };
|
|
1194
|
+
quotaCache = { at: Date.now(), ttl: QUOTA_ERROR_TTL_MS, value: v };
|
|
1195
|
+
return Response.json(v);
|
|
1196
|
+
};
|
|
1197
|
+
const creds = readClaudeCreds();
|
|
1198
|
+
if (!creds) {
|
|
1199
|
+
// 2.1.318 起凭据迁 keyring(auth 有效但无文件可读)——区分「真没登录」与「登录了但额度查询拿不到 token」
|
|
1200
|
+
const loggedIn = await claudeAuthReady();
|
|
1201
|
+
return fail(
|
|
1202
|
+
"no-auth",
|
|
1203
|
+
loggedIn
|
|
1204
|
+
? "已登录但凭据在系统 keyring(2.1.318+),额度查询无可读 token——跑一次 `claude setup-token` 导出后自愈"
|
|
1205
|
+
: "未找到 Claude 登录凭据(~/.claude/.credentials.json)",
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
const v = await fetchClaudeQuota({ token: (creds as { token: string }).token, ...((creds as { expiresAt?: number }).expiresAt !== undefined ? { expiresAt: (creds as { expiresAt?: number }).expiresAt } : {}) });
|
|
1210
|
+
quotaCache = { at: Date.now(), ttl: QUOTA_TTL_MS, value: v };
|
|
1211
|
+
return Response.json(v);
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
return fail("fetch-failed", e instanceof Error ? e.message : String(e));
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** 额度路由(先于受控登录层:该层对未知 /login/* 会返回 404 吞掉本路由)。 */
|
|
1218
|
+
function quotaRoute(req: Request, ctx: { path: string[]; method: string; searchParams?: URLSearchParams }): Promise<Response> | null {
|
|
1219
|
+
const segs = Array.isArray(ctx?.path) ? ctx.path : [];
|
|
1220
|
+
if (segs[0] !== "login" || segs[1] !== "quota") return null;
|
|
1221
|
+
if (String(ctx?.method ?? "GET").toUpperCase() !== "GET") return null;
|
|
1222
|
+
let force = ctx?.searchParams?.get("force") === "1";
|
|
1223
|
+
if (!force) {
|
|
1224
|
+
try { force = new URL(req.url).searchParams.get("force") === "1"; } catch { /* 相对 URL 兜底 */ }
|
|
1225
|
+
}
|
|
1226
|
+
return quotaView(force);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** GET /login/status 的登录态裁决(agent-manager extReportsAuthState 语义)。
|
|
1230
|
+
* 三级:凭据文件 / env token / `claude auth status`(2.1.318 起凭据迁 keyring,
|
|
1231
|
+
* 文件探测失效——CLI 是唯一可信源;60s 缓存防 manager 扫描放大 spawn)。 */
|
|
1232
|
+
let authStatusCache: { at: number; ready: boolean } | null = null;
|
|
1233
|
+
async function claudeAuthReady(): Promise<boolean> {
|
|
1234
|
+
if (readClaudeCreds()) return true;
|
|
1235
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_AUTH_TOKEN || process.env.ANTHROPIC_API_KEY) return true;
|
|
1236
|
+
if (authStatusCache && Date.now() - authStatusCache.at < 60_000) return authStatusCache.ready;
|
|
1237
|
+
let ready = false;
|
|
1238
|
+
try {
|
|
1239
|
+
const { execFile } = await import("node:child_process");
|
|
1240
|
+
const { promisify } = await import("node:util");
|
|
1241
|
+
const out = await promisify(execFile)("claude", ["auth", "status"], { timeout: 8_000 })
|
|
1242
|
+
.then((r: { stdout: string }) => String(r.stdout || ""))
|
|
1243
|
+
.catch((e: { stdout?: string }) => String(e?.stdout || ""));
|
|
1244
|
+
let parsed: { loggedIn?: unknown } | null = null;
|
|
1245
|
+
try { parsed = JSON.parse(out) as { loggedIn?: unknown }; } catch { /* 非 JSON 输出走正则 */ }
|
|
1246
|
+
ready = parsed?.loggedIn === true || /\bloggedIn"?\s*[:=]\s*true\b/i.test(out);
|
|
1247
|
+
} catch { /* CLI 缺席/异常 = missing */ }
|
|
1248
|
+
authStatusCache = { at: Date.now(), ready };
|
|
1249
|
+
return ready;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export default {
|
|
1253
|
+
id: "aiti-claude-code-agent",
|
|
1254
|
+
name: "Claude Code (resume)",
|
|
1255
|
+
version: "0.1.0",
|
|
1256
|
+
icon: "🟣",
|
|
1257
|
+
// 受控登录统一弹窗(零构建原生 h() client;client/ 下两文件随 deploy 拷贝)
|
|
1258
|
+
// 静态插入点声明(与 client registerItems 的 id 一一对应)
|
|
1259
|
+
// 登录 UI 已统一到 aiti-agent-manager(受控登录弹窗退役,2026-09-13):不再声明
|
|
1260
|
+
// client/ui —— 服务端 /login/* 与 .agent 描述符保留(manager 代理消费)。
|
|
1261
|
+
|
|
1262
|
+
features: [spawnEnvFeature("Claude Code"), spawnCommandFeature("Claude Code"), spawnArgsPrefixFeature("Claude Code")],
|
|
1263
|
+
|
|
1264
|
+
server: {
|
|
1265
|
+
startup(ctx: {
|
|
1266
|
+
/** 扩展数据目录(宿主 serverContext 恒提供;类型上按需声明)。 */
|
|
1267
|
+
configDir?: string;
|
|
1268
|
+
sessionProviders: {
|
|
1269
|
+
register(provider: {
|
|
1270
|
+
type: string;
|
|
1271
|
+
label: string;
|
|
1272
|
+
agentKey?: string;
|
|
1273
|
+
priority?: number;
|
|
1274
|
+
maxCapabilities: SessionCapabilitiesLike;
|
|
1275
|
+
createSession(options: {
|
|
1276
|
+
sessionId?: string;
|
|
1277
|
+
cwd?: string;
|
|
1278
|
+
extraArgs?: string[];
|
|
1279
|
+
env?: Record<string, string>;
|
|
1280
|
+
bin?: string;
|
|
1281
|
+
permissionMode?: string;
|
|
1282
|
+
listModels?: () => Promise<Array<{ id: string; provider: string; label?: string }>>;
|
|
1283
|
+
}): Promise<ClaudeCodeHandle>;
|
|
1284
|
+
}): string;
|
|
1285
|
+
};
|
|
1286
|
+
/** 跨扩展服务 slot(P2 协议委托:<extId>.launch 提供给 aiti-custom-agent;缺省=旧宿主降级)。 */
|
|
1287
|
+
services?: {
|
|
1288
|
+
provide<T>(key: string, impl: T): () => void;
|
|
1289
|
+
get?(name: string): unknown;
|
|
1290
|
+
};
|
|
1291
|
+
}) {
|
|
1292
|
+
registered = [];
|
|
1293
|
+
hostSvc = (ctx as { services?: { get?(name: string): unknown } }).services ?? null;
|
|
1294
|
+
captureSpawnEnvFeatures((ctx as any)?.features ?? null); // D1:spawn.env feature 捕获(否则 spawnEnvList 恒空)
|
|
1295
|
+
// 队列崩溃恢复 sidecar 根(<stateDir>/queues/<sessionId>.json;docs/aiti-remote-queue-and-sidecar-plan.md §3)
|
|
1296
|
+
queueStateDir = (ctx as { stateDir?: string }).stateDir ?? join((ctx as { configDir?: string }).configDir ?? extDataDir(), "state");
|
|
1297
|
+
try { mkdirSync(join(queueStateDir, "queues"), { recursive: true }); } catch { /* exists */ }
|
|
1298
|
+
// 受控登录(工具条 🔑,paste 模式):spawn `claude auth login`,code 由前端回填 stdin。
|
|
1299
|
+
// consoleMode 档(API 计费)经 start body {consoleMode:true} 传入。
|
|
1300
|
+
const login = createControlledLogin({
|
|
1301
|
+
driver: claudeLoginDriver,
|
|
1302
|
+
configDir: (ctx as { configDir?: string }).configDir ?? extDataDir(),
|
|
1303
|
+
resolveRuntime: async () => {
|
|
1304
|
+
const bin = process.env.CLAUDE_CLI_PATH || "claude";
|
|
1305
|
+
if (!bin.includes("/") && !which(bin)) {
|
|
1306
|
+
throw new Error(
|
|
1307
|
+
`未找到 claude CLI(PATH 上无 "${bin}",也未设置 CLAUDE_CLI_PATH)——请先安装 Claude Code;` +
|
|
1308
|
+
"或在有浏览器的机器上运行 `claude /login` 完成授权",
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
return bin;
|
|
1312
|
+
},
|
|
1313
|
+
log: (m) => console.log(`[aiti-claude-code-agent] ${m}`),
|
|
1314
|
+
});
|
|
1315
|
+
loginApi = mountControlledLoginApi(login);
|
|
1316
|
+
loginCtl = login;
|
|
1317
|
+
for (const member of FAMILY) {
|
|
1318
|
+
if (!which(member.bin)) continue; // 未安装 → 不注册(装机才接)
|
|
1319
|
+
const key = ctx.sessionProviders.register({
|
|
1320
|
+
type: member.name,
|
|
1321
|
+
label: member.label,
|
|
1322
|
+
agentKey: member.agentKey,
|
|
1323
|
+
priority: 10,
|
|
1324
|
+
maxCapabilities: capsFor(member),
|
|
1325
|
+
// 静态目录(新建页选后端时给选择器供数据;无会话即可取)——上次使用的模型置顶,
|
|
1326
|
+
// 前端默认选中它(list[0]),新建会话即默认该 agent 上次使用的模型
|
|
1327
|
+
listModels: async () => withLastUsedFirst(await claudeCodeCatalog(), (await readAgentLastUsedModel("claude"))?.id),
|
|
1328
|
+
// cc-switch 档案清单(~/.claude/<name>.json;内容含凭证不出服务端)
|
|
1329
|
+
...(member.profilesDir
|
|
1330
|
+
? {
|
|
1331
|
+
listProfiles: async () => {
|
|
1332
|
+
const RESERVED = new Set([
|
|
1333
|
+
"settings.json", "auth.json", ".credentials.json", "backup.json",
|
|
1334
|
+
"stats-cache.json", "config.json", "statsig.json", "usage.json",
|
|
1335
|
+
"todos.json", "ide-lock.json",
|
|
1336
|
+
]);
|
|
1337
|
+
const dir = `${process.env.HOME ?? process.env.USERPROFILE}/${member.profilesDir}`;
|
|
1338
|
+
try {
|
|
1339
|
+
const files = await (await import("node:fs/promises")).readdir(dir);
|
|
1340
|
+
return files
|
|
1341
|
+
.filter((f) => f.endsWith(".json") && !f.startsWith(".") && !RESERVED.has(f))
|
|
1342
|
+
.map((f) => ({ name: f.replace(/\.json$/, ""), path: `${dir}/${f}` }));
|
|
1343
|
+
} catch {
|
|
1344
|
+
return [];
|
|
1345
|
+
}
|
|
1346
|
+
},
|
|
1347
|
+
}
|
|
1348
|
+
: {}),
|
|
1349
|
+
// 冷恢复 cwd(2026-09-16):真实历史 jsonl 按会话原 directory 存放,resume 的
|
|
1350
|
+
// cwd 也应回到原目录。但原目录可能已删(项目清理)——spawn cwd 必须存在(否则
|
|
1351
|
+
// ENOENT 打不开会话,比空白更糟),此时 spawn 落显式/兜底 cwd,历史回放仍按
|
|
1352
|
+
// 原目录找 jsonl(historyCwd 与 spawn cwd 解耦)。
|
|
1353
|
+
createSession: async (options) => {
|
|
1354
|
+
let cwd = options.cwd;
|
|
1355
|
+
let historyCwd: string | undefined;
|
|
1356
|
+
if (!cwd && options.sessionId) {
|
|
1357
|
+
const restored = await restoredCwdFor(options.sessionId);
|
|
1358
|
+
if (restored) {
|
|
1359
|
+
if (existsSync(restored)) cwd = restored;
|
|
1360
|
+
else historyCwd = restored;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return ClaudeCodeHandle.start({
|
|
1364
|
+
...options,
|
|
1365
|
+
...(cwd ? { cwd } : {}),
|
|
1366
|
+
...(historyCwd ? { historyCwd } : {}),
|
|
1367
|
+
bin: member.bin,
|
|
1368
|
+
caps: capsFor(member),
|
|
1369
|
+
});
|
|
1370
|
+
},
|
|
1371
|
+
});
|
|
1372
|
+
registered.push(key);
|
|
1373
|
+
}
|
|
1374
|
+
console.log(`[aiti-claude-code-agent] registered family members: ${registered.join(", ") || "(none on PATH)"}`);
|
|
1375
|
+
// agent 描述符:agent 管理面(aiti-agent-manager)动态发现用——manager 不写死各家
|
|
1376
|
+
ctx.services?.provide?.("aiti-claude-code-agent.agent", {
|
|
1377
|
+
agent: "claude",
|
|
1378
|
+
label: "Claude Code",
|
|
1379
|
+
modes: ["paste"],
|
|
1380
|
+
accountQuota: true, // 支持账号额度查询(agent-manager 聚合 GET /login/quota)
|
|
1381
|
+
// 2026-11-05 claude 2.1.318:凭据迁出明文文件(keyring 域,路径不可 stat)——
|
|
1382
|
+
// fs 探测表会误报 missing;改由本扩展 /login/status 端点裁决(claude auth status 兜底)。
|
|
1383
|
+
extReportsAuthState: true,
|
|
1384
|
+
dependencyHint: "claude CLI(npm i -g @anthropic-ai/claude-code)",
|
|
1385
|
+
auth: [
|
|
1386
|
+
{ kind: "file", path: [".claude", ".credentials.json"], label: "~/.claude/.credentials.json" },
|
|
1387
|
+
{ kind: "env", path: ["CLAUDE_CODE_OAUTH_TOKEN"], label: "env CLAUDE_CODE_OAUTH_TOKEN" },
|
|
1388
|
+
{ kind: "env", path: ["ANTHROPIC_AUTH_TOKEN"], label: "env ANTHROPIC_AUTH_TOKEN" },
|
|
1389
|
+
],
|
|
1390
|
+
accountSlots: (c: { home: string }) => [
|
|
1391
|
+
{ key: "claude-oauth", label: "OAuth 凭据", path: join(c.home, ".claude", ".credentials.json") },
|
|
1392
|
+
],
|
|
1393
|
+
});
|
|
1394
|
+
// P2 协议委托:向 aiti-custom-agent 暴露 launch 服务(成员的启动包装 → 本扩展完整协议会话)。
|
|
1395
|
+
// 契约最小化:agentKey + capabilities + listModels + createSessionFromLaunch(launch, options)。
|
|
1396
|
+
const primary = FAMILY.find((m) => which(m.bin)) ?? FAMILY[0];
|
|
1397
|
+
ctx.services?.provide?.("aiti-claude-code-agent.launch", {
|
|
1398
|
+
agentKey: primary.agentKey,
|
|
1399
|
+
capabilities: capsFor(primary),
|
|
1400
|
+
listModels: async () => withLastUsedFirst(await claudeCodeCatalog(), (await readAgentLastUsedModel("claude"))?.id),
|
|
1401
|
+
createSessionFromLaunch: (launch: LaunchOverride, options: Record<string, unknown>) =>
|
|
1402
|
+
ClaudeCodeHandle.start({
|
|
1403
|
+
...(options as Parameters<typeof ClaudeCodeHandle.start>[0]),
|
|
1404
|
+
launchOverride: launch,
|
|
1405
|
+
caps: capsFor(primary),
|
|
1406
|
+
}),
|
|
1407
|
+
});
|
|
1408
|
+
console.log("[aiti-claude-code-agent] launch service provided: aiti-claude-code-agent.launch");
|
|
1409
|
+
},
|
|
1410
|
+
|
|
1411
|
+
/** 受控登录 API:POST /login/start · GET /login/status · POST /login/cancel · POST /login/submit(paste 模式)+ 会话 recycle。 */
|
|
1412
|
+
async api(req: Request, ctx: { path: string[]; method: string; searchParams: URLSearchParams }) {
|
|
1413
|
+
const recycled = await recycleApi(req, ctx);
|
|
1414
|
+
if (recycled) return recycled;
|
|
1415
|
+
const quota = quotaRoute(req, ctx);
|
|
1416
|
+
if (quota) return quota;
|
|
1417
|
+
// agent-manager 优先语义(extReportsAuthState):先于 loginApi(其对未知 /login/* 返 404)
|
|
1418
|
+
if (ctx?.path?.[0] === "login" && ctx?.path?.[1] === "status" && String(ctx.method ?? "GET").toUpperCase() === "GET") {
|
|
1419
|
+
return Response.json({ state: (await claudeAuthReady()) ? "ready" : "missing" });
|
|
1420
|
+
}
|
|
1421
|
+
return loginApi ? await loginApi(req, ctx) : null;
|
|
1422
|
+
},
|
|
1423
|
+
|
|
1424
|
+
/** 扩展停卸/reload:终止进行中的登录进程(状态文件留档,下次 status reconcile 为 failed)。 */
|
|
1425
|
+
shutdown() {
|
|
1426
|
+
loginCtl?.dispose();
|
|
1427
|
+
loginCtl = null;
|
|
1428
|
+
loginApi = null;
|
|
1429
|
+
},
|
|
1430
|
+
},
|
|
1431
|
+
};
|