thincoder 0.12.53 → 0.12.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/acp.mjs +60 -18
  6. package/src/advisor/messages.mjs +4 -2
  7. package/src/advisor/run.mjs +2 -2
  8. package/src/agent/dispatch.mjs +66 -26
  9. package/src/agent/helpers.mjs +13 -2
  10. package/src/agent/setup.mjs +16 -3
  11. package/src/agent/spawn-child.mjs +3 -1
  12. package/src/agent-tools/advisor.mjs +19 -9
  13. package/src/agent-tools/eng.mjs +2 -0
  14. package/src/agent-tools/subagent-check.mjs +107 -0
  15. package/src/agent-tools/subagent.mjs +205 -42
  16. package/src/agent.mjs +68 -3
  17. package/src/cli/make-agent.mjs +25 -0
  18. package/src/cli/memory-command.mjs +28 -7
  19. package/src/config.mjs +120 -8
  20. package/src/context.mjs +28 -7
  21. package/src/escape.mjs +110 -22
  22. package/src/git/checkpoint.mjs +32 -6
  23. package/src/mcp/transport-http.mjs +13 -1
  24. package/src/mcp.mjs +52 -7
  25. package/src/memory/core.mjs +78 -10
  26. package/src/memory/docs.mjs +33 -7
  27. package/src/memory.mjs +1 -1
  28. package/src/model-specs.mjs +23 -0
  29. package/src/prompts/discipline.md +17 -3
  30. package/src/prompts/engineering.md +62 -5
  31. package/src/prompts/main.md +1 -0
  32. package/src/prompts/system.md +2 -1
  33. package/src/provider/anthropic.mjs +7 -5
  34. package/src/provider/core.mjs +90 -26
  35. package/src/provider/google.mjs +57 -24
  36. package/src/provider/normalize.mjs +1 -1
  37. package/src/provider/rate.mjs +0 -2
  38. package/src/provider/responses.mjs +8 -13
  39. package/src/provider/sse.mjs +20 -0
  40. package/src/session-migrate.mjs +6 -0
  41. package/src/session-slots.mjs +361 -0
  42. package/src/session.mjs +282 -306
  43. package/src/tools/apply_patch.md +2 -0
  44. package/src/tools/bash.md +2 -2
  45. package/src/tools/edit-batch.mjs +104 -0
  46. package/src/tools/edit.md +3 -0
  47. package/src/tools/execute.md +4 -4
  48. package/src/tools/execute.mjs +14 -22
  49. package/src/tools/file.mjs +17 -55
  50. package/src/tools/file_ops.md +1 -1
  51. package/src/tools/git-checkpoint.mjs +143 -0
  52. package/src/tools/git-ext.mjs +173 -0
  53. package/src/tools/git.md +21 -8
  54. package/src/tools/git.mjs +55 -177
  55. package/src/tools/lint.md +1 -1
  56. package/src/tools/linter.mjs +9 -37
  57. package/src/tools/patch.mjs +1 -1
  58. package/src/tools/shared.mjs +7 -20
  59. package/src/tui/agent-turn.mjs +3 -3
  60. package/src/tui/ansi.mjs +2 -0
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-eng.mjs +1 -0
  63. package/src/tui/cmd-mcp-form.mjs +197 -0
  64. package/src/tui/cmd-mcp.mjs +255 -114
  65. package/src/tui/cmd-new.mjs +6 -6
  66. package/src/tui/cmd-restore.mjs +27 -6
  67. package/src/tui/cmd-session.mjs +17 -4
  68. package/src/tui/index.mjs +28 -27
  69. package/src/tui/interaction.mjs +28 -1
  70. package/src/tui/key-handler.mjs +14 -2
  71. package/src/tui/layout.mjs +81 -25
  72. package/src/tui/mouse.mjs +41 -2
  73. package/src/tui/pickers.mjs +62 -4
  74. package/src/tui/render-conversation.mjs +36 -93
  75. package/src/tui/render-frame.mjs +40 -16
  76. package/src/tui/render-loop.mjs +1 -1
  77. package/src/tui/render.mjs +4 -4
  78. package/src/tui/startup.mjs +4 -2
  79. package/src/tui/subagent-blocks.mjs +119 -4
  80. package/src/tui/subagent-panel.mjs +81 -0
  81. package/src/tui/tool-events.mjs +61 -16
  82. package/src/tui/tui-lifecycle.mjs +45 -0
@@ -1,16 +1,28 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
+ import { cloneEntry, fieldPicker, maskToken } from "./cmd-mcp-form.mjs"
2
3
 
3
- /** /mcp command handler: view/add/remove/reconnect MCP server.
4
- * Extracted from slash-commands.mjs, includes /mcp-specific parseHeaders / addAndConnect helpers.
5
- * ctx: { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, ansi, C } */
4
+ /** /mcp command handler: view/add/edit/remove/test/reconnect MCP server.
5
+ * ctx: { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, ansi, C }
6
+ * MCP.md §5(2026-09-02,v2):列表即菜单(F7/D-2)、edit/add 统一字段 picker 表单
7
+ * (F3/F3b/D-1——表单机制在 cmd-mcp-form.mjs,本文件只调 fieldPicker)、保存前预览
8
+ * +探活(F2:探活失败回同一 fieldPicker——AC2)、AI 降 transport picker 末位(F4)、
9
+ * 菜单打开边界磁盘重读 config.json(F5①/D-3,reloadMcpFromDisk in config.mjs)、
10
+ * save&test 确认问句废除(§5 变更段 D-Q1:probe ✓ 直接保存、✗ 报错回表单零保存通道)。 */
6
11
 
7
- function parseHeaders(pairs) {
8
- const headers = {}
9
- for (const pair of pairs) {
10
- const eq = pair.indexOf("=")
11
- if (eq > 0) headers[pair.slice(0, eq)] = pair.slice(eq + 1).replace(/^["']|["']$/g, "")
12
- }
13
- return headers
12
+ /** F7/D-2:主菜单行与子菜单 picker 行共用——●/○ 连接态 + (端点) + N tools。 */
13
+ function serverLine(srv, connected, toolCount) {
14
+ const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
15
+ return `${connected ? "●" : "○"} ${srv.name} (${desc})${connected ? ` — ${toolCount} tools` : ""}`
16
+ }
17
+
18
+ /** persistRaw/edit 落盘的磁盘 entry 形态(name + 按 transport 的可选字段)——add 与
19
+ * edit 共用,两处序列化不再漂移。 */
20
+ function configEntry(srv) {
21
+ const entry = { name: srv.name }
22
+ if (srv.url) { entry.url = srv.url; if (srv.token) entry.token = srv.token; if (srv.headers) entry.headers = srv.headers }
23
+ else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.token) entry.token = srv.token; if (srv.headers) entry.headers = srv.headers }
24
+ else { entry.command = srv.command; if (srv.args) entry.args = srv.args; if (srv.env) entry.env = srv.env }
25
+ return entry
14
26
  }
15
27
 
16
28
  /** /mcp shared helper: save config + connect (persistRaw obtained from ctx) */
@@ -18,11 +30,7 @@ async function addAndConnect(ctx, srv) {
18
30
  const { agent, pushLine, pushLabel, persistRaw } = ctx
19
31
  await persistRaw((raw) => {
20
32
  raw.mcp ??= { servers: [] }
21
- const entry = { name: srv.name }
22
- if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
23
- else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.headers) entry.headers = srv.headers }
24
- else { entry.command = srv.command; if (srv.args) entry.args = srv.args; if (srv.env) entry.env = srv.env }
25
- raw.mcp.servers.push(entry)
33
+ raw.mcp.servers.push(configEntry(srv))
26
34
  })
27
35
  agent.config ??= {}
28
36
  agent.config.mcp ??= { servers: [] }
@@ -45,6 +53,20 @@ export async function handleMcpCommand(ctx, args = []) {
45
53
  const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw } = ctx
46
54
  // 每轮重读:原本无 mcp 配置时 `?? []` 会拿到游离数组,Add server 后快照过期
47
55
  const getServers = () => agent.config?.mcp?.servers ?? []
56
+ // D-3/T24:本菜单会话内最新一次磁盘重读是否失败(畸形 config.json → 内存态兜底)
57
+ let diskUnreadable = false
58
+ /** F5①/D-3:菜单打开边界统一走这里——磁盘→内存仅替换 mcp 段(config.mjs 的
59
+ * reloadMcpFromDisk)。畸形磁盘配置回退内存态 + 提示行(T24);persistRaw 落盘后
60
+ * 重读幂等(fingerprint 一致 → 无 ⚠ disk changed 标记,D-3 防环)。ctx.configPath
61
+ * 测试注入 tmp config 路径用(生产 undefined → 默认 ~/.thincoder)。 */
62
+ let changedNames = [] // 最近一次成功重读的对账结果(T23 ⚠ 标记)
63
+ async function reloadFromDisk() {
64
+ const { reloadMcpFromDisk } = await import("../config.mjs")
65
+ const r = reloadMcpFromDisk(agent, ctx.configPath)
66
+ diskUnreadable = !r.ok
67
+ if (r.ok) changedNames = r.changedNames
68
+ return r
69
+ }
48
70
 
49
71
  function listServers() {
50
72
  const servers = getServers()
@@ -54,17 +76,15 @@ export async function handleMcpCommand(ctx, args = []) {
54
76
  }
55
77
  for (const srv of servers) {
56
78
  const connected = agent.tools.some((t) => t._mcpName === srv.name)
57
- const mark = connected ? "●" : "○"
58
- const color = connected ? C.tool : C.dim
59
79
  const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
60
- const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
61
- pushLine(` ${mark} ${srv.name} (${desc})${connected ? ` — ${toolCount} tools` : ""}`, color)
80
+ pushLine(` ${serverLine(srv, connected, toolCount)}`, connected ? C.tool : C.dim)
62
81
  }
63
82
  }
64
83
 
65
84
  async function removeServer(name) {
66
85
  agent.config.mcp.servers = getServers().filter((s) => s.name !== name)
67
- await persistRaw((raw) => { raw.mcp.servers = agent.config.mcp.servers })
86
+ // 评审 #1:磁盘无 mcp 段时(T23 场景——mcp 段被整体删除而连接保留)raw.mcp undefined → 先建段再写
87
+ await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = agent.config.mcp.servers })
68
88
  // Remove from tool list
69
89
  const { removeMcpTools } = await import("../mcp.mjs")
70
90
  removeMcpTools(agent, name)
@@ -86,17 +106,59 @@ export async function handleMcpCommand(ctx, args = []) {
86
106
  }
87
107
  }
88
108
 
89
- async function addWithTransport(transport) {
90
- if (transport === "ai") {
91
- const description = await askQuestion("Describe the MCP server you want to add (e.g. 'a filesystem server that gives access to /tmp'):")
92
- if (!description) return
93
- pushLine("[mcp] Generating config from description...", C.dim)
94
- try {
95
- const { chat } = await import("../provider/index.mjs")
96
- const res = await chat(agent.provider, {
97
- messages: [{
98
- role: "user",
99
- content: `Generate an MCP server configuration JSON from this description. Return ONLY the JSON object, no explanation.
109
+ /** F2(D-1):预览表——name/transport/端点/token 遮蔽 + headers/env 键列表;
110
+ * probeLine 非空时拼在尾部(✓ C.tool / ✗ C.error 色)——预览与探活报告同屏。 */
111
+ function showPreview(entry, probeLine) {
112
+ const endpoint = entry.wsUrl ?? entry.url ?? `${entry.command} ${(entry.args ?? []).join(" ")}`.trim()
113
+ const transport = entry.wsUrl ? "WebSocket" : entry.url ? "HTTP" : "stdio"
114
+ pushLine(` name: ${entry.name}`, C.tool)
115
+ pushLine(` transport: ${transport}`, C.tool)
116
+ pushLine(` endpoint: ${endpoint}`, C.tool)
117
+ if (entry.token) pushLine(` token: ${maskToken(entry.token)}`, C.tool)
118
+ if (entry.headers) pushLine(` headers: ${Object.keys(entry.headers).join(", ")}`, C.tool)
119
+ if (entry.env) pushLine(` env: ${Object.keys(entry.env).join(", ")}`, C.tool)
120
+ if (probeLine) pushLine(` ${probeLine}`, probeLine.startsWith("✓") ? C.tool : C.error)
121
+ }
122
+
123
+ /** F2:保存前探活——probeMcpServer(§4 资产,零副作用:不进 _sessions、不动
124
+ * agent.tools、探完即关)。未保存的临时 entry 直接传 probe(§5 D-1)。 */
125
+ async function probeLineFor(entry) {
126
+ pushLine(`[mcp] Probing ${entry.name}...`, C.dim)
127
+ const { probeMcpServer } = await import("../mcp.mjs")
128
+ const r = await probeMcpServer(entry)
129
+ return r.ok ? `✓ ${r.toolCount} tools, ${r.latencyMs}ms` : `✗ ${r.error}`
130
+ }
131
+
132
+ /** F2 确认环(D-Q1,2026-09-02 变更段):预览+探活 → probe ✓ 直接保存(返回
133
+ * entry——无任何问句);probe ✗ → 报错 + retryEntry 回 fieldPicker 改字段复 probe
134
+ * (AC2:retryEntry 回调复用同一表单、已改值保留;save-anyway 整个废除——失败
135
+ * 配置零保存通道)。返回 entry / null(null = retryEntry 表单层 Esc 放弃)。 */
136
+ async function confirmLoop(entry, retryEntry) {
137
+ for (;;) {
138
+ pushLabel(`❯ MCP Preview`, ansi.bold + C.tool)
139
+ const probe = await probeLineFor(entry)
140
+ showPreview(entry, probe)
141
+ if (probe.startsWith("✓")) return entry // D-Q1:探活成功 → 直接保存(问句废除)
142
+ // D-Q1:探活失败 → 无保存通道——报错 + 回 fieldPicker 改字段复 probe(AC2)
143
+ pushLine(`[mcp] Probe failed: ${probe.slice(2)} — fix it in the form`, C.error)
144
+ const retried = await retryEntry(entry)
145
+ if (!retried) return null
146
+ entry = retried
147
+ }
148
+ }
149
+
150
+ /** F4:AI 生成配置(transport picker 末位——文案不再首推)。生成的 entry 同走
151
+ * 预览+探活+确认环(F2 语义一致——D-Q1:✓ 直接保存 / ✗ 回表单)。 */
152
+ async function addWithAI() {
153
+ const description = await askQuestion("Describe the MCP server you want to add (e.g. 'a filesystem server that gives access to /tmp'):")
154
+ if (!description) return
155
+ pushLine("[mcp] Generating config from description...", C.dim)
156
+ try {
157
+ const { chat } = await import("../provider/index.mjs")
158
+ const res = await chat(agent.provider, {
159
+ messages: [{
160
+ role: "user",
161
+ content: `Generate an MCP server configuration JSON from this description. Return ONLY the JSON object, no explanation.
100
162
 
101
163
  Description: "${description}"
102
164
 
@@ -109,130 +171,209 @@ Example HTTP: {"name":"filesystem","url":"https://example.com/mcp","headers":{"A
109
171
  Example stdio: {"name":"filesystem","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}
110
172
 
111
173
  Return ONLY the JSON object:`,
112
- }],
113
- tools: [],
114
- signal: AbortSignal.timeout(15_000),
174
+ }],
175
+ tools: [],
176
+ signal: AbortSignal.timeout(15_000),
177
+ })
178
+ const jsonMatch = (res.content ?? "").match(/\{[\s\S]*\}/)
179
+ if (!jsonMatch) { pushLine("[mcp] AI response not valid JSON", C.error); return }
180
+ const srv = JSON.parse(jsonMatch[0])
181
+ if (!srv.name) { pushLine("[mcp] AI response missing 'name' field", C.error); return }
182
+ // F2:同一预览+探活+确认环;探活失败回 fieldPicker 重输(AC2——AI 生成的 entry
183
+ // 字段不完整时可在表单里补齐/修正,mode add = name 可改)
184
+ const saved = await confirmLoop(srv, async (cur) => {
185
+ const r = await fieldPicker(ctx, {
186
+ title: `Fix MCP: ${cur.name ?? "ai"}`,
187
+ mode: "add",
188
+ entry: cur,
189
+ existingNames: getServers().map((s) => s.name),
115
190
  })
116
- const jsonMatch = (res.content ?? "").match(/\{[\s\S]*\}/)
117
- if (!jsonMatch) { pushLine("[mcp] AI response not valid JSON", C.error); return }
118
- const srv = JSON.parse(jsonMatch[0])
119
- if (!srv.name) { pushLine("[mcp] AI response missing 'name' field", C.error); return }
120
- // Show preview and confirm
121
- pushLine(`[mcp] Generated config: ${JSON.stringify(srv)}`, C.tool)
122
- const confirm = await askQuestion("Add this server? (y/n):")
123
- if (confirm?.toLowerCase() !== "y") { pushLine("[mcp] Cancelled", C.dim); return }
124
- await addAndConnect(ctx, srv)
125
- } catch (err) {
126
- pushLine(`[mcp] AI generation failed: ${err.message}`, C.error)
127
- }
128
- return
191
+ return r.action === "save" ? r.entry : null
192
+ })
193
+ if (saved) await addAndConnect(ctx, saved)
194
+ else pushLine("[mcp] Cancelled", C.dim)
195
+ } catch (err) {
196
+ pushLine(`[mcp] AI generation failed: ${err.message}`, C.error)
129
197
  }
130
- const name = await askQuestion("Server name:")
131
- if (!name) return
132
- const existing = (agent.config?.mcp?.servers ?? []).find((s) => s.name === name)
133
- if (existing) { pushLine(`[mcp] "${name}" already exists`, C.error); return }
134
- if (transport === "stdio") {
135
- const cmd = await askQuestion("Command (e.g. npx, python):")
136
- if (!cmd) return
137
- const argsInput = await askQuestion("Arguments (space-separated, or leave empty):")
138
- const cmdArgs = argsInput ? argsInput.split(/\s+/) : undefined
139
- const envInput = await askQuestion("Environment variables (KEY=value, space-separated, or leave empty):")
140
- const env = envInput ? parseHeaders(envInput.split(/\s+/)) : undefined
141
- await addAndConnect(ctx, { name, command: cmd, args: cmdArgs, env })
142
- } else {
143
- const urlPrompt = transport === "ws" ? "WebSocket URL (ws://…):" : "HTTP URL (https://…):"
144
- const url = await askQuestion(urlPrompt)
145
- if (!url) return
146
- const headersInput = await askQuestion("Headers (key=value, space-separated, or leave empty):")
147
- const headers = headersInput ? parseHeaders(headersInput.split(/\s+/)) : undefined
148
- const srv = transport === "ws"
149
- ? { name, wsUrl: url, headers: Object.keys(headers ?? {}).length > 0 ? headers : undefined }
150
- : { name, url, headers: Object.keys(headers ?? {}).length > 0 ? headers : undefined }
151
- await addAndConnect(ctx, srv)
198
+ }
199
+
200
+ /** F1/F3b(D-1 v2):add 流程——fieldPicker entry 起(必填 (required) 标注、
201
+ * 只填所选字段、headers/env 不选即跳过)→ 预览+探活+确认环 addAndConnect。 */
202
+ async function addWithTransport(transport) {
203
+ if (transport === "ai") { await addWithAI(); return }
204
+ const entry = {}
205
+ const existingNames = getServers().map((s) => s.name)
206
+ const formOpts = {
207
+ title: `Add MCP: ${transport === "ws" ? "WebSocket" : transport === "http" ? "HTTP" : "stdio"}`,
208
+ mode: "add",
209
+ transport,
210
+ existingNames,
152
211
  }
212
+ const first = await fieldPicker(ctx, { ...formOpts, entry })
213
+ if (first.action === "cancel") return
214
+ const saved = await confirmLoop(first.entry, async (cur) => {
215
+ const r = await fieldPicker(ctx, { ...formOpts, entry: cur })
216
+ return r.action === "save" ? r.entry : null
217
+ })
218
+ if (saved) await addAndConnect(ctx, saved)
219
+ else pushLine("[mcp] Cancelled", C.dim)
153
220
  }
154
221
 
222
+ /** F4:transport picker——HTTP / WebSocket / stdio / AI(末位,文案不再首推)。 */
155
223
  async function addFlow() {
156
- // Pick transport type first, then ask name + URL/command
157
224
  const te = await showPicker("MCP Transport", [
158
- { type: "header", text: "Select transport or use AI assist" },
159
- { type: "item", text: "🤖 Describe with AI — natural language → config", action: "ai" },
225
+ { type: "header", text: "Select transport" },
160
226
  { type: "item", text: "HTTP (https://…)", action: "http" },
161
227
  { type: "item", text: "WebSocket (ws://…)", action: "ws" },
162
228
  { type: "item", text: "stdio (local command)", action: "stdio" },
229
+ { type: "item", text: "AI — describe in natural language", action: "ai" },
163
230
  ])
164
231
  if (te) await addWithTransport(te.action)
165
232
  }
166
233
 
167
- /** remove/connect 的服务器选择 picker + 执行。返回 true = 已执行;false = Esc 取消。 */
168
- async function pickAndRun(action) {
234
+ /** 服务器选择共用:带 name 直达(校验存在性);无 name picker。返回 srv / null。
235
+ * NF1:带 name 的直达路径语义与旧实现逐字一致(空列表也报 no server named)。 */
236
+ async function resolveServer(nameArg, { title, headerText }) {
169
237
  const servers = getServers()
238
+ if (nameArg) {
239
+ const srv = servers.find((s) => s.name === nameArg) // NF1:与旧直达路径逐字一致(精确匹配)
240
+ if (!srv) {
241
+ pushLine(`[mcp] no server named "${nameArg}" (${servers.map((s) => s.name).join(", ") || "none configured"})`, C.error)
242
+ return null
243
+ }
244
+ return srv
245
+ }
170
246
  if (servers.length === 0) {
171
247
  pushLine("[mcp] no MCP server configured", C.error)
172
- return true
248
+ return null
173
249
  }
174
- const subEntries = [
175
- { type: "header", text: action === "remove" ? "Select server to remove" : "Select server to reconnect" },
250
+ const se = await showPicker(title, [
251
+ { type: "header", text: headerText },
176
252
  ...servers.map((s) => ({ type: "item", text: `${s.name} (${s.wsUrl ?? s.url ?? s.command})`, name: s.name })),
177
- ]
178
- const se = await showPicker(action === "remove" ? "Remove MCP Server" : "Reconnect MCP", subEntries)
179
- if (!se) return false // Esc 取消
180
- if (action === "remove") await removeServer(se.name)
181
- else await connectServer(se.name)
182
- return true
253
+ ])
254
+ if (!se) return null // Esc 取消
255
+ return servers.find((s) => s.name === se.name) ?? null
183
256
  }
184
257
 
185
- // Direct args: /mcp list │ /mcp add │ /mcp http|ws|stdio|ai │ /mcp remove [name] /mcp connect [name]
258
+ /** F4(§4 资产):`/mcp test [name]`——probeMcpServer 一次性探活(零副作用)。 */
259
+ async function testServer(nameArg) {
260
+ const srv = await resolveServer(nameArg, { title: "Test MCP Server", headerText: "Select server to test" })
261
+ if (!srv) return
262
+ pushLine(`[mcp] Testing ${srv.name}...`, C.dim)
263
+ const r = await probeLineFor(srv)
264
+ pushLine(`[mcp] ${srv.name}: ${r}`, r.startsWith("✓") ? C.tool : C.error)
265
+ }
266
+
267
+ /** F3(D-1 v2):edit = 字段 picker 表单——fieldPicker 列可编辑字段行(URL/Token/
268
+ * Headers 或 Command/Args/Env;name 不可改——无 name 行)+ `✓ Save & test`;
269
+ * 收集完成走 confirmLoop(预览+探活——D-Q1:✓ 直接保存 / ✗ 回 fieldPicker
270
+ * AC2/AC5)→ persistRaw 原位替换保数组序 → connectServer 自动重连。取消 → 零保存。 */
271
+ async function editServerWithConfirm(srv) {
272
+ const name = srv.name
273
+ const entry = cloneEntry(srv) // 工作副本——取消不污染原配置
274
+ const formOpts = { title: `Edit MCP: ${name}`, mode: "edit" }
275
+ const first = await fieldPicker(ctx, { ...formOpts, entry })
276
+ if (first.action === "cancel") { pushLine("[mcp] Edit cancelled — nothing saved", C.dim); return }
277
+ const saved = await confirmLoop(first.entry, async (cur) => {
278
+ const r = await fieldPicker(ctx, { ...formOpts, entry: cur })
279
+ return r.action === "save" ? r.entry : null
280
+ })
281
+ if (!saved) { pushLine("[mcp] Edit cancelled — nothing saved", C.dim); return }
282
+ await persistRaw((raw) => {
283
+ // 评审 #2:磁盘无 mcp 段时(T23 场景)先建段——否则静默 return 导致"updated"报错但磁盘零写入
284
+ raw.mcp ??= { servers: [] }
285
+ const servers = raw.mcp.servers
286
+ if (!Array.isArray(servers)) return
287
+ const idx = servers.findIndex((s) => s?.name === name)
288
+ if (idx === -1) return
289
+ servers[idx] = configEntry(saved) // 原位替换——数组序保持
290
+ })
291
+ agent.config.mcp.servers = getServers().map((s) => (s.name === name ? saved : s))
292
+ pushLine(`[mcp] ${name} updated`, C.tool)
293
+ await connectServer(name) // F3:自动重连
294
+ }
295
+
296
+ /** edit 入口:`/mcp edit [name]`——带 name 直达,否则 picker 选择。 */
297
+ async function editFlowWithConfirm(nameArg) {
298
+ const srv = await resolveServer(nameArg, { title: "Edit MCP Server", headerText: "Select server to edit" })
299
+ if (!srv) return
300
+ await editServerWithConfirm(srv)
301
+ }
302
+
303
+ // Direct args: /mcp list │ /mcp add │ /mcp http|ws|stdio|ai │ /mcp edit [name] │ /mcp test [name] │ /mcp remove [name] │ /mcp connect [name]
186
304
  const sub = args[0]?.toLowerCase()
187
305
  if (sub === "list") { listServers(); return }
188
306
  if (sub === "add") { await addFlow(); return }
189
307
  if (sub === "http" || sub === "ws" || sub === "stdio" || sub === "ai") { await addWithTransport(sub); return }
308
+ if (sub === "edit") { await editFlowWithConfirm(args[1]); return }
309
+ if (sub === "test") { await testServer(args[1]); return }
190
310
  if (sub === "remove" || sub === "connect") {
191
- const name = args[1]
192
- if (name) {
193
- const servers = getServers()
194
- if (!servers.some((s) => s.name === name)) {
195
- pushLine(`[mcp] no server named "${name}" (${servers.map((s) => s.name).join(", ") || "none configured"})`, C.error)
196
- return
197
- }
198
- if (sub === "remove") await removeServer(name)
199
- else await connectServer(name)
200
- return
201
- }
202
- // 已明确 remove/connect 意图但没带 name → 直接进服务器选择 picker,不落主菜单
203
- await pickAndRun(sub)
311
+ const srv = await resolveServer(args[1], {
312
+ title: sub === "remove" ? "Remove MCP Server" : "Reconnect MCP",
313
+ headerText: sub === "remove" ? "Select server to remove" : "Select server to reconnect",
314
+ })
315
+ if (!srv) return
316
+ if (sub === "remove") await removeServer(srv.name)
317
+ else await connectServer(srv.name)
204
318
  return
205
319
  } else if (sub) {
206
- pushLine("Usage: /mcp [list|add|http|ws|stdio|ai|remove [name]|connect [name]]", C.error)
320
+ pushLine("Usage: /mcp [list|add|edit [name]|test [name]|http|ws|stdio|ai|remove [name]|connect [name]]", C.error)
207
321
  return
208
322
  }
209
323
 
210
- // 主菜单循环:选中即关闭,子菜单 Esc 返回主菜单,主菜单 Esc 退出
324
+ // F7/D-2 主菜单循环:列表即菜单(server → per-server 子菜单;Esc 回主菜单),
325
+ // 顶部固定 agent 代配提示行(F5②)+ 每轮边界磁盘重读(F5①/D-3)。
326
+ // 主菜单 Esc 退出;Add/Refresh 动作后 continue(Refresh 显式重开菜单重读磁盘)。
327
+ // server 行 action 用 `@name:` 命名空间——与 add/refresh 保留动作永不撞名
328
+ //(server 可以叫 "add" 或 "refresh")。
211
329
  for (;;) {
330
+ await reloadFromDisk()
212
331
  const servers = getServers()
332
+ const connected = (s) => agent.tools.some((t) => t._mcpName === s.name)
333
+ const toolCount = (s) => agent.tools.filter((t) => t._mcpName === s.name).length
213
334
  const entries = [
214
- { type: "header", text: `${servers.length} MCP servers configured` },
215
- { type: "item", text: "View list", action: "list" },
216
- { type: "item", text: "Add server", action: "add" },
335
+ { type: "header", text: "Tip: complex configs — ask the agent to edit the mcp.servers section of ~/.thincoder/config.json directly, then Reconnect here" },
217
336
  ]
337
+ if (diskUnreadable) entries.push({ type: "header", text: "⚠ disk config unreadable — showing in-memory state" })
218
338
  if (servers.length > 0) {
339
+ // D-3/T23 对账:disk 删除/变更的已连接 server——连接不断,行尾 ⚠ disk changed
340
+ const drift = (s) => (changedNames.includes(s.name) ? " — ⚠ disk changed" : "")
219
341
  entries.push(
220
- { type: "item", text: "Remove server", action: "remove" },
221
- { type: "item", text: "Reconnect server", action: "connect" },
342
+ { type: "header", text: `${servers.length} MCP server${servers.length === 1 ? "" : "s"} configured` },
343
+ ...servers.map((s) => ({ type: "item", text: `${serverLine(s, connected(s), toolCount(s))}${drift(s)}`, action: `@${s.name}:` })),
222
344
  )
345
+ } else {
346
+ entries.push({ type: "header", text: "No MCP servers configured" })
223
347
  }
348
+ entries.push(
349
+ { type: "item", text: "+ Add server", action: "add" },
350
+ { type: "item", text: "↻ Refresh", action: "refresh" },
351
+ )
224
352
  const e = await showPicker("MCP", entries)
225
353
  if (!e) return // Esc 退出
226
- if (e.action === "list") {
227
- listServers()
228
- return
229
- }
230
- if (e.action === "add") {
231
- await addFlow()
232
- continue
354
+ if (e.action === "add") { await addFlow(); continue }
355
+ if (e.action === "refresh") continue // 重开菜单即重读磁盘(reloadFromDisk 在循环顶部)
356
+ if (!e.action.startsWith("@")) continue // 未知动作防御——回主菜单
357
+ // 选中 server 行 → per-server 子菜单:Edit config / Test connection / Reconnect / Remove
358
+ const name = e.action.slice(1, -1)
359
+ const se = await showPicker(`MCP: ${name}`, [
360
+ { type: "header", text: "Server actions" },
361
+ { type: "item", text: "Edit config", action: "edit" },
362
+ { type: "item", text: "Test connection", action: "test" },
363
+ { type: "item", text: "Reconnect", action: "connect" },
364
+ { type: "item", text: "Remove", action: "remove" },
365
+ ])
366
+ if (!se) continue // 子菜单 Esc → 回主菜单
367
+ const srv = getServers().find((s) => s.name === name)
368
+ if (se.action === "edit") await editFlowWithConfirm(name)
369
+ else if (se.action === "test") await testServer(name)
370
+ else if (se.action === "connect") {
371
+ if (srv) await connectServer(name)
372
+ else pushLine(`[mcp] no server named "${name}"`, C.error)
373
+ } else if (se.action === "remove") {
374
+ if (srv) await removeServer(name)
375
+ else pushLine(`[mcp] no server named "${name}"`, C.error)
233
376
  }
234
- const done = await pickAndRun(e.action)
235
- if (!done) continue // 子菜单 Esc → 回主菜单
236
- return
377
+ continue
237
378
  }
238
379
  }
@@ -1,4 +1,4 @@
1
- import { newSession } from "../session.mjs"
1
+ import { newSession, resetSessionState } from "../session.mjs"
2
2
  import { C } from "./ansi.mjs"
3
3
 
4
4
  /** /new command: start a new session in a fresh slot.
@@ -8,11 +8,11 @@ export async function handleNewCommand(ctx) {
8
8
 
9
9
  const doNewSession = () => {
10
10
  const slot = newSession(agent.cwd)
11
- agent.history = []
12
- agent.tasks = []
13
- agent.planMode = false
14
- agent.goal = null
15
- agent._pendingReminders = []
11
+ // 2026-08-31 会诊 F3:resetSessionState 清全量会话态(_fullHistory/title/_sessionStart/
12
+ // _engDesignToken/压缩与验证计数等)——原实现只清 agent.history,新会话首次落盘把旧
13
+ // 会话完整人类线 + 旧标题写进新 slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
14
+ resetSessionState(agent)
15
+ agent._slot = slot
16
16
  state.tasks = []
17
17
  state.lines = []
18
18
  state.streaming = ""
@@ -1,7 +1,8 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
 
3
- /** /restore command: list git checkpoints and roll back to selected snapshot.
4
- * ctx: { agent, showPicker, pushLine, pushLabel } */
3
+ /** /restore command: pick a snapshot, then pick ONE file from it to restore.
4
+ * Full restore is disabled (v2) this is the user-side per-file recovery entry
5
+ * (CHECKPOINT.md D8). ctx: { agent, showPicker, pushLine, pushLabel } */
5
6
  export async function handleRestoreCommand(ctx) {
6
7
  const { agent, showPicker, pushLine, pushLabel } = ctx
7
8
  const { listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
@@ -9,25 +10,45 @@ export async function handleRestoreCommand(ctx) {
9
10
  pushLine("[rewind] not a git repository, checkpoints unavailable", C.error)
10
11
  return
11
12
  }
13
+ // F6 lazy fallback——与 git 工具 checkpoint list/create 入口一致(git-checkpoint.mjs):外部
14
+ // git commit 后(HEAD 时间 > 最新快照)先清空过期快照,/restore 不列出 commit 前状态。
15
+ const { lazyClearIfCommitted } = await import("../tools/git-checkpoint.mjs")
16
+ await lazyClearIfCommitted(agent.cwd)
12
17
  const cps = await listCheckpoints(agent.cwd)
13
18
  if (cps.length === 0) {
14
19
  pushLine("(no checkpoints — created automatically before each task)", C.dim)
15
20
  return
16
21
  }
22
+ // Level 1: pick a snapshot (untracked shown as array length — CHECKPOINT.md D8).
17
23
  const entries = [
18
- { type: "header", text: "Checkpoints (↑↓ select, Enter restore, Esc cancel)" },
24
+ { type: "header", text: "Checkpoints — 全量恢复已禁用,逐文件恢复 (↑↓ select, Enter next, Esc cancel)" },
19
25
  ...cps.slice(0, 12).map((cp) => ({
20
26
  type: "item",
21
- text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} untracked files)`,
27
+ text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${(cp.untracked ?? []).length} untracked files)`,
22
28
  id: cp.id,
23
29
  })),
24
30
  ]
25
31
  const e = await showPicker("Restore Checkpoint", entries)
26
32
  if (!e) return
33
+ const cp = cps.find((c) => c.id === e.id)
34
+ if (!cp) return
35
+ // Level 2: pick a file from the snapshot's tracked/untracked merged list.
36
+ const files = [...(cp.tracked ?? []), ...(cp.untracked ?? [])]
37
+ if (files.length === 0) {
38
+ pushLine("该快照无文件,无法逐文件恢复", C.dim)
39
+ return
40
+ }
41
+ const fileEntries = [
42
+ { type: "header", text: `Files in ${cp.id} (↑↓ select, Enter restore, Esc cancel)` },
43
+ ...files.map((f) => ({ type: "item", text: f, id: f })),
44
+ ]
45
+ const fe = await showPicker("Restore File", fileEntries)
46
+ if (!fe) return
27
47
  try {
28
- const summary = await rewind(agent.cwd, e.id)
48
+ // v2 return: { path, type, restored } — rewind snapshots current state first (reversible).
49
+ const summary = await rewind(agent.cwd, cp.id, { path: fe.id })
29
50
  pushLabel(`❯ Rewind`, ansi.bold + C.warn)
30
- pushLine(`Restored to ${e.id}: patch ${summary.patchApplied ? "applied" : "none"}, deleted ${summary.deleted} new files, restored ${summary.restored} file(s)`, C.tool)
51
+ pushLine(`Restored ${summary.type}: ${summary.path}${summary.restored ? "" : " nothing restored (snapshot copy missing)"}`, C.tool)
31
52
  pushLine("(current state saved as new checkpoint; /restore again to go back)", C.dim)
32
53
  } catch (error) {
33
54
  pushLine(`[rewind] ${error.message}`, C.error)
@@ -1,11 +1,14 @@
1
- import { listSlots, switchToSlot, applySession, renameSlot, activeSlot } from "../session.mjs"
1
+ import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy } from "../session.mjs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
3
  import { restoreLines } from "./startup.mjs"
4
+ import { stringWidth, sliceByWidth } from "./render.mjs"
4
5
 
5
6
  /** /rename <title> — rename the ACTIVE session (slot file + manifest, shared with VS Code). */
6
7
  export async function handleRenameCommand(ctx, args) {
7
8
  const { agent, pushLine, pushLabel, render } = ctx
8
- const slot = activeSlot(agent.cwd)
9
+ // 2026-09-01 会诊 glm 🟡:用粘性槽而非 activeSlot(后者有认领副作用)——与 saveSession
10
+ // 的 _slot ??= activeSlot 语义一致:已钉槽直接重命名,未钉才认领。
11
+ const slot = agent._slot ?? activeSlot(agent.cwd)
9
12
  const current = agent.title || "(untitled)"
10
13
  const title = args.join(" ").trim()
11
14
  if (!title) {
@@ -38,11 +41,15 @@ export async function handleSessionCommand(ctx) {
38
41
  const dt = new Date(d)
39
42
  return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
40
43
  }
41
- const truncate = (s, n) => s.length <= n ? s : s.slice(0, n - 1) + "…"
44
+ const truncate = (s, n) => (stringWidth(s) <= n ? s : sliceByWidth(s, Math.max(1, n - 1)) + "…")
42
45
  const entries = [
43
46
  { type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel; /rename <title> renames the active one)` },
44
47
  ...slots.map((s) => {
45
- const label = s.title || (s.firstMessage ? `"${truncate(s.firstMessage, 40)}"` : "(empty)")
48
+ // 2026-08-31 会诊:行宽必须按显示宽度截断(原按 UTF-16 length 40 = 中文 80 格,
49
+ // 顶到右边距后叠加二义字符宽度低估(│/—/● 渲染 2 格算 1 格)→ 行实际超宽 → 物理
50
+ // wrap → 残影);title 此前完全不截断(可任意长)也是超宽源,一并宽度截断。
51
+ const rawLabel = s.title || (s.firstMessage ? `"${s.firstMessage}"` : "(empty)")
52
+ const label = truncate(rawLabel, 36)
46
53
  const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
47
54
  const when = shortDate(s.updatedAt)
48
55
  const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
@@ -56,11 +63,17 @@ export async function handleSessionCommand(ctx) {
56
63
  ]
57
64
  const e = await showPicker("Sessions", entries)
58
65
  if (!e) return
66
+ // 2026-08-31 会诊 deepseek 🔴:目标槽被另一活进程占用时 switchToSlot 不认领(避免
67
+ // 劫持对方 active),本次会话仍读到数据,但下次保存会 fork 到新槽——提前提示。
68
+ const occ = slotOccupancy(agent.cwd, e.slot)
59
69
  const data = switchToSlot(agent.cwd, e.slot)
60
70
  if (!data) {
61
71
  pushLine(`Slot ${e.slot} not found`, C.dim)
62
72
  return
63
73
  }
74
+ if (occ.occupied) {
75
+ pushLine(`⚠ Slot ${e.slot} is being used by another live process (${occ.owner}) — continuing here will create a new copy on the next save`, C.warn)
76
+ }
64
77
  applySession(agent, data)
65
78
  // Rebuild from history (lazy) — the display snapshot is deprecated.
66
79
  state.lines = []