agent-loft 0.2.31 → 0.2.33
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/README.md +14 -5
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +90 -8
- package/src/agents/claude/session.ts +81 -12
- package/src/agents/codex/session.ts +70 -13
- package/src/channels/feishu/bot.ts +2 -2
- package/src/channels/web/api.ts +72 -2
- package/src/channels/web/conv-pages.ts +68 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +20 -3
- package/src/channels/web/skills-api.ts +225 -0
- package/src/core/chat-mode.ts +3 -3
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +168 -18
- package/src/core/personal.ts +3 -1
- package/src/core/skill-runtime.ts +359 -0
- package/src/core/skills-bridge.ts +288 -0
- package/src/core/skills.ts +676 -0
- package/src/core/tasks-bridge.ts +5 -1
- package/src/core/tasks-mcp.ts +11 -0
- package/src/core/types.ts +44 -3
- package/src/core/usage.ts +70 -0
- package/src/core/zip.ts +98 -0
- package/src/main.ts +4 -0
- package/src/mcp/tasks-server.ts +125 -4
- package/web/css/composer.css +0 -25
- package/web/css/messages.css +169 -0
- package/web/css/sidebar.css +5 -5
- package/web/css/skills.css +385 -0
- package/web/css/topbar.css +131 -0
- package/web/index.html +1 -0
- package/web/lib/dom.js +3 -1
- package/web/lib/markdown.js +84 -14
- package/web/lib/ws.js +49 -5
- package/web/settings/index.js +7 -3
- package/web/settings/skills.js +617 -0
- package/web/state.js +4 -3
- package/web/views/composer.js +16 -3
- package/web/views/interaction.js +12 -4
- package/web/views/layout.js +22 -15
- package/web/views/mention.js +193 -0
- package/web/views/messages.js +239 -81
- package/web/views/msg-nav.js +120 -0
- package/web/views/quota.js +1 -0
- package/web/views/sidebar.js +114 -16
- package/web/views/tasks.js +11 -2
- package/web/views/topbar.js +168 -40
package/README.md
CHANGED
|
@@ -49,16 +49,21 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
49
49
|
- 滚动和 ChatGPT 一样:发出的问题顶到最上面、下面留白,回复长到屏幕底部才跟着往下滚;回复过程中自己滚动了就不再自动滚,下次发消息再恢复。
|
|
50
50
|
- 定时发送:右上角「更多」→「定时发送」,默认排在 5 小时额度重置后 5 分钟(弹窗里提示 5 小时 / 7 天额度的重置时间),由服务端到点发出,关掉页面也会发。适合额度用完、要等刷新才能接着干活的时候。
|
|
51
51
|
- 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
|
|
52
|
-
-
|
|
52
|
+
- 回复过程中接着发的消息排队,标着「排队中」,上一条回复完自动接着处理;点「打断并发送」停掉正在进行的回复、马上处理这条;点停止则连排着的一起取消(标「没有发出」)。
|
|
53
|
+
- 太长的消息先收起,点「展开显示」看全文;对话里有 3 条以上消息时,右边有一列快速定位,鼠标移上去列出每条消息的开头,点一下跳过去。
|
|
54
|
+
- 侧栏按页加载:「最近」先显示 10 条、每个项目目录先显示 5 条,点「展开显示」每次再拉 10 条。
|
|
55
|
+
- 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置);关了额度查询就整块不显示。
|
|
56
|
+
- 右上角「更多」最下面显示当前会话累计消耗的 token 分布(输入 / 缓存写入 / 缓存读取 / 输出,Claude 还有按 API 价格估算的费用)。设置也在「更多」里。在桌面端里,这个菜单并进桌面端顶栏的「更多」,排在最上面。
|
|
53
57
|
- 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
|
|
54
58
|
- **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
|
|
55
59
|
- **跨渠道同步**:飞书里的会话在 Web 上也能打开接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
|
|
56
60
|
- **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
|
|
57
61
|
- **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
|
|
58
62
|
- **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
|
|
59
|
-
- **聊天模式 /
|
|
63
|
+
- **聊天模式 / 工作模式**:右上角「更多」里的「聊天模式」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
|
|
60
64
|
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
|
|
61
65
|
- **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
|
|
66
|
+
- **技能(skills)**:设置 → 技能。技能就是 Claude Code / Codex 用的那种 SKILL.md(可以带脚本和资料),由 agent-loft 按人管理,两个 agent 都能用。**来源**:在页面上写、上传技能包(`.zip` / `.skill` / 单个 SKILL.md / 整个文件夹),或者在对话里让 agent「把刚才的做法存成技能」(agent 用 `create_skill` / `update_skill`,先弹授权卡)。每人管自己的,不做分享和技能广场(以前从别人那装过的照常能用,可以卸载)。**使用**:工作模式下 agent 遇到对得上的事自己用;在输入框或定时任务的「要做的事」里打 `@` 选技能,点名这次用哪个(聊天模式下会把技能内容直接附上);飞书里发 `/skills` 看能 @ 哪些。**变量**:技能可以声明要用的环境变量(接口地址、密钥),每个人填自己的值,只注入自己的会话;密钥再写上「只允许发往」的地址就成了**受保护的密钥**——不进环境变量,agent 看不到值,只能用 `skill_request` 工具让 agent-loft 代发请求(在地址、请求头、请求体里写 `{{变量名}}`),只发往你确认过的地址、不跟随跳转、响应里出现的值会被抹掉;作者后来改了地址,要你再确认一次才放行。**机器上原有的技能**(`~/.claude/skills`、`~/.agents/skills`、插件市场和 claude.ai 同步的)只给管理员:成员的会话里看不到、调不了(Claude 用技能白名单,`/技能名` 也会被拦下;Codex 关掉它自己的技能清单),管理员在「设置 → 技能 → 本机技能」里能看到。
|
|
62
67
|
- **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
|
|
63
68
|
|
|
64
69
|
## 安装
|
|
@@ -129,7 +134,7 @@ agent-loft service install
|
|
|
129
134
|
|
|
130
135
|
`aloft` 是 `agent-loft` 的简写。
|
|
131
136
|
|
|
132
|
-
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/autoedit [on|off]` `/approve <id> allow-once|allow-always|allow-session|deny` `/chat` `/work` `/help`;只在飞书里有的:`/web` `/detach` `/bind <绑定码>`
|
|
137
|
+
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/autoedit [on|off]` `/approve <id> allow-once|allow-always|allow-session|deny` `/chat` `/work` `/skills` `/help`;只在飞书里有的:`/web` `/detach` `/bind <绑定码>`
|
|
133
138
|
|
|
134
139
|
自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
|
|
135
140
|
|
|
@@ -144,6 +149,7 @@ agent-loft service install
|
|
|
144
149
|
- 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
|
|
145
150
|
- 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
|
|
146
151
|
- agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
|
|
152
|
+
- 技能存在 `data/skills/`:技能本体在 `files/<技能 id>/`,每个人装了哪些、变量的值在 `users/<用户 id>.json`(0600)。普通变量注入到会话环境里,agent 能读到;受保护的密钥 agent 看不到值,但和多用户一样不是安全边界——工作模式下 agent 能直接读这些文件。别人分享的技能会在你的会话里照着执行,装之前在技能页点开看一眼它要做什么、要哪些变量。
|
|
147
153
|
- 记忆存在 `data/memory/<用户 id>.json`,聊天记录索引在 `data/history/<用户 id>.jsonl`。记给谁由 agent-loft 在拉起记忆工具时定死(工具参数里没有用户),模型想写别人的记忆也写不了;但和多用户一样不是安全边界:工作模式下 agent 能读到这些文件。聊天模式没有读写本机文件的工具,网页里的提示注入也骗不走本机文件。
|
|
148
154
|
|
|
149
155
|
## 结构
|
|
@@ -163,11 +169,14 @@ src/
|
|
|
163
169
|
chat-mode.ts 聊天模式告诉 agent 的话(Claude 换系统提示,Codex 附在消息后面)
|
|
164
170
|
tasks.ts 定时任务的存储(按人隔离);task-schedule.ts 频率和 cron;task-prompt.ts 每次运行附给 agent 的说明和静默暗号
|
|
165
171
|
task-runner.ts 定时任务的调度:到点核对主人和对话、送进对话、记下结果
|
|
166
|
-
tasks-bridge.ts 定时任务工具在主进程这头:核对签名、弹授权卡、增删改(tasks-mcp.ts
|
|
172
|
+
tasks-bridge.ts 定时任务工具在主进程这头:核对签名、弹授权卡、增删改(tasks-mcp.ts 定怎么拉起);技能工具也走这条通道
|
|
173
|
+
skills.ts 技能的存储:可见性、安装、停用、变量和受保护密钥确认过的地址;zip.ts 解技能包
|
|
174
|
+
skill-runtime.ts 技能怎么交给 agent:Claude 的插件目录和成员白名单、Codex 的清单、@ 技能、机器上原有的技能
|
|
175
|
+
skills-bridge.ts 技能工具在主进程这头:建 / 改技能(弹授权卡)、skill_request 代发带密钥的请求
|
|
167
176
|
pairing.ts uploads.ts commands.ts …
|
|
168
177
|
mcp/
|
|
169
178
|
memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
|
|
170
|
-
tasks-server.ts 定时任务工具的 stdio MCP 服务:只传话,经 unix socket
|
|
179
|
+
tasks-server.ts 定时任务工具的 stdio MCP 服务:只传话,经 unix socket 转给主进程;AGENT_LOFT_TOOLSET=skills 时是技能工具
|
|
171
180
|
agents/
|
|
172
181
|
claude/ Claude Agent SDK 驱动
|
|
173
182
|
codex/ codex app-server 驱动
|
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
HistoryMessage,
|
|
24
24
|
InteractionHost,
|
|
25
25
|
ModelInfo,
|
|
26
|
+
QuestionSpec,
|
|
26
27
|
QuotaInfo,
|
|
27
28
|
RuntimeInfo,
|
|
28
29
|
SessionOptions,
|
|
@@ -31,7 +32,8 @@ import type {
|
|
|
31
32
|
import { describeUsage, readUsage, toQuota } from "./account.js";
|
|
32
33
|
import { cachedProbe, withProbe } from "./probe.js";
|
|
33
34
|
import { diagnose } from "./errors.js";
|
|
34
|
-
import { ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
|
|
35
|
+
import { claudeBuiltins, ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
|
|
36
|
+
import { parseQuestions } from "./permissions.js";
|
|
35
37
|
|
|
36
38
|
/** 模型列表几乎不变,缓存 10 分钟;读失败抛给设置页,让它退回手填 */
|
|
37
39
|
const readCatalog = cachedProbe(10 * 60_000, "模型列表", () => withProbe(async (q) => toModelInfo(await q.supportedModels())));
|
|
@@ -52,11 +54,15 @@ function readCommands(cwd: string): Promise<AgentCommand[]> {
|
|
|
52
54
|
() =>
|
|
53
55
|
withProbe(
|
|
54
56
|
async (q) =>
|
|
55
|
-
(await q.supportedCommands()).map((c) =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
(await q.supportedCommands()).map((c) => {
|
|
58
|
+
if (c.builtin) claudeBuiltins.add(c.name);
|
|
59
|
+
return {
|
|
60
|
+
name: c.name,
|
|
61
|
+
description: c.description,
|
|
62
|
+
...(c.argumentHint ? { args: c.argumentHint } : {}),
|
|
63
|
+
...(c.builtin ? { builtin: true } : {}),
|
|
64
|
+
};
|
|
65
|
+
}),
|
|
60
66
|
cwd,
|
|
61
67
|
),
|
|
62
68
|
[] as AgentCommand[],
|
|
@@ -75,7 +81,55 @@ function titleOf(info: { customTitle?: string; summary?: string; firstPrompt?: s
|
|
|
75
81
|
return raw.length > 60 ? `${raw.slice(0, 60)}…` : raw;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
type Block = {
|
|
84
|
+
type Block = {
|
|
85
|
+
type?: string;
|
|
86
|
+
text?: string;
|
|
87
|
+
name?: string;
|
|
88
|
+
input?: unknown;
|
|
89
|
+
thinking?: string;
|
|
90
|
+
id?: string;
|
|
91
|
+
tool_use_id?: string;
|
|
92
|
+
content?: unknown;
|
|
93
|
+
is_error?: boolean;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** tool_result 的内容:字符串,或者一组 text 块 */
|
|
97
|
+
function resultText(content: unknown): string {
|
|
98
|
+
if (typeof content === "string") return content;
|
|
99
|
+
if (!Array.isArray(content)) return "";
|
|
100
|
+
return content.map((b) => (typeof b?.text === "string" ? b.text : "")).join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 从 AskUserQuestion 的工具结果里取回答。Claude Code 写的是
|
|
105
|
+
* Your questions have been answered: "问题1"="答案1", "问题2"="答案2". You can now continue…
|
|
106
|
+
* 问题原文已知(工具参数里有),按顺序切开,答案里有引号、逗号也不怕。多选的答案是「a, b」
|
|
107
|
+
*/
|
|
108
|
+
export function parseQuestionAnswers(text: string, questions: QuestionSpec[]): { selections: number[][]; freeText: Record<number, string> } | null {
|
|
109
|
+
const body = text.replace(/^[\s\S]*?answered:\s*/, "").replace(/\.\s*You can now continue[\s\S]*$/, "");
|
|
110
|
+
const selections: number[][] = questions.map(() => []);
|
|
111
|
+
const freeText: Record<number, string> = {};
|
|
112
|
+
let found = false;
|
|
113
|
+
questions.forEach((q, i) => {
|
|
114
|
+
const head = `"${q.question}"="`;
|
|
115
|
+
const start = body.indexOf(head);
|
|
116
|
+
if (start < 0) return;
|
|
117
|
+
const from = start + head.length;
|
|
118
|
+
const nextQ = questions[i + 1];
|
|
119
|
+
const next = nextQ ? body.indexOf(`", "${nextQ.question}"="`, from) : -1;
|
|
120
|
+
const answer = body.slice(from, next >= 0 ? next : body.lastIndexOf('"')).trim();
|
|
121
|
+
if (!answer) return;
|
|
122
|
+
found = true;
|
|
123
|
+
const index = (label: string) => q.options.findIndex((o) => o.label === label);
|
|
124
|
+
// 整句就是一个选项(选项文字里本身带逗号)→ 按「, 」拆开都是选项(多选)→ 都不是就是自由作答
|
|
125
|
+
const whole = index(answer);
|
|
126
|
+
const parts = answer.split(", ").map(index);
|
|
127
|
+
if (whole >= 0) selections[i] = [whole];
|
|
128
|
+
else if (parts.every((n) => n >= 0)) selections[i] = parts;
|
|
129
|
+
else freeText[i] = answer;
|
|
130
|
+
});
|
|
131
|
+
return found ? { selections, freeText } : null;
|
|
132
|
+
}
|
|
79
133
|
|
|
80
134
|
function blocksOf(message: unknown): Block[] {
|
|
81
135
|
const content = (message as { content?: unknown } | null)?.content;
|
|
@@ -131,6 +185,8 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
131
185
|
* 把会话记录整理成「一问一答」:用户一条,助手把这一轮所有文本拼成一条,
|
|
132
186
|
* 中间的工具调用作为过程摘要挂在助手那条上。工具结果(type=user 的 tool_result)
|
|
133
187
|
* 和子 agent 的消息不算对话。
|
|
188
|
+
* 例外是 AskUserQuestion:在那里把这一轮切开,中间放一条 question(问了什么、你怎么答的),
|
|
189
|
+
* 和实时看到的一样;还没答的不放(它还挂着,页面从待处理里拿)。
|
|
134
190
|
*/
|
|
135
191
|
async readHistory(id: string, cwd: string): Promise<HistoryMessage[]> {
|
|
136
192
|
if (!SESSION_ID.test(id)) return [];
|
|
@@ -141,12 +197,22 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
141
197
|
if (assistant && (assistant.text.trim() || assistant.steps?.length)) out.push(assistant);
|
|
142
198
|
assistant = null;
|
|
143
199
|
};
|
|
200
|
+
/** 问了、还没对上回答的:tool_use id → 那条 question */
|
|
201
|
+
const asked = new Map<string, HistoryMessage>();
|
|
144
202
|
for (const msg of raw) {
|
|
145
203
|
if (msg.parent_tool_use_id) continue;
|
|
146
204
|
const blocks = blocksOf(msg.message);
|
|
147
205
|
// 运行时带着 timestamp(ISO 字符串),类型声明里没有
|
|
148
206
|
const ts = Date.parse((msg as { timestamp?: string }).timestamp ?? "");
|
|
149
207
|
if (msg.type === "user") {
|
|
208
|
+
for (const b of blocks) {
|
|
209
|
+
const q = b.type === "tool_result" && b.tool_use_id ? asked.get(b.tool_use_id) : undefined;
|
|
210
|
+
if (!q?.question) continue;
|
|
211
|
+
asked.delete(b.tool_use_id!);
|
|
212
|
+
const answers = b.is_error ? null : parseQuestionAnswers(resultText(b.content), q.question.questions);
|
|
213
|
+
if (answers) Object.assign(q.question, answers);
|
|
214
|
+
else q.question.skipped = true;
|
|
215
|
+
}
|
|
150
216
|
// Claude Code 自己塞进记录的「用户消息」不是人发的:后台命令跑完的 <task-notification> 等,
|
|
151
217
|
// 记录里 origin.kind 不是 human。老记录可能没有 origin,再按正文开头兜底
|
|
152
218
|
const origin = (msg as { origin?: { kind?: string } }).origin;
|
|
@@ -180,6 +246,21 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
180
246
|
for (const b of blocks) {
|
|
181
247
|
if (b.type === "text" && b.text?.trim()) {
|
|
182
248
|
assistant.text += (assistant.text ? "\n\n" : "") + b.text;
|
|
249
|
+
} else if (b.type === "tool_use" && b.name === "AskUserQuestion" && b.id) {
|
|
250
|
+
const questions = parseQuestions((b.input ?? {}) as Record<string, unknown>);
|
|
251
|
+
if (!questions) continue;
|
|
252
|
+
// 前半段封口,提问卡放中间,之后的输出另起一条
|
|
253
|
+
assistant.segment = true;
|
|
254
|
+
flush();
|
|
255
|
+
const q: HistoryMessage = {
|
|
256
|
+
role: "question",
|
|
257
|
+
text: "",
|
|
258
|
+
question: { id: `hist:${b.id}`, title: "Claude 想确认一下", questions, selections: questions.map(() => []), freeText: {} },
|
|
259
|
+
...(Number.isFinite(ts) ? { ts } : {}),
|
|
260
|
+
};
|
|
261
|
+
asked.set(b.id, q);
|
|
262
|
+
out.push(q);
|
|
263
|
+
assistant = { role: "assistant", text: "", steps: [] };
|
|
183
264
|
} else if (b.type === "tool_use" && b.name) {
|
|
184
265
|
assistant.steps!.push(`🔧 ${describeToolUse(b.name, b.input)}`);
|
|
185
266
|
} else if (b.type === "thinking" && b.thinking) {
|
|
@@ -190,7 +271,8 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
190
271
|
}
|
|
191
272
|
}
|
|
192
273
|
flush();
|
|
193
|
-
|
|
274
|
+
// 还没答的提问不放:它还挂着,页面从待处理里拿;进程挂了没答上的,也就不显示了
|
|
275
|
+
return out.filter((m) => m.role !== "question" || !asked.has(m.question!.id.slice(5)));
|
|
194
276
|
}
|
|
195
277
|
|
|
196
278
|
async forkAt(id: string, cwd: string, anchor?: string): Promise<string> {
|
|
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
|
|
7
7
|
import {
|
|
8
8
|
query,
|
|
9
9
|
type McpServerConfig,
|
|
10
|
+
type ModelUsage,
|
|
10
11
|
type Options,
|
|
11
12
|
type PermissionResult,
|
|
12
13
|
type Query,
|
|
@@ -16,7 +17,8 @@ import {
|
|
|
16
17
|
import { agentEnvironment } from "../../core/agent-env.js";
|
|
17
18
|
import { chatSystemPrompt } from "../../core/chat-mode.js";
|
|
18
19
|
import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
|
|
19
|
-
import { TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
20
|
+
import { SKILLS_MCP_NAME, skillsMcpSpawn, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
21
|
+
import { PLUGIN_NAME, projectCommandNames } from "../../core/skill-runtime.js";
|
|
20
22
|
import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
|
|
21
23
|
import type {
|
|
22
24
|
AgentEvents,
|
|
@@ -25,7 +27,9 @@ import type {
|
|
|
25
27
|
ModelInfo,
|
|
26
28
|
RuntimeInfo,
|
|
27
29
|
SavedAttachment,
|
|
30
|
+
InterruptOptions,
|
|
28
31
|
SessionOptions,
|
|
32
|
+
TokenUsage,
|
|
29
33
|
UserInput,
|
|
30
34
|
} from "../../core/types.js";
|
|
31
35
|
import {
|
|
@@ -130,6 +134,13 @@ function buildContent(input: UserInput, chat: boolean): string | ContentBlock[]
|
|
|
130
134
|
const MEMORY_TOOLS = new Set(["remember", "forget", "search_past_chats"].map((t) => `mcp__${MEMORY_MCP_NAME}__${t}`));
|
|
131
135
|
/** 定时任务工具:要用户确认的那几个由 agent-loft 自己弹卡(tasks-bridge.ts),这里不再问一遍 */
|
|
132
136
|
const TASK_TOOLS = new Set(["list_tasks", "create_task", "update_task", "delete_task"].map((t) => `mcp__${TASKS_MCP_NAME}__${t}`));
|
|
137
|
+
/** 技能工具:建、改技能前由 agent-loft 自己弹卡(skills-bridge.ts) */
|
|
138
|
+
const SKILL_TOOLS = new Set(["list_skills", "get_skill", "create_skill", "update_skill", "skill_request"].map((t) => `mcp__${SKILLS_MCP_NAME}__${t}`));
|
|
139
|
+
/**
|
|
140
|
+
* Claude Code 自带的命令和技能名(supportedCommands 里标了 builtin 的),driver 读命令表时顺手记下。
|
|
141
|
+
* 成员会话的技能白名单要带上它们;还没读到过就先不带(只少了 /simplify 这类自带技能)。
|
|
142
|
+
*/
|
|
143
|
+
export const claudeBuiltins = new Set<string>();
|
|
133
144
|
/** 聊天模式的全部工具:只能联网,碰不到本机 */
|
|
134
145
|
const CHAT_TOOLS = ["WebSearch", "WebFetch"];
|
|
135
146
|
|
|
@@ -152,7 +163,12 @@ export function modeOptions(opts: SessionOptions): Partial<Options> {
|
|
|
152
163
|
...(memory
|
|
153
164
|
? { [MEMORY_MCP_NAME]: { type: "stdio", ...memoryMcpSpawn(memory.userId, "claude", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } }
|
|
154
165
|
: {}),
|
|
155
|
-
...(opts.tasks
|
|
166
|
+
...(opts.tasks
|
|
167
|
+
? {
|
|
168
|
+
[TASKS_MCP_NAME]: { type: "stdio", ...tasksMcpSpawn(opts.tasks.userId, opts.tasks.convKey, "claude") },
|
|
169
|
+
[SKILLS_MCP_NAME]: { type: "stdio", ...skillsMcpSpawn(opts.tasks.userId, opts.tasks.convKey, "claude") },
|
|
170
|
+
}
|
|
171
|
+
: {}),
|
|
156
172
|
};
|
|
157
173
|
if (opts.mode === "chat") {
|
|
158
174
|
return {
|
|
@@ -168,13 +184,24 @@ export function modeOptions(opts: SessionOptions): Partial<Options> {
|
|
|
168
184
|
const switched = opts.announceMode
|
|
169
185
|
? "这条对话刚从「聊天模式」切到「工作模式」:之前聊天模式下不能执行命令、不能读写文件的限制已经解除,现在可以正常干活。不用为之前说做不到而道歉。"
|
|
170
186
|
: "";
|
|
171
|
-
const
|
|
187
|
+
const skills = opts.skills;
|
|
188
|
+
const append = [memory?.prompt, skills?.prompt, switched].filter(Boolean).join("\n\n");
|
|
172
189
|
return {
|
|
173
190
|
systemPrompt: { type: "preset", preset: "claude_code", ...(append ? { append } : {}), snapshot: false },
|
|
174
191
|
...(Object.keys(mcpServers).length ? { mcpServers } : {}),
|
|
192
|
+
...(skills?.pluginDir ? { plugins: [{ type: "local" as const, path: skills.pluginDir, skipMcpDiscovery: true }] } : {}),
|
|
193
|
+
...(skills && !skills.admin ? { skills: memberSkillAllowlist(skills, opts.cwd) } : {}),
|
|
175
194
|
};
|
|
176
195
|
}
|
|
177
196
|
|
|
197
|
+
/**
|
|
198
|
+
* 成员会话能用的技能:agent-loft 里启用的、Claude Code 自带的、项目目录里的。机器上另外装的
|
|
199
|
+
* (~/.claude/skills、插件市场、claude.ai 同步的)不在里面:模型看不到,Skill 工具也调不了。
|
|
200
|
+
*/
|
|
201
|
+
function memberSkillAllowlist(skills: NonNullable<SessionOptions["skills"]>, cwd: string): string[] {
|
|
202
|
+
return [...new Set([...skills.list.map((s) => `${PLUGIN_NAME}:${s.name}`), ...claudeBuiltins, ...projectCommandNames(cwd)])];
|
|
203
|
+
}
|
|
204
|
+
|
|
178
205
|
export class ClaudeSession implements AgentSession {
|
|
179
206
|
private queue = new AsyncQueue<SDKUserMessage>();
|
|
180
207
|
private q: Query | null = null;
|
|
@@ -202,12 +229,19 @@ export class ClaudeSession implements AgentSession {
|
|
|
202
229
|
* 恢复历史、进程重启),就重新逐条询问。
|
|
203
230
|
*/
|
|
204
231
|
private allowAll = false;
|
|
232
|
+
/**
|
|
233
|
+
* 上一轮结束时的累计费用。result 里的 total_cost_usd 是这个会话到目前的累计值,这一轮的 = 差值。
|
|
234
|
+
* 恢复 / 分叉来的会话第一轮不知道之前累计了多少,这一轮先不报费用
|
|
235
|
+
*/
|
|
236
|
+
private costSoFar: number | undefined;
|
|
205
237
|
|
|
206
238
|
constructor(
|
|
207
239
|
private readonly opts: SessionOptions,
|
|
208
240
|
private readonly events: AgentEvents,
|
|
209
241
|
private readonly host: InteractionHost,
|
|
210
|
-
) {
|
|
242
|
+
) {
|
|
243
|
+
this.costSoFar = opts.resumeId ? undefined : 0;
|
|
244
|
+
}
|
|
211
245
|
|
|
212
246
|
get sessionId(): string | undefined {
|
|
213
247
|
return this.currentId;
|
|
@@ -228,7 +262,8 @@ export class ClaudeSession implements AgentSession {
|
|
|
228
262
|
prompt: input.stream(),
|
|
229
263
|
options: {
|
|
230
264
|
cwd: this.opts.cwd,
|
|
231
|
-
|
|
265
|
+
// 技能变量只在工作模式注入:聊天模式没有执行命令的工具,用不上
|
|
266
|
+
env: { ...agentEnvironment(), ...(this.opts.mode === "chat" ? {} : (this.opts.skills?.env ?? {})) },
|
|
232
267
|
// 只能是 default 或 acceptEdits:bypassPermissions 会让 canUseTool 变成死代码。
|
|
233
268
|
// acceptEdits 只放行工作目录(含附件目录)内的改文件,其它照样走 canUseTool
|
|
234
269
|
permissionMode: this.opts.autoEdit ? "acceptEdits" : "default",
|
|
@@ -311,10 +346,12 @@ export class ClaudeSession implements AgentSession {
|
|
|
311
346
|
}
|
|
312
347
|
|
|
313
348
|
if (message.type === "result") {
|
|
349
|
+
const costUsd = this.turnCost(message.total_cost_usd);
|
|
350
|
+
this.reportUsage(message.modelUsage);
|
|
314
351
|
if (message.subtype === "success") {
|
|
315
352
|
this.events.onResult({
|
|
316
353
|
text: message.result,
|
|
317
|
-
costUsd:
|
|
354
|
+
...(costUsd !== undefined ? { costUsd } : {}),
|
|
318
355
|
durationMs: message.duration_ms,
|
|
319
356
|
...(this.lastAssistantUuid ? { anchor: this.lastAssistantUuid } : {}),
|
|
320
357
|
});
|
|
@@ -326,6 +363,32 @@ export class ClaudeSession implements AgentSession {
|
|
|
326
363
|
}
|
|
327
364
|
}
|
|
328
365
|
|
|
366
|
+
/** 这一轮的费用 = 累计费用的差值。崩溃时的 result 费用是 0,不当成新的起点 */
|
|
367
|
+
private turnCost(total: number | undefined): number | undefined {
|
|
368
|
+
if (typeof total !== "number" || !(total > 0)) return undefined;
|
|
369
|
+
const prev = this.costSoFar;
|
|
370
|
+
this.costSoFar = total;
|
|
371
|
+
return prev !== undefined && total >= prev ? total - prev : undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 报这个会话到目前的累计用量。用 modelUsage 而不是 usage:usage 只有主循环这一轮的,
|
|
376
|
+
* modelUsage 含子 agent、压缩等所有调用,而且是累计值(恢复的会话接着原来的总数算)
|
|
377
|
+
*/
|
|
378
|
+
private reportUsage(modelUsage: Record<string, ModelUsage> | undefined): void {
|
|
379
|
+
const sum: TokenUsage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, costUsd: 0 };
|
|
380
|
+
for (const m of Object.values(modelUsage ?? {})) {
|
|
381
|
+
sum.input += m.inputTokens || 0;
|
|
382
|
+
sum.cacheRead += m.cacheReadInputTokens || 0;
|
|
383
|
+
sum.cacheWrite += m.cacheCreationInputTokens || 0;
|
|
384
|
+
sum.output += m.outputTokens || 0;
|
|
385
|
+
sum.reasoning! += m.thinkingTokens || 0;
|
|
386
|
+
sum.costUsd! += m.costUSD || 0;
|
|
387
|
+
}
|
|
388
|
+
// 崩溃时的 result 全是 0
|
|
389
|
+
if (sum.input + sum.cacheRead + sum.cacheWrite + sum.output > 0) this.events.onUsage?.(sum, true);
|
|
390
|
+
}
|
|
391
|
+
|
|
329
392
|
/**
|
|
330
393
|
* 一轮结束后问一次上下文占用(和 /context 同一份数据)。summary 模式只用上一次响应的
|
|
331
394
|
* usage 加本地估算,不额外调 token 计数接口,很便宜。读不到就算了,只是少个指示器。
|
|
@@ -357,7 +420,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
357
420
|
// 自家的记忆工具,以及聊天模式下的联网工具:不问。
|
|
358
421
|
// 不用 allowedTools 放行 —— SDK 会对每个会话打一条「canUseTool 被绕过」的警告
|
|
359
422
|
if (MEMORY_TOOLS.has(toolName) && this.opts.memory) return { behavior: "allow", updatedInput: input };
|
|
360
|
-
if (TASK_TOOLS.has(toolName) && this.opts.tasks) return { behavior: "allow", updatedInput: input };
|
|
423
|
+
if ((TASK_TOOLS.has(toolName) || SKILL_TOOLS.has(toolName)) && this.opts.tasks) return { behavior: "allow", updatedInput: input };
|
|
361
424
|
if (this.opts.mode === "chat" && CHAT_TOOLS.includes(toolName)) return { behavior: "allow", updatedInput: input };
|
|
362
425
|
|
|
363
426
|
// 提问不是授权:它问的是「你选哪个」
|
|
@@ -512,11 +575,17 @@ export class ClaudeSession implements AgentSession {
|
|
|
512
575
|
return this.runtime;
|
|
513
576
|
}
|
|
514
577
|
|
|
515
|
-
async interrupt(): Promise<void> {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
578
|
+
async interrupt(opts: InterruptOptions = {}): Promise<void> {
|
|
579
|
+
if (opts.keepQueue) {
|
|
580
|
+
// 打断并发送:排着的留着,选中的挪到最前面,这一轮出结果后 onTurnEnd 就交出它
|
|
581
|
+
const i = opts.first ? this.pending.indexOf(opts.first) : -1;
|
|
582
|
+
if (i > 0) this.pending.unshift(...this.pending.splice(i, 1));
|
|
583
|
+
} else {
|
|
584
|
+
// 停止 = 这一轮和排着队的都不要了
|
|
585
|
+
const dropped = this.pending.length + this.queue.clear();
|
|
586
|
+
this.pending = [];
|
|
587
|
+
if (dropped > 0) this.events.onDropped?.(dropped);
|
|
588
|
+
}
|
|
520
589
|
if (!this.q) return;
|
|
521
590
|
try {
|
|
522
591
|
await this.q.interrupt();
|
|
@@ -7,7 +7,8 @@ import { refusalFor, UnsupportedRequest } from "./refusals.js";
|
|
|
7
7
|
import { config } from "../../core/config.js";
|
|
8
8
|
import { contextBlock } from "../../core/chat-mode.js";
|
|
9
9
|
import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
|
|
10
|
-
import { TASK_TOOL_TIMEOUT_SEC, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
10
|
+
import { SKILLS_MCP_NAME, skillsMcpSpawn, TASK_TOOL_TIMEOUT_SEC, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
11
|
+
import { codexSkillLines } from "../../core/skill-runtime.js";
|
|
11
12
|
import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
|
|
12
13
|
import type {
|
|
13
14
|
AgentEvents,
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
InteractionHost,
|
|
16
17
|
InteractionOutcome,
|
|
17
18
|
InteractionSpec,
|
|
19
|
+
InterruptOptions,
|
|
18
20
|
QuestionSpec,
|
|
19
21
|
RuntimeInfo,
|
|
20
22
|
SavedAttachment,
|
|
@@ -131,7 +133,7 @@ export async function userMcpServers(): Promise<string[]> {
|
|
|
131
133
|
if (userMcpCache && Date.now() - userMcpCache.at < 60_000) return userMcpCache.names;
|
|
132
134
|
try {
|
|
133
135
|
const res = await appServer.call<{ config?: { mcp_servers?: Record<string, unknown> | null } }>("config/read", {});
|
|
134
|
-
const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME && n !== TASKS_MCP_NAME);
|
|
136
|
+
const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME && n !== TASKS_MCP_NAME && n !== SKILLS_MCP_NAME);
|
|
135
137
|
userMcpCache = { names, at: Date.now() };
|
|
136
138
|
return names;
|
|
137
139
|
} catch (err) {
|
|
@@ -181,6 +183,16 @@ function allowed(s: Settlement): boolean {
|
|
|
181
183
|
|
|
182
184
|
type ParsedQuestion = QuestionSpec & { secret: boolean };
|
|
183
185
|
|
|
186
|
+
/** thread/tokenUsage/updated 里的一份用量(app-server 的 TokenUsageBreakdown) */
|
|
187
|
+
type TokenBreakdown = {
|
|
188
|
+
totalTokens?: number;
|
|
189
|
+
inputTokens?: number;
|
|
190
|
+
cachedInputTokens?: number;
|
|
191
|
+
cacheWriteInputTokens?: number;
|
|
192
|
+
outputTokens?: number;
|
|
193
|
+
reasoningOutputTokens?: number;
|
|
194
|
+
};
|
|
195
|
+
|
|
184
196
|
function parseQuestions(raw: unknown): ParsedQuestion[] | null {
|
|
185
197
|
if (!Array.isArray(raw) || raw.length === 0) return null;
|
|
186
198
|
const specs: ParsedQuestion[] = [];
|
|
@@ -250,6 +262,10 @@ export class CodexSession implements AgentSession {
|
|
|
250
262
|
private allowAll = false;
|
|
251
263
|
private replyText = "";
|
|
252
264
|
private lastTokens: number | undefined;
|
|
265
|
+
/** 上次看到的 thread 累计 token 数:变了才说明有新的模型请求(同一次请求可能报好几遍) */
|
|
266
|
+
private seenTotal: number | undefined;
|
|
267
|
+
/** 这个对象里开始过轮次:恢复 thread 时补报的旧用量不算新的消耗 */
|
|
268
|
+
private turnSeen = false;
|
|
253
269
|
/** 分叉已经真正完成(拿到并接管了新 thread)。失败了不算,重试还要接着分叉 */
|
|
254
270
|
private forked = false;
|
|
255
271
|
/** agentMessage 的 itemId → 是不是旁白(commentary 属于过程,不流式到正文里) */
|
|
@@ -368,13 +384,33 @@ export class CodexSession implements AgentSession {
|
|
|
368
384
|
const mcp = {
|
|
369
385
|
...(memory ? { [`mcp_servers.${MEMORY_MCP_NAME}`]: memoryMcpSpawn(memory.userId, "codex", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } : {}),
|
|
370
386
|
// 新建 / 修改任务要等用户点授权卡,Codex 默认 60 秒就算工具超时
|
|
371
|
-
...(tasks
|
|
387
|
+
...(tasks
|
|
388
|
+
? {
|
|
389
|
+
[`mcp_servers.${TASKS_MCP_NAME}`]: { ...tasksMcpSpawn(tasks.userId, tasks.convKey, "codex"), tool_timeout_sec: TASK_TOOL_TIMEOUT_SEC },
|
|
390
|
+
[`mcp_servers.${SKILLS_MCP_NAME}`]: { ...skillsMcpSpawn(tasks.userId, tasks.convKey, "codex"), tool_timeout_sec: TASK_TOOL_TIMEOUT_SEC },
|
|
391
|
+
}
|
|
392
|
+
: {}),
|
|
372
393
|
};
|
|
373
394
|
if (this.chat && !minimal) {
|
|
374
395
|
const off = Object.fromEntries((await userMcpServers()).map((n) => [`mcp_servers.${n}.enabled`, false]));
|
|
375
396
|
return { config: { ...CHAT_CONFIG, ...off, ...mcp } };
|
|
376
397
|
}
|
|
377
|
-
|
|
398
|
+
const work = { ...mcp, ...(minimal ? {} : this.skillConfig()) };
|
|
399
|
+
return Object.keys(work).length ? { config: work } : {};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 工作模式的技能设置:变量逐个写进 shell_environment_policy.set(只作用于这个 thread 里跑的命令,
|
|
404
|
+
* 一个个键写不会冲掉用户 config.toml 里本来设的);成员关掉 Codex 自己的技能清单 ——
|
|
405
|
+
* 那里面是机器上装的技能,只给管理员。成员的技能走 contextBlock 里的清单。
|
|
406
|
+
*/
|
|
407
|
+
private skillConfig(): Record<string, unknown> {
|
|
408
|
+
const skills = this.opts.skills;
|
|
409
|
+
if (!skills) return {};
|
|
410
|
+
return {
|
|
411
|
+
...Object.fromEntries(Object.entries(skills.env).map(([k, v]) => [`shell_environment_policy.set.${k}`, v])),
|
|
412
|
+
...(skills.admin ? {} : { "skills.include_instructions": false, "features.skill_search": false }),
|
|
413
|
+
};
|
|
378
414
|
}
|
|
379
415
|
|
|
380
416
|
/**
|
|
@@ -385,7 +421,7 @@ export class CodexSession implements AgentSession {
|
|
|
385
421
|
private contextSent = false;
|
|
386
422
|
|
|
387
423
|
private get contextPending(): boolean {
|
|
388
|
-
return !this.contextSent && (this.chat || !!this.opts.memory || !!this.opts.announceMode);
|
|
424
|
+
return !this.contextSent && (this.chat || !!this.opts.memory || !!this.opts.announceMode || codexSkillLines(this.opts.skills).length > 0);
|
|
389
425
|
}
|
|
390
426
|
|
|
391
427
|
private async openOnce(): Promise<string> {
|
|
@@ -503,7 +539,7 @@ export class CodexSession implements AgentSession {
|
|
|
503
539
|
const note = input.attachments.length ? describeAttachments(input.attachments, inlined, chat) : "";
|
|
504
540
|
const texts = chat ? inlineTextBlocks(input.attachments, textMap) : "";
|
|
505
541
|
// 放在最后:Codex 拿第一条消息的开头当会话预览,不能被这段背景占掉
|
|
506
|
-
const context = this.contextPending ? contextBlock(chat ? "chat" : "work", this.opts.memory?.prompt, "联网搜索") : "";
|
|
542
|
+
const context = this.contextPending ? contextBlock(chat ? "chat" : "work", this.opts.memory?.prompt, "联网搜索", codexSkillLines(this.opts.skills)) : "";
|
|
507
543
|
const text = [input.text, note, texts, context].filter(Boolean).join("\n\n");
|
|
508
544
|
const images = input.attachments.filter((a) => a.kind === "image").map((a) => a.path);
|
|
509
545
|
let startedOn: string | undefined;
|
|
@@ -580,9 +616,15 @@ export class CodexSession implements AgentSession {
|
|
|
580
616
|
return this.runtime;
|
|
581
617
|
}
|
|
582
618
|
|
|
583
|
-
async interrupt(): Promise<void> {
|
|
584
|
-
|
|
585
|
-
|
|
619
|
+
async interrupt(opts: InterruptOptions = {}): Promise<void> {
|
|
620
|
+
if (opts.keepQueue) {
|
|
621
|
+
// 打断并发送:排着的留着,选中的挪到最前面,这一轮结束后 drainQueue 就开它
|
|
622
|
+
const i = opts.first ? this.queue.indexOf(opts.first) : -1;
|
|
623
|
+
if (i > 0) this.queue.unshift(...this.queue.splice(i, 1));
|
|
624
|
+
} else {
|
|
625
|
+
// 停止 = 这一轮和排着队的都不要了
|
|
626
|
+
this.queue = [];
|
|
627
|
+
}
|
|
586
628
|
if (this.phase === "starting" && !this.turnId) {
|
|
587
629
|
// 轮次还没真正开始:记下来,拿到轮次 id 立刻中断
|
|
588
630
|
this.stopRequested = true;
|
|
@@ -736,6 +778,7 @@ export class CodexSession implements AgentSession {
|
|
|
736
778
|
if (this.phase === "idle" || (id && this.endedTurns.has(id))) return;
|
|
737
779
|
if (id) this.onTurnId(id);
|
|
738
780
|
this.commentaryItems.clear();
|
|
781
|
+
this.turnSeen = true;
|
|
739
782
|
this.events.onTurnStart?.();
|
|
740
783
|
return;
|
|
741
784
|
}
|
|
@@ -755,10 +798,24 @@ export class CodexSession implements AgentSession {
|
|
|
755
798
|
}
|
|
756
799
|
|
|
757
800
|
case "thread/tokenUsage/updated": {
|
|
758
|
-
// last
|
|
759
|
-
const usage = (
|
|
760
|
-
|
|
761
|
-
|
|
801
|
+
// last 是最近一次模型请求的用量,total 是整条 thread 的累计;页脚显示最近一次的
|
|
802
|
+
const usage = (params as { tokenUsage?: { total?: TokenBreakdown; last?: TokenBreakdown; modelContextWindow?: number | null } })
|
|
803
|
+
.tokenUsage;
|
|
804
|
+
const total = usage?.total?.totalTokens;
|
|
805
|
+
if (this.turnSeen && usage?.last && typeof total === "number" && total !== this.seenTotal) {
|
|
806
|
+
const l = usage.last;
|
|
807
|
+
// OpenAI 的 inputTokens 含命中缓存的部分,拆出来
|
|
808
|
+
const cached = l.cachedInputTokens ?? 0;
|
|
809
|
+
const written = l.cacheWriteInputTokens ?? 0;
|
|
810
|
+
this.events.onUsage?.({
|
|
811
|
+
input: Math.max(0, (l.inputTokens ?? 0) - cached - written),
|
|
812
|
+
cacheRead: cached,
|
|
813
|
+
cacheWrite: written,
|
|
814
|
+
output: l.outputTokens ?? 0,
|
|
815
|
+
reasoning: l.reasoningOutputTokens ?? 0,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
if (typeof total === "number") this.seenTotal = total;
|
|
762
819
|
const turnTokens = usage?.last?.totalTokens;
|
|
763
820
|
if (typeof turnTokens === "number") this.lastTokens = turnTokens;
|
|
764
821
|
// 最近一次请求的用量 ≈ 当前上下文大小(Codex 自己的「剩余上下文」也是这么算的)
|
|
@@ -272,9 +272,9 @@ export class FeishuBot {
|
|
|
272
272
|
// 最近几句:读历史可能要一会儿,读不到也不影响接上
|
|
273
273
|
const history = await hub.history(webKey).catch(() => []);
|
|
274
274
|
const recent = history
|
|
275
|
-
.filter((m) => m.text.trim())
|
|
275
|
+
.filter((m) => m.role !== "question" && m.text.trim())
|
|
276
276
|
.slice(-4)
|
|
277
|
-
.map((m) => ({ role: m.role, text: m.text }));
|
|
277
|
+
.map((m) => ({ role: m.role === "user" ? ("user" as const) : ("assistant" as const), text: m.text }));
|
|
278
278
|
await this.client.sendCard(
|
|
279
279
|
chatId,
|
|
280
280
|
attachedCard({ conv: entry, recent, sessionKey: feishuKey, ctx: this.cardCtx(feishuKey, chatId) }),
|