thincoder 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Sharp Code, Zero Bloat.**
4
4
 
5
- **一个"薄"的 AI 编程 agent:纯 `.mjs`、无构建、零 npm 依赖、Node.js 原生。**
5
+ **一个"薄"的 AI 编程 agent:纯** `.mjs`**、无构建、零 npm 依赖、Node.js 原生。**
6
6
 
7
7
  ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害**——像刀刃。
8
8
  在 AI agent 都在卷"全能"的今天,ThinCoder 打的是反面那张牌:**克制、精准、不废话**。
@@ -12,32 +12,28 @@ ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害*
12
12
 
13
13
  ## 特性
14
14
 
15
- - **Agent 主循环**:LLM ↔ 工具调用循环,直到任务完成(上限 100 轮防失控)
16
- - **工具集**:`read` / `write` / `edit` / `bash` / `glob` / `grep` / `websearch` / `ls` / `fetch` + `code_search` / `doc_search` / `repo_outline` + MCP,全部零依赖实现
17
- - **代码库理解**:`repo_outline`(依赖大纲)、`code_search`(源码 FTS5 + 向量 + JSDoc)、`doc_search`(文档分块检索)——启动时后台索引,状态栏显示进度;write/edit/delete 后自动增量更新
18
- - **两段式工具调度**:权限确认串行(一个一个问),只读工具并行执行,有副作用工具串行
19
- - **会话持久化**:退出自动保存,启动自动恢复(按项目目录隔离),`/new` 开始新会话
20
- - **子 agent 并发**:`subagent` 工具派发独立子任务,`role="explore"`(只读搜索)和 `role="coder"`(全套工具;写操作需 AUTO 模式),并发执行;coder 完成后自动提醒主 agent 校验报告
21
- - **Plan Mode**:`plan` 工具进入规划模式——只读探索 + 架构设计 + 方案展示,用户确认后退出并实现;TUI 状态栏显示 PLAN 标识
22
- - **AUTO 模式**:`/auto` `chat --auto` 完全授权,长任务免确认,状态栏黄色 AUTO 标识
23
- - **任务跟踪**:`task` 工具让 agent 拆解多步任务并跟踪进度(pending/in_progress/done),TUI 状态栏实时显示 ▶n/m;上下文压缩后自动回注
24
- - **Goal 跟踪**:`goal` 工具设置长期目标,每 ~10 轮自动提醒,跨压缩会话保持
25
- - **Skills 系统**:`.thincoder/skills/*.md` 按需加载,可复用工作流
26
- - **质量验证**:`verify` 工具——完成前自查 git diff + task 列表 + 6 项自检清单
27
- - **MCP 支持**:在 `config.json` `mcp.servers[]` 配 `command` / `args`,启动时自动连接并发现工具
28
- - **流式 TUI**:裸 ANSI 实现(无 UI 库),对话流 / 流式输出 / 权限确认(y/n/a,a=批准并转 AUTO)/ 翻页 / 输入历史 / 斜杠命令 Tab 补全
29
- - **上下文压缩**:对话超阈值时自动摘要(保留最早 2 条 + 最近 10 条,中间 LLM 摘要)
30
- - **三层记忆 + 团队共享**(见下)
31
- - **LLM 调用**:原生 `fetch` 直连 OpenAI 兼容协议(OpenAI / DeepSeek / Moonshot / Ollama),流式 SSE,指数退避重试,支持 `reasoning_content` 思考流
15
+ - **Agent 主循环**:LLM ↔ 工具调用循环,上限 100 轮防失控,完成守卫拦截未验证的改动
16
+ - **代码库理解** ⭐0.5.0:`repo_outline`(依赖大纲,启动自动注入)、`code_search`(源码 FTS5 + 向量 + JSDoc 提取)、`doc_search`(文档按 ## 标题分块检索)——后台索引、写文件自动增量更新、三工具按"结构→意图→细节"引导
17
+ - **模型适配** ⭐:5 家国产大模型内置预设(DeepSeek/Kimi/GLM/Qwen/MiniMax),自动匹配上下文窗口、截断续写协议(prefix/partial)、思考模式 API(thinking.type / reasoning_effort)、输出上限
18
+ - **工具集**:`read` / `write` / `edit` / `bash` / `glob`(支持 `**`) / `grep` / `websearch` / `ls` / `fetch` + 三个检索工具 + MCP,全部零依赖,文件工具目录隔离
19
+ - **记忆系统**:三层(personal/project/team),FTS5 + 向量 RRF 混合检索,markdown 格式 git 友好
20
+ - **两段式工具调度**:权限确认串行,只读工具并行,副作用工具串行
21
+ - **会话持久化** ⭐0.5.0:最多 5 个归档槽位,`/session` 随时切换,恢复时工具结果可见
22
+ - **子 agent 并发**:`explore`/`plan`/`coder` 三种角色,并行派发,流式输出可见,报告进对话区
23
+ - **Plan Mode**:只读探索 + 方案设计,用户确认后实现
24
+ - **AUTO 模式**:`/auto` 完全授权,长任务免确认
25
+ - **任务跟踪**:`task` 工具拆解多步任务,状态栏 ✓n/m 实时进度,自动过滤已完成项
26
+ - **Goal/Verify/Skills**:长目标跟踪、完成验证、可复用技能
27
+ - **流式 TUI**:裸 ANSI,权限预览紧挨输入框,write/edit 自动展示 diff
32
28
 
33
29
  ## 记忆系统:一人学到,全队皆知
34
30
 
35
31
  三层记忆,全部"有就查、没有就跳过",统一混合检索:
36
32
 
37
- | 层 | 位置 | 同步方式 |
38
- |---|---|---|
39
- | **Personal** | `~/.thincoder/memory.db`(sqlite) | 不同步,私有 |
40
- | **Project** | 项目仓库 `.thincoder/memory/*.md` | 随项目 git(ThinCoder **只写文件,绝不替你 commit**) |
33
+ | 层 | 位置 | 同步方式 |
34
+ | ---------------- | --------------------------------------------------- | --------------------------------------------------------------------------------- |
35
+ | **Personal** | `~/.thincoder/memory.db`(sqlite) | 不同步,私有 |
36
+ | **Project** | 项目仓库 `.thincoder/memory/*.md` | 随项目 git(ThinCoder **只写文件,绝不替你 commit**) |
41
37
  | **Team**(可选) | 独立记忆仓库,clone 到 `~/.thincoder/teams/<name>/` | `thincoder sync`(pull --rebase);写入时自动 commit + push(专用设施,可选启用) |
42
38
 
43
39
  - **混合检索**:FTS5(BM25,中文逐字索引,双字词可命中)+ embedding 向量(暴力余弦)+ RRF(k=60) 融合排序
@@ -48,7 +44,7 @@ ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害*
48
44
 
49
45
  ## 要求
50
46
 
51
- - Node.js >= 22(记忆功能用到 `node:sqlite`;推荐 24
47
+ - Node.js >= 24
52
48
  - 一个 OpenAI 兼容端点的 API key
53
49
  - 可选:embedding 服务的 key(不配置则退化为纯 FTS 检索)
54
50
 
@@ -88,7 +84,7 @@ thincoder upgrade
88
84
 
89
85
  从源码运行:把上面的 `thincoder` 换成 `node bin/thincoder.mjs`。
90
86
 
91
- TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全部模型;`/model <名称>` 直接切换)、`/provider`(增/删 provider、配 key,支持自定义端点)、`/think`(思维模式开关与推理强度)、`/config`(查看配置、`/config embedkey` 配 embedding key、`/config set` 改参数)、`/distill`(从当前会话提取知识)、`/clear`、`/exit`。输入 `/` 时状态栏实时提示匹配命令。
87
+ TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全部模型;`/model <名称>` 直接切换)、`/provider`(增/删 provider、配 key,支持自定义端点)、`/think`(思维模式开关与推理强度)、`/config`(查看配置、`/config embedkey` 配 embedding key、`/config set` 改参数)、`/session`(列出/切换归档会话)、`/reindex`(重建索引)、`/distill`(从当前会话提取知识)、`/clear`、`/exit`。输入 `/` 时状态栏实时提示匹配命令。
92
88
 
93
89
  环境变量:`THINCODER_API_KEY`(或 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`)、`THINCODER_BASE_URL`、`THINCODER_MODEL`、`SILICONFLOW_API_KEY`。
94
90
 
@@ -98,41 +94,45 @@ TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全
98
94
 
99
95
  ```jsonc
100
96
  {
101
- "providers": [ // 可配多个,/model <名称> 切换
97
+ "providers": [
98
+ // 可配多个,/model <名称> 切换
102
99
  {
103
100
  "name": "deepseek",
104
- "baseURL": "https://api.deepseek.com/v1", // 任意 OpenAI 兼容端点
105
- "apiKey": "sk-...", // 或留空走环境变量
106
- "model": "deepseek-chat"
107
- }
101
+ "baseURL": "https://api.deepseek.com/v1", // 任意 OpenAI 兼容端点
102
+ "apiKey": "sk-...", // 或留空走环境变量
103
+ "model": "deepseek-chat",
104
+ },
108
105
  ],
109
- "activeProvider": "deepseek", // 当前激活的 provider 名
110
- "embedding": { // 可选:不配则纯 FTS 检索
106
+ "activeProvider": "deepseek", // 当前激活的 provider 名
107
+ "embedding": {
108
+ // 可选:不配则纯 FTS 检索
111
109
  "baseURL": "https://api.siliconflow.cn/v1",
112
- "apiKey": "sk-...", // 或 SILICONFLOW_API_KEY
113
- "model": "BAAI/bge-m3"
110
+ "apiKey": "sk-...", // 或 SILICONFLOW_API_KEY
111
+ "model": "BAAI/bge-m3",
114
112
  },
115
113
  "agent": {
116
- "maxTurns": 100, // 工具循环上限
117
- "compactThreshold": 100000 // 上下文压缩阈值(约 token 数)
114
+ "maxTurns": 100, // 工具循环上限
115
+ "compactThreshold": 100000, // 上下文压缩阈值(约 token 数)
118
116
  },
119
117
  "memory": {
120
- "dbPath": "~/.thincoder/memory.db", // sqlite 索引库路径
121
- "projectDir": ".thincoder/memory", // Project 层目录(相对项目根)
122
- "team": { // 可选:不配则 Team 层禁用
118
+ "dbPath": "~/.thincoder/memory.db", // sqlite 索引库路径
119
+ "projectDir": ".thincoder/memory", // Project 层目录(相对项目根)
120
+ "team": {
121
+ // 可选:不配则 Team 层禁用
123
122
  "name": "myteam",
124
- "repo": "git@github.com:org/team-memory.git"
125
- }
123
+ "repo": "git@github.com:org/team-memory.git",
124
+ },
126
125
  },
127
- "mcp": { // 可选:MCP server 列表
126
+ "mcp": {
127
+ // 可选:MCP server 列表
128
128
  "servers": [
129
129
  {
130
130
  "name": "filesystem",
131
131
  "command": "npx",
132
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
133
- }
134
- ]
135
- }
132
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
133
+ },
134
+ ],
135
+ },
136
136
  }
137
137
  ```
138
138
 
@@ -187,6 +187,44 @@ node scripts/verify-team.mjs # 团队记忆 A->git->B 全链路验证(本
187
187
  - MCP HTTP transport(当前仅 stdio)
188
188
  - 更多内置 skills
189
189
 
190
+ ## 更新日志
191
+
192
+ ### 0.5.0(2026-07)
193
+ - **代码库理解**:`repo_outline`(依赖大纲,启动自动注入)、`code_search`(FTS5 + 向量 + JSDoc)、`doc_search`(按 ## 标题分块),写文件自动增量索引
194
+ - **模型适配**:5 家内置预设(DeepSeek/Kimi/GLM/Qwen/MiniMax),maxTokens 拉满、截断续写、思考模式 API 自动匹配
195
+ - 会话 5 槽位归档、`/session` 切换、恢复时展示工具结果
196
+ - 子 agent 流式输出可见、最终报告进对话区
197
+ - 文件工具目录隔离、权限预览紧挨输入框
198
+ - write/edit 自动附 git diff、edit 错误信息增强提示
199
+ - task 自动过滤已完成项、全部 done 时主动提醒
200
+ - 提示词引导"查官方文档 → 不一致就存项目记忆"
201
+
202
+ ### 0.4.0
203
+ - 权限审批展示文件内容预览(write 内容、edit diff、bash 命令)
204
+ - todo 面板进度可视、状态栏 token 用量与上下文利用率
205
+ - 项目指令双层合并(全局 + 项目 AGENTS.md)
206
+
207
+ ### 0.3.0
208
+ - MCP 客户端(JSON-RPC + stdio,零依赖)
209
+ - Skills 系统(`.thincoder/skills/*.md`)
210
+ - Plan/Goal/Question 工具
211
+ - 提示词外部化到 `.md` 文件、子 agent 角色 overlay
212
+ - task 严格纪律(keep ONE in_progress)、完成守卫(改文件未 verify 拦截)
213
+ - DeepSeek thinking 回传、system prompt 前缀缓存
214
+ - checkpoint 存档点 + `/rewind` 回滚
215
+
216
+ ### 0.2.0
217
+ - multi-provider 配置(支持多端点切换)
218
+ - 初始配置向导(方向键选模型、配 key)
219
+ - `/think` 思维模式开关与推理强度
220
+ - `/model` 模型选择器
221
+ - bash 流式输出实时透传
222
+
223
+ ### 0.1.0
224
+ - Agent 主循环、14 个内置工具、零依赖 TUI
225
+ - 三层记忆(personal/project/team)、FTS5 检索
226
+ - 会话持久化、上下文压缩、流式 SSE
227
+
190
228
  ## License
191
229
 
192
230
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -17,7 +17,7 @@
17
17
  "thincoder": "./bin/thincoder.mjs"
18
18
  },
19
19
  "engines": {
20
- "node": ">=22"
20
+ "node": ">=24"
21
21
  },
22
22
  "files": [
23
23
  "bin/",
@@ -25,6 +25,7 @@ Coding discipline (rigor over speed—tokens spent on verification are well spen
25
25
  - Before fixing a bug, find the root cause: read the error output, reproduce it, trace the code path. Don't patch symptoms.
26
26
  - Match the surrounding code: comment density, naming, structure. Prefer the project's existing patterns over your own defaults.
27
27
  - Before using a library or utility, confirm the project already depends on it (check imports, manifest, lockfile). If it's missing, surface that instead of silently adding a dependency.
28
+ - When you need facts that may be outdated in your training data—API docs, framework versions, language features, npm packages, CLI flags, pricing, CVEs, platform differences—verify with authoritative sources first: read the project's own files (package.json, lockfile), check official docs (websearch/fetch), or test the actual environment. If findings contradict your training data, save the corrected fact to project memory so future sessions benefit.
28
29
  - Refactoring: update every caller when an interface changes; never change existing test logic just to make tests pass.
29
30
  - Deliver complete changes: no placeholder stubs, no "// rest unchanged", no TODO gaps left for the user to fill in.
30
31
  - After changing behavior, sweep comments and docstrings that now describe the old behavior and bring them in line with the code.
package/src/agent.mjs CHANGED
@@ -296,6 +296,12 @@ export const subagentTool = {
296
296
  const relayPrefix = role ? `${role}/` : "sub/"
297
297
  const childOpts = {
298
298
  onPermissionRequest: childPermission,
299
+ onToken: ctx.callbacks?.onToken
300
+ ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`)
301
+ : null,
302
+ onReasoning: ctx.callbacks?.onReasoning
303
+ ? (t) => ctx.callbacks.onReasoning(`${relayPrefix}${t}`)
304
+ : null,
299
305
  onToolCall: ctx.callbacks?.onToolCall
300
306
  ? (name, args) => ctx.callbacks.onToolCall(`${relayPrefix}${name}`, args)
301
307
  : null,
@@ -373,10 +379,14 @@ export const taskTool = {
373
379
  },
374
380
  readonly: true,
375
381
  async execute(args, ctx) {
376
- const items = (args.items ?? []).map((it) => ({
382
+ // 只保留非 done + 最近完成的 3 项(上下文参考),上限 20 项防堆积
383
+ const raw = (args.items ?? []).map((it) => ({
377
384
  title: String(it.title ?? "").slice(0, 200),
378
385
  status: VALID_TASK_STATUS.has(it.status) ? it.status : "pending",
379
386
  }))
387
+ const pending = raw.filter((t) => t.status !== "done")
388
+ const recentDone = raw.filter((t) => t.status === "done").slice(-3)
389
+ const items = [...pending, ...recentDone].slice(0, 20)
380
390
  ctx.agent.tasks = items
381
391
  ctx.agent._turnsSinceTaskUpdate = 0
382
392
  ctx.agent._onTaskUpdate?.(items)
@@ -678,6 +688,16 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
678
688
  if (tree) {
679
689
  agent.history.push({ role: "user", content: `[System reminder: working directory snapshot:\n${tree}]`, transient: true })
680
690
  }
691
+ // 依赖大纲:模型开局就能看见谁 import 谁,不用盲调 repo_outline
692
+ if (agent.memory) {
693
+ try {
694
+ const { buildOutline } = await import("./repomap.mjs")
695
+ const outline = buildOutline(agent.memory.db, agent.cwd, null)
696
+ if (outline && !outline.startsWith("(no indexed")) {
697
+ agent.history.push({ role: "user", content: `[System reminder: project dependency outline:\n${outline}]`, transient: true })
698
+ }
699
+ } catch { /* 索引未就绪不报错 */ }
700
+ }
681
701
  }
682
702
  // 相关记忆作为独立 user 上下文消息注入,而不是塞进 system prompt——
683
703
  // system prompt 跨 run 逐字节一致,DeepSeek context caching(前缀缓存,命中便宜 ~120x)才能命中
@@ -922,6 +942,12 @@ function tryCanonicalize(name, args) {
922
942
  role: "user",
923
943
  content: "[System reminder: no task list is being tracked. If the current work is a multi-step task, consider using the task tool to plan and track progress. This is a gentle reminder; ignore it if not applicable. Never mention this reminder to the user.]",
924
944
  })
945
+ } else {
946
+ // 全部 done 但面板可能有残留:提示模型要么清掉要么加新任务
947
+ agent.history.push({
948
+ role: "user",
949
+ content: "[System reminder: all tracked tasks are marked done. Use the task tool to clear the list or add new tasks if there's more work. Never mention this reminder to the user.]",
950
+ })
925
951
  }
926
952
  agent._turnsSinceTaskUpdate = 0
927
953
  }
package/src/config.mjs CHANGED
@@ -14,10 +14,11 @@ export const configPath = join(configDir, "config.json")
14
14
 
15
15
  /** 内置提供商预设:/provider add <预设名>、首次启动向导共用 */
16
16
  export const PROVIDER_PRESETS = {
17
- deepseek: { baseURL: "https://api.deepseek.com/v1", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", desc: "DeepSeek" },
18
- kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "high", desc: "Kimi / Moonshot" },
19
- glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", desc: "智谱 GLM" },
20
- qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen-plus", desc: "通义千问" },
17
+ deepseek: { baseURL: "https://api.deepseek.com/v1", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
18
+ kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
19
+ glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 131072, desc: "智谱 GLM" },
20
+ qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", maxTokens: 131072, desc: "通义千问" },
21
+ minimax: { baseURL: "https://api.minimax.chat/v1", chatPath: "/text/chatcompletion_v2", model: "MiniMax-M3", maxTokens: 131072, desc: "MiniMax" },
21
22
  }
22
23
 
23
24
  // 默认 provider 跟 deepseek 预设保持一致(去掉 desc 展示字段)
@@ -45,28 +46,47 @@ const DEFAULTS = {
45
46
  }
46
47
 
47
48
  /**
48
- * 已知模型的规格表(前缀匹配,长的在前)。
49
- * context: 上下文窗口
50
- * partialMode: Kimi Partial Mode 截断续写协议(assistant 消息带 partial:true;Kimi 与 Qwen 同协议,仅非思考模式)
51
- * prefixMode: DeepSeek Chat Prefix Completion 截断续写协议(assistant 消息带 prefix:true,走 /beta 端点)
52
- * compactThreshold 未显式配置时,按窗口 * COMPACT_RATIO 自动推导。
53
- * 注:deepseek-chat / deepseek-reasoner 已于 2026-07-24 弃用,官方映射到 v4-flash 非思考/思考模式。
49
+ * 已知模型的能力规格表(前缀匹配,长的在前)。
50
+ * 用于压缩阈值推导、截断续写协议选择、能力感知优化。
51
+ *
52
+ * context: 上下文窗口(tokens)
53
+ * maxOutput: 最大输出 tokens(默认 context)
54
+ * thinking: 是否支持思考/推理模式
55
+ * partialMode: Kimi/Qwen Partial Mode 截断续写(assistant 消息带 partial:true)
56
+ * prefixMode: DeepSeek Prefix Completion 截断续写(走 /beta 端点,带 prefix:true)
57
+ * multimodal: 是否多模态(支持图片/视觉输入)
58
+ * cacheMode: 上下文缓存方式:"auto"=自动/"prompt"=需显式/"none"=不支持
59
+ * thinkApi: 思考模式 API 类型:"type"=thinking.type 字段 / "effort"=reasoning_effort 字段
54
60
  */
55
61
  const MODEL_SPECS = [
56
- ["deepseek-v4-pro", { context: 1_000_000, prefixMode: true }],
57
- ["deepseek-v4-flash", { context: 256_000, prefixMode: true }],
58
- ["deepseek-reasoner", { context: 256_000, prefixMode: true }],
59
- ["deepseek-chat", { context: 256_000, prefixMode: true }],
60
- ["kimi-k3", { context: 1_000_000, partialMode: true }],
61
- ["kimi-k2", { context: 256_000 }],
62
- ["moonshot", { context: 128_000 }],
63
- ["glm-5", { context: 1_000_000 }],
64
- ["glm-4", { context: 128_000 }],
65
- ["gpt-4.1", { context: 1_000_000 }],
66
- ["gpt-4o", { context: 128_000 }],
67
- ["qwen", { context: 1_000_000, partialMode: true }],
62
+ // DeepSeek V4 系列
63
+ ["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
64
+ ["deepseek-v4-flash", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
65
+ ["deepseek-reasoner", { context: 256_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
66
+ ["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type" }],
67
+ // Kimi 系列
68
+ ["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "prompt", thinkApi: "effort" }],
69
+ ["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
70
+ ["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
71
+ // GLM 系列
72
+ ["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
73
+ ["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
74
+ ["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type" }],
75
+ // GPT 系列
76
+ ["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
77
+ ["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
78
+ // Qwen 系列
79
+ ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
80
+ ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
81
+ ["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
82
+ ["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
83
+ ["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, cacheMode: "none", thinkApi: "effort" }],
84
+ // MiniMax 系列
85
+ ["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
86
+ ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
87
+ ["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
68
88
  ]
69
- const DEFAULT_SPEC = { context: 128_000 }
89
+ const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
70
90
  // 窗口利用率上限:0.8(DeepSeek 内部即全窗口;压缩本身要花一次 LLM 调用,过早压缩是纯浪费。
71
91
  // 留 20% 余量给压缩后的尾部增长与输出 token)
72
92
  const COMPACT_RATIO = 0.8
package/src/memory.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  import { DatabaseSync } from "node:sqlite"
14
14
  import { mkdirSync } from "node:fs"
15
15
  import { readFile, readdir, stat, writeFile, mkdir } from "node:fs/promises"
16
- import { dirname, join } from "node:path"
16
+ import { dirname, join, relative } from "node:path"
17
17
  import { parseEntry, serializeEntry, entryFilename } from "./markdown.mjs"
18
18
  import { embed, cosine, toBlob, fromBlob } from "./embedding.mjs"
19
19
  import { commitAndPush } from "./gitmem.mjs"
@@ -664,6 +664,33 @@ function extractLeadingDoc(lines, lineNum, ext) {
664
664
  return text.length > 0 && text.length < 300 ? text : ""
665
665
  }
666
666
 
667
+ /** 单文件入索引:删除旧块 → 分块 → 插入新块(codeSync 和 reindexFile 共用) */
668
+ function _upsertCodeFile(memory, rel, lines, lang, mtimeMs) {
669
+ const chunks = chunkCode(lines, rel)
670
+ memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
671
+ const insert = memory.db.prepare(`
672
+ INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
673
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
674
+ `)
675
+ for (const c of chunks) {
676
+ const isFile = c.name === rel
677
+ insert.run(rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
678
+ }
679
+ }
680
+
681
+ function _upsertDocFile(memory, rel, lines, mtimeMs) {
682
+ const chunks = chunkMarkdown(lines, rel)
683
+ const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
684
+ memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
685
+ const insert = memory.db.prepare(`
686
+ INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
688
+ `)
689
+ for (const c of chunks) {
690
+ insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
691
+ }
692
+ }
693
+
667
694
  /**
668
695
  * 同步代码索引:扫描 dir 下所有源文件 → 分块 → upsert 到 code_chunks。
669
696
  * 按 mtime 增量——只重建变更过的文件块。
@@ -713,28 +740,7 @@ export async function codeSync(memory, dir, { onProgress } = {}) {
713
740
  try { text = await readFile(abs, "utf8") } catch { continue }
714
741
  const lines = text.split("\n")
715
742
  const lang = detectLanguage(abs)
716
- const chunks = chunkCode(lines, rel)
717
-
718
- // 删除该文件的旧块,插入新块
719
- memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
720
- const insert = memory.db.prepare(`
721
- INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
722
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
723
- `)
724
- for (const c of chunks) {
725
- const isFile = c.name === rel
726
- insert.run(
727
- rel,
728
- lang,
729
- isFile ? "file" : "symbol",
730
- isFile ? "" : c.name.slice(rel.length + 1), // 去掉 "filepath:" 前缀
731
- c.content,
732
- c.line_start,
733
- c.line_end,
734
- mtimeMs,
735
- segmentCJK(c.content),
736
- )
737
- }
743
+ _upsertCodeFile(memory, rel, lines, lang, mtimeMs)
738
744
  updated++
739
745
 
740
746
  if (onProgress && i % 10 === 0) {
@@ -851,48 +857,27 @@ export function codeSearchTool(memory) {
851
857
  */
852
858
  export async function reindexFile(memory, cwd, absPath) {
853
859
  const ext = absPath.slice(absPath.lastIndexOf(".")).toLowerCase()
854
- const rel = absPath.slice(cwd.length + 1).replaceAll("\\", "/")
860
+ const rel = relative(cwd, absPath).replaceAll("\\", "/")
861
+ if (rel.startsWith("..")) return // 越界路径拒索引
862
+
863
+ let text
864
+ try { text = await readFile(absPath, "utf8") } catch {
865
+ // 文件已删:清理索引
866
+ if (CODE_EXTS.has(ext)) memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
867
+ else if (DOC_EXTS.has(ext)) memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
868
+ return
869
+ }
870
+ const lines = text.split("\n")
855
871
 
856
872
  if (CODE_EXTS.has(ext)) {
857
- // 文件已删?清理索引
858
- let text
859
- try { text = await readFile(absPath, "utf8") } catch {
860
- memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
861
- return
862
- }
863
- const lines = text.split("\n")
864
873
  const lang = detectLanguage(absPath)
865
- const chunks = chunkCode(lines, rel)
866
- memory.db.prepare(`DELETE FROM code_chunks WHERE path = ?`).run(rel)
867
- const insert = memory.db.prepare(`
868
- INSERT INTO code_chunks (path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
869
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
870
- `)
871
874
  let mtimeMs = 0
872
875
  try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
873
- for (const c of chunks) {
874
- const isFile = c.name === rel
875
- insert.run(rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
876
- }
876
+ _upsertCodeFile(memory, rel, lines, lang, mtimeMs)
877
877
  } else if (DOC_EXTS.has(ext)) {
878
- let text
879
- try { text = await readFile(absPath, "utf8") } catch {
880
- memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
881
- return
882
- }
883
- const lines = text.split("\n")
884
- const chunks = chunkMarkdown(lines, rel)
885
- memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
886
- const insert = memory.db.prepare(`
887
- INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
888
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
889
- `)
890
- const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
891
878
  let mtimeMs = 0
892
879
  try { mtimeMs = Math.floor((await stat(absPath)).mtimeMs) } catch { /* 新文件 */ }
893
- for (const c of chunks) {
894
- insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
895
- }
880
+ _upsertDocFile(memory, rel, lines, mtimeMs)
896
881
  }
897
882
  }
898
883
 
@@ -968,17 +953,7 @@ export async function docSync(memory, dir, { onProgress } = {}) {
968
953
  let text
969
954
  try { text = await readFile(abs, "utf8") } catch { continue }
970
955
  const lines = text.split("\n")
971
- const chunks = chunkMarkdown(lines, rel)
972
-
973
- memory.db.prepare(`DELETE FROM doc_chunks WHERE path = ?`).run(rel)
974
- const insert = memory.db.prepare(`
975
- INSERT INTO doc_chunks (path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
977
- `)
978
- const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
979
- for (const c of chunks) {
980
- insert.run(rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
981
- }
956
+ _upsertDocFile(memory, rel, lines, mtimeMs)
982
957
  updated++
983
958
 
984
959
  if (onProgress && i % 10 === 0) {
package/src/provider.mjs CHANGED
@@ -132,7 +132,7 @@ async function requestWithRetry(provider, body, signal) {
132
132
 
133
133
  let response
134
134
  try {
135
- response = await fetch(`${provider.baseURL}/chat/completions`, {
135
+ response = await fetch(`${provider.baseURL}${provider.chatPath ?? "/chat/completions"}`, {
136
136
  method: "POST",
137
137
  headers: {
138
138
  "Content-Type": "application/json",
package/src/tools/bash.md CHANGED
@@ -12,3 +12,4 @@ Notes:
12
12
  - Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
13
13
  - Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
14
14
  - After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
15
+ - Prefer read/glob/grep/ls for file operations inside the project — bash has no directory confinement.
package/src/tools/glob.md CHANGED
@@ -1,4 +1,4 @@
1
- Find files by glob pattern (e.g. 'src/**/*.mjs'). Returns matching paths.
1
+ Find files by glob pattern. Returns matching paths. Supports `**` for recursive matching (e.g. `src/**/*.mjs` for all .mjs in src/, `**/*.test.mjs` for all test files).
2
2
 
3
3
  Parameters:
4
4
  - pattern (required): Glob pattern — supports **, *, ?, and character classes
package/src/tools.mjs CHANGED
@@ -48,6 +48,22 @@ function truncate(text, max = MAX_OUTPUT_CHARS) {
48
48
  return text.slice(0, max) + `\n... (truncated, ${text.length - max} chars omitted)`
49
49
  }
50
50
 
51
+ /** 对单个文件取 git diff,失败静默返回空 */
52
+ function gitDiffOne(cwd, abs) {
53
+ try {
54
+ const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
55
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024,
56
+ }).trim()
57
+ if (!diff) return ""
58
+ // 截断超长 diff(超大文件改动 diff 可能几十 KB),只保留前 200 行
59
+ const lines = diff.split("\n")
60
+ if (lines.length <= 200) return diff
61
+ return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
62
+ } catch {
63
+ return ""
64
+ }
65
+ }
66
+
51
67
  function resolveInCwd(ctx, p) {
52
68
  const resolved = resolve(ctx.cwd, p)
53
69
  if (relative(ctx.cwd, resolved).startsWith("..")) throw new Error(`Access denied outside working directory: ${p}`)
@@ -102,7 +118,8 @@ const writeTool = {
102
118
  const st = await stat(abs).catch(() => null)
103
119
  if (st?.isDirectory()) throw new Error(`Path is a directory: ${abs}`)
104
120
  await writeFile(abs, args.content, "utf8")
105
- return `Wrote ${args.content.length} chars to ${abs}`
121
+ const diff = gitDiffOne(ctx.cwd, abs)
122
+ return `Wrote ${args.content.length} chars to ${abs}${diff ? "\n" + diff : ""}`
106
123
  },
107
124
  }
108
125
 
@@ -127,7 +144,13 @@ const editTool = {
127
144
  const content = await readFile(abs, "utf8")
128
145
  const occurrences = content.split(args.old_string).length - 1
129
146
  if (occurrences === 0) {
130
- throw new Error(`old_string not found in ${abs}`)
147
+ // 给出线索帮模型定位:首行预览 + 常见原因
148
+ const preview = args.old_string.slice(0, 100).split("\n")[0]
149
+ throw new Error(
150
+ `old_string not found in ${abs}\n` +
151
+ ` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
152
+ ` hints: whitespace mismatch? file already changed? try reading the file first`
153
+ )
131
154
  }
132
155
  if (occurrences > 1 && !args.replace_all) {
133
156
  throw new Error(`old_string matches ${occurrences} times in ${abs}; provide more context or set replace_all`)
@@ -136,7 +159,8 @@ const editTool = {
136
159
  ? content.split(args.old_string).join(args.new_string)
137
160
  : content.replace(args.old_string, args.new_string)
138
161
  await writeFile(abs, updated, "utf8")
139
- return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)`
162
+ const diff = gitDiffOne(ctx.cwd, abs)
163
+ return `Edited ${abs}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}`
140
164
  },
141
165
  }
142
166
 
@@ -272,7 +296,7 @@ const globTool = {
272
296
  },
273
297
  readonly: true,
274
298
  async execute(args, ctx) {
275
- const base = resolve(ctx.cwd, args.path ?? ".")
299
+ const base = resolveInCwd(ctx, args.path ?? ".")
276
300
  const regex = globToRegex(args.pattern)
277
301
  const results = []
278
302
  for await (const relPath of walkFiles(base)) {
@@ -336,7 +360,7 @@ const grepTool = {
336
360
  },
337
361
  readonly: true,
338
362
  async execute(args, ctx) {
339
- const base = resolve(ctx.cwd, args.path ?? ".")
363
+ const base = resolveInCwd(ctx, args.path ?? ".")
340
364
  const regex = new RegExp(args.pattern)
341
365
  const fileFilter = args.glob ? globToRegex(args.glob) : null
342
366
  const matches = []
@@ -461,7 +485,7 @@ const lsTool = {
461
485
  },
462
486
  readonly: true,
463
487
  async execute(args, ctx) {
464
- const abs = resolve(ctx.cwd, args.path ?? ".")
488
+ const abs = resolveInCwd(ctx, args.path ?? ".")
465
489
  const entries = await readdir(abs, { withFileTypes: true })
466
490
  const rows = await Promise.all(
467
491
  entries.slice(0, 500).map(async (e) => {
package/src/tui.mjs CHANGED
@@ -265,20 +265,28 @@ export async function startTUI(agent, opts = {}) {
265
265
  processing: false,
266
266
  controller: null, // AbortController for current agent run
267
267
  permission: null, // { name, args, resolve }
268
+ permissionPreview: [], // 权限审批的内容预览行(渲染在输入框上方,不分隔)
268
269
  question: null, // { text, options, resolve } — agent 的 question 工具回调
269
270
  picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
270
271
  wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
271
- tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上
272
+ tasks: agent.tasks ?? [], // task 工具的任务列表(状态栏显示进度);会话恢复时直接带上,全完成自动收起
272
273
  tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // 累计 token 用量(状态栏显示)
273
274
  ctxCache: { len: -1, tokens: 0 }, // 上下文占用估算缓存(estimateTokens 是 O(n),history 变长才重算)
274
275
  reasoning: "", // 思考流缓冲(暗色展示)
275
276
  completion: null, // Tab 补全状态 { candidates, index }
276
277
  toolStreams: {}, // 各工具的实时输出(按工具名隔离,并行工具互不串扰)
278
+ subOutput: "", // 子 agent 流式输出(滚动显示,最长保留末尾 300 字符)
279
+ currentSub: null, // 当前活跃的子 agent 角色名
277
280
  currentTool: null, // 正在执行的工具名(状态栏显示)
278
281
  processingStarted: 0, // 本轮处理开始时间(状态栏计时)
279
282
  status: "Ready",
280
283
  }
281
284
 
285
+ // 恢复的会话如果所有任务已完成,自动收起 todo 面板(对齐运行时行为)
286
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
287
+ state.tasks = []
288
+ }
289
+
282
290
  // 输入流先过一道滤网:鼠标序列(滚轮)在这里拦截处理,剥净后才交给 keypress 解析,
283
291
  // 防止序列残片(如 "64;72;42M")漏进输入框
284
292
  const keyStream = new PassThrough()
@@ -424,7 +432,11 @@ export async function startTUI(agent, opts = {}) {
424
432
  visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
425
433
  }
426
434
  const taskPanelH = visibleTasks.length
427
- const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH)
435
+ // agent 流式输出占位(显示时占最多 2 行)
436
+ const subOutLen = (state.subOutput && state.processing) ? wrapText(state.subOutput, W - 8).slice(-2).length : 0
437
+ // 权限预览占位
438
+ const permPreviewLen = state.permission ? 1 + state.permissionPreview.reduce((s, l) => s + wrapText(` ${l}`, W - 1).length, 0) : 0
439
+ const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH - taskPanelH - subOutLen - permPreviewLen)
428
440
 
429
441
  // 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
430
442
  const convLines = []
@@ -498,6 +510,25 @@ export async function startTUI(agent, opts = {}) {
498
510
  out.push(`${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}${ansi.clearLine}`)
499
511
  }
500
512
 
513
+ // 子 agent 流式输出(最多 2 行,滚动显示最新内容)
514
+ if (state.subOutput && state.processing) {
515
+ const lines = wrapText(state.subOutput, W - 8)
516
+ const tail = lines.slice(-2)
517
+ for (const l of tail) {
518
+ out.push(`${C.dim}[${state.currentSub}] ${l}${ansi.reset}${ansi.clearLine}`)
519
+ }
520
+ }
521
+
522
+ // 权限审批内容预览(黄色,紧挨输入框上方)
523
+ if (state.permission) {
524
+ out.push(`${ansi.bold}${C.warn}❯ 权限请求${ansi.reset}${ansi.clearLine}`)
525
+ for (const line of state.permissionPreview) {
526
+ for (const wrapped of wrapText(` ${line}`, W - 1)) {
527
+ out.push(`${C.warn}${wrapped}${ansi.reset}${ansi.clearLine}`)
528
+ }
529
+ }
530
+ }
531
+
501
532
  // 输入框(全边框,宽 W)
502
533
  let borderColor = C.tool
503
534
  let title
@@ -571,7 +602,7 @@ export async function startTUI(agent, opts = {}) {
571
602
  }
572
603
  } else {
573
604
  const taskHint = state.tasks.length > 0
574
- ? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
605
+ ? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
575
606
  : ""
576
607
  // token 用量:↑输入 ↓输出 + 缓存命中率(DeepSeek usage 带 prompt_cache_hit/miss_tokens)
577
608
  const tk = state.tokens
@@ -666,6 +697,14 @@ export async function startTUI(agent, opts = {}) {
666
697
 
667
698
  const callbacks = {
668
699
  onToken: (t) => {
700
+ // 子 agent 流式输出:前缀匹配 explore/coder/plan 的 token 进 subOutput
701
+ const subMatch = t.match(/^(explore|coder|plan)\//)
702
+ if (subMatch) {
703
+ state.currentSub = subMatch[1]
704
+ state.subOutput = (state.subOutput + t.slice(subMatch[0].length)).slice(-300)
705
+ scheduleRender()
706
+ return
707
+ }
669
708
  ensureAssistantLabel()
670
709
  state.streaming += t
671
710
  scheduleRender()
@@ -683,14 +722,27 @@ export async function startTUI(agent, opts = {}) {
683
722
  },
684
723
  onToolResult: (name, result) => {
685
724
  state.currentTool = null
725
+ // 子 agent 结束:清空流式缓冲,报告进对话区
726
+ const isSubagent = name.startsWith("explore/") || name.startsWith("coder/") || name.startsWith("plan/")
727
+ if (isSubagent) {
728
+ state.subOutput = ""
729
+ state.currentSub = null
730
+ // 子 agent 报告摘要(最多 8 行)直接展示在对话区
731
+ const lines = result.split("\n")
732
+ const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
733
+ if (preview) pushLine(preview, C.dim)
734
+ if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
735
+ }
686
736
  const stream = state.toolStreams[name]
687
737
  if (stream) {
688
738
  const tail = stream.trimEnd().slice(-4000)
689
739
  if (tail) pushLine(tail, C.dim)
690
740
  delete state.toolStreams[name]
691
741
  }
692
- const first = result.split("\n")[0]
693
- pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
742
+ if (!isSubagent) {
743
+ const first = result.split("\n")[0]
744
+ pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
745
+ }
694
746
  },
695
747
  onToolOutput: (name, chunk) => {
696
748
  state.toolStreams[name] = (state.toolStreams[name] ?? "") + chunk
@@ -798,10 +850,8 @@ export async function startTUI(agent, opts = {}) {
798
850
  pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
799
851
  return Promise.resolve(true)
800
852
  }
801
- // 把关键参数摆出来:批什么要让人看明白
802
- // 内容行用警告色——与正常输出(白)区分,滚动回看也能认出这是待审批内容
803
- pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
804
- for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.warn)
853
+ // 预览内容存到 permissionPreview,渲染在输入框上方紧挨"Allow?"提示
854
+ state.permissionPreview = formatPermission(name, args)
805
855
  return new Promise((resolve) => {
806
856
  state.permission = { name, args, resolve }
807
857
  state.status = `Waiting: ${name}`
@@ -918,6 +968,10 @@ export async function startTUI(agent, opts = {}) {
918
968
  ? data.display.map((l) => ({ text: l.text, color: l.color }))
919
969
  : []
920
970
  state.tasks = agent.tasks ?? []
971
+ // 切换过来的会话如果任务全完成,自动收起面板
972
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
973
+ state.tasks = []
974
+ }
921
975
  pushLabel(`── 已切换到槽位 ${slotNum}(${data.history.length} 条消息)──`, C.warn)
922
976
  render()
923
977
  }
@@ -1226,12 +1280,23 @@ export async function startTUI(agent, opts = {}) {
1226
1280
  // ---- /think on / off ----
1227
1281
  if (sub === "on" || sub === "off") {
1228
1282
  const enable = sub === "on"
1229
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1230
- if (!enable) delete cur.reasoningEffort
1231
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1232
- await syncProviderField("thinking", cur.thinking)
1233
- if (!enable) await syncProviderField("reasoningEffort", undefined)
1234
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1283
+ // 仅用 reasoning_effort 的模型(K3):不碰 thinking 字段,只设/删 reasoningEffort
1284
+ const { specForModel } = await import("./config.mjs")
1285
+ const spec = specForModel(cur.model)
1286
+ if (spec.thinkApi === "effort") {
1287
+ // 仅用 reasoning_effort 的模型(K3 / Qwen):不碰 thinking 字段
1288
+ if (!enable) delete cur.reasoningEffort
1289
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1290
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1291
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1292
+ } else {
1293
+ cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
1294
+ if (!enable) delete cur.reasoningEffort
1295
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
1296
+ await syncProviderField("thinking", cur.thinking)
1297
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
1298
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
1299
+ }
1235
1300
  pushLabel(`❯ Think`, ansi.bold + C.tool)
1236
1301
  pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
1237
1302
  if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
@@ -1864,6 +1929,7 @@ export async function startTUI(agent, opts = {}) {
1864
1929
  if (validKeys.includes(answer) || key.name === "escape") {
1865
1930
  const { resolve, name } = state.permission
1866
1931
  state.permission = null
1932
+ state.permissionPreview = []
1867
1933
  state.status = "Processing..."
1868
1934
  if (answer === "a" && !isContinue) {
1869
1935
  agent.autoApprove = true
@@ -2076,8 +2142,9 @@ export async function startTUI(agent, opts = {}) {
2076
2142
  }
2077
2143
 
2078
2144
  // 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
2145
+ // \r\n 在 Windows raw mode 下可能漏进来冲乱页面
2079
2146
  if (str && !key.ctrl && !key.meta) {
2080
- const chars = [...str.replace(/\r/g, "").replace(/\t/g, " ")]
2147
+ const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
2081
2148
  state.input.splice(state.cursor, 0, ...chars)
2082
2149
  state.cursor += chars.length
2083
2150
  render()
@@ -2099,7 +2166,9 @@ export async function startTUI(agent, opts = {}) {
2099
2166
  state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
2100
2167
  pushLabel(`── 已恢复上次会话(退出前原样回放);/new 开始新会话 ──`, C.warn)
2101
2168
  } else if (opts.restored?.history?.length) {
2102
- for (const m of opts.restored.history) {
2169
+ // 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
2170
+ for (let i = 0; i < opts.restored.history.length; i++) {
2171
+ const m = opts.restored.history[i]
2103
2172
  if (m.role === "user") {
2104
2173
  if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
2105
2174
  pushLabel(`❯ You:`, ansi.bold + C.user)
@@ -2108,10 +2177,14 @@ export async function startTUI(agent, opts = {}) {
2108
2177
  pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
2109
2178
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
2110
2179
  for (const tc of m.tool_calls ?? []) {
2111
- pushLine(` [tool] ${tc.function?.name ?? "?"}`, C.tool)
2180
+ // 找到下一条对应的 tool 结果,显示首行摘要
2181
+ const toolResult = opts.restored.history[i + 1]
2182
+ const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
2183
+ const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
2184
+ pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
2112
2185
  }
2113
2186
  }
2114
- // tool 角色的结果行省略:调用行已足够还原现场
2187
+ // tool 消息本身不单独渲染——已在 assistant 的 tool_calls 后以摘要形式展示
2115
2188
  }
2116
2189
  pushLabel(`── 已恢复上次会话(${opts.restored.history.length} 条消息);/new 开始新会话 ──`, C.warn)
2117
2190
  }