agent-loft 0.0.0-stage → 0.2.7
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/LICENSE +21 -0
- package/README.md +110 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/account.ts +122 -0
- package/src/agents/claude/driver.ts +216 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +260 -0
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +487 -0
- package/src/agents/codex/account.ts +122 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +210 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +773 -0
- package/src/channels/feishu/bot.ts +669 -0
- package/src/channels/feishu/cards.ts +360 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +117 -0
- package/src/channels/feishu/index.ts +62 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +645 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/push.ts +218 -0
- package/src/channels/web/server.ts +446 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +137 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +104 -0
- package/src/core/config.ts +285 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/footers.ts +88 -0
- package/src/core/hub.ts +1152 -0
- package/src/core/interactions.ts +273 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +178 -0
- package/src/core/types.ts +213 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +43 -0
- package/src/main.ts +68 -0
- package/web/app.js +3731 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +12 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +15 -0
- package/web/style.css +2604 -0
- package/web/sw.js +76 -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,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agent-loft
|
|
2
2
|
|
|
3
|
-
|
|
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 Push,不用注册任何开发者账号;需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
|
|
17
|
+
- 收到推送时,主屏 / 桌面 PWA 图标上显示未读角标(iOS 16.4+、桌面 Chrome / Edge),打开页面自动清零。
|
|
18
|
+
- 切换工作目录可以在目录树里一层层点选;新对话发第一条消息前也能先选好目录。
|
|
19
|
+
- 语音输入:输入框旁的麦克风按钮,用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
|
|
20
|
+
- 新对话页:大标题里点目录名切换工作目录;输入框上方的条里选目录和 agent,右下角直接选模型和思考程度(弹出菜单,不用开弹窗)。会话中 agent 不切换(命令 `/agent` 仍可切),模型和思考程度随时可换。
|
|
21
|
+
- 输入框里显示上下文窗口占用(小圆环,悬停看已用 / 剩余 token)。
|
|
22
|
+
- 输入框的模型按钮直接显示实际的模型和思考程度(如「Opus 5.5 high」),和选项里的名字一致;列表里显示哪些模型在 设置 → Agent 里勾选。
|
|
23
|
+
- 每条回复下方显示用时、费用和完成时间,最后一条常驻,前面的悬停才出现;可以复制,也可以「从这里分叉」成一条新对话(只保留到这条回复,原对话不动)。
|
|
24
|
+
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行并带飞书图标;分叉出来的对话挂在主对话下面,用虚线连起来;分组可折叠,宽度可拖动,双击会话重命名,名字太长时悬停滚动显示。
|
|
25
|
+
- 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,悬停看重置时间);空闲时按设置的间隔自动刷新(默认 10 分钟),跑完一轮后也会刷新。
|
|
26
|
+
- **跨渠道同步**:飞书里的会话在 Web 上也能打开,接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
|
|
27
|
+
- **设置都在网页上**:工作目录、各 agent 的默认模型和思考程度、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、登录设备管理。
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g agent-loft # 或者在仓库里 npm install 后用 node bin/cli.js
|
|
33
|
+
agent-loft start # 前台运行,日志里会打印首次登录用的设置码
|
|
34
|
+
agent-loft service install # 注册为开机自启服务(systemd / launchd)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
打开 `http://<机器 IP>:8800`:第一次需要输入日志里的设置码,然后设一个登录密码。
|
|
38
|
+
|
|
39
|
+
### 从旧桥接器迁移
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
agent-loft import --dry-run # 先看会导入什么
|
|
43
|
+
systemctl --user disable --now feishu-claude-bridge feishu-codex-bridge # 同一个机器人不能被两个程序同时连
|
|
44
|
+
agent-loft import # 导入机器人凭据、白名单、配对、会话绑定、总是允许名单
|
|
45
|
+
agent-loft service install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
导入后,飞书群里原来的会话会接着聊。
|
|
49
|
+
|
|
50
|
+
## 命令
|
|
51
|
+
|
|
52
|
+
| 命令 | 作用 |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `agent-loft start` | 启动 |
|
|
55
|
+
| `agent-loft import [--only claude\|codex] [--disabled] [--dry-run]` | 从旧桥接器导入 |
|
|
56
|
+
| `agent-loft pair list \| approve <码> \| revoke <机器人id> <open_id>` | 配对管理(Web 设置页里也能做) |
|
|
57
|
+
| `agent-loft passwd [新密码]` | 重置 Web 登录密码,所有设备下线 |
|
|
58
|
+
| `agent-loft service install \| uninstall \| restart \| status` | 开机自启 |
|
|
59
|
+
| `agent-loft doctor` | 体检 |
|
|
60
|
+
|
|
61
|
+
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`
|
|
62
|
+
|
|
63
|
+
## 安全
|
|
64
|
+
|
|
65
|
+
这个服务能让 agent 在你的机器上执行命令,等于一个远程 shell。
|
|
66
|
+
|
|
67
|
+
- Web 登录密码用 scrypt 哈希保存。首次设置必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
|
|
68
|
+
- 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
|
|
69
|
+
- 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)访问,并把反代的 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。
|
|
70
|
+
- 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
|
|
71
|
+
- agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
|
|
72
|
+
|
|
73
|
+
## 结构
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
src/
|
|
77
|
+
core/ 与渠道、agent 都无关的中枢
|
|
78
|
+
hub.ts 会话调度:事件广播、命令、切 agent / 模型 / 目录
|
|
79
|
+
interactions.ts 审批与提问:一个请求,多个渠道都能处理
|
|
80
|
+
store.ts 会话持久化(每个 agent 的会话 id 各自保存)
|
|
81
|
+
config.ts 配置(Web 可改,改完热加载)
|
|
82
|
+
pairing.ts uploads.ts commands.ts …
|
|
83
|
+
agents/
|
|
84
|
+
claude/ Claude Agent SDK 驱动
|
|
85
|
+
codex/ codex app-server 驱动
|
|
86
|
+
channels/
|
|
87
|
+
feishu/ 飞书:一个 FeishuBot 对应一个机器人,支持多个
|
|
88
|
+
web/ HTTP + WebSocket + 鉴权
|
|
89
|
+
web/ 前端(原生 JS,不需要构建)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 接入新的消息渠道(比如 Telegram)
|
|
93
|
+
|
|
94
|
+
参照 `channels/feishu/bot.ts`,做三件事:
|
|
95
|
+
|
|
96
|
+
1. 收到消息 → `hub.ensure()` 建会话 → `parseCommand()`。是命令就调 `hub.command()`,再把 `CommandReply` 画出来;是聊天就调 `hub.send()`。
|
|
97
|
+
2. 订阅 `hub.on("event")`,挑出自己的会话(键前缀如 `telegram:<botId>:`),把 step / text / result / error 画出来。
|
|
98
|
+
3. 订阅 `interactions` 的 `created / updated / settled`,把授权卡和提问卡画成按钮。用户点击后调 `interactions.decide / select / submit / skip`。
|
|
99
|
+
|
|
100
|
+
### 接入新的 agent
|
|
101
|
+
|
|
102
|
+
实现 `core/types.ts` 里的 `AgentDriver`,然后在 `main.ts` 里 `hub.registerDriver()`。`AgentDriver` 包括开会话、发消息、中断、切模型、列历史、读历史,审批通过传入的 `InteractionHost` 发起。以后要接 ACP 协议的 agent(Gemini CLI、OpenCode 等),写一个通用的 ACP 驱动就行。
|
|
103
|
+
|
|
104
|
+
## 开发
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install
|
|
108
|
+
npm run dev # AGENT_LOFT_HOME=./.dev,改代码自动重启
|
|
109
|
+
npm test
|
|
110
|
+
npm run typecheck
|
|
111
|
+
```
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 统一入口:agent-loft <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 = `agent-loft —— 用飞书 / Web 驱动本机的 Claude Code 和 Codex
|
|
17
|
+
|
|
18
|
+
用法:
|
|
19
|
+
agent-loft start 启动(默认命令)
|
|
20
|
+
agent-loft import 从旧的 feishu-claude-bridge / feishu-codex-bridge 导入配置和会话
|
|
21
|
+
agent-loft pair ... 配对管理:list | approve <配对码> | revoke <机器人id> <open_id>
|
|
22
|
+
agent-loft passwd 重置 Web 登录密码
|
|
23
|
+
agent-loft service ... 开机自启:install | uninstall | restart | status
|
|
24
|
+
agent-loft doctor 体检:配置 / agent / 飞书连通性
|
|
25
|
+
|
|
26
|
+
配置目录:~/.agent-loft(可用 AGENT_LOFT_HOME 覆盖;已有旧的 ~/.im-agent 会继续沿用)
|
|
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.AGENT_LOFT_DEBUG || 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": "agent-loft",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.7",
|
|
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/agent-loft#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/qijieinnet/agent-loft/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/qijieinnet/agent-loft.git"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"agent-loft": "bin/cli.js",
|
|
30
|
+
"aloft": "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": "AGENT_LOFT_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,122 @@
|
|
|
1
|
+
// 账号额度:Claude 订阅的 5 小时 / 7 天用量窗口,和 Codex 那边的 account.ts 对齐。
|
|
2
|
+
//
|
|
3
|
+
// 数据来自 SDK 的 usage 控制请求 —— 也就是 Claude Code 里 `/usage` 背后的那份结构化数据,
|
|
4
|
+
// 通过探针 query 读取(见 probe.ts)。
|
|
5
|
+
//
|
|
6
|
+
// 注意:SDK 把它标成了 EXPERIMENTAL,方法名以后会改。改了的话这里只会读失败,
|
|
7
|
+
// /status 少几行而已,不影响别的。
|
|
8
|
+
import type { QuotaInfo } from "../../core/types.js";
|
|
9
|
+
import { cachedProbe, withProbe } from "./probe.js";
|
|
10
|
+
|
|
11
|
+
type Window = { utilization: number | null; resets_at: string | null } | null | undefined;
|
|
12
|
+
|
|
13
|
+
type LimitRow = {
|
|
14
|
+
kind: string;
|
|
15
|
+
group: string;
|
|
16
|
+
percent: number;
|
|
17
|
+
resets_at: string | null;
|
|
18
|
+
severity?: string;
|
|
19
|
+
scope?: { model?: { display_name: string } | null; surface?: { display_name: string } | null } | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ClaudeUsage = {
|
|
23
|
+
subscriptionType: string | null;
|
|
24
|
+
rows: { label: string; short: string; percent: number; resetsAt: string | null; severity?: string }[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function labelOf(row: LimitRow): string {
|
|
28
|
+
const scope = row.scope?.model?.display_name || row.scope?.surface?.display_name;
|
|
29
|
+
const base = row.kind === "session" ? "5 小时" : row.group === "weekly" ? "7 天" : row.kind;
|
|
30
|
+
return scope ? `${base}(${scope})` : base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 侧栏紧凑显示用:5h / 7d,带范围的加上范围名 */
|
|
34
|
+
function shortOf(row: LimitRow): string {
|
|
35
|
+
const scope = row.scope?.model?.display_name || row.scope?.surface?.display_name;
|
|
36
|
+
const base = row.kind === "session" ? "5h" : row.group === "weekly" ? "7d" : row.kind;
|
|
37
|
+
return scope ? `${base} ${scope}` : base;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalize(res: {
|
|
41
|
+
subscription_type: string | null;
|
|
42
|
+
rate_limits_available: boolean;
|
|
43
|
+
rate_limits: Record<string, unknown> | null;
|
|
44
|
+
}): ClaudeUsage | null {
|
|
45
|
+
if (!res.rate_limits_available || !res.rate_limits) return null;
|
|
46
|
+
const rl = res.rate_limits;
|
|
47
|
+
const limits = rl.limits as LimitRow[] | null | undefined;
|
|
48
|
+
let rows: ClaudeUsage["rows"];
|
|
49
|
+
if (Array.isArray(limits)) {
|
|
50
|
+
// 新版服务端直接给出要显示哪些行、什么顺序,照单全收
|
|
51
|
+
rows = limits.map((r) => ({
|
|
52
|
+
label: labelOf(r),
|
|
53
|
+
short: shortOf(r),
|
|
54
|
+
percent: r.percent,
|
|
55
|
+
resetsAt: r.resets_at,
|
|
56
|
+
...(r.severity ? { severity: r.severity } : {}),
|
|
57
|
+
}));
|
|
58
|
+
} else {
|
|
59
|
+
// 老格式兜底:只认两个主窗口
|
|
60
|
+
rows = [];
|
|
61
|
+
for (const [key, label, short] of [
|
|
62
|
+
["five_hour", "5 小时", "5h"],
|
|
63
|
+
["seven_day", "7 天", "7d"],
|
|
64
|
+
] as const) {
|
|
65
|
+
const w = rl[key] as Window;
|
|
66
|
+
if (w && w.utilization != null) rows.push({ label, short, percent: w.utilization, resetsAt: w.resets_at });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { subscriptionType: res.subscription_type, rows };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function fetchUsage(): Promise<ClaudeUsage | null> {
|
|
73
|
+
const res = await withProbe((q) =>
|
|
74
|
+
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }),
|
|
75
|
+
);
|
|
76
|
+
return normalize(res as Parameters<typeof normalize>[0]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 同 Codex:缓存 60 秒,挡住连敲几次 /status,又不至于数字明显过期。
|
|
81
|
+
* 读不到额度不该让整条 /status 失败 —— 它只是其中几行,所以失败时给 null。
|
|
82
|
+
*/
|
|
83
|
+
export const readUsage = cachedProbe(60_000, "额度", fetchUsage, null);
|
|
84
|
+
|
|
85
|
+
/** 距离重置还有多久,和 Codex 那边同一个说法 */
|
|
86
|
+
function untilReset(resetsAt: string | null, now = Date.now()): string {
|
|
87
|
+
if (!resetsAt) return "";
|
|
88
|
+
const ms = Date.parse(resetsAt);
|
|
89
|
+
if (Number.isNaN(ms)) return "";
|
|
90
|
+
const seconds = Math.floor((ms - now) / 1000);
|
|
91
|
+
if (seconds <= 0) return "即将重置";
|
|
92
|
+
const days = Math.floor(seconds / 86400);
|
|
93
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
94
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
95
|
+
if (days > 0) return `${days} 天 ${hours} 小时后重置`;
|
|
96
|
+
if (hours > 0) return `${hours} 小时 ${minutes} 分后重置`;
|
|
97
|
+
return `${minutes} 分钟后重置`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 侧栏用的结构化额度 */
|
|
101
|
+
export function toQuota(usage: ClaudeUsage | null): QuotaInfo | null {
|
|
102
|
+
if (!usage || usage.rows.length === 0) return null;
|
|
103
|
+
return {
|
|
104
|
+
...(usage.subscriptionType ? { plan: usage.subscriptionType } : {}),
|
|
105
|
+
windows: usage.rows.map((r) => {
|
|
106
|
+
const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN;
|
|
107
|
+
return { label: r.label, short: r.short, percent: r.percent, ...(Number.isNaN(at) ? {} : { resetsAt: at }) };
|
|
108
|
+
}),
|
|
109
|
+
...(usage.rows.some((r) => r.percent >= 100) ? { reached: true } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 渲染成 /status 里的几行;没有可显示的就返回空数组(API key / Bedrock 之类没有订阅额度) */
|
|
114
|
+
export function describeUsage(usage: ClaudeUsage | null, now = Date.now()): string[] {
|
|
115
|
+
if (!usage || usage.rows.length === 0) return [];
|
|
116
|
+
const rows = usage.rows.map((r) => {
|
|
117
|
+
const reset = untilReset(r.resetsAt, now);
|
|
118
|
+
return ` ${r.label} 已用 ${Math.round(r.percent)}%${reset ? ` ${reset}` : ""}`;
|
|
119
|
+
});
|
|
120
|
+
if (usage.rows.some((r) => r.percent >= 100)) rows.push(" ⚠️ 已达上限,等窗口重置后才能继续");
|
|
121
|
+
return [`额度${usage.subscriptionType ? `(${usage.subscriptionType})` : ""}`, ...rows];
|
|
122
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Claude Code 驱动:官方 Claude Agent SDK。
|
|
2
|
+
//
|
|
3
|
+
// 会话列表 / 历史全部走 SDK 自带的 listSessions / getSessionInfo / getSessionMessages,
|
|
4
|
+
// 不再自己去扒 ~/.claude/projects 下的 jsonl —— 那是 CLI 的内部约定,SDK 才是公开接口。
|
|
5
|
+
import {
|
|
6
|
+
forkSession,
|
|
7
|
+
getSessionInfo,
|
|
8
|
+
getSessionMessages,
|
|
9
|
+
listSessions,
|
|
10
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import type { AgentCommand } from "../../core/command-catalog.js";
|
|
13
|
+
import { config } from "../../core/config.js";
|
|
14
|
+
import type {
|
|
15
|
+
AgentDriver,
|
|
16
|
+
AgentEvents,
|
|
17
|
+
AgentSession,
|
|
18
|
+
HistoryMessage,
|
|
19
|
+
InteractionHost,
|
|
20
|
+
ModelInfo,
|
|
21
|
+
QuotaInfo,
|
|
22
|
+
RuntimeInfo,
|
|
23
|
+
SessionOptions,
|
|
24
|
+
SessionSummary,
|
|
25
|
+
} from "../../core/types.js";
|
|
26
|
+
import { describeUsage, readUsage, toQuota } from "./account.js";
|
|
27
|
+
import { cachedProbe, withProbe } from "./probe.js";
|
|
28
|
+
import { diagnose } from "./errors.js";
|
|
29
|
+
import { ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
|
|
30
|
+
|
|
31
|
+
/** 模型列表几乎不变,缓存 10 分钟;读失败抛给设置页,让它退回手填 */
|
|
32
|
+
const readCatalog = cachedProbe(10 * 60_000, "模型列表", () => withProbe(async (q) => toModelInfo(await q.supportedModels())));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 斜杠命令(skills、/compact 这些)。项目目录里可能有自己的命令,所以按 cwd 分开缓存;
|
|
36
|
+
* 装了新 skill 最多 5 分钟后出现。读不到就只显示 agent-loft 自己的命令。
|
|
37
|
+
*/
|
|
38
|
+
const commandReaders = new Map<string, () => Promise<AgentCommand[]>>();
|
|
39
|
+
function readCommands(cwd: string): Promise<AgentCommand[]> {
|
|
40
|
+
let reader = commandReaders.get(cwd);
|
|
41
|
+
if (!reader) {
|
|
42
|
+
// 目录数量一般很少;万一很多,丢掉最早的,别无限涨
|
|
43
|
+
if (commandReaders.size >= 50) commandReaders.delete(commandReaders.keys().next().value!);
|
|
44
|
+
reader = cachedProbe(
|
|
45
|
+
5 * 60_000,
|
|
46
|
+
"Claude 命令列表",
|
|
47
|
+
() =>
|
|
48
|
+
withProbe(
|
|
49
|
+
async (q) =>
|
|
50
|
+
(await q.supportedCommands()).map((c) => ({
|
|
51
|
+
name: c.name,
|
|
52
|
+
description: c.description,
|
|
53
|
+
...(c.argumentHint ? { args: c.argumentHint } : {}),
|
|
54
|
+
})),
|
|
55
|
+
cwd,
|
|
56
|
+
),
|
|
57
|
+
[] as AgentCommand[],
|
|
58
|
+
);
|
|
59
|
+
commandReaders.set(cwd, reader);
|
|
60
|
+
}
|
|
61
|
+
return reader();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** sessionId 会被拼进路径,先验形,防 /resume ../../x 越界 */
|
|
65
|
+
const SESSION_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
66
|
+
|
|
67
|
+
function titleOf(info: { customTitle?: string; summary?: string; firstPrompt?: string }): string {
|
|
68
|
+
const raw = (info.customTitle || info.summary || info.firstPrompt || "").replace(/\s+/g, " ").trim();
|
|
69
|
+
if (!raw) return "(无标题)";
|
|
70
|
+
return raw.length > 60 ? `${raw.slice(0, 60)}…` : raw;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Block = { type?: string; text?: string; name?: string; input?: unknown; thinking?: string };
|
|
74
|
+
|
|
75
|
+
function blocksOf(message: unknown): Block[] {
|
|
76
|
+
const content = (message as { content?: unknown } | null)?.content;
|
|
77
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
78
|
+
return Array.isArray(content) ? (content as Block[]) : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 用户消息里我们自己拼进去的附件说明,历史里只留一行简述 */
|
|
82
|
+
function cleanUserText(text: string): string {
|
|
83
|
+
const cut = [text.indexOf("[用户通过飞书发来了以下附件"), text.indexOf("[用户发来了以下附件")]
|
|
84
|
+
.filter((i) => i >= 0)
|
|
85
|
+
.sort((a, b) => a - b)[0];
|
|
86
|
+
if (cut === undefined) return text;
|
|
87
|
+
const files = (text.slice(cut).match(/^- /gm) ?? []).length;
|
|
88
|
+
return `${text.slice(0, cut).trim()}${files ? `\n\n📎 ${files} 个附件` : ""}`.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class ClaudeDriver implements AgentDriver {
|
|
92
|
+
readonly id = "claude";
|
|
93
|
+
readonly label = "Claude Code";
|
|
94
|
+
readonly capabilities = { effort: true, fork: true, cost: true, streaming: true };
|
|
95
|
+
|
|
96
|
+
async start(): Promise<void> {}
|
|
97
|
+
async stop(): Promise<void> {}
|
|
98
|
+
|
|
99
|
+
createSession(opts: SessionOptions, events: AgentEvents, host: InteractionHost): AgentSession {
|
|
100
|
+
return new ClaudeSession(opts, events, host);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async listSessions(cwd: string | undefined, limit = 10): Promise<SessionSummary[]> {
|
|
104
|
+
try {
|
|
105
|
+
const sessions = await listSessions({ ...(cwd ? { dir: cwd } : {}), limit });
|
|
106
|
+
return sessions.map((s) => ({
|
|
107
|
+
id: s.sessionId,
|
|
108
|
+
title: titleOf(s),
|
|
109
|
+
...(s.cwd ? { cwd: s.cwd } : {}),
|
|
110
|
+
updatedAt: s.lastModified,
|
|
111
|
+
}));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error("[claude] 读取会话列表失败:", err);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async sessionExists(id: string, cwd: string): Promise<boolean> {
|
|
119
|
+
if (!SESSION_ID.test(id)) return false;
|
|
120
|
+
try {
|
|
121
|
+
return !!(await getSessionInfo(id, { dir: cwd }));
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 把会话记录整理成「一问一答」:用户一条,助手把这一轮所有文本拼成一条,
|
|
129
|
+
* 中间的工具调用作为过程摘要挂在助手那条上。工具结果(type=user 的 tool_result)
|
|
130
|
+
* 和子 agent 的消息不算对话。
|
|
131
|
+
*/
|
|
132
|
+
async readHistory(id: string, cwd: string): Promise<HistoryMessage[]> {
|
|
133
|
+
if (!SESSION_ID.test(id)) return [];
|
|
134
|
+
const raw = await getSessionMessages(id, { dir: cwd });
|
|
135
|
+
const out: HistoryMessage[] = [];
|
|
136
|
+
let assistant: HistoryMessage | null = null;
|
|
137
|
+
const flush = () => {
|
|
138
|
+
if (assistant && (assistant.text.trim() || assistant.steps?.length)) out.push(assistant);
|
|
139
|
+
assistant = null;
|
|
140
|
+
};
|
|
141
|
+
for (const msg of raw) {
|
|
142
|
+
if (msg.parent_tool_use_id) continue;
|
|
143
|
+
const blocks = blocksOf(msg.message);
|
|
144
|
+
// 运行时带着 timestamp(ISO 字符串),类型声明里没有
|
|
145
|
+
const ts = Date.parse((msg as { timestamp?: string }).timestamp ?? "");
|
|
146
|
+
if (msg.type === "user") {
|
|
147
|
+
const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
|
|
148
|
+
if (texts.length === 0) continue; // 纯 tool_result
|
|
149
|
+
const text = cleanUserText(texts.join("\n"));
|
|
150
|
+
if (!text || text.startsWith("<command-") || text.startsWith("<local-command")) continue;
|
|
151
|
+
flush();
|
|
152
|
+
out.push({ role: "user", text, ...(Number.isFinite(ts) ? { ts } : {}) });
|
|
153
|
+
} else if (msg.type === "assistant") {
|
|
154
|
+
assistant ??= { role: "assistant", text: "", steps: [] };
|
|
155
|
+
// 这一轮最后一条助手消息:分叉到这里为止,时间也取它的
|
|
156
|
+
assistant.anchor = msg.uuid;
|
|
157
|
+
if (Number.isFinite(ts)) assistant.ts = ts;
|
|
158
|
+
for (const b of blocks) {
|
|
159
|
+
if (b.type === "text" && b.text?.trim()) {
|
|
160
|
+
assistant.text += (assistant.text ? "\n\n" : "") + b.text;
|
|
161
|
+
} else if (b.type === "tool_use" && b.name) {
|
|
162
|
+
assistant.steps!.push(`🔧 ${describeToolUse(b.name, b.input)}`);
|
|
163
|
+
} else if (b.type === "thinking" && b.thinking) {
|
|
164
|
+
const t = summarize(b.thinking);
|
|
165
|
+
if (t) assistant.steps!.push(`💭 ${t}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
flush();
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async forkAt(id: string, cwd: string, anchor?: string): Promise<string> {
|
|
175
|
+
if (!SESSION_ID.test(id)) throw new Error("会话 id 不对");
|
|
176
|
+
if (anchor) {
|
|
177
|
+
// 先确认这条回复在记录里:SDK 遇到不认识的 id 不一定报错,可能整条复制
|
|
178
|
+
const raw = await getSessionMessages(id, { dir: cwd });
|
|
179
|
+
if (!raw.some((m) => m.uuid === anchor)) throw new Error("在会话记录里找不到这条回复");
|
|
180
|
+
}
|
|
181
|
+
const res = await forkSession(id, { dir: cwd, ...(anchor ? { upToMessageId: anchor } : {}) });
|
|
182
|
+
return res.sessionId;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async listModels(session: AgentSession): Promise<ModelInfo[]> {
|
|
186
|
+
return (session as ClaudeSession).listModels();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
catalog(): Promise<ModelInfo[]> {
|
|
190
|
+
return readCatalog();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
commands(cwd?: string): Promise<AgentCommand[]> {
|
|
194
|
+
return readCommands(cwd ?? os.homedir());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async defaults(): Promise<RuntimeInfo> {
|
|
198
|
+
const c = config().agents.claude;
|
|
199
|
+
return {
|
|
200
|
+
...(c.defaultModel ? { model: c.defaultModel } : {}),
|
|
201
|
+
...(c.defaultEffort ? { effort: c.defaultEffort } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async statusLines(): Promise<string[]> {
|
|
206
|
+
return describeUsage(await readUsage());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async quota(): Promise<QuotaInfo | null> {
|
|
210
|
+
return toQuota(await readUsage());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
diagnose(err: unknown) {
|
|
214
|
+
return diagnose(err);
|
|
215
|
+
}
|
|
216
|
+
}
|