im-agent 0.0.0-stage → 0.1.1

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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 qijieinnet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # im-agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 用飞书(以后还有 Telegram 等)和 Web 页面,驱动本机的 **Claude Code** 和 **Codex**。
4
+
5
+ 由 `feishu-claude-bridge` 和 `feishu-codex-bridge` 合并而来:一个进程、一份配置,飞书和网页共用同一套会话、审批和模型切换。
6
+
7
+ ## 能做什么
8
+
9
+ - **多个 agent**:Claude Code(官方 Claude Agent SDK)、Codex(官方 `codex app-server`)。在同一条会话里 `/agent` 切换,各 agent 的上下文各自保留。
10
+ - **多个飞书机器人**:一个机器人对应一个 agent,或者一个机器人能切换多个 agent,都在设置页里勾选。机器人能在 Web 上扫码创建,改完配置立即生效,不用重启。
11
+ - **Web 聊天页**:电脑和手机都能用,界面参照 ChatGPT。
12
+ - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠。
13
+ - 授权卡、提问卡直接在页面上点。
14
+ - 支持上传附件(粘贴、拖拽,手机上可以拍照)。
15
+ - 可以「添加到主屏幕」当 App 用。手机切到后台再回来会自动重连,并补上错过的内容。
16
+ - **跨渠道同步**:飞书里的会话在 Web 上也能打开,接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
17
+ - **设置都在网页上**:工作目录、各 agent 的默认模型和思考程度、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、登录设备管理。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ npm install -g im-agent # 或者在仓库里 npm install 后用 node bin/cli.js
23
+ im-agent start # 前台运行,日志里会打印首次登录用的设置码
24
+ im-agent service install # 注册为开机自启服务(systemd / launchd)
25
+ ```
26
+
27
+ 打开 `http://<机器 IP>:8800`:第一次需要输入日志里的设置码,然后设一个登录密码。
28
+
29
+ ### 从旧桥接器迁移
30
+
31
+ ```bash
32
+ im-agent import --dry-run # 先看会导入什么
33
+ systemctl --user disable --now feishu-claude-bridge feishu-codex-bridge # 同一个机器人不能被两个程序同时连
34
+ im-agent import # 导入机器人凭据、白名单、配对、会话绑定、总是允许名单
35
+ im-agent service install
36
+ ```
37
+
38
+ 导入后,飞书群里原来的会话会接着聊。
39
+
40
+ ## 命令
41
+
42
+ | 命令 | 作用 |
43
+ |---|---|
44
+ | `im-agent start` | 启动 |
45
+ | `im-agent import [--only claude\|codex] [--disabled] [--dry-run]` | 从旧桥接器导入 |
46
+ | `im-agent pair list \| approve <码> \| revoke <机器人id> <open_id>` | 配对管理(Web 设置页里也能做) |
47
+ | `im-agent passwd [新密码]` | 重置 Web 登录密码,所有设备下线 |
48
+ | `im-agent service install \| uninstall \| restart \| status` | 开机自启 |
49
+ | `im-agent doctor` | 体检 |
50
+
51
+ 聊天里的命令(飞书和 Web 通用):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|deny` `/help`
52
+
53
+ ## 安全
54
+
55
+ 这个服务能让 agent 在你的机器上执行命令,等于一个远程 shell。
56
+
57
+ - Web 登录密码用 scrypt 哈希保存。首次设置必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
58
+ - 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
59
+ - 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)访问,并把反代的 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。
60
+ - 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
61
+ - agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`IM_AGENT_*` 等环境变量会被剔除)。
62
+
63
+ ## 结构
64
+
65
+ ```
66
+ src/
67
+ core/ 与渠道、agent 都无关的中枢
68
+ hub.ts 会话调度:事件广播、命令、切 agent / 模型 / 目录
69
+ interactions.ts 审批与提问:一个请求,多个渠道都能处理
70
+ store.ts 会话持久化(每个 agent 的会话 id 各自保存)
71
+ config.ts 配置(Web 可改,改完热加载)
72
+ pairing.ts uploads.ts commands.ts …
73
+ agents/
74
+ claude/ Claude Agent SDK 驱动
75
+ codex/ codex app-server 驱动
76
+ channels/
77
+ feishu/ 飞书:一个 FeishuBot 对应一个机器人,支持多个
78
+ web/ HTTP + WebSocket + 鉴权
79
+ web/ 前端(原生 JS,不需要构建)
80
+ ```
81
+
82
+ ### 接入新的消息渠道(比如 Telegram)
83
+
84
+ 参照 `channels/feishu/bot.ts`,做三件事:
85
+
86
+ 1. 收到消息 → `hub.ensure()` 建会话 → `parseCommand()`。是命令就调 `hub.command()`,再把 `CommandReply` 画出来;是聊天就调 `hub.send()`。
87
+ 2. 订阅 `hub.on("event")`,挑出自己的会话(键前缀如 `telegram:<botId>:`),把 step / text / result / error 画出来。
88
+ 3. 订阅 `interactions` 的 `created / updated / settled`,把授权卡和提问卡画成按钮。用户点击后调 `interactions.decide / select / submit / skip`。
89
+
90
+ ### 接入新的 agent
91
+
92
+ 实现 `core/types.ts` 里的 `AgentDriver`,然后在 `main.ts` 里 `hub.registerDriver()`。`AgentDriver` 包括开会话、发消息、中断、切模型、列历史、读历史,审批通过传入的 `InteractionHost` 发起。以后要接 ACP 协议的 agent(Gemini CLI、OpenCode 等),写一个通用的 ACP 驱动就行。
93
+
94
+ ## 开发
95
+
96
+ ```bash
97
+ npm install
98
+ npm run dev # IM_AGENT_HOME=./.dev,改代码自动重启
99
+ npm test
100
+ npm run typecheck
101
+ ```
package/bin/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // 统一入口:im-agent <start|import|pair|passwd|service|doctor>
3
+ //
4
+ // 源码是 TypeScript,用 tsx 的 ESM loader 直接加载,装完即可运行,不需要先 build。
5
+ import { register } from "tsx/esm/api";
6
+
7
+ const COMMANDS = {
8
+ start: "../src/main.ts",
9
+ import: "../src/cli/import.ts",
10
+ pair: "../src/cli/pair.ts",
11
+ passwd: "../src/cli/passwd.ts",
12
+ service: "../src/cli/service.ts",
13
+ doctor: "../src/cli/doctor.ts",
14
+ };
15
+
16
+ const USAGE = `im-agent —— 用飞书 / Web 驱动本机的 Claude Code 和 Codex
17
+
18
+ 用法:
19
+ im-agent start 启动(默认命令)
20
+ im-agent import 从旧的 feishu-claude-bridge / feishu-codex-bridge 导入配置和会话
21
+ im-agent pair ... 配对管理:list | approve <配对码> | revoke <机器人id> <open_id>
22
+ im-agent passwd 重置 Web 登录密码
23
+ im-agent service ... 开机自启:install | uninstall | restart | status
24
+ im-agent doctor 体检:配置 / agent / 飞书连通性
25
+
26
+ 配置目录:~/.im-agent(可用 IM_AGENT_HOME 覆盖)
27
+ `;
28
+
29
+ const [, , rawCommand, ...rest] = process.argv;
30
+ const command = rawCommand ?? "start";
31
+
32
+ if (command === "-h" || command === "--help" || command === "help") {
33
+ process.stdout.write(USAGE);
34
+ process.exit(0);
35
+ }
36
+
37
+ const target = COMMANDS[command];
38
+ if (!target) {
39
+ process.stderr.write(`未知命令:${command}\n\n${USAGE}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ process.argv = [process.argv[0], process.argv[1], ...rest];
44
+
45
+ register();
46
+ try {
47
+ await import(new URL(target, import.meta.url).href);
48
+ } catch (err) {
49
+ if (process.env.IM_AGENT_DEBUG) throw err;
50
+ process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
51
+ process.exit(1);
52
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "im-agent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "把飞书(以及以后的 Telegram 等)和 Web 页面桥接到本机 Claude Code / Codex:聊天驱动、卡片审批、会话与模型切换",
6
+ "keywords": [
7
+ "feishu",
8
+ "lark",
9
+ "claude-code",
10
+ "codex",
11
+ "bridge",
12
+ "agent",
13
+ "飞书"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "qijieinnet",
17
+ "homepage": "https://github.com/qijieinnet/im-agent#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/qijieinnet/im-agent/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/qijieinnet/im-agent.git"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "bin": {
29
+ "im-agent": "bin/cli.js",
30
+ "ima": "bin/cli.js"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "src",
35
+ "web",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "start": "node bin/cli.js start",
41
+ "dev": "IM_AGENT_HOME=./.dev tsx watch src/main.ts",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "node --import tsx --test test/*.test.ts",
44
+ "prepublishOnly": "npm run typecheck"
45
+ },
46
+ "dependencies": {
47
+ "@anthropic-ai/claude-agent-sdk": "^0.3.302",
48
+ "@highlightjs/cdn-assets": "^11.12.0",
49
+ "@larksuiteoapi/node-sdk": "^1.73.0",
50
+ "dompurify": "^3.2.0",
51
+ "marked": "^16.0.0",
52
+ "qrcode": "^1.5.4",
53
+ "tsx": "^4.20.6",
54
+ "ws": "^8.18.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^24.10.1",
58
+ "@types/qrcode": "^1.5.6",
59
+ "@types/ws": "^8.5.12",
60
+ "typescript": "^5.9.3"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public",
64
+ "registry": "https://registry.npmjs.org/"
65
+ }
66
+ }
@@ -0,0 +1,149 @@
1
+ // Claude Code 驱动:官方 Claude Agent SDK。
2
+ //
3
+ // 会话列表 / 历史全部走 SDK 自带的 listSessions / getSessionInfo / getSessionMessages,
4
+ // 不再自己去扒 ~/.claude/projects 下的 jsonl —— 那是 CLI 的内部约定,SDK 才是公开接口。
5
+ import {
6
+ getSessionInfo,
7
+ getSessionMessages,
8
+ listSessions,
9
+ } from "@anthropic-ai/claude-agent-sdk";
10
+ import { config } from "../../core/config.js";
11
+ import type {
12
+ AgentDriver,
13
+ AgentEvents,
14
+ AgentSession,
15
+ HistoryMessage,
16
+ InteractionHost,
17
+ ModelInfo,
18
+ RuntimeInfo,
19
+ SessionOptions,
20
+ SessionSummary,
21
+ } from "../../core/types.js";
22
+ import { diagnose } from "./errors.js";
23
+ import { ClaudeSession, describeToolUse, summarize } from "./session.js";
24
+
25
+ /** sessionId 会被拼进路径,先验形,防 /resume ../../x 越界 */
26
+ const SESSION_ID = /^[A-Za-z0-9_-]{8,64}$/;
27
+
28
+ function titleOf(info: { customTitle?: string; summary?: string; firstPrompt?: string }): string {
29
+ const raw = (info.customTitle || info.summary || info.firstPrompt || "").replace(/\s+/g, " ").trim();
30
+ if (!raw) return "(无标题)";
31
+ return raw.length > 60 ? `${raw.slice(0, 60)}…` : raw;
32
+ }
33
+
34
+ type Block = { type?: string; text?: string; name?: string; input?: unknown; thinking?: string };
35
+
36
+ function blocksOf(message: unknown): Block[] {
37
+ const content = (message as { content?: unknown } | null)?.content;
38
+ if (typeof content === "string") return [{ type: "text", text: content }];
39
+ return Array.isArray(content) ? (content as Block[]) : [];
40
+ }
41
+
42
+ /** 用户消息里我们自己拼进去的附件说明,历史里只留一行简述 */
43
+ function cleanUserText(text: string): string {
44
+ const cut = [text.indexOf("[用户通过飞书发来了以下附件"), text.indexOf("[用户发来了以下附件")]
45
+ .filter((i) => i >= 0)
46
+ .sort((a, b) => a - b)[0];
47
+ if (cut === undefined) return text;
48
+ const files = (text.slice(cut).match(/^- /gm) ?? []).length;
49
+ return `${text.slice(0, cut).trim()}${files ? `\n\n📎 ${files} 个附件` : ""}`.trim();
50
+ }
51
+
52
+ export class ClaudeDriver implements AgentDriver {
53
+ readonly id = "claude";
54
+ readonly label = "Claude Code";
55
+ readonly capabilities = { effort: true, fork: true, cost: true, streaming: true };
56
+
57
+ async start(): Promise<void> {}
58
+ async stop(): Promise<void> {}
59
+
60
+ createSession(opts: SessionOptions, events: AgentEvents, host: InteractionHost): AgentSession {
61
+ return new ClaudeSession(opts, events, host);
62
+ }
63
+
64
+ async listSessions(cwd: string | undefined, limit = 10): Promise<SessionSummary[]> {
65
+ try {
66
+ const sessions = await listSessions({ ...(cwd ? { dir: cwd } : {}), limit });
67
+ return sessions.map((s) => ({
68
+ id: s.sessionId,
69
+ title: titleOf(s),
70
+ ...(s.cwd ? { cwd: s.cwd } : {}),
71
+ updatedAt: s.lastModified,
72
+ }));
73
+ } catch (err) {
74
+ console.error("[claude] 读取会话列表失败:", err);
75
+ return [];
76
+ }
77
+ }
78
+
79
+ async sessionExists(id: string, cwd: string): Promise<boolean> {
80
+ if (!SESSION_ID.test(id)) return false;
81
+ try {
82
+ return !!(await getSessionInfo(id, { dir: cwd }));
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 把会话记录整理成「一问一答」:用户一条,助手把这一轮所有文本拼成一条,
90
+ * 中间的工具调用作为过程摘要挂在助手那条上。工具结果(type=user 的 tool_result)
91
+ * 和子 agent 的消息不算对话。
92
+ */
93
+ async readHistory(id: string, cwd: string): Promise<HistoryMessage[]> {
94
+ if (!SESSION_ID.test(id)) return [];
95
+ const raw = await getSessionMessages(id, { dir: cwd });
96
+ const out: HistoryMessage[] = [];
97
+ let assistant: HistoryMessage | null = null;
98
+ const flush = () => {
99
+ if (assistant && (assistant.text.trim() || assistant.steps?.length)) out.push(assistant);
100
+ assistant = null;
101
+ };
102
+ for (const msg of raw) {
103
+ if (msg.parent_tool_use_id) continue;
104
+ const blocks = blocksOf(msg.message);
105
+ if (msg.type === "user") {
106
+ const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
107
+ if (texts.length === 0) continue; // 纯 tool_result
108
+ const text = cleanUserText(texts.join("\n"));
109
+ if (!text || text.startsWith("<command-") || text.startsWith("<local-command")) continue;
110
+ flush();
111
+ out.push({ role: "user", text });
112
+ } else if (msg.type === "assistant") {
113
+ assistant ??= { role: "assistant", text: "", steps: [] };
114
+ for (const b of blocks) {
115
+ if (b.type === "text" && b.text?.trim()) {
116
+ assistant.text += (assistant.text ? "\n\n" : "") + b.text;
117
+ } else if (b.type === "tool_use" && b.name) {
118
+ assistant.steps!.push(`🔧 ${describeToolUse(b.name, b.input)}`);
119
+ } else if (b.type === "thinking" && b.thinking) {
120
+ const t = summarize(b.thinking);
121
+ if (t) assistant.steps!.push(`💭 ${t}`);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ flush();
127
+ return out;
128
+ }
129
+
130
+ async listModels(session: AgentSession): Promise<ModelInfo[]> {
131
+ return (session as ClaudeSession).listModels();
132
+ }
133
+
134
+ async defaults(): Promise<RuntimeInfo> {
135
+ const c = config().agents.claude;
136
+ return {
137
+ ...(c.defaultModel ? { model: c.defaultModel } : {}),
138
+ ...(c.defaultEffort ? { effort: c.defaultEffort } : {}),
139
+ };
140
+ }
141
+
142
+ async statusLines(): Promise<string[]> {
143
+ return [];
144
+ }
145
+
146
+ diagnose(err: unknown) {
147
+ return diagnose(err);
148
+ }
149
+ }
@@ -0,0 +1,129 @@
1
+ // 把 SDK / CLI 抛出的原始错误翻译成人能看懂、且知道下一步做什么的提示。
2
+ //
3
+ // 原则:聊天里给一句人话 + 一条明确动作;终端给完整修复步骤。
4
+ // 不要把 SDK 堆栈直接甩给用户 —— 那既吓人又没有信息量。
5
+
6
+ export type DiagnosisKind =
7
+ | "auth_expired"
8
+ | "auth_missing"
9
+ | "cli_missing"
10
+ | "rate_limit"
11
+ | "billing"
12
+ | "turn_aborted"
13
+ | "unknown";
14
+
15
+ export type Diagnosis = {
16
+ kind: DiagnosisKind;
17
+ /** 发到聊天里的简短说明 */
18
+ chatMessage: string;
19
+ /** 打在终端的修复步骤 */
20
+ consoleHint: string;
21
+ /** 是否属于「配置好之前重试也没用」的问题 */
22
+ fatal: boolean;
23
+ };
24
+
25
+ const LOGIN_STEPS = [
26
+ " 1) npm i -g @anthropic-ai/claude-code",
27
+ " 2) claude # 然后用 /login 登录",
28
+ ' 3) claude -p "回复 OK" # 验证认证是否恢复',
29
+ " 4) 重启 im-agent:im-agent service restart",
30
+ ].join("\n");
31
+
32
+ export function diagnose(err: unknown): Diagnosis {
33
+ const raw = err instanceof Error ? err.message : String(err);
34
+ const text = raw.toLowerCase();
35
+
36
+ if (
37
+ text.includes("oauth session expired") ||
38
+ text.includes("could not be refreshed") ||
39
+ text.includes("failed to authenticate")
40
+ ) {
41
+ return {
42
+ kind: "auth_expired",
43
+ chatMessage:
44
+ "Claude 的登录状态已过期,我暂时无法工作。\n请机器主人在终端重新登录(`claude` → `/login`),完成后我就能继续。",
45
+ consoleHint: `Claude Code 登录已过期,需要重新登录:\n${LOGIN_STEPS}`,
46
+ fatal: true,
47
+ };
48
+ }
49
+
50
+ if (
51
+ text.includes("invalid api key") ||
52
+ text.includes("authentication_error") ||
53
+ text.includes("unauthorized")
54
+ ) {
55
+ return {
56
+ kind: "auth_missing",
57
+ chatMessage:
58
+ "Claude 的凭证无效,我暂时无法工作。\n请机器主人检查登录状态或 ANTHROPIC_API_KEY。",
59
+ consoleHint: `Claude 凭证无效。若用订阅账号,重新登录:\n${LOGIN_STEPS}\n若用 API key,检查 ANTHROPIC_API_KEY 是否正确。`,
60
+ fatal: true,
61
+ };
62
+ }
63
+
64
+ if (
65
+ text.includes("enoent") ||
66
+ text.includes("command not found") ||
67
+ text.includes("spawn") ||
68
+ text.includes("could not find claude")
69
+ ) {
70
+ return {
71
+ kind: "cli_missing",
72
+ chatMessage:
73
+ "本机还没装好 Claude Code,我暂时无法工作。\n请机器主人先完成安装。",
74
+ consoleHint: `找不到可用的 Claude Code。安装并登录:\n${LOGIN_STEPS}`,
75
+ fatal: true,
76
+ };
77
+ }
78
+
79
+ if (text.includes("rate limit") || text.includes("usage limit")) {
80
+ return {
81
+ kind: "rate_limit",
82
+ chatMessage: "Claude 的用量已达上限,请稍后再试。",
83
+ consoleHint: "已达用量上限。等配额恢复,或换用额度更充足的账号 / API key。",
84
+ fatal: false,
85
+ };
86
+ }
87
+
88
+ // CLI 在一轮结束时发现 transcript 收尾不自洽(典型形态:最后一条是用户侧
89
+ // 消息,而助手那边还停在 tool_use),就判为 error_during_execution。
90
+ // 也就是工具调用循环跑到一半被打断了 —— /stop、审批超时、CLI 进程挂掉都算。
91
+ // SDK 会把它包成 Error("Claude Code returned an error result: …") 抛出来。
92
+ if (
93
+ text.includes("error_during_execution") ||
94
+ text.includes("returned an error result")
95
+ ) {
96
+ return {
97
+ kind: "turn_aborted",
98
+ chatMessage:
99
+ "这一轮没能正常收尾,多半是工具跑到一半被打断了(/stop、审批超时,或 Claude Code 自己退出了)。\n上下文没丢,把刚才的需求再说一遍就行。",
100
+ consoleHint:
101
+ "一轮执行被中途打断(error_during_execution)。\n" +
102
+ "常见原因:用户发了 /stop、审批超时按拒绝处理、或 Claude Code 进程退出。\n" +
103
+ "偶发不用管,下一条消息会带着原 sessionId 重新拉起。\n" +
104
+ "如果反复出现,去 ~/.claude/projects 下看对应会话的 jsonl 尾部,确认是不是每次都断在同一个工具上。",
105
+ fatal: false,
106
+ };
107
+ }
108
+
109
+ if (text.includes("credit balance") || text.includes("billing")) {
110
+ return {
111
+ kind: "billing",
112
+ chatMessage: "Claude 账户余额不足,请机器主人处理后再试。",
113
+ consoleHint: "账户余额不足,请到 Claude 控制台充值或检查订阅状态。",
114
+ fatal: false,
115
+ };
116
+ }
117
+
118
+ return {
119
+ kind: "unknown",
120
+ // 未知错误才把原文带给用户 —— 有原文总比一句「出错了」强
121
+ chatMessage: `出错了:${raw}`,
122
+ consoleHint: raw,
123
+ fatal: false,
124
+ };
125
+ }
126
+
127
+ /** 同一类问题只在终端刷一次完整指引,避免每条消息都刷屏 */
128
+ const announced = new Set<DiagnosisKind>();
129
+