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
@@ -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
package/src/tui/ansi.mjs CHANGED
@@ -25,6 +25,8 @@ export const ansi = {
25
25
  restoreCursor: `${ESC}8`, // DECRC — restore cursor position
26
26
  syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
27
27
  syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
28
+ wrapOff: `${ESC}[?7l`, // DECRST 7 — disable auto-wrap: over-wide rows hard-truncate at the margin instead of wrapping to the next physical line (2026-08-31 会诊:Ambiguous 宽度字符如 │/—/●/▸ 在中文 locale 终端渲染 2 格而 stringWidth 按 1 格算 → 行实际超宽 → wrap 污染下一物理行 + \x1b[K 清错行 → picker 残影)
29
+ wrapOn: `${ESC}[?7h`, // DECSET 7 — restore auto-wrap (TUI exit; also re-enable per-frame after write)
28
30
  reset: `${ESC}[0m`,
29
31
  dim: `${ESC}[2m`,
30
32
  bold: `${ESC}[1m`,
@@ -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
+ }