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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * provider/errors.mjs — 错误分类与流规则编译族(2026-09-05 module-split:core.mjs
3
3
  * 557 > 500 硬限——parseRetryAfter/isNonRetryableError/betaBaseURL/compileStreamRules
4
- * verbatim 迁入,语义零变;core.mjs import 回(chat/listModels 调用点零改)。
4
+ * verbatim 迁入,语义零变;core.mjs import 回(chat 调用点零改)。
5
5
  * 注:retry.mjs(anthropic/google/responses 通道)自 2026-09-08 起导入本文件的
6
6
  * parseRetryAfter/isNonRetryableError(ENG-SESSION-PROVIDER-CLEANUP D2.2/D2.4 去重——
7
7
  * 单实现;早先的"循环依赖回避"复制已随依赖方向实测消解)。
@@ -74,3 +74,28 @@ export function compileStreamRules(rules) {
74
74
  }
75
75
  }).filter(Boolean)
76
76
  }
77
+
78
+ /**
79
+ * Provider-level pre-flight error (MODEL-400-FIX F-1 — 请求体组装前断言): carries the
80
+ * provider identity so a fail-fast throw is readable ("which provider + what to fix")
81
+ * instead of a wire-time serde 400 or a bare message without context.
82
+ */
83
+ export class ProviderError extends Error {
84
+ constructor(provider, message) {
85
+ super(`provider "${provider?.name ?? provider?.model ?? "unknown"}": ${message}`)
86
+ this.name = "ProviderError"
87
+ }
88
+ }
89
+
90
+ /**
91
+ * F-1 (MODEL-400-FIX) 根因兜底——请求体组装前断言:渠道裸克隆(`{...渠道}`——渠道只带
92
+ * 默认单值 `model`,克隆链未重派生 `.model` 时)provider.model 为 undefined/null——
93
+ * JSON.stringify 会丢 undefined 键 → 无 model 请求 → serde 400。
94
+ * fail-fast 报可读错误(带 provider 名 + 修复线索),不发病体。core.mjs chatImpl openai body 组装
95
+ * 前调用(单行——core.mjs 500 行硬限)。
96
+ */
97
+ export function assertProviderModel(provider) {
98
+ if (!provider.model) {
99
+ throw new ProviderError(provider, "model is undefined — provider cloned without model re-derivation (set providers[].model — the channel default model)")
100
+ }
101
+ }
@@ -7,6 +7,7 @@
7
7
  import { proxyFetch } from "../proxy.mjs"
8
8
  import { requestWithRetry } from "./retry.mjs"
9
9
  import { effectiveFetchTimeoutMs } from "./core.mjs"
10
+ import { abortError, timeoutError } from "../abort-provenance.mjs"
10
11
 
11
12
  /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
12
13
  function mapFunctionCallingConfig(choice) {
@@ -104,7 +105,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
104
105
  // Gemini uses API key as query parameter
105
106
  const url = `${provider.baseURL}/models/${provider.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(provider.apiKey)}`
106
107
 
107
- if (signal?.aborted) throw Object.assign(new DOMException("Aborted", "AbortError"), { reason: signal.reason })
108
+ if (signal?.aborted) throw abortError(signal, "provider", "transport-google")
108
109
 
109
110
  // 会诊 #6:TPM/RPM 闸门 + 记账
110
111
  const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
@@ -116,7 +117,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
116
117
  const response = await requestWithRetry(
117
118
  () => proxyFetch(url, {
118
119
  method: "POST",
119
- headers: { "Content-Type": "application/json" },
120
+ headers: { ...(provider.headers ?? {}), "Content-Type": "application/json" }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
120
121
  body: JSON.stringify(body),
121
122
  // 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
122
123
  signal,
@@ -199,7 +200,7 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
199
200
  const armIdle = () => {
200
201
  if (idleTimer) clearTimeout(idleTimer)
201
202
  idleTimer = setTimeout(() => {
202
- try { response.body?.destroy(new Error(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`)) } catch { /* already gone */ }
203
+ try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")) } catch { /* already gone */ }
203
204
  }, READ_IDLE_MS)
204
205
  idleTimer.unref?.()
205
206
  }
@@ -208,9 +209,7 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
208
209
  for await (const chunk of response.body) {
209
210
  armIdle()
210
211
  if (signal?.aborted) {
211
- const e = new DOMException("Aborted", "AbortError")
212
- e.reason = signal.reason
213
- throw e
212
+ throw abortError(signal, "provider", "transport-google")
214
213
  }
215
214
  buffer += decoder.decode(chunk, { stream: true })
216
215
  // BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
@@ -2,5 +2,6 @@
2
2
  * provider/index.mjs — backward-compatible re-export
3
3
  * import { chat } from "./provider" → resolves to this file
4
4
  */
5
- export { chat, createProvider, listModels, stripImagesForTextModel } from "./core.mjs"
5
+ export { chat, createProvider, stripImagesForTextModel } from "./core.mjs"
6
+ export { listModels } from "./list-models.mjs"
6
7
  export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
@@ -0,0 +1,93 @@
1
+ /**
2
+ * list-models.mjs — provider 模型清单拉取(GET /models,按 provider.format 分派——PROVIDER.md §16 M1)。
3
+ *
4
+ * 2026-09-10 自 core.mjs 迁出(原实现仅 OpenAI 形状)+ 扩 anthropic / google 两分支:
5
+ * - openai(缺省/未知 format——与 chat 分派缺省一致):`GET {baseURL}/models` + Bearer;解析 `data[].id`
6
+ * - anthropic:`GET {baseURL}/models?limit=1000` + `x-api-key` / `anthropic-version`;`has_more` 时以
7
+ * `last_id` 作 `after_id` 翻页跟随(≤10 页——防死循环)
8
+ * - google:`GET {baseURL}/models?key=…&pageSize=1000`;剥 `models/` 前缀;`nextPageToken` 翻页跟随(≤10 页)
9
+ *
10
+ * URL 组合 = `{baseURL}` + 相对路径,与 chat 各 transport 同构——baseURL 自带版本段
11
+ * (claude 预设 `…/v1` → `…/v1/models`;gemini 预设 `…/v1beta` → `…/v1beta/models`)。
12
+ * 超时制度沿用原实现(整体 15s + header 15s + body idle 15s;翻页时逐页各自计时;调用方可传
13
+ * `signal` 短路)。HTTP 非 2xx / 网络失败**抛出**(调用方决定降级——与现实现同);
14
+ * 解析保持防御性(字段缺失即跳过该项)。候选不过滤非对话模型(embedding 等——§16.6 #14)。
15
+ */
16
+ import { proxyFetch } from "../proxy.mjs"
17
+
18
+ const LIST_TIMEOUT_MS = 15_000
19
+ /** 翻页上限(cursor loop 防死循环——任一分页失败即整体抛出,不部分返回)。 */
20
+ const MAX_PAGES = 10
21
+ const ANTHROPIC_VERSION = "2023-06-01"
22
+
23
+ /** 单页 GET + JSON 解析(非 2xx → throw;畸形 JSON → null——解析侧各自防御)。 */
24
+ async function fetchJson(provider, url, headers, signal) {
25
+ const opts = {
26
+ headers,
27
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(LIST_TIMEOUT_MS)]) : AbortSignal.timeout(LIST_TIMEOUT_MS),
28
+ _headerTimeoutMs: LIST_TIMEOUT_MS,
29
+ _bodyIdleMs: LIST_TIMEOUT_MS,
30
+ }
31
+ const response = await (provider.proxyUri ? proxyFetch(url, opts, provider.proxyUri) : fetch(url, opts))
32
+ if (!response.ok) {
33
+ const text = await response.text().catch(() => "")
34
+ throw new Error(`GET /models failed ${response.status}: ${text}`)
35
+ }
36
+ return response.json().catch(() => null)
37
+ }
38
+
39
+ /** 防御性收集:字段缺失/非字符串项跳过。 */
40
+ function collect(rows, pick) {
41
+ if (!Array.isArray(rows)) return []
42
+ const out = []
43
+ for (const row of rows) {
44
+ const v = pick(row)
45
+ if (typeof v === "string" && v) out.push(v)
46
+ }
47
+ return out
48
+ }
49
+
50
+ async function listOpenai(provider, signal) {
51
+ const url = `${provider.baseURL}/models`
52
+ const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
53
+ return collect(data?.data, (m) => m?.id)
54
+ }
55
+
56
+ async function listAnthropic(provider, signal) {
57
+ const headers = { ...(provider.headers ?? {}), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
58
+ const base = `${provider.baseURL}/models?limit=1000`
59
+ const out = []
60
+ let afterId = null
61
+ for (let page = 0; page < MAX_PAGES; page++) {
62
+ const url = afterId ? `${base}&after_id=${encodeURIComponent(afterId)}` : base
63
+ const data = await fetchJson(provider, url, headers, signal)
64
+ out.push(...collect(data?.data, (m) => m?.id))
65
+ if (!data?.has_more) return out
66
+ afterId = typeof data?.last_id === "string" && data.last_id ? data.last_id : null
67
+ if (!afterId) return out // has_more 但无游标——无法继续,避免死循环
68
+ }
69
+ return out
70
+ }
71
+
72
+ async function listGoogle(provider, signal) {
73
+ const headers = { ...(provider.headers ?? {}) }
74
+ const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
75
+ const out = []
76
+ let pageToken = null
77
+ for (let page = 0; page < MAX_PAGES; page++) {
78
+ const url = pageToken ? `${base}&pageToken=${encodeURIComponent(pageToken)}` : base
79
+ const data = await fetchJson(provider, url, headers, signal)
80
+ out.push(...collect(data?.models, (m) => (typeof m?.name === "string" ? m.name.replace(/^models\//, "") : null)))
81
+ if (typeof data?.nextPageToken !== "string" || !data.nextPageToken) return out
82
+ pageToken = data.nextPageToken
83
+ }
84
+ return out
85
+ }
86
+
87
+ /** List available model IDs from the provider's /models endpoint (format 分派——M1)。 */
88
+ export async function listModels(provider, { signal } = {}) {
89
+ const format = provider?.format
90
+ if (format === "anthropic") return listAnthropic(provider, signal)
91
+ if (format === "google") return listGoogle(provider, signal)
92
+ return listOpenai(provider, signal)
93
+ }
@@ -2,6 +2,7 @@
2
2
  * provider/rate.mjs — TPM/RPM proactive throttling gate
3
3
  * Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
4
4
  */
5
+ import { abortError } from "../abort-provenance.mjs"
5
6
 
6
7
  export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
7
8
  export const MAX_RETRIES = 3
@@ -86,7 +87,7 @@ export async function rateGate(provider, estimated, onWait, signal) {
86
87
  waitMs = Math.max(waitMs, 50)
87
88
  onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
88
89
  await _rateHooks.sleep(waitMs)
89
- if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
90
+ if (signal?.aborted) throw abortError(signal, "provider", "rate-gate")
90
91
  }
91
92
  }
92
93
 
@@ -420,6 +420,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
420
420
  // rateGate/recordRate 对齐 core(responses body 无 messages 键——按本地全量 messages 估算)
421
421
  const estimated = estimateRequestTokens({ messages })
422
422
  await rateGate(provider, estimated, onWait, signal)
423
+ // 定制头展开(PROVIDER.md §21):provider.headers 在前、内置头在后——同名内置头胜出(三处 fetch 共用)
424
+ const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
423
425
 
424
426
  // round2 复验 #1(2026-08-31):retry 层对 4xx 非可重试是 throw 而非返回——
425
427
  // requestWithRetry 从不返回 400/404 响应 → 下方 isChainInvalidError 分支原来不可达(D6 死代码)。
@@ -429,7 +431,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
429
431
  response = await requestWithRetry(
430
432
  () => proxyFetch(`${provider.baseURL}/responses`, {
431
433
  method: "POST",
432
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
434
+ headers,
433
435
  body: JSON.stringify(body),
434
436
  signal,
435
437
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
@@ -448,7 +450,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
448
450
  response = await requestWithRetry(
449
451
  () => proxyFetch(`${provider.baseURL}/responses`, {
450
452
  method: "POST",
451
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
453
+ headers,
452
454
  body: JSON.stringify(fullBody),
453
455
  signal,
454
456
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
@@ -466,7 +468,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
466
468
  response = await requestWithRetry(
467
469
  () => proxyFetch(`${provider.baseURL}/responses`, {
468
470
  method: "POST",
469
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
471
+ headers,
470
472
  body: JSON.stringify(fresh2.body),
471
473
  signal,
472
474
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
@@ -2,6 +2,7 @@
2
2
  * provider/sse.mjs — SSE stream reader
3
3
  * Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
4
4
  */
5
+ import { abortError, timeoutError } from "../abort-provenance.mjs"
5
6
 
6
7
  /**
7
8
  * Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
@@ -174,7 +175,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
174
175
  const armIdle = () => {
175
176
  if (idleTimer) clearTimeout(idleTimer)
176
177
  idleTimer = setTimeout(() => {
177
- try { response.body?.destroy(new Error(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`)) } catch { /* already gone */ }
178
+ try { response.body?.destroy(timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")) } catch { /* already gone */ }
178
179
  }, READ_IDLE_MS)
179
180
  idleTimer.unref?.()
180
181
  }
@@ -183,9 +184,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
183
184
  for await (const chunk of response.body) {
184
185
  armIdle()
185
186
  if (signal?.aborted) {
186
- const e = new DOMException("The operation was aborted", "AbortError")
187
- e.reason = signal.reason
188
- throw e
187
+ throw abortError(signal, "provider", "stream-read")
189
188
  }
190
189
  buffer += decoder.decode(chunk, { stream: true })
191
190
  // UTF-8 BOM 剥除(2026-08-31 会诊 #12):某些网关/负载均衡在流首注入 \uFEFF,
package/src/proxy.mjs CHANGED
@@ -6,6 +6,7 @@ import { connect } from "node:net";
6
6
  import { connect as tlsConnect } from "node:tls";
7
7
  import { PassThrough } from "node:stream";
8
8
  import { URL } from "node:url";
9
+ import { abortError, timeoutError } from "./abort-provenance.mjs";
9
10
 
10
11
  const FETCH_TIMEOUT = 15_000
11
12
 
@@ -52,12 +53,6 @@ export function injectProxy(providers, config) {
52
53
  }
53
54
  }
54
55
 
55
- function abortError(signal) {
56
- const e = new DOMException("The operation was aborted", "AbortError")
57
- e.reason = signal?.reason
58
- return e
59
- }
60
-
61
56
  /**
62
57
  * 在已建立的 socket 上发 HTTP 请求,响应头到齐即 resolve(流式)。
63
58
  * 返回 Response-like: { ok, status, headers: Headers, body: PassThrough(异步迭代), text(): Promise<string> }
@@ -76,15 +71,15 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
76
71
  const headers = opts.headers ?? {}
77
72
  const signal = opts.signal
78
73
 
79
- if (signal?.aborted) { sock.destroy(); return reject(abortError(signal)) }
74
+ if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
80
75
 
81
76
  const body = new PassThrough()
82
77
  let settled = false
83
78
  let headerBuf = ""
84
79
  let idleTimer = null
85
80
 
86
- const timer = setTimeout(() => fail(new Error("Response timeout")), timeout)
87
- const onAbort = () => { sock.destroy(); fail(abortError(signal)) }
81
+ const timer = setTimeout(() => fail(timeoutError("Response timeout", "provider", "proxy-header")), timeout)
82
+ const onAbort = () => { sock.destroy(); fail(abortError(signal, "provider", "proxy")) }
88
83
  signal?.addEventListener("abort", onAbort, { once: true })
89
84
 
90
85
  function cleanup() {
@@ -101,7 +96,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
101
96
  /** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
102
97
  function armIdle() {
103
98
  clearTimeout(idleTimer)
104
- if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(new Error("Response body timeout (idle)")), bodyIdleMs)
99
+ if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
105
100
  }
106
101
 
107
102
  sock.on("data", (d) => {
@@ -190,11 +185,11 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
190
185
  const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
191
186
  const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
192
187
 
193
- if (signal?.aborted) return reject(abortError(signal))
188
+ if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
194
189
 
195
190
  const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
196
191
  const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
197
- const onAbort = () => { sock.destroy(); reject(abortError(signal)) }
192
+ const onAbort = () => { sock.destroy(); reject(abortError(signal, "provider", "proxy")) }
198
193
  signal?.addEventListener("abort", onAbort, { once: true })
199
194
  sock.on("connect", () => sock.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}\r\n\r\n`))
200
195
 
@@ -229,14 +224,14 @@ function tcpConnectProxy(proxyUri, signal, timeout) {
229
224
  return new Promise((resolve, reject) => {
230
225
  const proxy = new URL(proxyUri)
231
226
  const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
232
- if (signal?.aborted) { sock.destroy(); return reject(abortError(signal)) }
227
+ if (signal?.aborted) { sock.destroy(); return reject(abortError(signal, "provider", "proxy")) }
233
228
  const onAbort = () => sock.destroy()
234
229
  signal?.addEventListener("abort", onAbort, { once: true })
235
230
  const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
236
231
  const onError = (e) => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) }
237
232
  const onClose = () => {
238
233
  clearTimeout(timer)
239
- reject(signal?.aborted ? abortError(signal) : new Error("Proxy connection closed before tunnel established"))
234
+ reject(signal?.aborted ? abortError(signal, "provider", "proxy") : new Error("Proxy connection closed before tunnel established"))
240
235
  }
241
236
  sock.once("connect", () => {
242
237
  clearTimeout(timer)
@@ -19,7 +19,7 @@
19
19
  * import,启动钩子(session.mjs resumeSlot 包装)只一行调用。
20
20
  */
21
21
 
22
- import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs"
22
+ import { readdirSync, readFileSync, statSync, unlinkSync, existsSync, rmSync } from "node:fs"
23
23
  import { join, dirname, basename } from "node:path"
24
24
  import { configDir } from "./config.mjs"
25
25
  import { sessionPath, isProcessAlive } from "./session-slots.mjs"
@@ -153,7 +153,14 @@ export function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), ali
153
153
  if (!target) return { ok: false, reason: "not-cold", deleted: [] }
154
154
  const deleted = []
155
155
  for (const name of target.files) {
156
- try { unlinkSync(join(dir, name)); deleted.push(name) } catch { /* 占用/竞态——跳过 */ }
156
+ const p = join(dir, name)
157
+ try {
158
+ // §14.3.8(TUI-OOM-ROOTCAUSE):目录项(记录存储 sidecar `{prefix}.N.d`)递归删——
159
+ // 现 unlinkSync 对目录静默跳过(旧实现漏删 sidecar);数据文件维持 unlink。
160
+ if (statSync(p).isDirectory()) rmSync(p, { recursive: true, force: true })
161
+ else unlinkSync(p)
162
+ deleted.push(name)
163
+ } catch { /* 占用/竞态——跳过 */ }
157
164
  }
158
165
  return { ok: true, deleted }
159
166
  }
@@ -11,6 +11,17 @@
11
11
 
12
12
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs"
13
13
  import { basename } from "node:path"
14
+ // §14.3.8①(修正轮 #1):.bak 轮转对**非本会话活动绑定面**联动改名 sidecar(`{p}.d → {bak}.d`)。
15
+ import { RECORD_DIR_SUFFIX } from "./session-store.mjs"
16
+
17
+ /** 轮转时随迁记录存储(.bak 路径联动)——本会话活动绑定面跳过(防拔掉活动存储)。 */
18
+ function moveSidecar(src, dst, agent) {
19
+ const dir = src + RECORD_DIR_SUFFIX
20
+ if (agent?._recordStore?.dir === dir) return // 本会话活动绑定面——原地保留(§14.3.8①)
21
+ try {
22
+ if (existsSync(dir)) renameSync(dir, dst + RECORD_DIR_SUFFIX)
23
+ } catch { /* 随迁失败不阻断轮转(现场原地保留——读侧身份核验兜底) */ }
24
+ }
14
25
 
15
26
  export function guardForeignSlotFile(agent, p, slot) {
16
27
  try {
@@ -29,6 +40,7 @@ export function guardForeignSlotFile(agent, p, slot) {
29
40
  if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
30
41
  const bak = `${p}.bak-${Date.now()}`
31
42
  renameSync(p, bak)
43
+ moveSidecar(p, bak, agent)
32
44
  console.error(`[session] slot ${slot} holds ${diskIsNewer ? `a newer-version file (v${disk.version})` : diskForeign ? `a foreign-cwd file (${disk.cwd})` : `another session (start ${diskStart}, ours ${myStart})`} — preserved as ${basename(bak)}`)
33
45
  agent._slotMtime = st.mtimeMs
34
46
  return bak
@@ -0,0 +1,100 @@
1
+ /**
2
+ * session-segments.mjs — 记录存储的段文件原语 + 人读线条目形态(TUI-OOM-ROOTCAUSE 批)。
3
+ *
4
+ * 机制契约全文 = docs/design/SESSION.md §14.3(D2 单一权威源);本文件 = 「段 IO + 条目
5
+ * 形态」叶子层(`session-store.mjs` 拆分产物——503+ 行越 500 硬限,§14.5 表列模块按职责
6
+ * 拆分;store 继续 re-export 全部公开名,调用面零改)。零项目内依赖(仅 `node:`)。
7
+ */
8
+
9
+ import { readdirSync, readFileSync } from "node:fs"
10
+
11
+ /** 常量单源(§14.3.2):每段消息数 / sidecar 目录后缀。 */
12
+ export const RECORD_SEG_MESSAGES = 100
13
+ export const RECORD_DIR_SUFFIX = ".d"
14
+
15
+ /** 观测计数(测试缝——AC-RS9「恢复只读末段」以段读次数断言)。 */
16
+ export const _storeStats = { segmentReads: 0, segmentWrites: 0, appendedEntries: 0, catchUpRetries: 0 }
17
+
18
+ const LEGACY_TRANSIENT_PREFIXES = [
19
+ "[System reminder: working directory snapshot:",
20
+ "[Relevant memories from previous sessions",
21
+ ]
22
+
23
+ /** legacy transient 判定(旧进程写进人读线的机器注入——落盘/展示均跳过)。 */
24
+ export function isLegacyTransient(m) {
25
+ return (
26
+ m?.role === "user" &&
27
+ typeof m.content === "string" &&
28
+ LEGACY_TRANSIENT_PREFIXES.some((p) => m.content.startsWith(p))
29
+ )
30
+ }
31
+
32
+ /** Slim the HUMAN line for storage(自 session.mjs 迁出——§14.5):机器线(contextHistory)
33
+ * 逐字节保持。Copy-on-write ONLY(两线经 pushReal 共享对象引用):tool args → 300 字符头
34
+ * 截断 / tool content → 500 / 多模态 user → 保留 text、丢 image_url。 */
35
+ export function slimForDisplay(m) {
36
+ if (m && Array.isArray(m.content)) {
37
+ const textParts = m.content.filter((p) => p?.type !== "image_url")
38
+ if (textParts.length === m.content.length) return m
39
+ return { ...m, content: textParts }
40
+ }
41
+ if (m && m.role === "assistant" && Array.isArray(m.tool_calls)) {
42
+ let changed = false
43
+ const tool_calls = m.tool_calls.map((tc) => {
44
+ const args = tc.function?.arguments
45
+ if (typeof args === "string" && args.length > 300) {
46
+ changed = true
47
+ return { ...tc, function: { ...tc.function, arguments: args.slice(0, 300) + "…" } }
48
+ }
49
+ return tc
50
+ })
51
+ return changed ? { ...m, tool_calls } : m
52
+ }
53
+ if (m && m.role === "tool" && typeof m.content === "string" && m.content.length > 500) {
54
+ return { ...m, content: m.content.slice(0, 500) + "\n… (truncated for storage)" }
55
+ }
56
+ return m
57
+ }
58
+
59
+ /** 记录条目过滤(与 saveSession 同源):transient 机器注入与 legacy-transient 前缀不进记录。 */
60
+ export function shouldAppend(m) {
61
+ return !!m && typeof m === "object" && !m.transient && !isLegacyTransient(m)
62
+ }
63
+
64
+ /** 真实 user 消息(turnCount/firstMessage 口径——同 session-slots.extractSlotMeta)。 */
65
+ export function isRealUserMsg(m) {
66
+ return m?.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
67
+ }
68
+
69
+ /** sidecar 目录路径(`{slot 文件路径}.d`——同目录同前缀)。 */
70
+ export function recordDirOf(slotFile) { return slotFile + RECORD_DIR_SUFFIX }
71
+
72
+ /** 段文件名(六位零填充)。 */
73
+ export function segName(n) { return `seg-${String(n).padStart(6, "0")}.jsonl` }
74
+
75
+ /** 段号升序表(readdir——不读内容)。 */
76
+ export function listSegments(dir) {
77
+ let names
78
+ try { names = readdirSync(dir) } catch { return [] }
79
+ const out = []
80
+ for (const n of names) {
81
+ const m = /^seg-(\d+)\.jsonl$/.exec(n)
82
+ if (m) out.push(Number(m[1]))
83
+ }
84
+ return out.sort((a, b) => a - b)
85
+ }
86
+
87
+ /** 读段(原始行——去尾空行);读失败按空段容忍(读路径尽力面)。 */
88
+ export function readSegmentLines(path) {
89
+ _storeStats.segmentReads++
90
+ let text
91
+ try { text = readFileSync(path, "utf8") } catch { return [] }
92
+ const lines = text.split("\n")
93
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
94
+ return lines
95
+ }
96
+
97
+ /** 逐行容错解析:半行/损坏行 → undefined(调用方跳过——不抛、不自动修复)。 */
98
+ export function tryParse(line) {
99
+ try { return JSON.parse(line) } catch { return undefined }
100
+ }
@@ -18,6 +18,8 @@ import { migrateHashLength } from "./session-migrate.mjs"
18
18
  // legacy 过滤)属 session.mjs(500 行内不迁移)——静态环仅此一处:函数声明实例化期已初始化、
19
19
  // 只函数体内运行时使用(环安全);VS Code 端 session-slots ↔ session-io 同构镜像。
20
20
  import { loadSlotFile, isLegacyTransient } from "./session.mjs"
21
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.8):删槽联动记录存储(store 零项目内依赖——无环)。
22
+ import { unlinkRecordStore } from "./session-store.mjs"
21
23
 
22
24
  let currentSessionId = null
23
25
 
@@ -135,9 +137,11 @@ function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
135
137
  }
136
138
  }
137
139
 
138
- /** Extract preview summary from session data for manifest storage (with current timestamp) */
140
+ /** Extract preview summary from session data for manifest storage (with current timestamp)
141
+ * MODEL-MERGE-SESSION:摘要升级带 activeModel——listSlots 合成显 "p:m"(双端同规则)。 */
139
142
  export function slotDigest(data) {
140
143
  const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
144
+ if (data.activeModel) meta.activeModel = data.activeModel
141
145
  return { ts: Date.now(), ...meta }
142
146
  }
143
147
 
@@ -356,6 +360,7 @@ function loadSlotMeta(cwd, slot, v) {
356
360
  const data = JSON.parse(readFileSync(p, "utf8"))
357
361
  const history = data.history ?? []
358
362
  const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
363
+ if (data.activeModel) meta.activeModel = data.activeModel
359
364
  return { ts, ...meta }
360
365
  } catch {
361
366
  return { ts }
@@ -381,7 +386,9 @@ export function listSlots(cwd) {
381
386
  messageCount: meta.messageCount ?? 0,
382
387
  turnCount: meta.turnCount ?? 0,
383
388
  firstMessage: meta.firstMessage ?? "",
384
- activeProvider: meta.activeProvider ?? "",
389
+ // MODEL-MERGE-SESSION 摘要 "p:m":activeProvider 保持裸渠道名——列表消费面显复合
390
+ // (旧摘要无 activeModel → 回退裸渠道名——新老兼容;cmd-session/VSC sessions 行免改)
391
+ activeProvider: meta.activeProvider ? (meta.activeModel ? `${meta.activeProvider}:${meta.activeModel}` : meta.activeProvider) : "",
385
392
  updatedAt: meta.updatedAt ?? meta.ts,
386
393
  updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
387
394
  title: meta.title ?? "",
@@ -400,6 +407,7 @@ export function deleteSlot(cwd, slot) {
400
407
  delete m.slots[n]
401
408
  delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
402
409
  try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
410
+ unlinkRecordStore(slotPath(cwd, n)) // §14.3.8:删槽连带删除记录存储(sidecar)
403
411
  if (m.active === n) delete m.active
404
412
  // setActive: true —— 显式表达"删到 active 时 active 置空"的意图(saveManifest 默认
405
413
  // 保留 fresh.active,2026-09-01 会诊三家 🟡)