thincoder 0.12.54 → 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 (67) hide show
  1. package/CHANGELOG.md +57 -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/advisor/messages.mjs +4 -2
  6. package/src/advisor/run.mjs +2 -2
  7. package/src/agent/dispatch.mjs +66 -26
  8. package/src/agent/helpers.mjs +13 -2
  9. package/src/agent/setup.mjs +14 -2
  10. package/src/agent/spawn-child.mjs +3 -1
  11. package/src/agent-tools/advisor.mjs +19 -9
  12. package/src/agent-tools/eng.mjs +2 -0
  13. package/src/agent-tools/subagent-check.mjs +107 -0
  14. package/src/agent-tools/subagent.mjs +205 -42
  15. package/src/agent.mjs +68 -3
  16. package/src/cli/make-agent.mjs +25 -0
  17. package/src/cli/memory-command.mjs +28 -7
  18. package/src/config.mjs +120 -8
  19. package/src/context.mjs +28 -7
  20. package/src/escape.mjs +76 -23
  21. package/src/mcp/transport-http.mjs +13 -1
  22. package/src/mcp.mjs +52 -7
  23. package/src/memory/core.mjs +78 -10
  24. package/src/memory/docs.mjs +33 -7
  25. package/src/memory.mjs +1 -1
  26. package/src/model-specs.mjs +23 -0
  27. package/src/prompts/discipline.md +15 -1
  28. package/src/prompts/engineering.md +62 -5
  29. package/src/prompts/main.md +1 -0
  30. package/src/prompts/system.md +2 -1
  31. package/src/provider/anthropic.mjs +7 -5
  32. package/src/provider/core.mjs +48 -26
  33. package/src/provider/google.mjs +57 -24
  34. package/src/provider/normalize.mjs +1 -1
  35. package/src/provider/rate.mjs +0 -2
  36. package/src/provider/responses.mjs +8 -13
  37. package/src/provider/sse.mjs +20 -0
  38. package/src/session.mjs +15 -0
  39. package/src/tools/apply_patch.md +2 -0
  40. package/src/tools/bash.md +2 -2
  41. package/src/tools/edit-batch.mjs +104 -0
  42. package/src/tools/edit.md +3 -0
  43. package/src/tools/execute.md +4 -4
  44. package/src/tools/execute.mjs +14 -22
  45. package/src/tools/file.mjs +17 -55
  46. package/src/tools/file_ops.md +1 -1
  47. package/src/tools/git.md +1 -1
  48. package/src/tools/git.mjs +8 -16
  49. package/src/tools/lint.md +1 -1
  50. package/src/tools/linter.mjs +9 -37
  51. package/src/tools/patch.mjs +1 -1
  52. package/src/tools/shared.mjs +7 -20
  53. package/src/tui/agent-turn.mjs +3 -3
  54. package/src/tui/clipboard.mjs +2 -2
  55. package/src/tui/cmd-eng.mjs +1 -0
  56. package/src/tui/cmd-mcp-form.mjs +197 -0
  57. package/src/tui/cmd-mcp.mjs +255 -114
  58. package/src/tui/index.mjs +25 -5
  59. package/src/tui/interaction.mjs +28 -1
  60. package/src/tui/key-handler.mjs +14 -2
  61. package/src/tui/mouse.mjs +1 -1
  62. package/src/tui/pickers.mjs +62 -4
  63. package/src/tui/render-frame.mjs +18 -10
  64. package/src/tui/render.mjs +4 -4
  65. package/src/tui/startup.mjs +4 -2
  66. package/src/tui/subagent-blocks.mjs +119 -4
  67. package/src/tui/tool-events.mjs +60 -15
@@ -11,6 +11,6 @@ Parameters:
11
11
  - dest (required): destination path
12
12
 
13
13
  Notes:
14
- - Paths are confined to the working directory (same safety as write/edit) bash has NO directory confinement.
14
+ - Paths resolve relative to cwd no directory restriction (same boundary as bash; the approval gate is the guard).
15
15
  - `dest` is overwritten if it already exists. `copy` is recursive for directories.
16
16
  - To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
package/src/tools/git.md CHANGED
@@ -36,7 +36,7 @@ Run a git command. Only works inside a git repository.
36
36
 
37
37
  Parameters:
38
38
  - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote / clone / init / rebase / remote / clean / switch / apply / worktree / archive / blame / mv
39
- - workdir: run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd
39
+ - workdir: run git in this subdirectory (monorepo / multi-repo). Path relative to cwd — no directory restriction. Default: cwd
40
40
  - config: (network actions push/fetch/pull/ls-remote/clone) git -c overrides, e.g. ["http.proxy=http://10.2.2.112:3128"] for blocked remotes
41
41
  - path: (diff/log/add/commit/checkout/restore/rm/apply/archive/blame/mv/worktree) file or directory to scope / stage / restore
42
42
  - ref: (show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create/rebase/worktree:add/archive) commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)
package/src/tools/git.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  runGit
5
5
  } from "./shared.mjs";
6
6
  import { execFileSync } from "node:child_process";
7
- import { resolve, relative, isAbsolute, sep } from "node:path";
7
+ import { resolve } from "node:path";
8
8
  import { filterLines, runGitStrict, validateRef, gitConfigArgs, snapshotBefore, executeExtAction } from "./git-ext.mjs";
9
9
  import { executeCheckpointAction } from "./git-checkpoint.mjs";
10
10
 
@@ -23,20 +23,12 @@ function runGitRaw(cwd, cmdArgs, config = []) {
23
23
 
24
24
 
25
25
 
26
- /** True when `abs` is inside `root` (handles `..` and cross-drive, which relative()
27
- * returns as an absolute path on Windows). */
28
- function isInside(root, abs) {
29
- const rel = relative(root, abs)
30
- if (isAbsolute(rel)) return false
31
- return rel !== ".." && !rel.startsWith(".." + sep)
32
- }
33
-
34
- /** Resolve workdir relative to cwd, asserting it stays within the workspace. */
26
+ /** Resolve workdir relative to cwd no boundary assertion
27
+ * (§10.1 2026-09-02: workspace confinement removed; git itself is not
28
+ * directory-limited — same boundary as bash). */
35
29
  function resolveBaseDir(cwd, workdir) {
36
30
  if (!workdir || typeof workdir !== "string") return cwd
37
- const abs = resolve(cwd, workdir)
38
- if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
39
- return abs
31
+ return resolve(cwd, workdir)
40
32
  }
41
33
 
42
34
 
@@ -59,7 +51,7 @@ export const gitTool = {
59
51
  // write-op params
60
52
  name: { type: "string", description: "(branch/tag) The branch or tag name (create/delete/switch)" },
61
53
  remote: { type: "string", description: "(push/fetch/pull) Remote name (e.g. origin). Default: current upstream" },
62
- workdir: { type: "string", description: "Run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd" },
54
+ workdir: { type: "string", description: "Run git in this subdirectory (monorepo / multi-repo). Path relative to cwd — no directory restriction. Default: cwd" },
63
55
  config: { type: "array", items: { type: "string" }, description: "(network actions: push/fetch/pull/ls-remote) git -c overrides, e.g. [\"http.proxy=http://10.2.2.112:3128\"] for blocked remotes" },
64
56
  tags: { type: "boolean", description: "(push) Also push all tags (--tags)" },
65
57
  mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation(操作前自动快照,checkpointAction=rewind 恢复)" },
@@ -82,8 +74,8 @@ export const gitTool = {
82
74
  },
83
75
  readonly: false,
84
76
  async execute(args, ctx) {
85
- // workdir: run git in a workspace subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
86
- // every action + snapshotBefore + checkpoint resolves against the workdir, confined to the workspace.
77
+ // workdir: run git in a subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
78
+ // every action + snapshotBefore + checkpoint resolves against the workdir.
87
79
  if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
88
80
  // git -c overrides (proxy etc.) — only network actions need them; passing to every
89
81
  // action would be harmless but noisy. cfgArgs stays [] for local ops.
package/src/tools/lint.md CHANGED
@@ -1,6 +1,6 @@
1
1
  Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.
2
2
  Without 'full', runs a fast node --check (JS/TS syntax only, catches parse errors in milliseconds).
3
- With 'full', runs the language-aware cascade: eslint → tsc –noEmit → node --check (JS/TS/TSX); ruff (Python); cargo check (Rust); go vet (Go).
3
+ With 'full', runs the language-aware cascade: tsc –noEmit (TS); ruff (Python); cargo check (Rust); go vet (Go). JS/JSX files fall back to node --check; TS uses tsc --noEmit (requires tsconfig.json).
4
4
  Use the fast default after every write/edit; use 'full' before declaring a task complete.
5
5
 
6
6
  Parameters:
@@ -1,7 +1,7 @@
1
1
  import { DESC, resolveInCwd } from "./shared.mjs"
2
2
  import { execFileSync } from "node:child_process"
3
3
  import { existsSync } from "node:fs"
4
- import { join, relative } from "node:path"
4
+ import { join } from "node:path"
5
5
 
6
6
  export const lintTool = {
7
7
  name: "lint",
@@ -26,7 +26,8 @@ export const lintTool = {
26
26
  return nodeCheckResult(abs)
27
27
  }
28
28
 
29
- // Full cascade: language-aware (eslint → tsc → node --check, etc.)
29
+ // Full cascade: language-aware (tsc → node --check, ruff, cargo, go vet —
30
+ // third-party linter cascade removed 2026-09-02, TOOLS.md §10.2: zero-dependency lint)
30
31
  const ext = abs.split(".").pop()?.toLowerCase()
31
32
  const checkers = LANG_CHECKERS[ext]
32
33
  if (!checkers) return nodeCheckResult(abs) // fall back to node --check
@@ -54,33 +55,7 @@ function nodeCheckResult(abs) {
54
55
  }
55
56
  }
56
57
 
57
- // ─── Full-check cascade checkers ──────────────────────
58
-
59
- async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
60
- let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
61
- while (true) {
62
- for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
63
- if (existsSync(join(cwd, dir, cfg))) {
64
- try {
65
- const cfgDir = join(cwd, dir)
66
- const relPath = relative(cfgDir, file)
67
- execFileSync("npx", ["eslint", "--no-color", "--format", "compact", relPath], {
68
- cwd: cfgDir, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
69
- })
70
- return "✓ eslint: no issues"
71
- } catch (e) {
72
- const stdout = (e.stdout || "").trim()
73
- if (stdout) return stdout
74
- return `✗ eslint: ${(e.stderr || e.message).slice(0, 500)}`
75
- }
76
- }
77
- }
78
- const parent = dir.split("/").slice(0, -1).join("/")
79
- if (!parent || parent === dir) break
80
- dir = parent
81
- }
82
- return null
83
- }
58
+ // ─── Full-check cascade checkers (third-party linter branch removed 2026-09-02, TOOLS.md §10.2) ──────
84
59
 
85
60
  async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
86
61
  if (!existsSync(join(cwd, "tsconfig.json"))) return null
@@ -142,14 +117,11 @@ async function goVet(file, { cwd, execFileSync }) {
142
117
  }
143
118
 
144
119
  const LANG_CHECKERS = {
145
- js: [eslintCheck],
146
- mjs: [eslintCheck],
147
- cjs: [eslintCheck],
148
- jsx: [eslintCheck],
149
- ts: [eslintCheck, tscCheck],
150
- tsx: [eslintCheck, tscCheck],
151
- mts: [eslintCheck, tscCheck],
152
- cts: [eslintCheck, tscCheck],
120
+ // js/mjs/cjs/jsx fall back to node --check (no entry — third-party linter cascade removed 2026-09-02, TOOLS.md §10.2)
121
+ ts: [tscCheck],
122
+ tsx: [tscCheck],
123
+ mts: [tscCheck],
124
+ cts: [tscCheck],
153
125
  py: [ruffCheck],
154
126
  rs: [cargoCheck],
155
127
  go: [goVet],
@@ -103,7 +103,7 @@ export const applyPatchTool = {
103
103
  parameters: {
104
104
  type: "object",
105
105
  properties: {
106
- patch: { type: "string", description: "Unified diff. May span multiple files; --- / +++ headers per file, @@ -old,count +new,count @@ hunks. Use --- /dev/null to create a file." },
106
+ patch: { type: "string", description: "Unified diff. May span multiple files (multiple --- / +++ header pairs — including creating MULTIPLE new files via --- /dev/null); --- / +++ headers per file, @@ -old,count +new,count @@ hunks." },
107
107
  },
108
108
  required: ["patch"],
109
109
  },
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { spawn, execFileSync, execFile } from "node:child_process"
7
7
  import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
8
- import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
8
+ import { dirname, join, resolve } from "node:path"
9
9
  import { fileURLToPath } from "node:url"
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -170,7 +170,7 @@ export function toOpenAISchema(tool) {
170
170
  /** Strip ANSI escape sequences */
171
171
  export function sanitizeOutput(s) {
172
172
  return s
173
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
173
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
174
174
  .replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
175
175
  .replace(/\r\n/g, "\n")
176
176
  .replace(/\r/g, "\n")
@@ -276,27 +276,14 @@ function realCwd(cwd) {
276
276
  return realCwdCache.get(cwd)
277
277
  }
278
278
 
279
- /** Assert that a resolved path is inside cwd; throws on escape */
280
- function assertInside(cwd, resolved, p) {
281
- // relative() returns platform-native separators; ".." + sep therefore
282
- // matches both / and \ traversal on the respective platform.
283
- const rel = relative(cwd, resolved)
284
- if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
285
- throw new Error(`Access denied outside working directory: ${p}`)
286
- }
287
- }
288
-
289
- /** Resolve a user-supplied path relative to cwd, asserting it stays within cwd */
279
+ /** Resolve a user-supplied path relative to cwd no directory boundary assertion
280
+ * (§10.1 2026-09-02: workspace confinement removed; resolveInCwd ≡ resolveExternal,
281
+ * trust model + approval gate is the only guard — same boundary as bash). */
290
282
  export function resolveInCwd(ctx, p) {
291
- const cwd = realCwd(ctx.cwd)
292
- const resolved = resolve(cwd, p)
293
- assertInside(cwd, resolved, p)
294
- const real = realpathNearest(resolved)
295
- assertInside(cwd, real, p)
296
- return resolved
283
+ return resolveExternal(ctx, p)
297
284
  }
298
285
 
299
- /** Resolve a path relative to cwd without boundary check — use only when the user explicitly provides an external path */
286
+ /** Resolve a path relative to cwd without boundary check — kept for compatibility (≡ resolveInCwd) */
300
287
  export function resolveExternal(ctx, p) {
301
288
  const cwd = realCwd(ctx.cwd)
302
289
  return resolve(cwd, p)
@@ -23,10 +23,10 @@ const DISTILL_FLUSH_TIMEOUT_MS = 5000
23
23
  /** Execute one agent conversation turn (triggered by submit or queue).
24
24
  * Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
25
25
  * ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
26
- * ensureAssistantLabel, askPermission, askQuestion,
26
+ * ensureAssistantLabel, askPermission, askBatchPermission, askQuestion,
27
27
  * handleSlash, summarize } */
28
28
  export async function runAgentTurn(ctx, text) {
29
- const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash } = ctx
29
+ const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, handleSlash } = ctx
30
30
  // 可注入覆盖(测试用);默认走真实实现
31
31
  const runAgentImpl = ctx.runAgent ?? runAgent
32
32
  const saveSessionImpl = ctx.saveSession ?? saveSession
@@ -59,7 +59,7 @@ export async function runAgentTurn(ctx, text) {
59
59
  render()
60
60
 
61
61
  const { callbacks, flushStream } = buildToolCallbacks({
62
- agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl,
62
+ agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl,
63
63
  })
64
64
 
65
65
  // try/finally: every exit path — including an unexpected throw inside the catch
@@ -102,7 +102,7 @@ export function insertPastedText(state, rawText) {
102
102
  * Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
103
103
  * (degrades to a normal submit; Alt+Enter remains the fallback). */
104
104
  export function translateShiftEnter(text) {
105
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
105
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
106
106
  return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
107
107
  }
108
108
 
@@ -111,7 +111,7 @@ export function translateShiftEnter(text) {
111
111
  * modifyOtherKeys: \x1b[27;mod;key~ — function keys
112
112
  * Call AFTER translateShiftEnter (which already handles Shift+Enter). */
113
113
  export function stripKeyboardProtocol(text) {
114
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
114
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
115
115
  return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
116
116
  }
117
117
 
@@ -49,6 +49,7 @@ export async function handleEngCommand(ctx) {
49
49
  agent.config.agent.engineering = !agent.config.agent.engineering
50
50
  if (!agent.config.agent.engineering) {
51
51
  agent._engDesignToken = null // invalidate stale token
52
+ agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
52
53
  // OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
53
54
  // mode flip is invisible to the agent otherwise. (ON needs none here: the injector
54
55
  // in agent.mjs already announces ON transitions on the next turn.)
@@ -0,0 +1,197 @@
1
+ import { C } from "./ansi.mjs"
2
+
3
+ /** MCP.md §5 v2(D-1):edit/add 统一字段 picker 表单机制。
4
+ * fieldPicker 循环:picker 列字段行(label + 当前值打码)+ `✓ Save & test` 末行;
5
+ * 选中字段 → askQuestion 只输入该字段新值——空=不变、`-`=删除可选字段、`k=`=删除
6
+ * header/env 项、required 字段拒绝 `-`(不许删空)→ 回 picker(已改值保留——T18b
7
+ * 中间 Esc 回 picker 不丢);选 `✓ Save & test` → 必填校验(add 含 name 重复检查)
8
+ * → 调用方走 F2 预览+探活确认环;探活失败回同一 picker(AC2——独立 retry 路径废除)。
9
+ * 本文件为 v1 cmd-mcp.mjs 的 mergeKeyValuePairs/maskToken 迁移落点(逗号分隔 kv 解析
10
+ * 并入 mergeKeyValuePairs——v2 的 headers/env 输入统一按"键值对合并/删除"语义处理,
11
+ * 原 parseHeaders 整段替换语义不再需要);拆分前置——cmd-mcp.mjs 499 行压 500 硬限,评审 #1)。 */
12
+
13
+ /** F3 评审 #3 清除语义(T12):`k=`(空 value)= 从 merged 中删除该项;`k=v` = 设置。
14
+ * 返回更新后的对象(无项时 null——调用方据此 delete 字段)。 */
15
+ function mergeKeyValuePairs(merged, input) {
16
+ for (const pair of String(input).split(",")) {
17
+ const eq = pair.indexOf("=")
18
+ if (eq > 0) {
19
+ const key = pair.slice(0, eq).trim()
20
+ const value = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, "")
21
+ if (!key) continue
22
+ if (value) merged[key] = value
23
+ else delete merged[key]
24
+ }
25
+ }
26
+ return Object.keys(merged).length > 0 ? merged : null
27
+ }
28
+
29
+ /** F2(评审 #6):预览 token 遮蔽——len > 12 显示前 4 字符 + "…",否则全遮。 */
30
+ export function maskToken(token) {
31
+ const t = String(token ?? "")
32
+ if (!t) return ""
33
+ return t.length > 12 ? `${t.slice(0, 4)}…` : "•".repeat(t.length)
34
+ }
35
+
36
+ /** edit 工作副本——headers/env/args 嵌套独立;fieldPicker 原地改副本,取消/失败不
37
+ * 污染原配置(零副作用)。 */
38
+ export function cloneEntry(srv) {
39
+ return {
40
+ ...srv,
41
+ headers: srv.headers ? { ...srv.headers } : undefined,
42
+ env: srv.env ? { ...srv.env } : undefined,
43
+ args: srv.args ? [...srv.args] : undefined,
44
+ }
45
+ }
46
+
47
+ const FIELD_LABELS = {
48
+ name: "Name",
49
+ url: "HTTP URL",
50
+ wsUrl: "WebSocket URL",
51
+ token: "Token",
52
+ headers: "Headers",
53
+ command: "Command",
54
+ args: "Args",
55
+ env: "Env",
56
+ }
57
+
58
+ /** F3/F3b:字段行顺序——add 含 name(可编辑、`(required)` 标注);edit 无 name 行
59
+ * (name 不可改)。HTTP: url/token/headers;WS: wsUrl/token/headers;stdio:
60
+ * command/args/env。 */
61
+ function fieldsFor(transport, mode) {
62
+ const base = transport === "ws" ? ["wsUrl", "token", "headers"]
63
+ : transport === "stdio" ? ["command", "args", "env"]
64
+ : ["url", "token", "headers"]
65
+ return mode === "add" ? ["name", ...base] : base
66
+ }
67
+
68
+ /** 必填字段(F3b 校验口径):name + transport 的端点/命令字段。 */
69
+ function requiredFieldsFor(transport) {
70
+ return transport === "ws" ? ["name", "wsUrl"]
71
+ : transport === "stdio" ? ["name", "command"]
72
+ : ["name", "url"]
73
+ }
74
+
75
+ /** edit 模式的 transport 由 entry 推导(AI 生成的 entry 同理——url/wsUrl/command 判定)。 */
76
+ function deriveTransport(entry) {
77
+ return entry.wsUrl ? "ws" : entry.url ? "http" : "stdio"
78
+ }
79
+
80
+ /** 单行显示值截断——picker 行宽控制(URL/command 可能很长)。 */
81
+ function shortVal(value, max = 32) {
82
+ const v = String(value ?? "")
83
+ return v.length > max ? `${v.slice(0, max - 1)}…` : v
84
+ }
85
+
86
+ /** F3 字段行显示:label 右侧打码/摘要(`Name (required)`、`Token d90c26bb…`、
87
+ * `Headers 2 items`)。必填空 → (required)(add 初始标注——F3b);可选空 → (none)。 */
88
+ function fieldDisplay(entry, field, { add, required }) {
89
+ const v = entry[field]
90
+ if (field === "headers" || field === "env") return `${Object.keys(v ?? {}).length} items`
91
+ if (field === "args") return (v ?? []).length ? shortVal(v.join(" ")) : "(none)"
92
+ if (field === "token") return v ? maskToken(v) : "(none)"
93
+ // name/url/wsUrl/command:端点与命令非机密——截断显示
94
+ if (v) return shortVal(v)
95
+ return add && required ? "(required)" : "(none)"
96
+ }
97
+
98
+ /** picker 行集:字段行(action `field:<name>`)+ 末行 `✓ Save & test`(action "save")。
99
+ * label 补齐对齐(最小 10 列——F3 示例形态 `Token d90c26bb…`)。 */
100
+ function formEntries(entry, transport, mode) {
101
+ const fields = fieldsFor(transport, mode)
102
+ const required = requiredFieldsFor(transport)
103
+ const pad = Math.max(10, Math.max(...fields.map((f) => FIELD_LABELS[f].length)) + 1)
104
+ return [
105
+ ...fields.map((f) => ({
106
+ type: "item",
107
+ text: `${FIELD_LABELS[f].padEnd(pad)} ${fieldDisplay(entry, f, { add: mode === "add", required: required.includes(f) })}`,
108
+ action: `field:${f}`,
109
+ })),
110
+ { type: "item", text: "✓ Save & test", action: "save" },
111
+ ]
112
+ }
113
+
114
+ /** 字段输入提示 `(current: …)`——token 打码(maskToken)、headers/env 列键值对、
115
+ * args 空格串接;空值 → "none"。 */
116
+ function currentText(entry, field) {
117
+ const v = entry[field]
118
+ if (field === "token") return v ? maskToken(v) : "none"
119
+ if (field === "headers" || field === "env") {
120
+ const pairs = Object.entries(v ?? {}).map(([k, val]) => `${k}=${val}`)
121
+ return pairs.length ? pairs.join(", ") : "none"
122
+ }
123
+ if (field === "args") return (v ?? []).length ? v.join(" ") : "none"
124
+ return String(v ?? "") || "none"
125
+ }
126
+
127
+ const PROMPT_BASES = {
128
+ name: "Server name",
129
+ url: "HTTP URL",
130
+ wsUrl: "WebSocket URL",
131
+ token: "Auth token (Bearer, optional; '-' clears, empty keeps)",
132
+ headers: "Headers (key=value, comma-separated; key= removes; empty keeps; '-' clears all)",
133
+ command: "Command",
134
+ args: "Arguments (space-separated; '-' clears, empty keeps)",
135
+ env: "Environment variables (key=value, comma-separated; key= removes; empty keeps; '-' clears all)",
136
+ }
137
+
138
+ function fieldPrompt(entry, field) {
139
+ return `${PROMPT_BASES[field]} (current: ${currentText(entry, field)}):`
140
+ }
141
+
142
+ /** 字段输入应用(UI 决策 #3):空=不变;`-`=删除可选字段(token/headers/env/args);
143
+ * `k=`=删 header/env 项;required 字段(name/url/wsUrl/command)拒绝 `-`——必填不许删空。
144
+ * 返回错误文案(调用方 pushLine)或 null。 */
145
+ function applyFieldInput(entry, field, input, required) {
146
+ if (required) {
147
+ if (input === "-") return `${FIELD_LABELS[field]} is required — cannot be cleared`
148
+ if (input) entry[field] = input
149
+ return null
150
+ }
151
+ if (field === "token") {
152
+ if (input === "-") delete entry.token
153
+ else if (input) entry.token = input
154
+ } else if (field === "headers" || field === "env") {
155
+ if (input === "-") delete entry[field]
156
+ else if (input) {
157
+ const updated = mergeKeyValuePairs(entry[field] ? { ...entry[field] } : {}, input)
158
+ if (updated) entry[field] = updated
159
+ else delete entry[field]
160
+ }
161
+ } else if (field === "args") {
162
+ if (input === "-") delete entry.args
163
+ else if (input) entry.args = input.split(/\s+/)
164
+ }
165
+ return null
166
+ }
167
+
168
+ /** D-1 表单循环(F3/F3b——一处实现两处复用;F2 字段级重试 = 复用同一 picker)。
169
+ * entry 为工作副本:已改值在循环间保留(Esc/空输入不丢——T18b)。
170
+ * 返回 { action: "save" | "cancel", entry };cancel = picker 层 Esc(放弃整个表单)。 */
171
+ export async function fieldPicker(ctx, { title, mode, entry, transport, existingNames = [] }) {
172
+ const { showPicker, askQuestion, pushLine } = ctx
173
+ const tr = transport ?? deriveTransport(entry)
174
+ const required = requiredFieldsFor(tr)
175
+ for (;;) {
176
+ const sel = await showPicker(title, formEntries(entry, tr, mode))
177
+ if (!sel) return { action: "cancel", entry }
178
+ if (sel.action === "save") {
179
+ // F3b:Save 校验必填非空——未满足提示并留在 picker(不落盘)
180
+ const missing = fieldsFor(tr, mode).filter((f) => required.includes(f) && !String(entry[f] ?? "").trim())
181
+ if (missing.length) {
182
+ pushLine(`[mcp] Missing required: ${missing.map((f) => FIELD_LABELS[f]).join(", ")} — fill before saving`, C.error)
183
+ continue
184
+ }
185
+ if (mode === "add" && entry.name && existingNames.includes(entry.name)) {
186
+ pushLine(`[mcp] "${entry.name}" already exists`, C.error)
187
+ continue
188
+ }
189
+ return { action: "save", entry }
190
+ }
191
+ const field = sel.action.slice("field:".length)
192
+ const raw = ((await askQuestion(fieldPrompt(entry, field))) ?? "").trim()
193
+ const err = applyFieldInput(entry, field, raw, required.includes(field))
194
+ if (err) pushLine(`[mcp] ${err}`, C.error)
195
+ // 循环回 picker——已改值保留(T18b:中间 Esc 回 picker 不丢)
196
+ }
197
+ }