thincoder 0.12.60 → 0.12.62

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 (171) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +38 -124
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +19 -6
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +24 -7
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +43 -11
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/async-settle.mjs +13 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +28 -10
  32. package/src/agent-tools/design-token.mjs +14 -1
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +3 -3
  35. package/src/agent-tools/escalate-async.mjs +22 -13
  36. package/src/agent-tools/read-history.mjs +31 -6
  37. package/src/agent-tools/review-streak.mjs +93 -0
  38. package/src/agent-tools/settings.mjs +130 -17
  39. package/src/agent-tools/subagent-actions.mjs +18 -6
  40. package/src/agent-tools/subagent-async.mjs +66 -14
  41. package/src/agent-tools/subagent-panel.mjs +22 -15
  42. package/src/agent-tools/subagent-run.mjs +10 -7
  43. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  44. package/src/agent-tools/subagent-spawn.mjs +69 -16
  45. package/src/agent-tools/subagent.mjs +175 -49
  46. package/src/agent-tools/verify.mjs +13 -34
  47. package/src/agent-tools.mjs +1 -0
  48. package/src/agent.mjs +42 -21
  49. package/src/cli/distill-command.mjs +2 -2
  50. package/src/cli/make-agent.mjs +23 -7
  51. package/src/cli/memory-command.mjs +2 -2
  52. package/src/cli/setup-wizard.mjs +29 -9
  53. package/src/completions.mjs +114 -0
  54. package/src/config-migrate.mjs +70 -0
  55. package/src/config.mjs +132 -63
  56. package/src/context.mjs +12 -1
  57. package/src/conventions.mjs +223 -0
  58. package/src/crash-reports.mjs +35 -8
  59. package/src/expand-home.mjs +16 -0
  60. package/src/generate-title.mjs +9 -4
  61. package/src/heap-watch.mjs +88 -0
  62. package/src/hooks.mjs +7 -3
  63. package/src/ledger.mjs +227 -0
  64. package/src/memory/code-index.mjs +9 -3
  65. package/src/memory/code-sync.mjs +77 -36
  66. package/src/memory/core.mjs +8 -9
  67. package/src/memory/delete.mjs +2 -0
  68. package/src/memory/docs.mjs +17 -11
  69. package/src/memory/file-walk.mjs +109 -0
  70. package/src/memory/scan.mjs +95 -0
  71. package/src/memory/schema.mjs +15 -3
  72. package/src/model-ref.mjs +66 -0
  73. package/src/model-specs.mjs +42 -8
  74. package/src/prompt-overlays.mjs +73 -16
  75. package/src/prompts/advisor-design.md +19 -9
  76. package/src/prompts/advisor-round1.md +8 -2
  77. package/src/prompts/advisor-round2.md +14 -3
  78. package/src/prompts/advisor-round3.md +14 -3
  79. package/src/prompts/common.md +115 -0
  80. package/src/prompts/consult-base.md +2 -0
  81. package/src/prompts/discipline-engineering.md +258 -0
  82. package/src/prompts/discipline-normal.md +185 -0
  83. package/src/prompts/persona-coder.md +21 -0
  84. package/src/prompts/persona-eng-coder.md +37 -0
  85. package/src/prompts/persona-eng-designer.md +60 -0
  86. package/src/prompts/persona-engineering.md +55 -0
  87. package/src/prompts/persona-explore.md +15 -0
  88. package/src/prompts/persona-normal.md +27 -0
  89. package/src/prompts/persona-plan.md +26 -0
  90. package/src/provider/anthropic.mjs +4 -4
  91. package/src/provider/core.mjs +13 -32
  92. package/src/provider/errors.mjs +26 -1
  93. package/src/provider/google.mjs +5 -6
  94. package/src/provider/index.mjs +2 -1
  95. package/src/provider/list-models.mjs +93 -0
  96. package/src/provider/rate.mjs +2 -1
  97. package/src/provider/responses.mjs +5 -3
  98. package/src/provider/sse.mjs +3 -4
  99. package/src/proxy.mjs +9 -14
  100. package/src/session-gc.mjs +9 -2
  101. package/src/session-guard.mjs +12 -0
  102. package/src/session-segments.mjs +100 -0
  103. package/src/session-slots.mjs +10 -2
  104. package/src/session-store.mjs +441 -0
  105. package/src/session.mjs +137 -99
  106. package/src/text-budget.mjs +46 -0
  107. package/src/token-ttl.mjs +2 -1
  108. package/src/tools/{system.mjs → bash.mjs} +6 -243
  109. package/src/tools/file.mjs +30 -10
  110. package/src/tools/git.md +1 -1
  111. package/src/tools/git.mjs +15 -34
  112. package/src/tools/index.mjs +4 -2
  113. package/src/tools/ops.mjs +20 -7
  114. package/src/tools/question.md +1 -0
  115. package/src/tools/question.mjs +26 -0
  116. package/src/tools/read.md +1 -1
  117. package/src/tools/read_image.md +1 -1
  118. package/src/tools/search.mjs +236 -0
  119. package/src/traces/trace-store.mjs +195 -64
  120. package/src/tui/agent-turn.mjs +32 -13
  121. package/src/tui/ansi.mjs +2 -0
  122. package/src/tui/clipboard.mjs +7 -1
  123. package/src/tui/cmd-advisor.mjs +3 -2
  124. package/src/tui/cmd-clear.mjs +2 -0
  125. package/src/tui/cmd-config.mjs +108 -37
  126. package/src/tui/cmd-eng.mjs +11 -27
  127. package/src/tui/cmd-exit.mjs +6 -8
  128. package/src/tui/cmd-model.mjs +14 -12
  129. package/src/tui/cmd-new.mjs +5 -1
  130. package/src/tui/cmd-reindex.mjs +7 -0
  131. package/src/tui/cmd-session.mjs +8 -4
  132. package/src/tui/cmd-submodel.mjs +8 -5
  133. package/src/tui/cmd-undo.mjs +4 -3
  134. package/src/tui/display-budget.mjs +184 -0
  135. package/src/tui/index.mjs +69 -40
  136. package/src/tui/key-handler-search.mjs +9 -1
  137. package/src/tui/key-handler.mjs +61 -17
  138. package/src/tui/key-modes.mjs +86 -8
  139. package/src/tui/layout.mjs +18 -10
  140. package/src/tui/ledger-surface.mjs +69 -0
  141. package/src/tui/model-catalog.mjs +89 -0
  142. package/src/tui/model-picker.mjs +498 -0
  143. package/src/tui/mouse.mjs +47 -10
  144. package/src/tui/pickers.mjs +28 -410
  145. package/src/tui/render-frame.mjs +38 -16
  146. package/src/tui/render-loop.mjs +2 -0
  147. package/src/tui/render-segments.mjs +5 -19
  148. package/src/tui/render.mjs +37 -5
  149. package/src/tui/slash-commands.mjs +2 -2
  150. package/src/tui/startup.mjs +45 -13
  151. package/src/tui/subagent-blocks.mjs +70 -90
  152. package/src/tui/subagent-children.mjs +125 -67
  153. package/src/tui/subagent-freeze.mjs +48 -45
  154. package/src/tui/subagent-panel.mjs +21 -66
  155. package/src/tui/suspension-drive.mjs +31 -83
  156. package/src/tui/tool-args.mjs +9 -4
  157. package/src/tui/tool-display.mjs +20 -5
  158. package/src/tui/tool-events.mjs +76 -30
  159. package/src/tui/tui-lifecycle.mjs +27 -7
  160. package/src/tui/wizard.mjs +52 -18
  161. package/src/tui/wrapped-spawn.mjs +54 -0
  162. package/src/prompts/coder.md +0 -13
  163. package/src/prompts/discipline.md +0 -84
  164. package/src/prompts/eng-coder.md +0 -19
  165. package/src/prompts/engineering-sub.md +0 -14
  166. package/src/prompts/engineering.md +0 -87
  167. package/src/prompts/explore.md +0 -12
  168. package/src/prompts/main.md +0 -34
  169. package/src/prompts/methodology-template.md +0 -38
  170. package/src/prompts/plan.md +0 -9
  171. package/src/prompts/system.md +0 -44
@@ -0,0 +1,236 @@
1
+ import {
2
+ DESC,
3
+ truncate,
4
+ resolveInCwd,
5
+ globToRegex,
6
+ splitGlobPatterns,
7
+ compileGlobMatchers,
8
+ normalizeEOL,
9
+ IGNORED_DIRS,
10
+ } from "./shared.mjs";
11
+ import { readFile, readdir, stat, lstat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+
14
+ /** Escape a string for literal regex matching (grep literal=true). */
15
+ function escapeRegExp(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
17
+ }
18
+
19
+ // ---------------------------------------------------------------- glob
20
+
21
+ export const globTool = {
22
+ name: "glob",
23
+ description: DESC("glob"),
24
+ parameters: {
25
+ type: "object",
26
+ properties: {
27
+ pattern: { type: "string", description: "Glob pattern — supports **, *, ?, [..], {a,b} braces, and space-separated exclusion (\"**/*.js !test/**\")" },
28
+ path: { type: "string", description: "Directory to search in (default cwd)" },
29
+ },
30
+ required: ["pattern"],
31
+ },
32
+ readonly: true,
33
+ async execute(args, ctx) {
34
+ const base = resolveInCwd(ctx, args.path ?? ".")
35
+ // §17 调用侧拆分(评审 #5 职责分层):空格分隔 include !exclude 多模式在这里拆;
36
+ // globToRegex 只收单个模式(数组由 compileGlobMatchers 收)。
37
+ let match
38
+ try {
39
+ const parts = splitGlobPatterns(args.pattern)
40
+ if (parts.length === 0) return "Error: pattern is required"
41
+ match = compileGlobMatchers(parts).test
42
+ } catch (e) {
43
+ return `glob error: invalid pattern "${args.pattern}": ${e.message}`
44
+ }
45
+ const results = []
46
+ for await (const relPath of walkFiles(base)) {
47
+ if (match(relPath)) {
48
+ results.push(relPath)
49
+ if (results.length >= 1000) break
50
+ }
51
+ }
52
+ if (results.length === 0) return "(no matches)"
53
+ return truncate(results.sort().join("\n"))
54
+ },
55
+ }
56
+
57
+ /** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
58
+ async function* walkFiles(dir, rel = "") {
59
+ let entries
60
+ try {
61
+ entries = await readdir(dir, { withFileTypes: true })
62
+ } catch {
63
+ return
64
+ }
65
+ for (const e of entries) {
66
+ // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
67
+ if (e.isSymbolicLink()) continue
68
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
69
+ const relPath = rel ? `${rel}/${e.name}` : e.name
70
+ if (e.isDirectory()) {
71
+ yield* walkFiles(join(dir, e.name), relPath)
72
+ } else {
73
+ yield relPath
74
+ }
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------- grep
79
+
80
+ export const grepTool = {
81
+ name: "grep",
82
+ description: DESC("grep"),
83
+ parameters: {
84
+ type: "object",
85
+ properties: {
86
+ pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
87
+ path: { type: "string", description: "Directory or file to search (default cwd)" },
88
+ glob: { type: "string", description: "Only search files matching this glob — supports **, *, ?, [..], {a,b} braces and space-separated exclusion (\"**/*.js !test/**\")" },
89
+ ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
90
+ literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
91
+ before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
92
+ after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
93
+ },
94
+ required: ["pattern"],
95
+ },
96
+ readonly: true,
97
+ async execute(args, ctx) {
98
+ const base = resolveInCwd(ctx, args.path ?? ".")
99
+ let regex
100
+ try {
101
+ const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
102
+ regex = new RegExp(pat, args.ignoreCase ? "i" : "")
103
+ } catch (e) {
104
+ throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
105
+ }
106
+ // §17 调用侧拆分:glob 参数支持空格分隔 include !exclude 多模式(include/exclude 求交)。
107
+ let fileTest = null
108
+ if (args.glob) {
109
+ try {
110
+ const parts = splitGlobPatterns(args.glob)
111
+ if (parts.length > 0) fileTest = compileGlobMatchers(parts).test
112
+ } catch (e) {
113
+ throw new Error(`grep glob /${args.glob}/ is invalid: ${e.message}`, { cause: e })
114
+ }
115
+ }
116
+ const before = Math.max(0, Math.floor(args.before ?? 0))
117
+ const after = Math.max(0, Math.floor(args.after ?? 0))
118
+ const wantCtx = before > 0 || after > 0
119
+ const hits = [] // { file, line(1-based), text }
120
+ const fileLines = new Map() // file -> string[] (cached only when context lines requested)
121
+
122
+ async function search(file) {
123
+ let content
124
+ try {
125
+ // Large file guard: skip files over 10MB to prevent OOM
126
+ const fst = await stat(file)
127
+ if (fst.size > 10_000_000) return
128
+ content = normalizeEOL(await readFile(file, "utf8"))
129
+ } catch {
130
+ return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
131
+ }
132
+ const lines = content.split("\n")
133
+ if (wantCtx) fileLines.set(file, lines)
134
+ for (let i = 0; i < lines.length; i++) {
135
+ if (regex.test(lines[i])) {
136
+ hits.push({ file, line: i + 1, text: lines[i] })
137
+ if (hits.length >= 200) return
138
+ }
139
+ }
140
+ }
141
+
142
+ async function walk(target, rel) {
143
+ if (hits.length >= 200) return
144
+ // Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
145
+ let s
146
+ try { s = await lstat(target) } catch { return }
147
+ if (!s.isDirectory()) {
148
+ // rel 为相对搜索基的路径(排除前缀如 !test/** 必须按目录路径作用——不能用裸文件名);
149
+ // 单文件目标(path 指向文件)时 rel 为空 → 退化为按文件名过滤(既有语义)。
150
+ if (!fileTest || fileTest(rel || target.split(/[\\/]/).pop())) await search(target)
151
+ return
152
+ }
153
+ let entries
154
+ try {
155
+ entries = await readdir(target, { withFileTypes: true })
156
+ } catch {
157
+ return
158
+ }
159
+ for (const e of entries) {
160
+ if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
161
+ await walk(join(target, e.name), rel ? `${rel}/${e.name}` : e.name)
162
+ }
163
+ }
164
+
165
+ await walk(base, "")
166
+ if (hits.length === 0) return "(no matches)"
167
+
168
+ // No context: keep original path:line: content format
169
+ if (!wantCtx) {
170
+ return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
171
+ }
172
+
173
+ // With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
174
+ const fileMatched = new Map() // file -> Set<line>
175
+ for (const h of hits) {
176
+ if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
177
+ fileMatched.get(h.file).add(h.line)
178
+ }
179
+ const out = []
180
+ for (const [file, matchedLines] of fileMatched) {
181
+ const lines = fileLines.get(file) ?? []
182
+ const lineSet = new Set()
183
+ for (const ml of matchedLines) {
184
+ for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
185
+ }
186
+ for (const l of [...lineSet].sort((a, b) => a - b)) {
187
+ const sep = matchedLines.has(l) ? ":" : "-"
188
+ out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
189
+ }
190
+ }
191
+ return truncate(out.join("\n"))
192
+ },
193
+ }
194
+
195
+ export const lsTool = {
196
+ name: "ls",
197
+ description: DESC("ls"),
198
+ parameters: {
199
+ type: "object",
200
+ properties: {
201
+ path: { type: "string", description: "Directory path (default cwd)" },
202
+ filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
203
+ },
204
+ },
205
+ readonly: true,
206
+ async execute(args, ctx) {
207
+ const abs = resolveInCwd(ctx, args.path ?? ".")
208
+ const filterRe = args.filter ? globToRegex(args.filter) : null
209
+ let entries
210
+ try {
211
+ entries = await readdir(abs, { withFileTypes: true })
212
+ } catch (e) {
213
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`, { cause: e })
214
+ throw e
215
+ }
216
+ const rows = await Promise.all(
217
+ entries
218
+ .filter((e) => !filterRe || filterRe.test(e.name))
219
+ .slice(0, 500)
220
+ .map(async (e) => {
221
+ const s = await stat(join(abs, e.name)).catch(() => null)
222
+ const isDir = e.isDirectory()
223
+ return {
224
+ dir: isDir,
225
+ name: e.name + (isDir ? "/" : ""),
226
+ size: s?.size ?? 0,
227
+ mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
228
+ }
229
+ }),
230
+ )
231
+ rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
232
+ if (rows.length === 0) return "(no entries)"
233
+ const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
234
+ return truncate(out.join("\n"))
235
+ },
236
+ }
@@ -16,12 +16,21 @@
16
16
  * 与 log.mjs 的 THINCODER_LOG_DIR 同惯例)。
17
17
  * - 脱敏(D-TR2):复用 log.mjs 字段名黑名单(apikey/designtoken/password/secret/
18
18
  * token/authorization/proxyuri/proxy)+ SECRET_FORM 形态扫描(redactSecret)——对
19
- * messages/content/reasoning/toolCalls/error 全字段递归应用;不发明新遮蔽模式。
20
- * - 容量有界(N-TR1):单条轨迹一个文件,大小不限("成本不是问题"——不截断、不
21
- * 脱漏行);目录按日组织(YYYY-MM-DD),可按日/会话过滤;不自动清理(D-TR8——
22
- * 清理机制记 docs/TODO.md 技术组)。
19
+ * messages/content/reasoning/toolCalls/error 全字段应用;不发明新遮蔽模式。
20
+ * **单遍序列化(TUI-OOM-ROOTCAUSE——§23.3.2:修正轮 #2 使「不截断」表述作废)**:
21
+ * 脱敏内联进输出缓冲(不再构造复制图 + 二次全量字符串)——字段名/次序与既有
22
+ * `JSON.stringify` 形态同构(T-TR1 等价断言)。
23
+ * - 容量有界(N-TR1 修订):单消息字符串超 `TRACE_MESSAGE_MAX_CHARS` 截断(头 16K +
24
+ * 中段标记 + 尾 48K);序列化总长超 `TRACE_RECORD_MAX_CHARS` → `messages` 降级为
25
+ * stub 串(其余字段保留);在途写盘份数 ≤ `TRACE_PENDING_MAX`(超限丢弃并计数——
26
+ * 尽力面,可观测)。目录按日组织(YYYY-MM-DD),可按日/会话过滤;保留期清理见
27
+ * `cleanupTraces`(D-TR8)。
23
28
  * - seq = 当日目录内最大已有 seq + 1(D-TR3——跨会话/进程重启不覆写既有旧轨迹——
24
- * 18.6.1 评审 #3)。
29
+ * 18.6.1 评审 #3);**进程内 seqCache(§23.3.2——消除逐调用同步扫目录)**:首次
30
+ * readdirSync 后进程内递增预留,目录被清理(retention)后取下界重扫一次(防御)。
31
+ * 多进程语义(修正轮 #10——登记):同 cwd 多实例各自进程内缓存——seq 可撞、同
32
+ * sessionKey 记录并入同名文件(append 不覆写);**可容忍**(本机 traces = 诊断面、
33
+ * 默认 OFF、撞号不损坏数据);不做跨进程协调/落盘校验。
25
34
  * - 日期分日按本地时区(D-TR3——2026-09-04 fix round1:初版 toISOString()=UTC——
26
35
  * 本地 00:00-08:00 的调用会落进前一日目录——改本地日期字符串 YYYY-MM-DD——T-TR12)。
27
36
  * - isContinuation(D-TR1——2026-09-04 fix round1):续写/重试链标记——true = 该调用
@@ -35,6 +44,7 @@ import { createHash } from "node:crypto"
35
44
  import { configDir } from "../config.mjs"
36
45
  import { redactSecret, errText, classifyErr } from "../log.mjs"
37
46
  import { normalizeCwd } from "../session-slots.mjs"
47
+ import { capText } from "../text-budget.mjs"
38
48
 
39
49
  /** 轨迹根目录:THINCODER_TRACES_DIR(测试隔离/override——同 THINCODER_LOG_DIR
40
50
  * 惯例)> ~/.thincoder/traces(configDir——D-TR3——与 sessions/ 同域)。 */
@@ -65,10 +75,15 @@ export function localDateStr(date = new Date()) {
65
75
  return `${y}-${m}-${d}`
66
76
  }
67
77
 
68
- /** Test hooks(_rateHooks 同惯例——rate.mjs:测试可替换 now——T-TR12 注入本地日 ≠
69
- * UTC 日的时刻验证分日;生产永远走真实时钟)。 */
78
+ /** Test hooks(_rateHooks 同惯例——rate.mjs):now/readdir/exists/mkdir/append 注入缝
79
+ * (T-TR5 计数 readdirSync;T-TR4 慢写替身;T-TR12 注入本地日 ≠ UTC 日的时刻)。
80
+ * 生产永远走真实实现。 */
70
81
  export const _traceHooks = {
71
82
  now: () => new Date(),
83
+ readdir: (dir) => readdirSync(dir),
84
+ exists: (p) => existsSync(p),
85
+ mkdir: (dir) => mkdir(dir, { recursive: true }),
86
+ append: (path, text) => appendFile(path, text, "utf8"),
72
87
  }
73
88
 
74
89
  /** 当日轨迹目录(D-TR3:traces/YYYY-MM-DD——分日组织——N-TR1)。 */
@@ -76,32 +91,71 @@ export function tracesDirFor(dateStr) {
76
91
  return join(tracesRoot(), dateStr)
77
92
  }
78
93
 
79
- // 进程内 seq 预留表:同步预留(原子——异步写盘在途时并发调用不撞号);键 = 目录。
80
- // 跨进程/重启由磁盘 max 兜底(同键无预留时磁盘值即事实——T-TR10)。
81
- const _reservedSeq = new Map()
94
+ // ─── 额度常量(§23.3.2——编译期常量,不新增配置项 D-TR4)─────────────────────
95
+
96
+ /** 单消息字符串额度(消息内容/推理/工具参数串)——超限:头 16K + 中段标记 + 尾 48K。 */
97
+ export const TRACE_MESSAGE_MAX_CHARS = 65_536
98
+ /** 单记录总长额度——超限:`messages` 字段整体降级为 stub 串(元数据保留)。 */
99
+ export const TRACE_RECORD_MAX_CHARS = 4_000_000
100
+ /** 在途写盘份数上限(超限丢弃本记录 + 计数——不阻塞模型调用路径)。 */
101
+ export const TRACE_PENDING_MAX = 8
102
+
103
+ /** 内容额度标记(逐字——进测试断言;同族工具预览形态)。 */
104
+ const MSG_MARKER = "… [trace truncated: N chars omitted] …"
105
+ const HEAD_CHARS = 16_384
106
+ const TAIL_CHARS = 49_152
107
+
108
+ /** 受额度约束的字段名(消息内容/推理/工具参数串——§23.3.2 字面集)。 */
109
+ const CAPPED_KEYS = new Set(["content", "reasoning", "reasoning_content", "reasoningContent", "arguments"])
110
+
111
+ // ─── 在途计数(§23.3.2 表 4 候选 1:待写计数上限 8 + 超限丢弃计数)────────────
112
+
113
+ let _pendingWrites = 0
114
+ let _droppedRecords = 0
115
+ let _saturationNotified = false
116
+
117
+ // 进程内 seq 缓存(§23.3.2:dir → 已见/已预留 max——首次 readdirSync 后递增预留;
118
+ // 目录被清理(retention)后取下界重扫一次(防御))。
119
+ const _seqCache = new Map()
120
+
121
+ /** 在途/丢弃观测(测试缝——T-TR4)。 */
122
+ export function _traceStoreStats() {
123
+ return { pending: _pendingWrites, dropped: _droppedRecords }
124
+ }
125
+
126
+ /** 测试缝:清进程内状态(seqCache + 在途/丢弃计数)。 */
127
+ export function _resetTraceStateForTest() {
128
+ _seqCache.clear()
129
+ _pendingWrites = 0
130
+ _droppedRecords = 0
131
+ _saturationNotified = false
132
+ }
133
+
134
+ function scanMaxSeq(dir) {
135
+ if (!_traceHooks.exists(dir)) return 0
136
+ let names
137
+ try { names = _traceHooks.readdir(dir) } catch { return 0 }
138
+ let max = 0
139
+ for (const name of names) {
140
+ const m = name.match(/-(\d+)\.jsonl$/)
141
+ if (m) max = Math.max(max, Number(m[1]))
142
+ }
143
+ return max
144
+ }
82
145
 
83
146
  /** seq = max(当日目录最大已有 seq, 进程内已预留) + 1(D-TR3——跨会话/进程重启
84
- * 不覆写——T-TR8/T-TR10;同步预留 = 异步写盘启动前的原子号位分配)。 */
147
+ * 不覆写——T-TR8/T-TR10;同步预留 = 异步写盘启动前的原子号位分配)。
148
+ * §23.3.2:seqCache 进程内递增——不逐调用 readdirSync(目录被清理/缺失 → 重扫一次)。 */
85
149
  export function nextTraceSeq(dateStr) {
86
150
  const dir = tracesDirFor(dateStr)
87
- let max = 0
88
- if (existsSync(dir)) {
89
- let names
90
- try {
91
- names = readdirSync(dir)
92
- } catch {
93
- // 目录不可读——按预留表续号(不静默撞号)
94
- max = _reservedSeq.get(dir) ?? 0
95
- _reservedSeq.set(dir, max + 1)
96
- return max + 1
97
- }
98
- for (const name of names) {
99
- const m = name.match(/-(\d+)\.jsonl$/)
100
- if (m) max = Math.max(max, Number(m[1]))
101
- }
151
+ let max = _seqCache.get(dir)
152
+ if (max === undefined || !_traceHooks.exists(dir)) {
153
+ // 首次调用(或目录被 retention 清理/缺失)——重扫磁盘取真实下界(防御:清除后重号安全)
154
+ const disk = scanMaxSeq(dir)
155
+ max = Math.max(disk, max ?? 0)
102
156
  }
103
- const seq = Math.max(max, _reservedSeq.get(dir) ?? 0) + 1
104
- _reservedSeq.set(dir, seq)
157
+ const seq = max + 1
158
+ _seqCache.set(dir, seq)
105
159
  return seq
106
160
  }
107
161
 
@@ -111,18 +165,57 @@ export function tracesEnabled(logCtx) {
111
165
  return logCtx?.traces !== false
112
166
  }
113
167
 
114
- /** 递归脱敏(D-TR2):对 messages/content/reasoning/toolCalls/error 全字段应用——
115
- * 字段名命中黑名单 → 遮蔽;字符串内容命中密钥形态(sk-/Bearer/-key=)→ 截断到
116
- * 形态前 + 标记。数组/对象递归(消息 content 可为 parts 数组、toolCalls 嵌套)。 */
117
- function redactValue(fieldKey, value) {
118
- if (typeof value === "string") return redactSecret(fieldKey, value)
119
- if (Array.isArray(value)) return value.map((v) => redactValue(fieldKey, v))
120
- if (value && typeof value === "object") {
121
- const out = {}
122
- for (const [k, v] of Object.entries(value)) out[k] = redactValue(k, v)
123
- return out
168
+ // ─── 单遍序列化(§23.3.2 D-TR1——脱敏内联、零复制图、字段名/次序同构)────────
169
+
170
+ const OMIT = Symbol("omit") // JSON.stringify 语义:对象属性省略(数组元素 → null)
171
+
172
+ function scalarJson(key, v) {
173
+ if (typeof v === "string") {
174
+ // 额度(§23.3.2)+ 脱敏(D-TR2)单点:先限长(标记随附)再遮蔽形态扫描——
175
+ // 输出与既有 redactValue(key, s) 串值路径逐字同形(除外显式截断标记)。
176
+ if (v.length > TRACE_MESSAGE_MAX_CHARS && CAPPED_KEYS.has(key)) {
177
+ return JSON.stringify(redactSecret(key, capText(v, { max: TRACE_MESSAGE_MAX_CHARS, keepHead: HEAD_CHARS, keepTail: TAIL_CHARS, marker: MSG_MARKER })))
178
+ }
179
+ return JSON.stringify(redactSecret(key, v))
180
+ }
181
+ if (v === null) return "null"
182
+ if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null" // JSON.stringify(NaN/±Infinity) === "null"
183
+ if (typeof v === "boolean") return v ? "true" : "false"
184
+ return undefined // undefined/function/symbol/bigint → 省略(JSON.stringify 同语义)
185
+ }
186
+
187
+ function writeValue(out, key, v) {
188
+ const s = scalarJson(key, v)
189
+ if (s !== undefined) { out.push(s); return true }
190
+ if (Array.isArray(v)) {
191
+ out.push("[")
192
+ for (let i = 0; i < v.length; i++) {
193
+ if (i) out.push(",")
194
+ if (!writeValue(out, key, v[i])) out.push("null") // 数组元素不可省略
195
+ }
196
+ out.push("]")
197
+ return true
198
+ }
199
+ if (v && typeof v === "object") {
200
+ if (typeof v.toJSON === "function") { // Date/宿主对象:保持 JSON.stringify 等价
201
+ const js = JSON.stringify(v)
202
+ out.push(js === undefined ? "null" : js)
203
+ return true
204
+ }
205
+ out.push("{")
206
+ let first = true
207
+ for (const [k, val] of Object.entries(v)) {
208
+ const omit = val === undefined || typeof val === "function" || typeof val === "symbol" || typeof val === "bigint"
209
+ if (omit) continue
210
+ if (!first) out.push(",")
211
+ first = false
212
+ out.push(JSON.stringify(k), ":")
213
+ writeValue(out, k, val)
214
+ }
215
+ out.push("}")
216
+ return true
124
217
  }
125
- return value
218
+ return false // OMIT
126
219
  }
127
220
 
128
221
  /**
@@ -142,6 +235,16 @@ export function recordChatTrace(provider, opts = {}, result = null, error = null
142
235
  if (!writeEnabled()) return
143
236
  const logCtx = opts.logCtx ?? {}
144
237
  if (!tracesEnabled(logCtx)) return
238
+ // §23.3.2 在途上界(表 4 候选 1):饱和即丢弃本记录(尽力面——丢弃可观测),
239
+ // 首次饱和打一行 stderr(每饱和段一次;排空后复位)。
240
+ if (_pendingWrites >= TRACE_PENDING_MAX) {
241
+ _droppedRecords++
242
+ if (!_saturationNotified) {
243
+ _saturationNotified = true
244
+ console.error(`[trace] pending write queue full — ${_droppedRecords} record(s) dropped`)
245
+ }
246
+ return
247
+ }
145
248
  // cwd/session 经 logCtx 增补(调用点传 agent.cwd / agent._sessionStart——
146
249
  // D-TR3 的 cwdHash/命名与 D-TR1 的 session 字段所需);无 agent 作用域的调用点
147
250
  // 回退 process.cwd()——CLI 会话即工作区。该类点开关状态:distill 已含
@@ -154,44 +257,72 @@ export function recordChatTrace(provider, opts = {}, result = null, error = null
154
257
  const dateStr = localDateStr(now)
155
258
  const seq = nextTraceSeq(dateStr)
156
259
  const sessionKey = traceSessionKey(cwd)
157
- const record = {
158
- ts: now.toISOString(),
159
- session: logCtx.session ?? null,
160
- cwdHash: createHash("sha1").update(normalizeCwd(cwd)).digest("hex"),
161
- role: logCtx.role ?? null,
162
- depth: logCtx.depth ?? null,
163
- turn: logCtx.turn ?? null,
164
- provider: provider?.name ?? provider?.model ?? "unknown",
165
- model: provider?.model ?? "",
166
- stage: logCtx.stage ?? null,
167
- kind: logCtx.kind ?? null,
168
- // D-TR1(fix round1):续写/重试链标记——true = 该调用是续写链的一环
169
- // (core.mjs 续写递归传出);新调用缺省 false——round 字段已删(无来源)。
170
- isContinuation: logCtx.isContinuation === true,
171
- messages: redactValue("messages", opts.messages ?? []),
172
- content: redactValue("content", result?.content ?? null),
173
- reasoning: redactValue("reasoning", result?.reasoning ?? null),
174
- toolCalls: redactValue("toolCalls", result?.toolCalls ?? null),
175
- usage: result?.usage ?? null,
176
- finishReason: result?.finishReason ?? null,
260
+
261
+ // 单遍序列化:头段字段 + messages(独立缓冲——记录级额度降级用)+ 尾段字段。
262
+ const parts = ["{"]
263
+ let first = true
264
+ const put = (k, v) => {
265
+ if (!first) parts.push(",")
266
+ first = false
267
+ parts.push(JSON.stringify(k), ":")
268
+ writeValue(parts, k, v)
177
269
  }
270
+ put("ts", now.toISOString())
271
+ put("session", logCtx.session ?? null)
272
+ put("cwdHash", createHash("sha1").update(normalizeCwd(cwd)).digest("hex"))
273
+ put("role", logCtx.role ?? null)
274
+ put("depth", logCtx.depth ?? null)
275
+ put("turn", logCtx.turn ?? null)
276
+ put("provider", provider?.name ?? provider?.model ?? "unknown")
277
+ put("model", provider?.model ?? "")
278
+ put("stage", logCtx.stage ?? null)
279
+ put("kind", logCtx.kind ?? null)
280
+ // D-TR1(fix round1):续写/重试链标记——true = 该调用是续写链的一环
281
+ // (core.mjs 续写递归传出);新调用缺省 false——round 字段已删(无来源)。
282
+ put("isContinuation", logCtx.isContinuation === true)
283
+ const msgAt = parts.length
284
+ const msgParts = []
285
+ writeValue(msgParts, "messages", opts.messages ?? [])
286
+ put("content", result?.content ?? null)
287
+ put("reasoning", result?.reasoning ?? null)
288
+ put("toolCalls", result?.toolCalls ?? null)
289
+ put("usage", result?.usage ?? null)
290
+ put("finishReason", result?.finishReason ?? null)
178
291
  if (error != null) {
179
292
  // D-TR5:错误路径轨迹——error(errText 截断 + 类别)+ finishReason:null
180
- record.error = {
181
- err: redactValue("err", errText(error, 500)),
293
+ put("error", {
294
+ err: redactSecret("err", errText(error, 500)),
182
295
  kind: classifyErr(error, opts.signal),
183
- }
296
+ })
184
297
  }
298
+ const headStr = parts.slice(0, msgAt).join("")
299
+ const tailStr = parts.slice(msgAt).join("")
300
+ const msgStr = msgParts.join("")
301
+ const msgsArr = Array.isArray(opts.messages) ? opts.messages : []
302
+ const record = (() => {
303
+ const total = headStr.length + msgStr.length + tailStr.length + 1
304
+ if (total > TRACE_RECORD_MAX_CHARS) {
305
+ // 单记录额度(§23.3.2):messages 整体降级 stub——元数据字段保留、标记含计数
306
+ const stub = `[trace record truncated for size: ${total} chars / ${msgsArr.length} messages]`
307
+ return `${headStr},${JSON.stringify("messages")}:${JSON.stringify(stub)}${tailStr}}`
308
+ }
309
+ return `${headStr},${JSON.stringify("messages")}:${msgStr}${tailStr}}`
310
+ })()
311
+
185
312
  // 真 fire-and-forget(F-TR3——模型调用路径零额外阻塞):seq 已在上面同步预留
186
313
  // (原子号位——写盘在途并发不撞号);写盘异步(不 await——chat() 出口立即返回)。
187
314
  // 返回值 = 落盘 promise(测试 await 用;chat() 不消费——fire-and-forget 语义)。
315
+ _pendingWrites++
188
316
  return (async () => {
189
317
  try {
190
318
  const dir = tracesDirFor(dateStr)
191
- await mkdir(dir, { recursive: true }) // D-TR3:写前建目录(与 sessions/tool-results 同惯例)
192
- await appendFile(join(dir, `${sessionKey}-${seq}.jsonl`), JSON.stringify(record) + "\n", "utf8")
319
+ await _traceHooks.mkdir(dir) // D-TR3:写前建目录(与 sessions/tool-results 同惯例)
320
+ await _traceHooks.append(join(dir, `${sessionKey}-${seq}.jsonl`), record + "\n")
193
321
  } catch {
194
322
  // F-TR3:落盘失败静默降级——不抛错、不阻塞 chat() 返回
323
+ } finally {
324
+ _pendingWrites--
325
+ if (_pendingWrites === 0) _saturationNotified = false // 排空 → 下个饱和段可再报一次
195
326
  }
196
327
  })()
197
328
  }
@@ -21,13 +21,25 @@ import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
21
21
  import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 §17 段迁 suspension-drive.mjs
22
22
  import { ensureSessionTitle } from "../generate-title.mjs"
23
23
  import { logEvent, errText } from "../log.mjs"
24
- import { suspensionSession, poolLive, planQueuedInput } from "./suspension-drive.mjs"
24
+ import { suspensionSession, poolLive } from "./suspension-drive.mjs"
25
25
 
26
26
  /** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
27
27
  * wait at most this long for the in-flight distill before the final session save —
28
28
  * never let shutdown hang on the background summary call. */
29
29
  const DISTILL_FLUSH_TIMEOUT_MS = 5000
30
30
 
31
+ /**
32
+ * 第 33 批(TUI §14.3(d)——纯函数,供测试直驱):回合链尾「需要用户」谓词——agent 已停
33
+ * 且无自动续跑 ⇒ 置 attention 位。排除项语义(F13):`skipSession`(digest / 会话内回合
34
+ * ——由外层链尾统一置位)· 挂起两态 / 池 live / 队列非空(自动续跑中——不由用户接手)·
35
+ * processing(回合在跑)。
36
+ * @returns {boolean}
37
+ */
38
+ export function userNeededAtTurnEnd(state, agent, skipSession) {
39
+ return !skipSession && !state.suspended && !state._suspPending && !poolLive(agent)
40
+ && state.queue.length === 0 && !state.processing
41
+ }
42
+
31
43
  /**
32
44
  * LOGGING(docs/design/LOGGING.md)包装:回合骨架事件(turn:start/turn:end——kind
33
45
  * user/auto;result ok/stopped/error)。内层经 _logOutcome 载具回传终止原因(中止/
@@ -283,26 +295,25 @@ async function runAgentTurnInner(ctx, text, opts) {
283
295
  render()
284
296
  }
285
297
 
286
- // §17 偏差 #1 兜底:释放窗口期 Enter 已入 pendingInput——链条走到此处若池已空
287
- //(挂起会话不会启动,下方 while 是最后一个消费点)则转正队列照常续发,消息不滞留
288
- // 不并发。池非空时挂起会话先消费 pendingInput(D-S5 输入优先),无需此处处理。
298
+ // §17 偏差 #1 兜底(INPUT-LOCK 单槽化——2026-09-09):释放窗口期 Enter 已入
299
+ // pendingInput 单槽——链条走到此处若池已空(挂起会话不会启动,下方 while 是最后一个
300
+ // 消费点)则转正队列照常续发,消息不滞留不并发。池非空时挂起会话先消费 pendingInput
301
+ // (D-S5 输入优先),无需此处处理。单槽语义下残余至多一条。
289
302
  if (!skipSession && !state._suspAborted && (state.pendingInput?.length ?? 0) > 0 && !poolLive(agent)) {
290
- state.queue.push(...state.pendingInput.splice(0).map((t) => ({ text: String(t) })))
303
+ state.queue.push({ text: String(state.pendingInput.shift()) })
291
304
  }
292
305
 
293
- // Queued messages: auto-process next one(§24 D-24c/R15:攒批合并——合并仅限
294
- // 连续文本——/cmd 逐条保序即时——单条超长直发;每条 runAgentTurn 回合后余项
295
- // 由递归层的本循环续取——边界幂等不丢)
306
+ // 交接消息自动续发(INPUT-LOCK 单消息——2026-09-09):submit 不再排队(busy 提交吞)
307
+ // + R15 攒批删——state.queue 只剩残项单消息(释放窗口兜底/挂起中止残余——零丢失
308
+ // 承诺)——逐条直发;斜杠命令直接执行(保序);回合后的续发由递归层同循环续取。
296
309
  while (state.queue.length > 0 && !state.processing) {
297
- const head = planQueuedInput(state.queue.map((q) => q.text))[0]
298
- state.queue.splice(0, head.count)
299
- // Queued slash commands execute directly — order-preserved, never merged
300
- if (head.kind === "slash") {
310
+ const head = state.queue.shift()
311
+ // 残项斜杠命令直接执行——保序、绝不合并
312
+ if (head.text.startsWith("/")) {
301
313
  await handleSlash(head.text)
302
314
  render()
303
315
  continue
304
316
  }
305
- pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
306
317
  await runAgentTurn(ctx, head.text)
307
318
  return
308
319
  }
@@ -321,4 +332,12 @@ async function runAgentTurnInner(ctx, text, opts) {
321
332
  // 竞态复现)。
322
333
  state._suspAborted = false
323
334
  }
335
+
336
+ // 第 33 批(TUI §14.3(d)):顶层链尾(队列续发循环与挂起会话退出**之后**)——无人自动
337
+ // 接手 ⇒ 置 attention 位(渲染层实时派生 blocked/awaiting;用户任意输入清位)。D-AT6:
338
+ // 不放在回合末 finally——那之后还有队列续发 / 挂起会话(digest 自动消费),不是「需要用户」。
339
+ if (userNeededAtTurnEnd(state, agent, skipSession)) {
340
+ state.attentionAwaiting = true
341
+ render()
342
+ }
324
343
  }
package/src/tui/ansi.mjs CHANGED
@@ -31,6 +31,7 @@ export const ansi = {
31
31
  dim: `${ESC}[2m`,
32
32
  bold: `${ESC}[1m`,
33
33
  fg: (n) => `${ESC}[${30 + n}m`,
34
+ bg: (n) => `${ESC}[${40 + n}m`,
34
35
  gray: `${ESC}[90m`,
35
36
  }
36
37
 
@@ -44,5 +45,6 @@ export const C = {
44
45
  dim: ansi.gray,
45
46
  warn: ansi.fg(3),
46
47
  advisor: `${ESC}[92m`, // bright green — visible on dark backgrounds
48
+ attention: ansi.bg(3) + ansi.fg(0), // 第 33 批(TUI §14.3/D-AT3):attention 态整行色对——黄底黑字(\x1b[43m\x1b[30m,与 VSC warningBackground 同色系;浅深终端均可见——不依赖主题配色)
47
49
  fold: ansi.bold + ansi.fg(6), // bold cyan — fold markers must stay visible on light AND dark themes (dim white vanished on light backgrounds)
48
50
  }