im-agent 0.0.0-stage → 0.1.0

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/driver.ts +149 -0
  6. package/src/agents/claude/errors.ts +129 -0
  7. package/src/agents/claude/permissions.ts +227 -0
  8. package/src/agents/claude/session.ts +430 -0
  9. package/src/agents/codex/account.ts +92 -0
  10. package/src/agents/codex/app-server.ts +346 -0
  11. package/src/agents/codex/driver.ts +183 -0
  12. package/src/agents/codex/errors.ts +158 -0
  13. package/src/agents/codex/protocol.ts +284 -0
  14. package/src/agents/codex/refusals.ts +62 -0
  15. package/src/agents/codex/session.ts +738 -0
  16. package/src/channels/feishu/bot.ts +609 -0
  17. package/src/channels/feishu/cards.ts +309 -0
  18. package/src/channels/feishu/client.ts +185 -0
  19. package/src/channels/feishu/envelope.ts +112 -0
  20. package/src/channels/feishu/index.ts +52 -0
  21. package/src/channels/feishu/message.ts +115 -0
  22. package/src/channels/feishu/register.ts +171 -0
  23. package/src/channels/feishu/turn-stream.ts +98 -0
  24. package/src/channels/web/api.ts +505 -0
  25. package/src/channels/web/auth.ts +204 -0
  26. package/src/channels/web/http.ts +118 -0
  27. package/src/channels/web/server.ts +363 -0
  28. package/src/cli/doctor.ts +39 -0
  29. package/src/cli/import.ts +155 -0
  30. package/src/cli/pair.ts +35 -0
  31. package/src/cli/passwd.ts +15 -0
  32. package/src/cli/service.ts +117 -0
  33. package/src/core/agent-env.ts +11 -0
  34. package/src/core/commands.ts +99 -0
  35. package/src/core/config.ts +227 -0
  36. package/src/core/dedup.ts +16 -0
  37. package/src/core/hub.ts +933 -0
  38. package/src/core/interactions.ts +261 -0
  39. package/src/core/pairing.ts +156 -0
  40. package/src/core/store.ts +174 -0
  41. package/src/core/types.ts +159 -0
  42. package/src/core/uploads.ts +200 -0
  43. package/src/home.ts +36 -0
  44. package/src/main.ts +68 -0
  45. package/web/app.js +1939 -0
  46. package/web/icon.svg +6 -0
  47. package/web/index.html +23 -0
  48. package/web/manifest.webmanifest +13 -0
  49. package/web/style.css +1267 -0
  50. package/web/sw.js +5 -0
@@ -0,0 +1,227 @@
1
+ // Claude 的授权细节:「总是允许」名单、工具调用的人话描述、AskUserQuestion 的解析。
2
+ //
3
+ // Codex 把「总是允许」做进了协议(acceptForSession / execpolicy 补丁),Claude 没有,
4
+ // 只能自己记一份名单。特征刻意保守:Bash 只认命令的第一个词,文件类只认所在目录。
5
+ // 宁可多问几次,也不要因为特征太宽而放行了本不该放行的东西。
6
+ import path from "node:path";
7
+ import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
8
+ import type { QuestionSpec } from "../../core/types.js";
9
+
10
+ const allowlistPath = path.join(dataDir, "claude-allowlist.json");
11
+ let allowlist: string[] | null = null;
12
+
13
+ /** 「总是允许一次提问」没有意义:只会让提问卡再也不出现。 */
14
+ const NEVER_REMEMBER = new Set(["AskUserQuestion", "ExitPlanMode"]);
15
+
16
+ export function isRememberable(toolName: string): boolean {
17
+ return !NEVER_REMEMBER.has(toolName);
18
+ }
19
+
20
+ /** 按目录记的签名,目录必须是绝对路径;老版本因读错参数名记下的「NotebookEdit:.」之类一律作废 */
21
+ function validSignature(sig: string): boolean {
22
+ const m = /^(Edit|Write|NotebookEdit):(.*)$/.exec(sig);
23
+ return !m || path.isAbsolute(m[2]!);
24
+ }
25
+
26
+ export function isRememberableSignature(toolName: string, signature: string): boolean {
27
+ return isRememberable(toolName) && validSignature(signature);
28
+ }
29
+
30
+ function load(): string[] {
31
+ allowlist ??= readJson<string[]>(allowlistPath, []).filter(validSignature);
32
+ return allowlist;
33
+ }
34
+
35
+ /**
36
+ * 「总是允许」按多宽记。
37
+ *
38
+ * 默认只记**完整命令**。只有白名单里的命令才按「首词」记:它们不论带什么参数都
39
+ * 只是读读看看,放宽了也出不了大事。黑名单的思路试过,不可靠 —— 包装程序数不完
40
+ * (npm exec、python3.12 -c、各种解释器……),漏一个就是任意命令执行。
41
+ *
42
+ * git 单独处理:按「git + 子命令」记(批准过 git status 不等于批准 git push),
43
+ * 并且带 -c / --config / --exec-path 之类能改行为的全局参数时只记完整命令。
44
+ *
45
+ * 无论哪种,命令里只要有 shell 组合语法(; && | $() 重定向……)就只记完整命令:
46
+ * 否则 `ls; rm -rf ~` 的首词也是 ls。
47
+ */
48
+ /**
49
+ * 出现这些字符就不按首词记:shell 组合语法、变量 / 命令替换,以及引号和通配符 ——
50
+ * 引号能把危险参数藏起来('--output=/x' 在 shell 去掉引号后才生效),
51
+ * 通配符和 ~ 会在执行时展开成我们看不到的东西。
52
+ */
53
+ const SHELL_SYNTAX = /[;&|`<>\n\r\\'"*?[\]{}~#!]|\$/;
54
+
55
+ /**
56
+ * 可按首词放行的命令,以及它们「哪些参数不行」。
57
+ *
58
+ * 入选标准:无论带什么参数,都既不能执行别的程序,也不能写文件。为此去掉了
59
+ * 看似只读、其实带「后门参数」的:rg(--pre 能把文件当脚本跑)、sort / uniq / tree
60
+ * (-o 或位置参数能写文件)。剩下的少数命令里,个别参数仍有副作用(date -s 改系统时间),
61
+ * 单独拦掉。
62
+ */
63
+ const PREFIX_SAFE: Record<string, (args: string[]) => boolean> = {
64
+ ls: () => true,
65
+ cat: () => true,
66
+ head: () => true,
67
+ tail: () => true,
68
+ wc: () => true,
69
+ grep: () => true,
70
+ pwd: (a) => a.length === 0,
71
+ echo: () => true,
72
+ stat: () => true,
73
+ du: () => true,
74
+ df: () => true,
75
+ which: () => true,
76
+ // GNU 长选项可以缩写(--se 就是 --set),所以只认明确列出的参数
77
+ date: (a) => a.every((x) => /^\+/.test(x) || ["-u", "--utc", "-R", "-I", "--iso-8601"].includes(x)),
78
+ basename: () => true,
79
+ dirname: () => true,
80
+ realpath: () => true,
81
+ readlink: () => true,
82
+ uname: () => true,
83
+ whoami: (a) => a.length === 0,
84
+ id: () => true,
85
+ hostname: (a) => a.length === 0, // 带参数就是改主机名
86
+ uptime: () => true,
87
+ ps: () => true,
88
+ mkdir: () => true,
89
+ touch: () => true,
90
+ };
91
+
92
+ /**
93
+ * git 只放行纯查看的子命令,参数也只认白名单 —— git 的长选项同样能缩写
94
+ * (--outp 就是 --output),用黑名单拦不住。不是选项的参数(路径、提交号)照常放行。
95
+ */
96
+ const GIT_READONLY = new Set(["status", "log", "show", "diff"]);
97
+ const GIT_SAFE_FLAGS = new Set([
98
+ "-s", "--short", "-b", "--branch", "--porcelain", "-v", "--verbose", "-u", "--untracked-files",
99
+ "--oneline", "--graph", "--decorate", "--all", "--stat", "--shortstat", "--name-only", "--name-status",
100
+ "-p", "--patch", "--cached", "--staged", "--no-color", "--color", "-w", "--ignore-all-space",
101
+ "--reverse", "--first-parent", "--no-merges", "--merges", "--", "-q", "--quiet",
102
+ ]);
103
+
104
+ function gitArgOk(arg: string): boolean {
105
+ if (!arg.startsWith("-")) return true;
106
+ if (/^-\d+$/.test(arg)) return true; // -5:最近 5 条
107
+ if (/^(-n|--max-count)=?\d*$/.test(arg)) return true;
108
+ if (/^--(since|until|author|grep)=[^=]+$/.test(arg)) return true;
109
+ return GIT_SAFE_FLAGS.has(arg);
110
+ }
111
+
112
+ function prefixSignature(command: string): string | null {
113
+ const trimmed = command.trim();
114
+ if (!trimmed || SHELL_SYNTAX.test(trimmed)) return null;
115
+ const words = trimmed.split(/\s+/);
116
+ const head = words[0] ?? "";
117
+ const args = words.slice(1);
118
+ const check = Object.hasOwn(PREFIX_SAFE, head) ? PREFIX_SAFE[head] : undefined;
119
+ if (check) return check(args) ? `Bash:${head}` : null;
120
+ if (head === "git") {
121
+ const sub = args[0] ?? "";
122
+ if (!GIT_READONLY.has(sub) || !args.slice(1).every(gitArgOk)) return null;
123
+ return `Bash:git ${sub}`;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ export function signatureOf(toolName: string, input: Record<string, unknown>): string {
129
+ if (toolName === "Bash") {
130
+ const command = typeof input.command === "string" ? input.command : "";
131
+ return prefixSignature(command) ?? `BashExact:${command.trim()}`;
132
+ }
133
+ if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
134
+ // NotebookEdit 的路径参数叫 notebook_path,不是 file_path
135
+ const raw = toolName === "NotebookEdit" ? input.notebook_path : input.file_path;
136
+ const file = typeof raw === "string" ? raw : "";
137
+ // 拿不到绝对路径就没法按目录记:给一个不可记忆的签名(见 isRememberableSignature)
138
+ if (!file || !path.isAbsolute(file)) return `${toolName}:`;
139
+ return `${toolName}:${path.dirname(file)}`;
140
+ }
141
+ return toolName;
142
+ }
143
+
144
+ export function isPreApproved(toolName: string, signature: string): boolean {
145
+ // 防绕过靠的是 signatureOf:复合命令拿到的是 BashExact 签名,命中不了 Bash:<首词>
146
+ return isRememberableSignature(toolName, signature) && load().includes(signature);
147
+ }
148
+
149
+ export function rememberAlways(signature: string): void {
150
+ if (!validSignature(signature)) return;
151
+ const list = load();
152
+ if (list.includes(signature)) return;
153
+ list.push(signature);
154
+ writeJsonAtomic(allowlistPath, list);
155
+ }
156
+
157
+ export function listAllowlist(): string[] {
158
+ return [...load()];
159
+ }
160
+
161
+ export function setAllowlist(next: string[]): void {
162
+ allowlist = [...new Set(next.map((s) => s.trim()).filter(Boolean))];
163
+ writeJsonAtomic(allowlistPath, allowlist);
164
+ }
165
+
166
+ /** 「总是允许」按钮上写清楚到底放行多宽。 */
167
+ export function alwaysLabelOf(toolName: string, signature: string): string {
168
+ if (toolName === "Bash") {
169
+ return signature.startsWith("BashExact:") ? "总是允许这条完全相同的命令" : `总是允许 ${signature.slice(5)} 开头的单条命令`;
170
+ }
171
+ if (signature.includes(":")) return `总是允许在该目录下${toolName === "Write" ? "新建" : "修改"}`;
172
+ return `总是允许 ${toolName}`;
173
+ }
174
+
175
+ export function describeToolCall(toolName: string, input: Record<string, unknown>): string {
176
+ if (toolName === "Bash") {
177
+ const desc = typeof input.description === "string" ? `${input.description}\n\n` : "";
178
+ return `${desc}\`\`\`bash\n${String(input.command ?? "")}\n\`\`\``;
179
+ }
180
+ if (toolName === "Edit" || toolName === "Write") {
181
+ return `**文件**:\`${String(input.file_path ?? "")}\``;
182
+ }
183
+ // 计划本身就是写给人看的 markdown,套一层 JSON 只会让它没法读
184
+ if (toolName === "ExitPlanMode" && typeof input.plan === "string") return input.plan;
185
+ return `\`\`\`json\n${JSON.stringify(input, null, 2)}\n\`\`\``;
186
+ }
187
+
188
+ /**
189
+ * 从 AskUserQuestion 的入参里解出问题清单。解不出来返回 null,调用方退回普通授权卡
190
+ * —— 宁可显示得笨一点,也不要因为 SDK 改了 schema 就把一整轮卡死。
191
+ *
192
+ * 问题的 id 用问题原文:SDK 约定答案按「问题原文 → 答案」回填。
193
+ */
194
+ export function parseQuestions(input: Record<string, unknown>): QuestionSpec[] | null {
195
+ const raw = input.questions;
196
+ if (!Array.isArray(raw) || raw.length === 0) return null;
197
+ const specs: QuestionSpec[] = [];
198
+ for (const item of raw) {
199
+ if (typeof item !== "object" || item === null) return null;
200
+ const rec = item as Record<string, unknown>;
201
+ const question = typeof rec.question === "string" ? rec.question.trim() : "";
202
+ const rawOptions = Array.isArray(rec.options) ? rec.options : [];
203
+ if (!question || rawOptions.length === 0) return null;
204
+ const options: QuestionSpec["options"] = [];
205
+ for (const opt of rawOptions) {
206
+ if (typeof opt !== "object" || opt === null) return null;
207
+ const o = opt as Record<string, unknown>;
208
+ const label = typeof o.label === "string" ? o.label.trim() : "";
209
+ if (!label) return null;
210
+ options.push({
211
+ label,
212
+ ...(typeof o.description === "string" && o.description.trim()
213
+ ? { description: o.description.trim() }
214
+ : {}),
215
+ });
216
+ }
217
+ specs.push({
218
+ id: typeof rec.question === "string" ? rec.question : question,
219
+ header: typeof rec.header === "string" && rec.header.trim() ? rec.header.trim() : "选择",
220
+ question,
221
+ options,
222
+ multiSelect: rec.multiSelect === true,
223
+ allowOther: true,
224
+ });
225
+ }
226
+ return specs;
227
+ }
@@ -0,0 +1,430 @@
1
+ // 一条会话 ↔ 一个常驻的 Claude query。
2
+ //
3
+ // 必须用「流式输入」模式:setModel / interrupt / supportedModels / applyFlagSettings
4
+ // 这些控制方法在 SDK 里全都只在 streaming input mode 下可用,也就是 prompt 必须是
5
+ // async iterable。这是架构前提,别改。
6
+ import fs from "node:fs";
7
+ import {
8
+ query,
9
+ type PermissionResult,
10
+ type Query,
11
+ type SDKMessage,
12
+ type SDKUserMessage,
13
+ } from "@anthropic-ai/claude-agent-sdk";
14
+ import { agentEnvironment } from "../../core/agent-env.js";
15
+ import { describeAttachments } from "../../core/uploads.js";
16
+ import type {
17
+ AgentEvents,
18
+ AgentSession,
19
+ InteractionHost,
20
+ ModelInfo,
21
+ RuntimeInfo,
22
+ SavedAttachment,
23
+ SessionOptions,
24
+ UserInput,
25
+ } from "../../core/types.js";
26
+ import {
27
+ alwaysLabelOf,
28
+ describeToolCall,
29
+ isPreApproved,
30
+ isRememberableSignature,
31
+ parseQuestions,
32
+ rememberAlways,
33
+ signatureOf,
34
+ } from "./permissions.js";
35
+
36
+ class AsyncQueue<T> {
37
+ private items: T[] = [];
38
+ private waiting: ((result: IteratorResult<T>) => void)[] = [];
39
+ private closed = false;
40
+
41
+ push(item: T): void {
42
+ if (this.closed) return;
43
+ const waiter = this.waiting.shift();
44
+ if (waiter) waiter({ value: item, done: false });
45
+ else this.items.push(item);
46
+ }
47
+
48
+ /** 丢掉还没被消费的项(停止时:排着队的消息不该在中断后接着跑) */
49
+ clear(): number {
50
+ const n = this.items.length;
51
+ this.items = [];
52
+ return n;
53
+ }
54
+
55
+ close(): void {
56
+ this.closed = true;
57
+ for (const waiter of this.waiting.splice(0)) waiter({ value: undefined as never, done: true });
58
+ }
59
+
60
+ async *stream(): AsyncGenerator<T> {
61
+ while (true) {
62
+ const item = this.items.shift();
63
+ if (item !== undefined) {
64
+ yield item;
65
+ continue;
66
+ }
67
+ if (this.closed) return;
68
+ const next = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
69
+ if (next.done) return;
70
+ yield next.value;
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Claude 原生能看的内容块 —— 和 Anthropic API 的 content block 同形。 */
76
+ type ContentBlock =
77
+ | { type: "text"; text: string }
78
+ | { type: "image"; source: { type: "base64"; media_type: string; data: string } }
79
+ | { type: "document"; source: { type: "base64"; media_type: "application/pdf"; data: string } };
80
+
81
+ /** API 对单张图片的限制是 5MB(按 base64 后计),PDF 是整个请求 32MB。超了只给路径。 */
82
+ const INLINE_IMAGE_MAX = 3.75 * 1024 * 1024;
83
+ const INLINE_PDF_MAX = 20 * 1024 * 1024;
84
+
85
+ function canInline(a: SavedAttachment): boolean {
86
+ if (a.mediaType === "application/pdf") return a.size <= INLINE_PDF_MAX;
87
+ if (a.mediaType && /^image\/(png|jpeg|gif|webp)$/.test(a.mediaType)) return a.size <= INLINE_IMAGE_MAX;
88
+ return false;
89
+ }
90
+
91
+ /** 文字 + 附件清单,后面跟着能直接看的图片和 PDF。没有附件时就是原样的文字。 */
92
+ function buildContent(input: UserInput): string | ContentBlock[] {
93
+ if (input.attachments.length === 0) return input.text;
94
+ const note = describeAttachments(input.attachments, canInline);
95
+ const blocks: ContentBlock[] = [{ type: "text", text: input.text ? `${input.text}\n\n${note}` : note }];
96
+ for (const a of input.attachments) {
97
+ if (!canInline(a)) continue;
98
+ const data = fs.readFileSync(a.path).toString("base64");
99
+ blocks.push(
100
+ a.mediaType === "application/pdf"
101
+ ? { type: "document", source: { type: "base64", media_type: "application/pdf", data } }
102
+ : { type: "image", source: { type: "base64", media_type: a.mediaType!, data } },
103
+ );
104
+ }
105
+ return blocks;
106
+ }
107
+
108
+ export class ClaudeSession implements AgentSession {
109
+ private queue = new AsyncQueue<SDKUserMessage>();
110
+ private q: Query | null = null;
111
+ private pump: Promise<void> | null = null;
112
+ private currentId: string | undefined;
113
+ /**
114
+ * 分叉已经真正生效(CLI 报出了一个不同于原会话的新 session id)。
115
+ * 在那之前 CLI 起不来、半路退出,重试都还要带 forkSession,绝不能续写原会话。
116
+ */
117
+ private forked = false;
118
+ private runtime: RuntimeInfo = {};
119
+ private disposed = false;
120
+ /**
121
+ * 消息先留在本地,上一轮出结果了才交给 CLI。
122
+ *
123
+ * 一股脑塞给 CLI 的话,它会自己排队;而 interrupt() 只停当前这一轮,排在 CLI 里的
124
+ * 下一条照样会跑 —— 用户点了停止,agent 却还在干活。留在本地就能在停止时一并丢掉。
125
+ */
126
+ private turnBusy = false;
127
+ private pending: UserInput[] = [];
128
+
129
+ constructor(
130
+ private readonly opts: SessionOptions,
131
+ private readonly events: AgentEvents,
132
+ private readonly host: InteractionHost,
133
+ ) {}
134
+
135
+ get sessionId(): string | undefined {
136
+ return this.currentId;
137
+ }
138
+
139
+ private start(): void {
140
+ if (this.q || this.disposed) return;
141
+ // 重启时要接着最新的会话,而不是构造时那个 —— 否则中途崩一次,之后都被倒回去
142
+ const resume = this.opts.fork && !this.forked ? this.opts.resumeId : (this.currentId ?? this.opts.resumeId);
143
+ // fork 只在第一次生效:重启再 fork 一次就又分叉一条
144
+ const fork = this.opts.fork && !this.forked;
145
+
146
+ // 每个 query 一条自己的输入队列:CLI 崩溃后重新拉起时,旧 query 的输入循环可能还挂在
147
+ // 旧队列上等着;继续共用的话,下一条消息会被那个已经没人处理的旧循环吞掉。
148
+ const input = new AsyncQueue<SDKUserMessage>();
149
+ this.queue = input;
150
+ this.q = query({
151
+ prompt: input.stream(),
152
+ options: {
153
+ cwd: this.opts.cwd,
154
+ env: agentEnvironment(),
155
+ // 必须是 default:bypassPermissions 会让 canUseTool 变成死代码
156
+ permissionMode: "default",
157
+ // 逐字输出给 Web 用;飞书渠道直接忽略这些片段
158
+ includePartialMessages: true,
159
+ ...(this.opts.model ? { model: this.opts.model } : {}),
160
+ ...(this.opts.effort ? { effort: this.opts.effort as never } : {}),
161
+ additionalDirectories: [this.opts.uploadsDir],
162
+ ...(resume ? { resume } : {}),
163
+ ...(fork ? { forkSession: true } : {}),
164
+ canUseTool: async (toolName, input) => this.canUseTool(toolName, input as Record<string, unknown>),
165
+ },
166
+ });
167
+
168
+ this.pump = this.consume()
169
+ .catch((err) => {
170
+ if (!this.disposed) this.events.onError(err);
171
+ })
172
+ .finally(() => {
173
+ // query 真的结束了(CLI 退出或输入被关)。清掉之后下一条消息会带着最新的
174
+ // sessionId 重新拉起来,上下文不丢;不清的话消息会进一个没人消费的队列。
175
+ input.close();
176
+ this.q = null;
177
+ this.pump = null;
178
+ // CLI 半路退出:这一轮不会再有结果了,排队的下一条会重新拉起 CLI 接着跑
179
+ if (this.turnBusy) this.onTurnEnd();
180
+ });
181
+ }
182
+
183
+ private async consume(): Promise<void> {
184
+ if (!this.q) return;
185
+ for await (const message of this.q as AsyncIterable<SDKMessage>) this.handle(message);
186
+ }
187
+
188
+ private handle(message: SDKMessage): void {
189
+ if ("session_id" in message && typeof message.session_id === "string" && message.session_id) {
190
+ if (message.session_id !== this.currentId) {
191
+ // 分叉时 CLI 报出的新 id 和原会话不同,这才算分叉成功
192
+ if (this.opts.fork && message.session_id !== this.opts.resumeId) this.forked = true;
193
+ this.currentId = message.session_id;
194
+ this.events.onSessionId(message.session_id);
195
+ }
196
+ }
197
+
198
+ if (message.type === "system" && message.subtype === "init") {
199
+ this.runtime = { model: message.model, ...(message.effort ? { effort: message.effort } : {}) };
200
+ this.events.onTurnStart?.();
201
+ return;
202
+ }
203
+
204
+ // 逐字片段。子 agent 的输出(parent_tool_use_id 非空)不算回复正文
205
+ if (message.type === "stream_event") {
206
+ if (message.parent_tool_use_id) return;
207
+ const event = message.event as { type?: string; delta?: { type?: string; text?: string } };
208
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) {
209
+ this.events.onTextDelta(event.delta.text);
210
+ }
211
+ return;
212
+ }
213
+
214
+ if (message.type === "assistant") {
215
+ const blocks = message.message.content;
216
+ if (!Array.isArray(blocks)) return;
217
+ const sub = !!message.parent_tool_use_id;
218
+ for (const block of blocks) {
219
+ if (block.type === "thinking") {
220
+ const thought = summarize(block.thinking);
221
+ if (thought) this.events.onStep("thinking", `💭 ${thought}`);
222
+ } else if (block.type === "tool_use") {
223
+ this.events.onStep("tool", `${sub ? " ↳ " : ""}🔧 ${describeToolUse(block.name, block.input)}`);
224
+ } else if (block.type === "text" && block.text.trim() && !sub) {
225
+ this.events.onText(block.text);
226
+ }
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (message.type === "result") {
232
+ if (message.subtype === "success") {
233
+ this.events.onResult({
234
+ text: message.result,
235
+ costUsd: message.total_cost_usd,
236
+ durationMs: message.duration_ms,
237
+ });
238
+ } else {
239
+ this.events.onError(new Error(`运行结束但报错:${message.subtype}`));
240
+ }
241
+ this.onTurnEnd();
242
+ }
243
+ }
244
+
245
+ // ---------- 授权 ----------
246
+
247
+ /**
248
+ * canUseTool:预批过的直接放行,否则交给用户。
249
+ * 对同一 request_id 必须幂等 —— reinitialize 之后 SDK 会重投同一个请求。
250
+ */
251
+ private async canUseTool(toolName: string, input: Record<string, unknown>): Promise<PermissionResult> {
252
+ // 提问不是授权:它问的是「你选哪个」
253
+ if (toolName === "AskUserQuestion") {
254
+ const answered = await this.askQuestion(input);
255
+ if (answered) return answered;
256
+ }
257
+
258
+ const signature = signatureOf(toolName, input);
259
+ if (isPreApproved(toolName, signature)) return { behavior: "allow", updatedInput: input };
260
+
261
+ const outcome = await this.host({
262
+ kind: "approval",
263
+ title: `需要授权:${toolName}`,
264
+ detail: describeToolCall(toolName, input),
265
+ ...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature) } : {}),
266
+ });
267
+ if (outcome.settlement === "allow-always" && isRememberableSignature(toolName, signature)) rememberAlways(signature);
268
+ if (outcome.settlement === "allow-once" || outcome.settlement === "allow-always") {
269
+ return { behavior: "allow", updatedInput: input };
270
+ }
271
+ const why =
272
+ outcome.settlement === "timeout"
273
+ ? "用户长时间没有响应授权请求(已超时),按拒绝处理"
274
+ : outcome.settlement === "cancelled"
275
+ ? "授权请求已被取消(用户中断了这一轮或切换了会话)"
276
+ : "用户拒绝了此操作";
277
+ return { behavior: "deny", message: why };
278
+ }
279
+
280
+ /**
281
+ * AskUserQuestion:选了什么要顺着 updatedInput.answers 回填(问题原文 → 答案),
282
+ * 少了这一步工具会以「The user did not answer the questions.」收场。
283
+ */
284
+ private async askQuestion(input: Record<string, unknown>): Promise<PermissionResult | null> {
285
+ const questions = parseQuestions(input);
286
+ if (!questions) return null;
287
+ const outcome = await this.host({ kind: "question", title: "Claude 想确认一下", questions });
288
+ const answers: Record<string, string> = {};
289
+ for (const [id, picked] of Object.entries(outcome.answers)) answers[id] = picked.join(", ");
290
+ if (Object.keys(answers).length > 0) {
291
+ return { behavior: "allow", updatedInput: { ...input, answers } };
292
+ }
293
+ // 说清楚是「没作答」而不是「拒绝这件事」,并且明说别再问一遍
294
+ return {
295
+ behavior: "deny",
296
+ message:
297
+ "用户没有作答(超时或主动跳过)。不要重复提同样的问题:按你认为合理的默认继续,并在回复里说明你替他选了什么。",
298
+ };
299
+ }
300
+
301
+ // ---------- 对外 ----------
302
+
303
+ send(input: UserInput): void {
304
+ if (this.disposed) return;
305
+ if (this.turnBusy) {
306
+ this.pending.push(input);
307
+ return;
308
+ }
309
+ this.deliver(input);
310
+ }
311
+
312
+ /** 一轮结束(出结果、出错或 CLI 退出):交出下一条排队的消息 */
313
+ private onTurnEnd(): void {
314
+ this.turnBusy = false;
315
+ const next = this.pending.shift();
316
+ if (next && !this.disposed) this.deliver(next);
317
+ }
318
+
319
+ private deliver(input: UserInput): void {
320
+ // 先把内容组装出来再标记「忙」:附件读不出来(比如已被别处的 /new 清掉)时,
321
+ // 这条要明确报错并让会话恢复可用,而不是挂在一个永远等不到结果的「忙」上
322
+ let content: string | ContentBlock[];
323
+ try {
324
+ content = buildContent(input);
325
+ } catch (err) {
326
+ const why = (err as NodeJS.ErrnoException).code === "ENOENT" ? "附件文件已不存在,请重新上传" : String(err);
327
+ this.events.onError(new Error(`这条消息没能发出:${why}`));
328
+ const next = this.pending.shift();
329
+ if (next && !this.disposed) this.deliver(next);
330
+ return;
331
+ }
332
+ this.turnBusy = true;
333
+ this.start();
334
+ this.queue.push({
335
+ type: "user",
336
+ message: { role: "user", content },
337
+ parent_tool_use_id: null,
338
+ // 人敲的字必须显式标记来源;缺省会在严格的信任门上 fail closed
339
+ origin: { kind: "human" },
340
+ } as SDKUserMessage);
341
+ }
342
+
343
+ async listModels(): Promise<ModelInfo[]> {
344
+ this.start();
345
+ const models = await this.q!.supportedModels();
346
+ return models.map((m) => ({
347
+ id: m.value,
348
+ label: m.displayName || m.value,
349
+ ...(m.description ? { description: m.description } : {}),
350
+ ...(m.supportedEffortLevels?.length ? { efforts: [...m.supportedEffortLevels] } : {}),
351
+ }));
352
+ }
353
+
354
+ async setModel(model?: string, effort?: string): Promise<void> {
355
+ this.start();
356
+ if (model !== undefined) {
357
+ await this.q!.setModel(model);
358
+ this.opts.model = model;
359
+ this.runtime = { ...this.runtime, model };
360
+ }
361
+ if (effort !== undefined) {
362
+ await this.q!.applyFlagSettings({ effortLevel: effort as never });
363
+ this.opts.effort = effort;
364
+ this.runtime = { ...this.runtime, effort };
365
+ }
366
+ }
367
+
368
+ /**
369
+ * 当前实际生效的模型与思考程度。会拉起 CLI 进程,但不消耗对话轮次。
370
+ * getSettings 没写进 SDK 的 Query 类型,先探测再调用。
371
+ */
372
+ async describeRuntime(): Promise<RuntimeInfo> {
373
+ this.start();
374
+ const q = this.q as unknown as {
375
+ getSettings?: () => Promise<{ applied?: { model?: string; effort?: string | null } }>;
376
+ };
377
+ if (typeof q?.getSettings === "function") {
378
+ try {
379
+ const applied = (await q.getSettings()).applied;
380
+ if (applied?.model) {
381
+ this.runtime = { model: applied.model, ...(applied.effort ? { effort: applied.effort } : {}) };
382
+ }
383
+ } catch (err) {
384
+ console.error("[claude] 读取模型设置失败:", err);
385
+ }
386
+ }
387
+ return this.runtime;
388
+ }
389
+
390
+ async interrupt(): Promise<void> {
391
+ // 停止 = 这一轮和排着队的都不要了
392
+ const dropped = this.pending.length + this.queue.clear();
393
+ this.pending = [];
394
+ if (dropped > 0) this.events.onDropped?.(dropped);
395
+ if (!this.q) return;
396
+ try {
397
+ await this.q.interrupt();
398
+ } catch (err) {
399
+ console.warn("[claude] 中断失败:", err instanceof Error ? err.message : err);
400
+ }
401
+ }
402
+
403
+ async dispose(): Promise<void> {
404
+ this.disposed = true;
405
+ this.pending = [];
406
+ this.queue.close();
407
+ try {
408
+ await this.q?.interrupt();
409
+ } catch {
410
+ // 已经停了就无所谓
411
+ }
412
+ await this.pump?.catch(() => {});
413
+ this.q = null;
414
+ this.pump = null;
415
+ }
416
+ }
417
+
418
+ export function summarize(text: string, max = 80): string {
419
+ const line = text.replace(/\s+/g, " ").trim();
420
+ return line.length > max ? `${line.slice(0, max)}…` : line;
421
+ }
422
+
423
+ export function describeToolUse(name: string, input: unknown): string {
424
+ const record = (input ?? {}) as Record<string, unknown>;
425
+ if (name === "Bash" && typeof record.command === "string") return `Bash: ${summarize(record.command, 60)}`;
426
+ if (typeof record.file_path === "string") return `${name}: ${record.file_path}`;
427
+ if (typeof record.pattern === "string") return `${name}: ${summarize(record.pattern, 40)}`;
428
+ if (typeof record.description === "string") return `${name}: ${summarize(record.description, 50)}`;
429
+ return name;
430
+ }