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
package/src/session.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { readFileSync, renameSync, existsSync, statSync } from "node:fs"
17
17
  import { basename } from "node:path"
18
+ import { resolveCompactThreshold } from "./config.mjs"
18
19
  import {
19
20
  slotPath, writeSessionFile, loadManifest, saveManifest, slotDigest,
20
21
  activeSlot, getSessionId, isProcessAlive,
@@ -24,6 +25,14 @@ import {
24
25
  import { guardForeignSlotFile } from "./session-guard.mjs"
25
26
  import { scheduleSessionGC } from "./session-gc.mjs"
26
27
  import { engTokenSlotFields, restoreEngTokens } from "./token-ttl.mjs"
28
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §14):人读线记录存储(磁盘为准 + 内存窗口)。
29
+ // slimForDisplay/isLegacyTransient 迁入 store 模块(§14.5 文件表)——本文件 re-export 保持
30
+ // 既有 import 面(session-slots.mjs 引 isLegacyTransient)。依赖方向单向:store 零项目内依赖。
31
+ import {
32
+ bindRecordStore, unbindRecordStore, saveProjectedSlot, isLegacyTransient, slimForDisplay,
33
+ RECORD_WINDOW_MESSAGES,
34
+ } from "./session-store.mjs"
35
+ export { isLegacyTransient, slimForDisplay, bindRecordStore, unbindRecordStore }
27
36
 
28
37
  // re-export slot 管理(保持既有 import session.mjs 的调用点不变;resumeSlot 下方本地包装导出)
29
38
  export {
@@ -42,60 +51,44 @@ export function resumeSlot(cwd) {
42
51
  return slotsResumeSlot(cwd)
43
52
  }
44
53
 
45
- // ========== legacy transient prefix cleanup ==========
46
-
47
- const LEGACY_TRANSIENT_PREFIXES = [
48
- "[System reminder: working directory snapshot:",
49
- "[Relevant memories from previous sessions",
50
- ]
54
+ // ========== core read/write ==========
55
+ // (legacy transient 判定 + slimForDisplay 自本区迁至 session-store.mjs——§14.5 文件表;
56
+ // 上方 re-export 保持既有 import 面。)
51
57
 
52
- function isLegacyTransient(m) {
53
- return (
54
- m.role === "user" &&
55
- typeof m.content === "string" &&
56
- LEGACY_TRANSIENT_PREFIXES.some((p) => m.content.startsWith(p))
57
- )
58
+ /** 未绑定路径的人读线全量物化(模式 F——既有语义逐字保留)。 */
59
+ function legacyHistory(agent) {
60
+ return (agent._fullHistory ?? agent.history)
61
+ .filter((m) => !m.transient && !isLegacyTransient(m))
62
+ .map(slimForDisplay)
58
63
  }
59
64
 
60
- export { isLegacyTransient }
61
-
62
- // ========== core read/write ==========
63
-
64
- /** Slim the HUMAN line (history) for storage — the machine line (contextHistory)
65
- * keeps everything byte-identical for the provider. Deepseek-consult design
66
- * (2026-08-30): the human line is never compacted and carries the bulk of
67
- * session-file size (tool args JSON / full tool results / base64 images), while
68
- * nothing consumes its verbatim fidelity. Rules (copy-on-write ONLY — the two
69
- * lines share object references via pushReal; mutating in place would corrupt
70
- * the machine line and provider prefix cache):
71
- * - assistant.tool_calls[].function.arguments → trimmed to 300 chars (head + …)
72
- * - tool messages content → 500 chars (head + …)
73
- * - multimodal user content array → keep text parts, DROP image_url base64 parts
74
- * - plain string messages → untouched (not the size driver)
75
- */
76
- function slimForDisplay(m) {
77
- if (m && Array.isArray(m.content)) {
78
- // Multimodal user message: keep text parts, drop image parts.
79
- const textParts = m.content.filter((p) => p?.type !== "image_url")
80
- if (textParts.length === m.content.length) return m
81
- return { ...m, content: textParts }
65
+ /** 恢复描述符(§14.3.6——两调用点统一形态):`{ history, total, base }`。
66
+ * 绑定态:history = 尾窗 + ±1 页沿头一条(跨页回合标签判定用——不渲染)、total = 绝对
67
+ * 总条数(「N messages」标签口径)、base = history[0] 的绝对序号;未绑定(模式 F):
68
+ * 回退全量数组(total = 数组长)。 */
69
+ export function sessionDescriptor(agent, data) {
70
+ const store = agent?._recordStore
71
+ if (store) {
72
+ const total = store.total()
73
+ const win = store.page(Math.max(0, total - RECORD_WINDOW_MESSAGES), total, { margin: 1 })
74
+ return { history: win.messages, total, base: win.base }
82
75
  }
83
- if (m && m.role === "assistant" && Array.isArray(m.tool_calls)) {
84
- let changed = false
85
- const tool_calls = m.tool_calls.map((tc) => {
86
- const args = tc.function?.arguments
87
- if (typeof args === "string" && args.length > 300) {
88
- changed = true
89
- return { ...tc, function: { ...tc.function, arguments: args.slice(0, 300) + "…" } }
90
- }
91
- return tc
92
- })
93
- return changed ? { ...m, tool_calls } : m
94
- }
95
- if (m && m.role === "tool" && typeof m.content === "string" && m.content.length > 500) {
96
- return { ...m, content: m.content.slice(0, 500) + "\n… (truncated for storage)" }
76
+ const full = Array.isArray(data?.history) ? data.history : []
77
+ return { history: full, total: full.length, base: 0 }
78
+ }
79
+
80
+ /** 槽摘要(记录存储绑定路径——history 不物化):与 slotDigest(extractSlotMeta) 同形。 */
81
+ function digestFromStore(fields, counters) {
82
+ const meta = {
83
+ messageCount: counters.total,
84
+ turnCount: counters.userReal,
85
+ firstMessage: counters.firstMessage,
86
+ activeProvider: fields.activeProvider ?? "",
87
+ updatedAt: fields.updatedAt ?? Date.now(),
88
+ title: fields.title ?? "",
97
89
  }
98
- return m
90
+ if (fields.activeModel) meta.activeModel = fields.activeModel
91
+ return { ts: Date.now(), ...meta }
99
92
  }
100
93
 
101
94
  /** Save agent state to the active slot file (atomic write). `display` (the old
@@ -110,24 +103,25 @@ export function saveSession(agent) {
110
103
  // history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
111
104
  // contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
112
105
  // Human line: transient machine injections never enter the readable record.
113
- const history = (agent._fullHistory ?? agent.history)
114
- .filter((m) => !m.transient && !isLegacyTransient(m))
115
- .map(slimForDisplay)
106
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.5):绑定记录存储后 `history` 字段由
107
+ // saveProjectedSlot 流式拼接段原文生成(磁盘为准——不物化全量数组);未绑定
108
+ // (模式 F:thincoder chat/测试/未覆盖路径)→ 既有全量物化路径逐字保留(D-R6)。
116
109
  // Machine line (contextHistory): KEEP transient messages — resume must rebuild the
117
110
  // machine line byte-identical to what the provider cache saw. Dropping them made every
118
111
  // process restart diverge at the first injection position (git/OS/time reminders are
119
112
  // re-injected with FRESH content) → whole-prefix cache miss on the CLI's very first
120
113
  // request of each session (2026-08-16 cache-hit report; Kimi review).
121
114
  const contextHistory = agent.history.filter((m) => !isLegacyTransient(m))
122
- const data = {
115
+ // fields = 槽 JSON 数据面(history 由投影插入——键序与既有 data 形态同序,history/contextHistory 居后)
116
+ const fields = {
123
117
  version: 2,
124
118
  cwd: agent.cwd,
125
119
  title: agent.title ?? "",
126
- activeProvider: agent.activeProvider ?? agent.provider?.name,
127
- activeModel: agent.activeModel ?? null,
120
+ // MODEL-MERGE-SESSION 槽双字段恒非空(裁定 a):有 provider 即携带具体复合值——
121
+ // 恢复不看 config(F-4)——无渠道默认可回落 ""/null(全新未配态——读侧容忍)
122
+ activeProvider: agent.activeProvider ?? agent.provider?.name ?? "",
123
+ activeModel: agent.activeModel ?? agent.provider?.model ?? null,
128
124
  updatedAt: Date.now(),
129
- history,
130
- contextHistory,
131
125
  tasks: agent.tasks ?? [],
132
126
  planMode: agent.planMode ?? false,
133
127
  autoApprove: agent.autoApprove ?? false,
@@ -138,30 +132,36 @@ export function saveSession(agent) {
138
132
  pendingReminders: agent._pendingReminders ?? [],
139
133
  sessionStart: agent._sessionStart ?? null,
140
134
  }
141
- // 2026-09-05 §10 D-4:首认领(_slot 为 null——如 /session 切换后首保存、"查看对方活槽 →
142
- // 保存 fork 新槽"的落盘槽跟随)写本端记录——marker = 端内最后认领者;粘性 _slot 期间
135
+ // 2026-09-05 §10 D-4:首认领(_slot 为 null——如 /session 切换后首保存、“查看对方活槽 →
136
+ // 保存 fork 新槽”的落盘槽跟随)写本端记录——marker = 端内最后认领者;粘性 _slot 期间
143
137
  // 的日常保存不写(F1:保存永不参与竞争)
144
138
  const claimedNow = agent._slot == null
145
139
  const slot = agent._slot ??= activeSlot(agent.cwd)
146
140
  if (claimedNow) writeEndMarker(agent.cwd, slot)
147
141
  const p = slotPath(agent.cwd, slot)
142
+ // 首保存补绑(§14.3.4 表末行——兜底所有未覆盖路径):未绑定且有槽 → 绑定记录存储
143
+ // (baseHistory = 当前 _fullHistory 全量;identity = 当前 _sessionStart)
144
+ if (!agent._recordStore) {
145
+ bindRecordStore(agent, { slotFile: p, identity: agent._sessionStart ?? null, baseHistory: agent._fullHistory ?? [] })
146
+ }
148
147
  // 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
149
148
  // 进程/会话的现场)→ 先轮转 .bak 保留再写(11311 条历史被新进程覆盖的实锤场景)。
150
- // 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-slots.mjs——DESIGN-TOKEN-
151
- // SETTLEMENT D1)——saveSession 与 token-ttl persistEngTokens(settle 当场落盘)
152
- // 共用同一份(检查按 mtime 缓存 _slotMtime:自写未变跳过全量解析)。
153
149
  // 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-guard.mjs——DESIGN-TOKEN-
154
150
  // SETTLEMENT D1 二次拆分:先入 session-slots、再因 500 行硬限迁 session-guard)——
155
151
  // saveSession 与 token-ttl persistEngTokens(settle 当场落盘)共用同一份(检查按
156
152
  // mtime 缓存 _slotMtime:自写未变跳过全量解析)。
157
153
  const rotated = guardForeignSlotFile(agent, p, slot)
158
- writeSessionFile(p, data)
154
+ // 绑定态 → 流式投影(段原文拼接——VSC 兼容面逐字同形);未绑定 → 既有全量物化写
155
+ if (agent._recordStore) saveProjectedSlot(agent, p, fields, contextHistory)
156
+ else writeSessionFile(p, { ...fields, history: legacyHistory(agent), contextHistory })
159
157
  // 记录我们刚写的 mtime——下次保存跳过重复解析
160
158
  try { agent._slotMtime = statSync(p).mtimeMs } catch {}
161
159
  // Update slot metadata in manifest
162
160
  try {
163
161
  const m = loadManifest(agent.cwd)
164
- m.slots[slot] = slotDigest(data)
162
+ m.slots[slot] = agent._recordStore
163
+ ? digestFromStore(fields, agent._recordStore.counters())
164
+ : slotDigest({ ...fields, history: legacyHistory(agent) })
165
165
  saveManifest(agent.cwd, m)
166
166
  } catch (e) {
167
167
  // Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
@@ -255,25 +255,42 @@ function stripTruncatedToolArgs(m) {
255
255
  return changed ? { ...m, tool_calls } : m
256
256
  }
257
257
 
258
- /** Apply loaded session data onto an agent object; returns true if provider was switched.
259
- * 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领(F1b 回归:
260
- * switchToSlot 后保存必须落新槽而非旧槽)。 */
261
- export function applySession(agent, data) {
262
- // data.history is the FULL never-compacted record (human line); data.contextHistory is the
263
- // (possibly compacted) machine line. Restore each line from its own source — the machine
264
- // context keeps its compaction savings across resume. Legacy files without contextHistory
265
- // fall back to seeding the machine line from the full history (it re-compacts when needed).
266
- // 2026-08-31 会诊 deepseek 🟡:机读线必须从 contextHistory 恢复而非从完整 history 重建——
267
- // 后者会把已压缩的中间过程塞回上下文(实测 prompt 膨胀到 283%)。compactThresholdAuto 时
268
- // 按恢复后的模型重新推导阈值(bin/thincoder.mjs)。v1 老文件(无 contextHistory)回退
269
- // 播种时剥离被 slimForDisplay 截断的 tool_calls.arguments(以 … 结尾 → 置 {};会诊 F6——
270
- // 截断可劈断 \\uXXXX 产生 400 毒载荷)。2026-09-01 会诊三家:length > 0 才当机读线
271
- // (contextHistory: [] 是"无机读线"而非空机器线——空机器线会静默丢全部上下文)。
258
+ /** Apply loaded session data onto an agent object; returns true if the runtime composite
259
+ * was switched. 2026-08-31 会诊 F1:清空 _slot/_slotMtime 缓存——切换后下次保存重新认领。
260
+ *
261
+ * MODEL-MERGE-SESSION(F-4)applySession 重写为两支 + 删旧支(评审 #7/#9 措辞——防
262
+ * "三支"误读):
263
+ * ① 槽 provider 存在 → provider/model 按槽值设(双字段恒非空——模型取 data.activeModel,
264
+ * legacy 槽 null/空串回该渠道默认模型 `providers[].model`(单值——2026-09-10 MODEL-SELECTION
265
+ * v2;老 models[] 首候选形态已退场))+ 重算
266
+ * compactThreshold(auto 时——按恢复后模型)——不看 config(defaultModel 只是新会话起点);
267
+ * ② 槽 provider 没了 → D-S3 保留——静默保持现状(config 有效则有效——两方都无效由调用侧
268
+ * 复验 validateProvider 弹重选)。
269
+ * 删旧支:"activeModel==null 清 stale override 回渠道默认"——前提 = 渠道默认字段——已随
270
+ * 三旧层删除消失——双字段恒非空故不可能触发(评审 #7)。 */
271
+ export function applySession(agent, data, opts = {}) {
272
+ // 机读线语义(历史注释保留):data.history = FULL 未压缩记录(人读线);data.contextHistory =
273
+ // 压缩后机器线——恢复各从其源(保留压缩收益——从完整 history 重建会塞回中间过程——实测
274
+ // prompt 膨胀 283%)。v1 老文件(无 contextHistory)回退播种,剥离被 slimForDisplay 截断的
275
+ // tool_calls.arguments(… 结尾 → 置 {}——截断可劈断 \\uXXXX 产生 400 毒载荷)。length > 0 才
276
+ // 当机读线(contextHistory: [] 是"无机读线"而非空机器线——空机器线静默丢全部上下文)。
272
277
  agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
273
278
  const full = Array.isArray(data.history) ? data.history : []
279
+ // SESSION.md §11.2(2026-09-08——F2/F3):恢复事件按当前 data 重定——载入历史非空 = 会话
280
+ // 恢复 → 下个 depth-0 回合 env-state resumed:yes 一次(pushEnvStateReminder 读即清——每次恢复
281
+ // 一次)。/new 与空历史槽切换不武装(显式清 false)——无恢复事件恒 no(F3 伪触发消除——不再靠
282
+ // _sessionStart != null 推断;已武装 agent 切到空历史槽也不残留上次恢复事件——评审 round2 🟡)。
283
+ // _processRestartPending 不在此清——由启动路径(bin/thincoder.mjs)设、prepareRun 发句清
284
+ // (N6——双信号独立:切槽不报进程重启)。
285
+ // 跨端异名互指(结构债批 5 N7——双端同语义各自独立实现、命名不统一是刻意——SESSION.md
286
+ // §11.2):VSC 端同机制载体异名 = agent._resumedPending(src/agent/setup.mjs hydrateRun——
287
+ // restore:true 工厂 + fullHistory 非空时武装——每槽恢复一次)+ 模块级 restartDetectionDone
288
+ // 一次性闸(src/agent/setup-reminders.mjs——process restarted 句的进程级信号)。
289
+ agent._envResumed = full.length > 0
274
290
  const ch = data.contextHistory
275
291
  const machine = (Array.isArray(ch) && ch.length > 0) ? ch : full.map(stripTruncatedToolArgs)
276
- agent._fullHistory = [...full]
292
+ // 绑定态(opts.slot 在场):人读线 = 记录存储尾窗(下方绑定块填充)——不复制全量 JSON 数组
293
+ agent._fullHistory = opts.slot != null ? [] : [...full]
277
294
  agent.history = [...machine]
278
295
  agent.title = data.title ?? ""
279
296
  agent.tasks = data.tasks ?? []
@@ -300,27 +317,43 @@ export function applySession(agent, data) {
300
317
  agent._verifyPassed = false
301
318
  agent._slot = null // 粘性缓存清空——切换后重新认领(F1b)
302
319
  agent._slotMtime = null
303
- if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
304
- const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
305
- if (p) {
306
- agent.provider = { ...p }
307
- agent.activeProvider = p.name
308
- agent.activeModel = data.activeModel ?? null
309
- if (agent.activeModel) agent.provider.model = agent.activeModel
310
- return true
311
- }
312
- } else if (data.activeModel != null) {
313
- // Same provider, different model
314
- agent.activeModel = data.activeModel
315
- if (agent.activeModel && agent.provider) agent.provider.model = agent.activeModel
316
- } else if (data.activeProvider && data.activeProvider === agent.activeProvider) {
317
- // Same provider, session has no activeModel → clear stale override
318
- agent.activeModel = null
319
- if (agent.provider) {
320
- const p = agent.providers?.find((pr) => pr.name === agent.activeProvider)
321
- if (p) agent.provider.model = p.model
320
+ // ── 记录存储绑定(TUI-OOM-ROOTCAUSE——§14.3.4/§14.3.5)──
321
+ // opts.slot 在场(启动恢复 / 非占用的 /session 切换 / ACP 钉槽)→ 绑定(身份核验 + 对账 +
322
+ // 窗口)——人读线 = store.tail(200);未传(模式 F:被他人活进程占用的槽 / 测试 / ACP fork)
323
+ // → 解绑——人读线维持全量数组(D-R6 零回归)。
324
+ if (opts.slot != null) {
325
+ const store = bindRecordStore(agent, {
326
+ slotFile: slotPath(agent.cwd, opts.slot),
327
+ identity: data.sessionStart ?? null,
328
+ baseHistory: full,
329
+ })
330
+ agent._fullHistory = store.tail(RECORD_WINDOW_MESSAGES)
331
+ } else {
332
+ unbindRecordStore(agent)
333
+ }
334
+ // ── MODEL-MERGE-SESSION 恢复(F-4——两支)──
335
+ const slotProvider = data.activeProvider ? agent.providers?.find((pr) => pr.name === data.activeProvider) : null
336
+ if (slotProvider) {
337
+ // ① 槽 provider 存在 → 按槽值设(不看 config):双字段恒非空——legacy 槽 activeModel
338
+ // null/空串 = 无 override——回该渠道默认模型(`providers[].model` 单值——M3)。
339
+ // F-2d(MODEL-400-FIX):`||` 非 `??`——空串也兜(activeModel="" 的槽会让 `??` 不落链 →
340
+ // 空槽 model 恒有值,provider.model 键不缺失)
341
+ const prevName = agent.activeProvider
342
+ const prevModel = agent.activeModel ?? null
343
+ const slotModel = data.activeModel || slotProvider.model
344
+ const switched = prevName !== slotProvider.name || prevModel !== slotModel
345
+ agent.activeProvider = slotProvider.name
346
+ agent.activeModel = slotModel
347
+ agent.provider = { ...slotProvider }
348
+ if (slotModel) agent.provider.model = slotModel
349
+ // 重算 compactThreshold(auto 时——阈值跟模型走;原在 bin 的 switched 分支——收拢本处)
350
+ if (switched && agent.config?.agent?.compactThresholdAuto && agent.provider.model) {
351
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
322
352
  }
353
+ return switched
323
354
  }
355
+ // ② 槽 provider 没了 → D-S3 保留——静默保持现状(不报错不纠正——config 有效则静默用
356
+ // config/defaultModel 运行时;两方都无效由调用侧复验 validateProvider 弹重选)
324
357
  return false
325
358
  }
326
359
 
@@ -413,8 +446,13 @@ export function resetSessionState(agent) {
413
446
  agent._slot = null
414
447
  agent._slotMtime = null
415
448
  agent._osReminderInjected = false
416
- agent._restartReminderInjected = false
449
+ // §11.2(2026-09-08):一次性注入标志换名——_restartReminderInjected 推断退役(F3——
450
+ // _sessionStart != null 伪触发);/new 清零新标记——恢复武装/启动句标志不跨会话复活
451
+ agent._envResumed = false
452
+ agent._processRestartPending = false
417
453
  agent._lastEngState = false
454
+ // /new 或切换:记录存储解绑(新槽由 cmd-new / 首保存补绑重新挂载——§14.3.4)
455
+ unbindRecordStore(agent)
418
456
  }
419
457
 
420
458
  /** Switch the manifest active pointer to a slot. Returns the slot's session data
@@ -0,0 +1,46 @@
1
+ /**
2
+ * text-budget.mjs — 文本额度纯函数(零依赖——TUI-OOM-ROOTCAUSE 批,AGENT-LOOP.md §23.3.1 / TUI.md §15.3.2)。
3
+ *
4
+ * 单一来源(D2):agent 侧捕获截断(spawn-child.mjs——子代理 `_capturedOutput`)与 TUI 面
5
+ * 载体额度(tui/display-budget.mjs)共用本体的 `capText` / `appendCappedText`——两处各自
6
+ * 复制截断逻辑的漂移面被消除。本模块零 import(可被任意层直接引用)。
7
+ *
8
+ * 计量口径 = **UTF-16 码元**(`String.length`——与 JS 字符串内存近似、确定、O(1) 计长;
9
+ * TUI.md §15.2 表 1)。截断形态 = 头保 + 中段标记 + 尾保(标记含真实省略数 N)。
10
+ *
11
+ * 标记串约定:`marker` 模板中以字面 `N` 为省略数占位(逐字进测试断言——如
12
+ * `… [captured output truncated: N chars omitted] …`)。
13
+ */
14
+
15
+ /** 省略数占位符替换(首个 `N`——模板约定;TUI 面 capLines 等复用)。 */
16
+ export function fillMarker(marker, omitted) {
17
+ if (typeof marker === "function") return String(marker(omitted))
18
+ const tpl = String(marker ?? "")
19
+ const i = tpl.indexOf("N")
20
+ return i === -1 ? `${tpl} (${omitted} omitted)` : `${tpl.slice(0, i)}${omitted}${tpl.slice(i + 1)}`
21
+ }
22
+
23
+ /**
24
+ * 头尾保真截断:`text` ≤ `max` 时零拷贝原样返回;超限 → 头 `keepHead` + 标记 + 尾 `keepTail`。
25
+ * keepHead/keepTail 各自夹紧到文本长度(互不重叠——头 + 尾 ≤ 文本长)。
26
+ * @returns {string} 原串或截断串(含 `marker` 替换后的省略数)
27
+ */
28
+ export function capText(text, { max, keepHead = 0, keepTail = 0, marker = "" } = {}) {
29
+ const t = String(text ?? "")
30
+ if (!(max > 0) || t.length <= max) return t
31
+ const head = Math.max(0, Math.min(keepHead, t.length))
32
+ const tail = Math.max(0, Math.min(keepTail, t.length - head))
33
+ const omitted = t.length - head - tail
34
+ return t.slice(0, head) + fillMarker(marker, omitted) + (tail > 0 ? t.slice(t.length - tail) : "")
35
+ }
36
+
37
+ /**
38
+ * 流式累积(滞后水位——摊还 O(1)):`prev + add`;超 `hard` 时立即裁至
39
+ * 头 `head` + 标记 + 尾 `tail`(下一轮从截断值继续累积——稳态长度 ≤ hard)。
40
+ * @returns {string} 合并(可能已截断)的累积串
41
+ */
42
+ export function appendCappedText(prev, add, { hard, head = 0, tail = 0, marker = "" } = {}) {
43
+ const merged = String(prev ?? "") + String(add ?? "")
44
+ if (!(hard > 0) || merged.length <= hard) return merged
45
+ return capText(merged, { max: hard, keepHead: head, keepTail: tail, marker })
46
+ }
package/src/token-ttl.mjs CHANGED
@@ -237,7 +237,8 @@ export function persistEngTokens(agent) {
237
237
  cwd: agent.cwd,
238
238
  title: agent.title ?? "",
239
239
  activeProvider: agent.activeProvider ?? agent.provider?.name,
240
- activeModel: agent.activeModel ?? null,
240
+ // MODEL-MERGE-SESSION 恒非空形态:随 saveSession 同款回落链(无渠道仍容忍 null)
241
+ activeModel: agent.activeModel ?? agent.provider?.model ?? null,
241
242
  history: [],
242
243
  contextHistory: [],
243
244
  tasks: agent.tasks ?? [],