agent-loft 0.2.23 → 0.2.25

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 CHANGED
@@ -46,7 +46,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
46
46
  - 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
47
47
  - 新对话页:点大标题里的目录名,在目录树里逐层选工作目录;输入框上方选 agent,右下角选模型和思考程度。会话进行中用 `/agent` 切换 agent,模型和思考程度随时可换。
48
48
  - 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
49
- - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
49
+ - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类(目录可以拖动排序,右键可置顶),飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
50
50
  - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
51
51
  - 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
52
52
  - **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
@@ -54,6 +54,9 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
54
54
  - **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
55
55
  - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、用户管理、服务端升级。
56
56
  - **多用户**(信任型):管理员在 设置 → 用户 里给别人建账号。每个人只看得到自己的对话、授权和通知,设置只有管理员能改,见下面「安全」。
57
+ - **聊天模式 / 工作模式**:输入框右下角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
58
+ - **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
59
+ - **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
57
60
 
58
61
  ## 安装
59
62
 
@@ -89,6 +92,15 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
89
92
 
90
93
  桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
91
94
 
95
+ 桌面端一键安装的服务端放在 `~/.local`,无需管理员权限,也不修改 npm 的全局配置。如果旧版桌面端安装时出现 `EACCES /usr/local/lib/node_modules/agent-loft`,可以先在终端执行:
96
+
97
+ ```bash
98
+ npm install -g --prefix "$HOME/.local" agent-loft
99
+ PATH="$HOME/.local/bin:$PATH" "$HOME/.local/bin/agent-loft" service install
100
+ ```
101
+
102
+ 第一条成功后再执行第二条,然后重新打开桌面端,选择连接本机服务端即可。需要 Node.js 22 以上。
103
+
92
104
  ### 从旧桥接器迁移
93
105
 
94
106
  ```bash
@@ -114,7 +126,7 @@ agent-loft service install
114
126
 
115
127
  `aloft` 是 `agent-loft` 的简写。
116
128
 
117
- 聊天里的命令(飞书和 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` `/help`;只在飞书里有的:`/web` `/detach`
129
+ 聊天里的命令(飞书和 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 <绑定码>`
118
130
 
119
131
  自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
120
132
 
@@ -123,12 +135,13 @@ agent-loft service install
123
135
  这个服务能让 agent 在你的机器上执行命令,等于一个远程 shell。
124
136
 
125
137
  - Web 登录密码用 scrypt 哈希保存。首次建管理员必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
126
- - 多用户只隔开**界面上**的东西:会话列表、授权 / 提问卡、通知、`/sessions` 能列出和恢复的 agent 会话;设置、飞书机器人和飞书会话只给管理员;会落盘、对所有人生效的「总是允许」(Claude 的总是允许名单、Codex 的 execpolicy)只有管理员能点。**它不是安全边界**:所有 agent 都以同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(别人的对话记录、配置里的密钥),用的也是同一个 Claude / Codex 订阅。只给信任的人开账号;要真正隔离,就每人用自己的系统用户各跑一份 agent-loft。
138
+ - 多用户只隔开**界面上**的东西:会话列表、授权 / 提问卡、通知、记忆、`/sessions` 能列出和恢复的 agent 会话;设置、飞书机器人和飞书会话只给管理员(绑定了账号的飞书私聊除外,它归绑定的人);会落盘、对所有人生效的「总是允许」(Claude 的总是允许名单、Codex 的 execpolicy)只有管理员能点。**它不是安全边界**:所有 agent 都以同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(别人的对话记录、配置里的密钥),用的也是同一个 Claude / Codex 订阅。只给信任的人开账号;要真正隔离,就每人用自己的系统用户各跑一份 agent-loft。
127
139
  - 从老版本升级:原来的登录密码自动变成管理员 `admin`,已登录的设备不用重新登录。
128
140
  - 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
129
141
  - 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
130
142
  - 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
131
143
  - agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
144
+ - 记忆存在 `data/memory/<用户 id>.json`,聊天记录索引在 `data/history/<用户 id>.jsonl`。记给谁由 agent-loft 在拉起记忆工具时定死(工具参数里没有用户),模型想写别人的记忆也写不了;但和多用户一样不是安全边界:工作模式下 agent 能读到这些文件。聊天模式没有读写本机文件的工具,网页里的提示注入也骗不走本机文件。
132
145
 
133
146
  ## 结构
134
147
 
@@ -140,7 +153,14 @@ src/
140
153
  store.ts 会话持久化(每个 agent 的会话 id 各自保存)
141
154
  config.ts 配置(Web 可改,改完热加载)
142
155
  users.ts Web 登录用户和归属规则(谁能看到哪条会话)
156
+ memory.ts 记忆的存储和注入系统提示的那段文字;personal.ts 定记忆用谁的
157
+ chat-history.ts 聊天记录索引(每人一个 JSONL)和关键词搜索
158
+ memory-curator.ts 录入聊天、回填已有对话、聊完后自动整理记忆
159
+ feishu-links.ts 飞书账号 ↔ 用户的绑定和绑定码
160
+ chat-mode.ts 聊天模式告诉 agent 的话(Claude 换系统提示,Codex 附在消息后面)
143
161
  pairing.ts uploads.ts commands.ts …
162
+ mcp/
163
+ memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
144
164
  agents/
145
165
  claude/ Claude Agent SDK 驱动
146
166
  codex/ codex app-server 驱动
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -7,7 +7,9 @@ import {
7
7
  getSessionInfo,
8
8
  getSessionMessages,
9
9
  listSessions,
10
+ query,
10
11
  } from "@anthropic-ai/claude-agent-sdk";
12
+ import { agentEnvironment } from "../../core/agent-env.js";
11
13
  import os from "node:os";
12
14
  import type { AgentCommand } from "../../core/command-catalog.js";
13
15
  import { splitAttachmentNote } from "../../core/uploads.js";
@@ -16,6 +18,7 @@ import type {
16
18
  AgentDriver,
17
19
  AgentEvents,
18
20
  AgentSession,
21
+ CompleteRequest,
19
22
  HistoryMessage,
20
23
  InteractionHost,
21
24
  ModelInfo,
@@ -228,6 +231,44 @@ export class ClaudeDriver implements AgentDriver {
228
231
  return toQuota(await readUsage());
229
232
  }
230
233
 
234
+ /**
235
+ * 后台整理记忆:默认 Haiku(最便宜,用户可以在记忆设置里换)、不读本地设置、不给工具、不落会话记录(persistSession: false),
236
+ * 用结构化输出拿 JSON。跑在临时目录里,碰不到任何项目。
237
+ */
238
+ async complete(req: CompleteRequest): Promise<unknown> {
239
+ const abort = new AbortController();
240
+ const timer = setTimeout(() => abort.abort(), req.timeoutMs ?? 120_000);
241
+ try {
242
+ const q = query({
243
+ prompt: req.prompt,
244
+ options: {
245
+ cwd: os.tmpdir(),
246
+ env: agentEnvironment(),
247
+ model: req.model || "haiku",
248
+ systemPrompt: req.system,
249
+ tools: [],
250
+ settingSources: [],
251
+ strictMcpConfig: true,
252
+ mcpServers: {},
253
+ skills: [],
254
+ persistSession: false,
255
+ maxTurns: 3,
256
+ outputFormat: { type: "json_schema", schema: req.schema },
257
+ abortController: abort,
258
+ canUseTool: async () => ({ behavior: "deny", message: "后台整理不允许用工具" }),
259
+ },
260
+ });
261
+ for await (const m of q) {
262
+ if (m.type !== "result") continue;
263
+ if (m.subtype !== "success") throw new Error(`后台调用失败:${m.subtype}`);
264
+ return m.structured_output ?? JSON.parse(m.result);
265
+ }
266
+ throw new Error("后台调用没有结果");
267
+ } finally {
268
+ clearTimeout(timer);
269
+ }
270
+ }
271
+
231
272
  diagnose(err: unknown) {
232
273
  return diagnose(err);
233
274
  }
@@ -6,13 +6,17 @@
6
6
  import fs from "node:fs";
7
7
  import {
8
8
  query,
9
+ type McpServerConfig,
10
+ type Options,
9
11
  type PermissionResult,
10
12
  type Query,
11
13
  type SDKMessage,
12
14
  type SDKUserMessage,
13
15
  } from "@anthropic-ai/claude-agent-sdk";
14
16
  import { agentEnvironment } from "../../core/agent-env.js";
15
- import { describeAttachments } from "../../core/uploads.js";
17
+ import { chatSystemPrompt } from "../../core/chat-mode.js";
18
+ import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
19
+ import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
16
20
  import type {
17
21
  AgentEvents,
18
22
  AgentSession,
@@ -98,11 +102,17 @@ function canInline(a: SavedAttachment): boolean {
98
102
  return false;
99
103
  }
100
104
 
101
- /** 文字 + 附件清单,后面跟着能直接看的图片和 PDF。没有附件时就是原样的文字。 */
102
- function buildContent(input: UserInput): string | ContentBlock[] {
105
+ /**
106
+ * 文字 + 附件清单,后面跟着能直接看的图片和 PDF。没有附件时就是原样的文字。
107
+ * chat:聊天模式没有读文件的工具,文本附件的内容也直接放进来。
108
+ */
109
+ function buildContent(input: UserInput, chat: boolean): string | ContentBlock[] {
103
110
  if (input.attachments.length === 0) return input.text;
104
- const note = describeAttachments(input.attachments, canInline);
105
- const blocks: ContentBlock[] = [{ type: "text", text: input.text ? `${input.text}\n\n${note}` : note }];
111
+ const texts = chat ? readTextAttachments(input.attachments) : new Map<string, string>();
112
+ const inlined = (a: SavedAttachment) => canInline(a) || texts.has(a.path);
113
+ const note = describeAttachments(input.attachments, inlined, chat);
114
+ const head = [input.text, note, chat ? inlineTextBlocks(input.attachments, texts) : ""].filter(Boolean).join("\n\n");
115
+ const blocks: ContentBlock[] = [{ type: "text", text: head }];
106
116
  for (const a of input.attachments) {
107
117
  if (!canInline(a)) continue;
108
118
  const data = fs.readFileSync(a.path).toString("base64");
@@ -115,6 +125,50 @@ function buildContent(input: UserInput): string | ContentBlock[] {
115
125
  return blocks;
116
126
  }
117
127
 
128
+ /** 记忆工具在 Claude 里的全名 */
129
+ const MEMORY_TOOLS = new Set(["remember", "forget", "search_past_chats"].map((t) => `mcp__${MEMORY_MCP_NAME}__${t}`));
130
+ /** 聊天模式的全部工具:只能联网,碰不到本机 */
131
+ const CHAT_TOOLS = ["WebSearch", "WebFetch"];
132
+
133
+ /**
134
+ * 按模式拼 query 的选项。
135
+ *
136
+ * 聊天模式:自己的短系统提示、只给联网的两个工具、不读本地设置(技能、插件、CLAUDE.md、MCP 都不加载)。
137
+ * 实测一句话的输入从约 1.6 万 token 降到约 2 千。
138
+ * 工作模式:Claude Code 原装,有记忆就追加在它的系统提示后面。
139
+ * 两种模式下记忆工具都以 MCP 子进程的方式挂上(strictMcpConfig 只认这里给的)。
140
+ *
141
+ * 系统提示一律 snapshot: false。SDK 默认会把会话第一次请求时的系统提示记进记录,之后 resume 时传的
142
+ * 新提示一概忽略(实测:先用聊天提示开的会话,换成工作模式的提示后还是按聊天提示回答)——
143
+ * 那样切模式、改了记忆都不生效。snapshot: false 每次现渲染,已经记录过的老会话也能换过来(实测)。
144
+ * 代价只是 Claude Code 升级后提示变了会让缓存失效一次。
145
+ */
146
+ export function modeOptions(opts: SessionOptions): Partial<Options> {
147
+ const memory = opts.memory;
148
+ const mcpServers: Record<string, McpServerConfig> = memory
149
+ ? { [MEMORY_MCP_NAME]: { type: "stdio", ...memoryMcpSpawn(memory.userId, "claude", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } }
150
+ : {};
151
+ if (opts.mode === "chat") {
152
+ return {
153
+ systemPrompt: { type: "custom", prompt: chatSystemPrompt("WebSearch(搜索)和 WebFetch(读网页)", memory?.prompt), snapshot: false },
154
+ tools: CHAT_TOOLS,
155
+ settingSources: [],
156
+ strictMcpConfig: true,
157
+ skills: [],
158
+ mcpServers,
159
+ };
160
+ }
161
+ // 刚从聊天模式切回来:说一声,不然它会以为之前「不能执行命令」是自己说错了
162
+ const switched = opts.announceMode
163
+ ? "这条对话刚从「聊天模式」切到「工作模式」:之前聊天模式下不能执行命令、不能读写文件的限制已经解除,现在可以正常干活。不用为之前说做不到而道歉。"
164
+ : "";
165
+ const append = [memory?.prompt, switched].filter(Boolean).join("\n\n");
166
+ return {
167
+ systemPrompt: { type: "preset", preset: "claude_code", ...(append ? { append } : {}), snapshot: false },
168
+ ...(memory ? { mcpServers } : {}),
169
+ };
170
+ }
171
+
118
172
  export class ClaudeSession implements AgentSession {
119
173
  private queue = new AsyncQueue<SDKUserMessage>();
120
174
  private q: Query | null = null;
@@ -176,7 +230,9 @@ export class ClaudeSession implements AgentSession {
176
230
  includePartialMessages: true,
177
231
  ...(this.opts.model ? { model: this.opts.model } : {}),
178
232
  ...(this.opts.effort ? { effort: this.opts.effort as never } : {}),
179
- additionalDirectories: [this.opts.uploadsDir],
233
+ // 聊天模式不读本地文件,附件内容由 buildContent 直接放进消息
234
+ additionalDirectories: this.opts.mode === "chat" ? [] : [this.opts.uploadsDir],
235
+ ...modeOptions(this.opts),
180
236
  ...(resume ? { resume } : {}),
181
237
  ...(fork ? { forkSession: true } : {}),
182
238
  canUseTool: async (toolName, input) => this.canUseTool(toolName, input as Record<string, unknown>),
@@ -292,6 +348,11 @@ export class ClaudeSession implements AgentSession {
292
348
  * 对同一 request_id 必须幂等 —— reinitialize 之后 SDK 会重投同一个请求。
293
349
  */
294
350
  private async canUseTool(toolName: string, input: Record<string, unknown>): Promise<PermissionResult> {
351
+ // 自家的记忆工具,以及聊天模式下的联网工具:不问。
352
+ // 不用 allowedTools 放行 —— SDK 会对每个会话打一条「canUseTool 被绕过」的警告
353
+ if (MEMORY_TOOLS.has(toolName) && this.opts.memory) return { behavior: "allow", updatedInput: input };
354
+ if (this.opts.mode === "chat" && CHAT_TOOLS.includes(toolName)) return { behavior: "allow", updatedInput: input };
355
+
295
356
  // 提问不是授权:它问的是「你选哪个」
296
357
  if (toolName === "AskUserQuestion") {
297
358
  const answered = await this.askQuestion(input);
@@ -373,7 +434,7 @@ export class ClaudeSession implements AgentSession {
373
434
  // 这条要明确报错并让会话恢复可用,而不是挂在一个永远等不到结果的「忙」上
374
435
  let content: string | ContentBlock[];
375
436
  try {
376
- content = buildContent(input);
437
+ content = buildContent(input, this.opts.mode === "chat");
377
438
  } catch (err) {
378
439
  const why = (err as NodeJS.ErrnoException).code === "ENOENT" ? "附件文件已不存在,请重新上传" : String(err);
379
440
  this.events.onError(new Error(`这条消息没能发出:${why}`));
@@ -4,13 +4,16 @@
4
4
  import { appServer } from "./app-server.js";
5
5
  import { describeRateLimits, readAccountKey, readRateLimits, toQuota } from "./account.js";
6
6
  import { diagnose } from "./errors.js";
7
- import { CodexSession, summarize } from "./session.js";
7
+ import { CHAT_CONFIG, CodexSession, summarize, userMcpServers } from "./session.js";
8
+ import os from "node:os";
8
9
  import { config, onConfigChange } from "../../core/config.js";
9
10
  import { splitAttachmentNote } from "../../core/uploads.js";
11
+ import { stripContext } from "../../core/chat-mode.js";
10
12
  import type {
11
13
  AgentDriver,
12
14
  AgentEvents,
13
15
  AgentSession,
16
+ CompleteRequest,
14
17
  HistoryMessage,
15
18
  InteractionHost,
16
19
  ModelInfo,
@@ -24,7 +27,7 @@ import type { ModelListResponse, Thread, ThreadListResponse } from "./protocol.j
24
27
  function titleOf(thread: Thread): string {
25
28
  const name = thread.name?.trim();
26
29
  if (name) return name;
27
- const preview = thread.preview?.replace(/\s+/g, " ").trim();
30
+ const preview = thread.preview && stripContext(thread.preview).replace(/\s+/g, " ").trim();
28
31
  if (!preview) return "(无标题)";
29
32
  return preview.length > 60 ? `${preview.slice(0, 60)}…` : preview;
30
33
  }
@@ -125,7 +128,7 @@ export class CodexDriver implements AgentDriver {
125
128
  .filter(Boolean)
126
129
  .join("\n");
127
130
  // 附件清单拆出来单独展示;没有清单的图片(别处发的)只能留个占位
128
- const { text, attachments } = splitAttachmentNote(raw);
131
+ const { text, attachments } = splitAttachmentNote(stripContext(raw));
129
132
  const bareImages = attachments.length ? 0 : (item.content ?? []).filter((c) => c.type === "localImage").length;
130
133
  out.push({
131
134
  role: "user",
@@ -218,6 +221,55 @@ export class CodexDriver implements AgentDriver {
218
221
  return toQuota(limits, account);
219
222
  }
220
223
 
224
+ /**
225
+ * 后台整理记忆:临时 thread(ephemeral,不落记录、不进会话列表),工具、技能、MCP、联网全关,
226
+ * 只读沙箱,思考程度 low,用 outputSchema 拿 JSON。
227
+ */
228
+ async complete(req: CompleteRequest): Promise<unknown> {
229
+ const off = Object.fromEntries((await userMcpServers()).map((n) => [`mcp_servers.${n}.enabled`, false]));
230
+ const res = await appServer.call<{ thread: Thread }>("thread/start", {
231
+ cwd: os.tmpdir(),
232
+ ephemeral: true,
233
+ ...(req.model ? { model: req.model } : {}),
234
+ approvalPolicy: "never",
235
+ sandbox: "read-only",
236
+ baseInstructions: req.system,
237
+ config: { ...CHAT_CONFIG, ...off, web_search: "disabled" },
238
+ });
239
+ const threadId = res.thread.id;
240
+ let text = "";
241
+ let off2: (() => void) | undefined;
242
+ let timer: NodeJS.Timeout | undefined;
243
+ const done = new Promise<void>((resolve, reject) => {
244
+ timer = setTimeout(() => reject(new Error("后台调用超时")), req.timeoutMs ?? 120_000);
245
+ off2 = appServer.onNotification((method, params) => {
246
+ const p = params as { threadId?: string; item?: { type?: string; text?: string; phase?: string | null }; turn?: { status?: string; error?: { message?: string } } };
247
+ if (p?.threadId !== threadId) return;
248
+ if (method === "item/completed" && p.item?.type === "agentMessage" && p.item.phase !== "commentary") text = p.item.text ?? text;
249
+ if (method === "turn/completed") {
250
+ if (p.turn?.status === "failed") reject(new Error(p.turn.error?.message ?? "后台调用失败"));
251
+ else resolve();
252
+ }
253
+ });
254
+ });
255
+ // turn/start 自己就失败了的话没人等 done:别让它的超时变成未处理的拒绝
256
+ done.catch(() => {});
257
+ try {
258
+ await appServer.call("turn/start", {
259
+ threadId,
260
+ input: [{ type: "text", text: req.prompt, text_elements: [] }],
261
+ effort: "low",
262
+ outputSchema: req.schema,
263
+ });
264
+ await done;
265
+ return JSON.parse(text);
266
+ } finally {
267
+ clearTimeout(timer);
268
+ off2?.();
269
+ void appServer.call("thread/unsubscribe", { threadId }).catch(() => {});
270
+ }
271
+ }
272
+
221
273
  diagnose(err: unknown) {
222
274
  return diagnose(err);
223
275
  }
@@ -5,7 +5,9 @@
5
5
  import { appServer } from "./app-server.js";
6
6
  import { refusalFor, UnsupportedRequest } from "./refusals.js";
7
7
  import { config } from "../../core/config.js";
8
- import { describeAttachments } from "../../core/uploads.js";
8
+ import { contextBlock } from "../../core/chat-mode.js";
9
+ import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
10
+ import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
9
11
  import type {
10
12
  AgentEvents,
11
13
  AgentSession,
@@ -14,6 +16,7 @@ import type {
14
16
  InteractionSpec,
15
17
  QuestionSpec,
16
18
  RuntimeInfo,
19
+ SavedAttachment,
17
20
  SessionOptions,
18
21
  Settlement,
19
22
  UserInput,
@@ -79,6 +82,63 @@ function sandboxPolicyFor(mode: string, cwd: string): unknown {
79
82
  };
80
83
  }
81
84
 
85
+ // ---------- 聊天模式 ----------
86
+
87
+ /**
88
+ * 聊天模式要关掉的东西(thread/start / resume 的 config 覆盖)。实测一句话的输入从约 2 万 token
89
+ * 降到约 1.1 万,大头是技能清单(约 2.5 万字符)和权限说明(约 1.1 万字符)。剩下的是 Codex 自己的基础指令
90
+ * (改不得,见 modeParams)和关不掉的工具定义。
91
+ * 不认识的 feature 名 Codex 会忽略;万一哪天哪个键让 thread 起不来,openOnce 会退回不带这些覆盖再试一次。
92
+ */
93
+ export const CHAT_CONFIG: Record<string, unknown> = {
94
+ web_search: "live",
95
+ "skills.include_instructions": false,
96
+ include_permissions_instructions: false,
97
+ include_collaboration_mode_instructions: false,
98
+ include_apps_instructions: false,
99
+ include_apply_patch_tool: false,
100
+ ...Object.fromEntries(
101
+ [
102
+ "shell_tool",
103
+ "unified_exec",
104
+ "plugins",
105
+ "apps",
106
+ "browser_use",
107
+ "computer_use",
108
+ "multi_agent",
109
+ "image_generation",
110
+ "view_image",
111
+ "skill_search",
112
+ "tool_suggest",
113
+ "sleep_tool",
114
+ "hooks",
115
+ "goals",
116
+ "code_mode_host",
117
+ "worktrees",
118
+ "workspace_dependencies",
119
+ "in_app_browser",
120
+ "in_app_local_automation",
121
+ "realtime_conversation",
122
+ ].map((f) => [`features.${f}`, false]),
123
+ ),
124
+ };
125
+
126
+ /** 用户自己配的 MCP 服务(config.toml 里的 mcp_servers)。名字因人而异,只能读出来再逐个关 */
127
+ let userMcpCache: { names: string[]; at: number } | null = null;
128
+
129
+ export async function userMcpServers(): Promise<string[]> {
130
+ if (userMcpCache && Date.now() - userMcpCache.at < 60_000) return userMcpCache.names;
131
+ try {
132
+ const res = await appServer.call<{ config?: { mcp_servers?: Record<string, unknown> | null } }>("config/read", {});
133
+ const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME);
134
+ userMcpCache = { names, at: Date.now() };
135
+ return names;
136
+ } catch (err) {
137
+ console.warn("[codex] 读取 MCP 配置失败,聊天模式下可能仍会加载用户的 MCP:", err instanceof Error ? err.message : err);
138
+ return [];
139
+ }
140
+ }
141
+
82
142
  // ---------- 审批正文 ----------
83
143
 
84
144
  function describeCommand(params: CommandExecutionApprovalParams): string {
@@ -159,6 +219,13 @@ function parseQuestions(raw: unknown): ParsedQuestion[] | null {
159
219
  /** 会话已被丢弃,迟到的异步结果直接作废 */
160
220
  class DisposedError extends Error {}
161
221
 
222
+ /** 聊天模式带着全套配置覆盖开 thread 失败了(见 openOnce 的退路) */
223
+ class ChatConfigRejected extends Error {
224
+ constructor(readonly cause: unknown) {
225
+ super("聊天模式的配置覆盖被拒绝");
226
+ }
227
+ }
228
+
162
229
  export class CodexSession implements AgentSession {
163
230
  private threadId: string | undefined;
164
231
  private turnId: string | undefined;
@@ -277,13 +344,64 @@ export class CodexSession implements AgentSession {
277
344
  return this.opening;
278
345
  }
279
346
 
347
+ private get chat(): boolean {
348
+ return this.opts.mode === "chat";
349
+ }
350
+
351
+ /** 沙箱:聊天模式一律只读(它本来就没有执行命令的工具,这是第二道保险) */
352
+ private get sandboxMode(): string {
353
+ return this.chat ? "read-only" : config().agents.codex.sandbox;
354
+ }
355
+
356
+ /**
357
+ * 按模式给 thread 的配置覆盖。thread/start、resume、fork 三条路都带上:配置是每次 resume 现给的,
358
+ * 换模式时旧会话先退订(dispose),再 resume 回来就换上了(实测)。
359
+ *
360
+ * 不动 baseInstructions / developerInstructions:Codex 把它们钉在 thread 上,切回工作模式时还原不了
361
+ * (见 core/chat-mode.ts)。模式和记忆走 contextBlock,附在第一条消息后面。
362
+ * minimal:上一次带全套覆盖没能起来,退回只挂记忆工具。
363
+ */
364
+ private async modeParams(minimal = false): Promise<Record<string, unknown>> {
365
+ const memory = this.opts.memory;
366
+ const mcp = memory ? { [`mcp_servers.${MEMORY_MCP_NAME}`]: memoryMcpSpawn(memory.userId, "codex", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } : {};
367
+ if (this.chat && !minimal) {
368
+ const off = Object.fromEntries((await userMcpServers()).map((n) => [`mcp_servers.${n}.enabled`, false]));
369
+ return { config: { ...CHAT_CONFIG, ...off, ...mcp } };
370
+ }
371
+ return memory ? { config: mcp } : {};
372
+ }
373
+
374
+ /**
375
+ * 这个会话进程还没告诉过 Codex 当前的模式和记忆:第一条消息后面附上 contextBlock。
376
+ * 进程一换(切模式、记忆改了、服务重启)就是新的 CodexSession,会再附一次最新的。
377
+ * 工作模式、没有记忆、模式也没换过:什么都不附,和原来一模一样。
378
+ */
379
+ private contextSent = false;
380
+
381
+ private get contextPending(): boolean {
382
+ return !this.contextSent && (this.chat || !!this.opts.memory || !!this.opts.announceMode);
383
+ }
384
+
280
385
  private async openOnce(): Promise<string> {
386
+ try {
387
+ return await this.openWith(false);
388
+ } catch (err) {
389
+ // 聊天模式的那串配置覆盖是按当前版本的 Codex 测出来的:万一哪个键不认导致起不来,退回最小覆盖
390
+ if (!(err instanceof ChatConfigRejected) || this.disposed) throw err;
391
+ console.warn("[codex] 聊天模式的配置覆盖没能生效,退回只换系统提示:", err.cause instanceof Error ? err.cause.message : err.cause);
392
+ return this.openWith(true);
393
+ }
394
+ }
395
+
396
+ private async openWith(minimal: boolean): Promise<string> {
281
397
  const shared = {
282
398
  cwd: this.opts.cwd,
283
399
  approvalPolicy: "on-request",
284
- sandbox: config().agents.codex.sandbox,
400
+ sandbox: this.sandboxMode,
285
401
  ...(this.opts.model ? { model: this.opts.model } : {}),
402
+ ...(await this.modeParams(minimal)),
286
403
  };
404
+ if (this.disposed) throw new DisposedError();
287
405
  const resumeId = this.threadId ?? this.opts.resumeId;
288
406
  const fork = this.opts.fork && !this.forked;
289
407
  // 这个 thread 正在别的会话里跑:不接管。接管后它的后续输出和审批请求都按 threadId
@@ -303,6 +421,8 @@ export class CodexSession implements AgentSession {
303
421
  } else {
304
422
  res = await appServer.call<ThreadResponse>("thread/start", shared);
305
423
  }
424
+ } catch (err) {
425
+ throw this.chat && !minimal ? new ChatConfigRejected(err) : err;
306
426
  } finally {
307
427
  opening.delete(this);
308
428
  }
@@ -370,10 +490,15 @@ export class CodexSession implements AgentSession {
370
490
  this.turnId = undefined;
371
491
  this.stopRequested = false;
372
492
  const token = ++this.turnSeq;
373
- const note = input.attachments.length
374
- ? describeAttachments(input.attachments, (a) => a.kind === "image")
375
- : "";
376
- const text = note ? (input.text ? `${input.text}\n\n${note}` : note) : input.text;
493
+ // 聊天模式没有执行命令的工具,读不了文件:文本附件的内容直接放进消息
494
+ const chat = this.chat;
495
+ const textMap = chat ? readTextAttachments(input.attachments) : new Map<string, string>();
496
+ const inlined = (a: SavedAttachment) => a.kind === "image" || textMap.has(a.path);
497
+ const note = input.attachments.length ? describeAttachments(input.attachments, inlined, chat) : "";
498
+ const texts = chat ? inlineTextBlocks(input.attachments, textMap) : "";
499
+ // 放在最后:Codex 拿第一条消息的开头当会话预览,不能被这段背景占掉
500
+ const context = this.contextPending ? contextBlock(chat ? "chat" : "work", this.opts.memory?.prompt, "联网搜索") : "";
501
+ const text = [input.text, note, texts, context].filter(Boolean).join("\n\n");
377
502
  const images = input.attachments.filter((a) => a.kind === "image").map((a) => a.path);
378
503
  let startedOn: string | undefined;
379
504
  void this.open()
@@ -391,12 +516,14 @@ export class CodexSession implements AgentSession {
391
516
  approvalPolicy: "on-request",
392
517
  // 每一轮都按当前配置显式给沙箱:thread 是开着复用的,只在 thread/start 时给的话,
393
518
  // 设置里收紧了沙箱(比如改成只读),已有会话还会按旧权限干活
394
- sandboxPolicy: sandboxPolicyFor(config().agents.codex.sandbox, this.opts.cwd),
519
+ sandboxPolicy: sandboxPolicyFor(this.sandboxMode, this.opts.cwd),
395
520
  ...(this.opts.model ? { model: this.opts.model } : {}),
396
521
  ...(this.opts.effort ? { effort: this.opts.effort } : {}),
397
522
  });
398
523
  })
399
524
  .then((res) => {
525
+ // 轮次开起来了,背景已经进了 thread 的历史;没开起来的话下一条消息再附一次
526
+ if (context) this.contextSent = true;
400
527
  const id = res?.turn?.id;
401
528
  if (!id) return;
402
529
  if (token === this.turnSeq) {