@thincoder/core 0.10.9 → 0.10.11

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 (92) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/advisor/messages.mjs +4 -5
  3. package/advisor/project-context.mjs +69 -44
  4. package/agent/completion.mjs +4 -1
  5. package/agent/family-tools.mjs +12 -10
  6. package/agent/helpers.mjs +19 -11
  7. package/agent/light-round.mjs +1 -4
  8. package/agent/record-results.mjs +6 -1
  9. package/agent/run-start.mjs +1 -0
  10. package/agent/setup-reminders.mjs +1 -1
  11. package/agent/turn-loop.mjs +17 -13
  12. package/agent/unbooked-pen.mjs +62 -0
  13. package/agent-tools/advisor.mjs +15 -31
  14. package/agent-tools/audit-block.mjs +16 -62
  15. package/agent-tools/batch-gate.mjs +12 -21
  16. package/agent-tools/checkpoint.mjs +11 -6
  17. package/agent-tools/context.mjs +1 -1
  18. package/agent-tools/design-token.mjs +9 -2
  19. package/agent-tools/digest-budget.mjs +2 -0
  20. package/agent-tools/doc-check.mjs +17 -23
  21. package/agent-tools/manifest.mjs +45 -14
  22. package/agent-tools/review-facts.mjs +35 -81
  23. package/agent-tools/settings.mjs +65 -5
  24. package/agent-tools/spawn-gates.mjs +68 -31
  25. package/agent-tools/subagent-async.mjs +6 -3
  26. package/agent-tools/subagent-spawn.mjs +76 -80
  27. package/agent-tools/subagent.mjs +16 -16
  28. package/agent-tools.mjs +1 -1
  29. package/browser/launch.mjs +14 -3
  30. package/browser/queue.mjs +31 -3
  31. package/browser/session.mjs +4 -0
  32. package/config-defaults.mjs +8 -0
  33. package/config-presets.mjs +1 -1
  34. package/config.mjs +60 -11
  35. package/declaration.mjs +5 -12
  36. package/doc-check/anchors.mjs +53 -60
  37. package/doc-check/index.mjs +106 -140
  38. package/doc-check/paragraph.mjs +119 -0
  39. package/fts-text.mjs +4 -1
  40. package/i18n.mjs +5 -0
  41. package/ledger-cmd.mjs +17 -4
  42. package/ledger-db.mjs +14 -8
  43. package/ledger.mjs +1 -1
  44. package/manifest-discovery.mjs +19 -10
  45. package/manifest-schema.mjs +7 -35
  46. package/manifest.mjs +21 -9
  47. package/memory/code-search.mjs +5 -14
  48. package/memory/docs.mjs +5 -14
  49. package/memory/read-gate.mjs +26 -0
  50. package/model-context.mjs +98 -0
  51. package/model-specs.mjs +110 -37
  52. package/package.json +2 -2
  53. package/permission.mjs +3 -1
  54. package/prompts/advisor-design.md +104 -38
  55. package/prompts/advisor-round1.md +72 -24
  56. package/prompts/advisor-round2.md +62 -25
  57. package/prompts/advisor-round3.md +68 -24
  58. package/prompts/common.md +214 -58
  59. package/prompts/consult-base.md +10 -3
  60. package/prompts/discipline-engineering.md +202 -54
  61. package/prompts/discipline-normal.md +95 -44
  62. package/prompts/persona-coder.md +3 -1
  63. package/prompts/persona-eng-coder.md +29 -9
  64. package/prompts/persona-eng-designer.md +101 -35
  65. package/prompts/persona-engineering.md +320 -98
  66. package/prompts/persona-explore.md +1 -1
  67. package/prompts/persona-normal.md +16 -8
  68. package/provider/anthropic.mjs +127 -4
  69. package/provider/core.mjs +16 -6
  70. package/provider/google.mjs +8 -8
  71. package/provider/list-models.mjs +37 -5
  72. package/provider/normalize.mjs +2 -2
  73. package/provider/responses.mjs +7 -7
  74. package/provider/sse.mjs +6 -5
  75. package/proxy-transport.mjs +5 -3
  76. package/proxy.mjs +2 -1
  77. package/session-guard.mjs +7 -1
  78. package/session-lifecycle.mjs +23 -8
  79. package/session-segments.mjs +1 -0
  80. package/session-slot-claims.mjs +4 -0
  81. package/session-slot-write.mjs +6 -0
  82. package/session-slots-manifest.mjs +4 -0
  83. package/token-ttl.mjs +10 -0
  84. package/tool-docs/advisor.md +24 -5
  85. package/tool-docs/doc_check.md +44 -25
  86. package/tool-docs/ledger.md +2 -2
  87. package/tool-docs/manifest-read.md +8 -0
  88. package/tool-docs/manifest.md +9 -8
  89. package/tool-docs/subagent.md +41 -16
  90. package/tools/web-deepseek.mjs +26 -11
  91. package/doc-check/version.mjs +0 -84
  92. package/doc-check/width.mjs +0 -132
@@ -6,7 +6,7 @@ Your role is to search, read, and analyze. You do NOT have file editing tools.
6
6
 
7
7
  ## Reporting duty
8
8
  - If the expected pattern doesn't exist, report that explicitly: what you searched for, which tools you used, and that nothing matched.
9
- - Report findings in a structured format; the delivery table follows the unified format in the shared layer.
9
+ - Report findings as a table: one row per item, a status column on every row (✅ done / ⚠️ simplified / ❌ not done), and no "deferred/later" column — pushing to later means "not done now".
10
10
 
11
11
  ## Thoroughness levels — pick the depth the task actually needs (the parent agent may state one in the task description):
12
12
  - quick — a single targeted search answering one specific question
@@ -8,34 +8,42 @@ You own the code — the entire project is your code.
8
8
 
9
9
  ## Role and collaboration stance
10
10
  Programming is collaborative labor between you and the human.
11
- The human decides direction and makes the final call. You own the code — the entire project is your code.
11
+ The human decides direction and makes the final call.
12
12
  What you confirm is your contract.
13
13
  You are the lead engineer: you see the full picture, you coordinate complex work, and you are ultimately responsible for the result.
14
14
  When you delegate to subagents, hold them to the same bar: a subagent that takes shortcuts is your failure, not theirs.
15
15
  **Subagent ids carry their accounts in reports**: report subagent statuses as a table with a dedicated ledger column — form: `coder #3 | #938 · <topic> | <task detail> | <status>`; a bare serial number ⇒ violation (the user traces it without asking).
16
16
 
17
17
  ## Confirmation and approval gate (confirm before writing files)
18
- - **Before writing files, restate your understanding of the task + the plan points, and wait for explicit user confirmation ("OK / sure / continue" type) before executing.**
19
- **The restatement carries the checked-evidence line** — **a restatement without an evidence line is a guess, not a plan**.
18
+ - **Before writing files**: restate your understanding of the task + the plan points, and wait for explicit user confirmation ("OK / sure / continue" type) before executing.
19
+ **Checked-evidence line**: the restatement carries it — a restatement without an evidence line is a guess, not a plan.
20
20
  No confirmation, silence, or the user replying with a new question/requirement means no approval — touching the file anyway lands work the user has not approved, however small or obvious.
21
- - **Stated requirements are a contract**: every requirement the user states (in conversation/docs/plans) binds the moment it is stated — shrinking at delivery means the user approved the design and received a discount. Discovering mid-way that an element is costly → implement it anyway and note the cost, or stop and lay out the trade-off BEFORE shrinking; disclosing only after delivery is the same discount, paid later.
21
+ - **Stated requirements are a contract**: every requirement the user states (in conversation/docs/plans) binds the moment it is stated.
22
+ Why: shrinking at delivery means the user approved the design and received a discount.
23
+ - **Discovering mid-way that an element is costly** → implement it anyway and note the cost, or stop and lay out the trade-off BEFORE shrinking.
24
+ Why: disclosing only after delivery is the same discount, paid later.
22
25
  - **The only exemption (doc/code consistency)**: updating a doc whose topic you already own, recording a decision the user just made, closing a doc-code gap the advisor pointed out — these complete the same already-confirmed task, so they are done in the same turn and spend no new confirmation.
23
- - **Re-confirm when the requirement changes.**
26
+ - **When the requirement changes**: re-confirm.
24
27
  - Confirmations are delivered in your plain reply text: a routine confirm gate stays plain text — the `question` tool blocks, turning "shall I continue" into a hard stop.
25
28
 
26
29
  ## In-flight ask — non-blocking steer during long runs
27
- **During a long task (a long chain / subagents in flight) the ordinary reply text beats both bad choices**: blocking on the user stalls the run, and pushing through blind spends the decision without them — so raise the question there, state a **workable default**, and keep going.
30
+ **Long task, live question**: during a long chain or with subagents in flight, the ordinary reply text beats both bad choices — blocking stalls the run, pushing through blind spends the decision without them — so raise the question there, state a **workable default**, and keep going.
28
31
  - **Trigger (both must hold)**: ① you are mid-run and stopping to wait clearly costs; ② the question has a **workable default** — carrying it forward means a silent user does not leave you stalled.
29
32
  - **One-sentence form**: "I'm continuing with <default> (basis: <basis>); if you'd rather have <alternative>, say so and I'll switch." **`<basis>` = the checked-evidence line ∥ "checked, nothing relevant"**.
30
33
  **Example**: "I'm continuing with plan A (basis: `<path>:<line>` — the ruling written there); if you'd rather have B, say so and I'll switch."
31
- - **A steer corrects you at once**: the user's reply reaches you as an ordinary user message (a message queued while you were busy takes effect once the current step lands) — apply it to the work that follows; interrupting a tool already in flight throws away its result and re-runs the work, and re-running finished parts pays for the same work twice.
34
+ - **A steer corrects you at once**: the user's reply reaches you as an ordinary user message — apply it to the work that follows.
35
+ - A message queued while you were busy takes effect once the current step lands.
36
+ - **In-flight interruption**: the tool's result is thrown away and the work runs again.
37
+ Why: re-running finished parts pays for the same work twice.
32
38
  - **Defaults stay in bounds**: a default is drawn only from the scope already confirmed — anything past the confirm gate has no approval behind it, and an in-flight ask that widened scope would carry a decision nobody made.
33
39
  - **Channel boundary**: hard gate (new scope / a ruling on a criterion / no default can carry it) ⇒ the `question` tool (blocking) or stop and ask the user; soft steer ⇒ in-flight ask (non-blocking); routine confirm gates stay plain text.
34
40
  **All three routes follow one rule: check the docs first, and carry the checked-evidence line ∥ "checked, nothing relevant" when you ask** — asking without checking makes someone re-answer what is already written down; the user can check this on the spot.
35
41
  - **Discipline**: one question at a time (the `question` tool's ONE-question rule) · re-asking an authorized scope spends the user's attention · the default leads, because idling produces nothing.
36
42
 
37
43
  ## Main-agent role — only the top-level agent has these capabilities. Subagents do not.
38
- **The management face is yours**: normal mode has no designer role — requirements integration ∥ design text ∥ closeout documentation normalization are your own work (the same standard as the engineering-mode designer), then an independent review; the batch record ∥ ledger ∥ closeout are all yours to write.
44
+ **The management face is yours**: normal mode has no designer role.
45
+ **Requirements integration ∥ design text ∥ closeout documentation normalization** are your own work — the same standard as the engineering-mode designer; an independent review follows.
46
+ **The batch record ∥ ledger ∥ closeout** are all yours to write.
39
47
  Plan before building — for complex multi-step tasks, enter plan mode first.
40
48
  Explore the codebase read-only, design the architecture, present the plan. When approved, exit plan mode and implement.
41
49
  For tasks that match the Coding discipline's "complex" tier, plan mode is your design step; for "medium" tasks it's optional but recommended.
@@ -7,7 +7,7 @@
7
7
  import { specForModel, requestBudget } from "../config.mjs"
8
8
  import { proxyFetch } from "../proxy.mjs"
9
9
  import { requestWithRetry } from "./retry.mjs"
10
- import { effectiveFetchTimeoutMs } from "./core.mjs"
10
+ import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
11
11
  import { abortError } from "../abort-provenance.mjs"
12
12
 
13
13
  const ANTHROPIC_VERSION = "2023-06-01"
@@ -32,6 +32,123 @@ export function normalizeTools(tools) {
32
32
  }))
33
33
  }
34
34
 
35
+ // PROVIDER.md §6.27 常量:告警码唯一发射点 = R7 分支;占位文案由批内单测逐字锁定。
36
+ const TOOL_ARGS_MALFORMED = "anthropic-tool-args-malformed"
37
+ const CONTINUED_HISTORY_TEXT = "[conversation continued from the history below]"
38
+ const EMPTY_TOOL_RESULT = "(no output)"
39
+ const RAW_ARGS_EXCERPT = 80
40
+
41
+ /** R6 ∥ R7:OpenAI 形 `arguments` → Anthropic `input`。
42
+ * 缺 ∥ 空串 ∥ null ⇒ `{}`(R6,无告警);非法 JSON ∥ 解析结果非普通对象 ⇒ `{}` + 告警(R7)。
43
+ * 不抛:历史是持久的,抛错会让该会话此后每轮必死;真实结果仍在 tool_result 里(D-PR42)。 */
44
+ function toolInputOf(raw, toolName, warnings) {
45
+ if (raw == null || raw === "") return {}
46
+ let parsed
47
+ try {
48
+ parsed = JSON.parse(raw)
49
+ } catch {
50
+ parsed = undefined
51
+ }
52
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ warnings.push({
54
+ name: TOOL_ARGS_MALFORMED,
55
+ message: `tool "${toolName}" arguments are not a JSON object — input sent as {} (raw: ${String(raw).slice(0, RAW_ARGS_EXCERPT)})`,
56
+ })
57
+ return {}
58
+ }
59
+ return parsed
60
+ }
61
+
62
+ /** R12:`image_url` part → Anthropic image 块(data URL ⇒ base64 source;其余 ⇒ url source)。 */
63
+ function imageBlockOf(part) {
64
+ const url = part.image_url?.url || ""
65
+ const data = url.match(/^data:([^;,]+);base64,(.*)$/s)
66
+ if (data) return { type: "image", source: { type: "base64", media_type: data[1], data: data[2] } }
67
+ return { type: "image", source: { type: "url", url } }
68
+ }
69
+
70
+ /** R2–R5 ∥ R12 ∥ 表外默认:单个 OpenAI part → Anthropic 块。 */
71
+ function blockOfPart(part) {
72
+ if (!part || typeof part !== "object") return null
73
+ if (part.type === "text") return { type: "text", text: part.text }
74
+ if (part.type === "image_url") return imageBlockOf(part)
75
+ // 表外默认:未列出的 part 类型原样直通(上游按类型拒收 = 显式失败),本层不静默丢弃
76
+ return { ...part }
77
+ }
78
+
79
+ /** R8:tool 消息的 `content` → tool_result 内容;空 ∥ 缺失 ⇒ 占位(`record-results.mjs:85` 原样推,无 stdout 的成功调用 = "")。 */
80
+ function toolResultText(content) {
81
+ if (typeof content === "string") return content === "" ? EMPTY_TOOL_RESULT : content
82
+ if (content == null) return EMPTY_TOOL_RESULT
83
+ return content // 表外(本仓只产出字符串):原样透传,由上游显式判收
84
+ }
85
+
86
+ /** R8 ∥ R9 ∥ R10:user 消息内 `tool_result` 恒在其它块之前(官方约束,§6.27 R8)。 */
87
+ function toolResultsFirst(blocks) {
88
+ if (!blocks.some((b) => b.type === "tool_result")) return blocks
89
+ return [...blocks.filter((b) => b.type === "tool_result"), ...blocks.filter((b) => b.type !== "tool_result")]
90
+ }
91
+
92
+ /**
93
+ * PROVIDER.md §6.27:OpenAI 形态会话历史 → Anthropic Messages API 形态(纯函数)。
94
+ * 返回 `{ messages, warnings }`(I5 ∥ I6);入参零写——历史是四条通道共用的持久表示。
95
+ * system 已由 `chat()` 抽到顶层(R1),本函数只处理其余消息。
96
+ */
97
+ export function convertMessages(messages) {
98
+ const out = []
99
+ const warnings = []
100
+ const push = (role, blocks) => {
101
+ if (blocks.length === 0) return // R13:空块消息丢弃(上游拒收空 content 数组——I3)
102
+ const ordered = role === "user" ? toolResultsFirst(blocks) : blocks
103
+ const last = out[out.length - 1]
104
+ if (last && last.role === role) {
105
+ // R10:转换后相邻同 role 合并,块按原序拼接(user 侧 tool_result 仍恒在前)
106
+ last.content = role === "user" ? toolResultsFirst([...last.content, ...ordered]) : [...last.content, ...ordered]
107
+ return
108
+ }
109
+ out.push({ role, content: ordered })
110
+ }
111
+
112
+ for (const m of messages) {
113
+ if (!m || typeof m !== "object") continue
114
+ if (m.role === "system") continue // R1:system 由 chat() 抽到顶层,永不进 messages
115
+ if (m.role === "tool") {
116
+ // R8 ∥ R9:tool 消息 ⇒ 含 tool_result 块的 user 消息(连续多条经 push 的合并自然并入同一条)
117
+ push("user", [{ type: "tool_result", tool_use_id: m.tool_call_id, content: toolResultText(m.content) }])
118
+ continue
119
+ }
120
+ const blocks = []
121
+ if (typeof m.content === "string") {
122
+ if (m.content !== "") blocks.push({ type: "text", text: m.content }) // R13:空串 ⇒ 零块
123
+ } else if (Array.isArray(m.content)) {
124
+ for (const part of m.content) {
125
+ const block = blockOfPart(part)
126
+ if (block) blocks.push(block)
127
+ }
128
+ }
129
+ if (m.role === "assistant") {
130
+ // R5:tool_calls 追加 tool_use 块;文本块在前、tool_use 块在后
131
+ for (const tc of m.tool_calls ?? []) {
132
+ if (!tc || typeof tc !== "object") continue
133
+ blocks.push({
134
+ type: "tool_use",
135
+ id: tc.id,
136
+ name: tc.function?.name,
137
+ input: toolInputOf(tc.function?.arguments, tc.function?.name, warnings),
138
+ })
139
+ }
140
+ }
141
+ // user ∥ 未列出的 role:按 user 收口(出口 role 只允许 user/assistant——I1)
142
+ push(m.role === "assistant" ? "assistant" : "user", blocks)
143
+ }
144
+
145
+ // R11:首条须 user(精简历史可 assistant 开头);全空输入不造占位消息(I1)
146
+ if (out.length > 0 && out[0].role !== "user") {
147
+ out.unshift({ role: "user", content: [{ type: "text", text: CONTINUED_HISTORY_TEXT }] })
148
+ }
149
+ return { messages: out, warnings }
150
+ }
151
+
35
152
  /** Build and send an Anthropic chat request. Returns the same shape as core.mjs chat.
36
153
  * 2026-08-31 会诊 #6:接入 rateGate/recordRate + 429 Retry-After 单次重试
37
154
  * (原实现完全绕过 TPM/RPM 闸门与记账——用户配了 tpm 以为受控实际不受控)。
@@ -48,10 +165,13 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
48
165
  }
49
166
  }
50
167
 
168
+ // PROVIDER.md §6.27:OpenAI 形态历史 → Messages API 形态(system 抽离之后、请求体组装之前)
169
+ const { messages: adaptedMessages, warnings } = convertMessages(chatMessages)
170
+
51
171
  const spec = specForModel(provider.model)
52
172
  const body = {
53
173
  model: provider.model,
54
- messages: chatMessages,
174
+ messages: adaptedMessages,
55
175
  stream: true,
56
176
  // §6.17 发送面同源:anthropic 恒发 max_tokens(显式 ?? 规格 maxOutput——原第三级兜底不可达已随本批删净,
57
177
  // §6.17「装配面不留第二算式」)
@@ -102,14 +222,17 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
102
222
  // 响应头阶段仍用 fetchTimeoutMs(600s 默认),body 阶段由 parseAnthropicStream 读侧 idle 管
103
223
  signal,
104
224
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
105
- _bodyIdleMs: 120_000,
225
+ _bodyIdleMs: effectiveBodyIdleMs(provider),
106
226
  }, provider.proxyUri),
107
227
  { signal, onWait, buildMessage: (status, text) => `Anthropic API error ${status}: ${text}` },
108
228
  )
109
229
 
110
230
  const result = await parseAnthropicStream(response, { onToken, onReasoning, signal })
111
231
  recordRate(provider, estimated, result.usage)
112
- return finishAnthropic(result)
232
+ const finished = finishAnthropic(result)
233
+ // §6.27 告警投递:R7 的畸形参数告警并入既有 `_warnings` 机读线(消费面 agent/run-stages.mjs:44-45)
234
+ finished._warnings = [...(finished._warnings ?? []), ...warnings]
235
+ return finished
113
236
  }
114
237
 
115
238
  /** Anthropic `stop_reason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
package/provider/core.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { providerSpec, requestBudget, resolveEnableThinking } from "../config.mjs"
8
+ import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
8
9
  import { resolveProviderSecrets } from "../env-ref.mjs"
9
10
  import { proxyFetch } from "../proxy.mjs"
10
11
  import { escapeMessages, stripLocalMessageFields } from "../escape.mjs"
@@ -63,8 +64,7 @@ export function createProvider(config) {
63
64
 
64
65
  /** Send a streaming chat completion request with automatic continuation on truncation */
65
66
  // 2026-09-01 根因修复:600s 绝对墙钟曾腰斩长上下文子代理(eng-coder TTFB>10min 即死)——TTFB 阶段改用
66
- // fetchTimeoutMs(默认 600s,agent.fetchTimeoutMs 可配),body 阶段 idle 超时(FETCH_BODY_IDLE_MS,无新数据才断)。
67
- const FETCH_BODY_IDLE_MS = 120_000
67
+ // fetchTimeoutMs(默认 600s,agent.fetchTimeoutMs 可配),body 阶段 idle 超时(agent.bodyIdleTimeoutMs 可配,无新数据才断)。
68
68
  /** §6.14 设计值:prefix 续写只保留最近 8 条非工具文本(截断点语境足够,N 以测试锁定) */
69
69
  const PREFIX_CONTINUATION_KEEP = 8
70
70
 
@@ -73,6 +73,14 @@ export function effectiveFetchTimeoutMs(provider) {
73
73
  return Number.isFinite(provider?.fetchTimeoutMs) && provider.fetchTimeoutMs > 0 ? provider.fetchTimeoutMs : 600_000
74
74
  }
75
75
 
76
+ /** body 阶段读侧空闲超时(TIMEOUT-DIGEST.md §2.1 —— 唯一解析点,各消费点同读本函数):
77
+ * 键 `agent.bodyIdleTimeoutMs` 经 loadConfig 提升为 `provider.bodyIdleTimeoutMs`;
78
+ * 非法 ∥ 缺省 ⇒ 回退 DEFAULT_BODY_IDLE_MS(不抛错、不解释成「禁用」)。 */
79
+ export function effectiveBodyIdleMs(provider) {
80
+ return Number.isFinite(provider?.bodyIdleTimeoutMs) && provider.bodyIdleTimeoutMs > 0
81
+ ? provider.bodyIdleTimeoutMs : DEFAULT_BODY_IDLE_MS
82
+ }
83
+
76
84
  export async function chat(provider, opts = {}) {
77
85
  // LOGGING(docs/design/LOGGING.md):llm:* 事件统一在此落点——所有 chat 调用
78
86
  // (主回合/消化轮/compress/distill/advisor/子代理/auto-think/consult)都经本函数,
@@ -143,6 +151,8 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
143
151
  if (provider.format === "anthropic") {
144
152
  const { chat: anthropicChat } = await import("./anthropic.mjs")
145
153
  const { normalizeTools } = await import("./anthropic.mjs")
154
+ // §6.27:配对归一见转换前(缺回执的 tool_call 会变成孤儿 tool_use,上游同样拒收)——复用 OpenAI 径同一函数
155
+ messages = normalizeToolPairing(messages)
146
156
  const result = await anthropicChat(provider, {
147
157
  messages,
148
158
  tools: tools?.length ? normalizeTools(tools) : null,
@@ -243,7 +253,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
243
253
  await rateGate(provider, estimated, onWait, signal)
244
254
 
245
255
  const response = await requestWithRetry(provider, body, signal, onWait)
246
- const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns })
256
+ const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns, idleMs: effectiveBodyIdleMs(provider) })
247
257
  recordRate(provider, estimated, result.usage)
248
258
 
249
259
  // Stream rule triggered, user interrupted, or network partial — return immediately.
@@ -261,7 +271,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
261
271
  await sleepInterruptible(3000, signal)
262
272
  }
263
273
  const retryResponse = await requestWithRetry(provider, body, signal, onWait)
264
- const retryResult = await readSSE(retryResponse, { onToken, onReasoning })
274
+ const retryResult = await readSSE(retryResponse, { onToken, onReasoning, idleMs: effectiveBodyIdleMs(provider) })
265
275
  recordRate(provider, estimated, retryResult.usage)
266
276
  if (retryResult.finishReason !== "insufficient_system_resource") {
267
277
  // Merge any partial content from the failed attempt (streaming already showed it)
@@ -395,12 +405,12 @@ async function requestWithRetry(provider, body, signal, onWait) {
395
405
  body: JSON.stringify(body),
396
406
  // 2026-09-01 根因修复:原 600s 绝对墙钟会腰斩长上下文子代理(TTFB/首 token >10min 即死)。
397
407
  // 拆分语义:响应头阶段仍用 fetchTimeoutMs(600s,覆盖网关排队);body 阶段由读侧 idle 超时管
398
- // (sse.mjs readIdleMs——无新数据才断)。signal 只保留用户取消链,不再叠加绝对墙钟。
408
+ // (sse.mjs idleMs——effectiveBodyIdleMs 传入,无新数据才断)。signal 只保留用户取消链,不再叠加绝对墙钟。
399
409
  signal,
400
410
  // 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
401
411
  // DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
402
412
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
403
- _bodyIdleMs: FETCH_BODY_IDLE_MS,
413
+ _bodyIdleMs: effectiveBodyIdleMs(provider),
404
414
  }
405
415
  // #878 D-PX8:请求调用点收口——恒走 `proxyFetch`(无 proxyUri 即直连分支,内部 abort 通道单点建设;
406
416
  // 原 call-site 分叉删除)。
@@ -6,10 +6,11 @@
6
6
 
7
7
  import { proxyFetch } from "../proxy.mjs"
8
8
  import { requestWithRetry } from "./retry.mjs"
9
- import { effectiveFetchTimeoutMs } from "./core.mjs"
9
+ import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
10
10
  import { abortError, timeoutError } from "../abort-provenance.mjs"
11
11
  import { terminateBody } from "../stream-destroy.mjs"
12
12
  import { requestBudget } from "../config.mjs"
13
+ import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
13
14
 
14
15
  /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
15
16
  function mapFunctionCallingConfig(choice) {
@@ -130,12 +131,12 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
130
131
  // 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
131
132
  signal,
132
133
  _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
133
- _bodyIdleMs: 120_000,
134
+ _bodyIdleMs: effectiveBodyIdleMs(provider),
134
135
  }, provider.proxyUri),
135
136
  { signal, onWait, buildMessage: (status, text) => `Gemini API error ${status}: ${text}` },
136
137
  )
137
138
 
138
- const result = await parseGeminiStream(response, { onToken, onReasoning, signal })
139
+ const result = await parseGeminiStream(response, { onToken, onReasoning, signal, idleMs: effectiveBodyIdleMs(provider) })
139
140
  recordRate(provider, estimated, result.usage)
140
141
 
141
142
  const usage = result.usage
@@ -168,7 +169,7 @@ function mapGeminiFinishReason(reason) {
168
169
  * Parse Gemini SSE stream.
169
170
  * Format: data: {...}\n\n (each line is a complete JSON object)
170
171
  */
171
- async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
172
+ async function parseGeminiStream(response, { onToken, onReasoning, signal, idleMs = DEFAULT_BODY_IDLE_MS }) {
172
173
  const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
173
174
  const decoder = new TextDecoder()
174
175
  let buffer = ""
@@ -214,8 +215,7 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
214
215
  }
215
216
 
216
217
  if (!response.body) throw new Error("No stream response body")
217
- // 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 120s 无新 chunk 判死
218
- const READ_IDLE_MS = 120_000
218
+ // 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 `idleMs` 无新 chunk 判死
219
219
  let idleTimer = null
220
220
  let idleError = null
221
221
  const armIdle = () => {
@@ -223,9 +223,9 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
223
223
  idleTimer = setTimeout(() => {
224
224
  // #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道;
225
225
  // proxy 形回落 destroyBody(原契约)。相位串 = 本分支专属 google-sse-idle。
226
- idleError = timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")
226
+ idleError = timeoutError(`SSE idle timeout: no data for ${idleMs / 1000}s`, "provider", "google-sse-idle")
227
227
  try { terminateBody(response, idleError) } catch { /* already gone */ }
228
- }, READ_IDLE_MS)
228
+ }, idleMs)
229
229
  idleTimer.unref?.()
230
230
  }
231
231
  armIdle()
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { proxyFetch } from "../proxy.mjs"
17
17
  import { resolveProviderSecrets } from "../env-ref.mjs"
18
+ import { positiveInt, recordListedContext } from "../model-context.mjs"
18
19
 
19
20
  const LIST_TIMEOUT_MS = 15_000
20
21
  /** 翻页上限(cursor loop 防死循环——任一分页失败即整体抛出,不部分返回)。 */
@@ -57,10 +58,37 @@ function collect(rows, pick) {
57
58
  return out
58
59
  }
59
60
 
61
+ /** 列表窗口三字段名(取用序 = `context_window` > `context_length` > `max_model_len`)。
62
+ * 只从 openai 形态(`data[]`)取:anthropic / google 两分支不取(零实测——需求 §4.8 边界)。 */
63
+ const LISTED_CONTEXT_FIELDS = ["context_window", "context_length", "max_model_len"]
64
+
65
+ /** 三字段按序取第一个合法值(不是「取首字段」):字段缺失 ∥ 值非法 ⇒ 跳下一个;
66
+ * 都不可得 ⇒ `null`(L3 缺席——R26 与字段缺席逐字同判)。 */
67
+ function listedWindowOf(row) {
68
+ for (const field of LISTED_CONTEXT_FIELDS) {
69
+ const value = positiveInt(row?.[field])
70
+ if (value !== null) return value
71
+ }
72
+ return null
73
+ }
74
+
60
75
  async function listOpenai(provider, signal) {
61
76
  const url = `${provider.baseURL}/models`
62
77
  const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
63
- return collect(data?.data, (m) => m?.id)
78
+ // L3 写面的收集段(PROVIDER.md §6.15):解析 `data[]` 时顺带收 `id → 窗口`——三字段按序取
79
+ // 第一个**合法**值(R25 ∥ R26),非法相当于该字段缺席;三字段都不可得 = 该模型列表未报(不进表)。
80
+ const ids = []
81
+ // 形状门:载荷读得出来(data 是数组)才建表;非数组 = 读不出,返回 null 让调用侧不写缓存
82
+ // (「不报」与「读不出」分开——后者不清空上次成功值,同「拉取失败不清空」口径)。
83
+ const windows = Array.isArray(data?.data) ? new Map() : null
84
+ for (const m of Array.isArray(data?.data) ? data.data : []) {
85
+ const id = m?.id
86
+ if (typeof id !== "string" || !id) continue
87
+ ids.push(id)
88
+ const tokens = listedWindowOf(m)
89
+ if (tokens !== null) windows.set(id, tokens)
90
+ }
91
+ return { ids, windows }
64
92
  }
65
93
 
66
94
  async function listAnthropic(provider, signal) {
@@ -94,18 +122,22 @@ async function listGoogle(provider, signal) {
94
122
  return out
95
123
  }
96
124
 
97
- /** List available model IDs from the provider's /models endpoint (format 分派——M1)。 */
125
+ /** List available model IDs from the provider's /models endpoint (format 分派——M1)。
126
+ * 返回形态仍是 `string[]`(六个消费面只吃 id——零改);`openai` 分支在返回前把
127
+ * `id → 自报窗口` 写进核缓存(L3 写面——零新增请求)。 */
98
128
  export async function listModels(provider, { signal } = {}) {
99
129
  // #57(provider-config-family 批):清单拉取入口凭据值位解析(CONFIG.md §6.3)——三 format 分支共享。
100
130
  provider = resolveProviderSecrets(provider)
101
131
  const base = String(provider?.baseURL ?? "").replace(/\/+$/, "")
102
132
  if (!base) throw new Error("GET /models failed: baseURL is missing") // §2.5 #115 并入(VSC 明确报错)
103
133
  const format = provider?.format
104
- const ids = format === "anthropic" ? await listAnthropic(provider, signal)
105
- : format === "google" ? await listGoogle(provider, signal)
134
+ const collected = format === "anthropic" ? { ids: await listAnthropic(provider, signal) }
135
+ : format === "google" ? { ids: await listGoogle(provider, signal) }
106
136
  : await listOpenai(provider, signal)
137
+ // L3 写面:成功拉取后才写(失败照旧抛出、不清空上次成功值——上次自报比静态表更接近事实)。
138
+ if (collected.windows) recordListedContext(base, collected.windows)
107
139
  // §2.5 #115 并入(VSC 排序):返回前本端排序——UI 确定性。
108
- return ids.filter((id) => typeof id === "string" && id).sort()
140
+ return collected.ids.filter((id) => typeof id === "string" && id).sort()
109
141
  }
110
142
 
111
143
  /** 渠道准入失败消息(§2.5 #115 / VSC M8 并入——逐字长句 = 消息本体;状态标签由 UI 层组合)。
@@ -4,8 +4,7 @@
4
4
  * Split from core.mjs (TODO #2: 420 lines, past the 300 advisory). These two
5
5
  * pure functions sanitize the message array right before it hits the wire;
6
6
  * no dependency on chat()/retry logic. core.mjs re-exports them so
7
- * provider/index.mjs and tool-pairing.test.mjs keep their import paths.
8
- * The caller passes the spec (providerSpec from core.mjs — provider-aware).
7
+ * provider/index.mjs keeps its import paths.
9
8
  */
10
9
 
11
10
  const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
@@ -148,6 +147,7 @@ export function normalizeToolPairing(messages) {
148
147
  out.push(m)
149
148
  if (m.role !== "assistant" || !m.tool_calls?.length) continue
150
149
  for (const tc of m.tool_calls) {
150
+ if (!tc || typeof tc !== "object") continue // 空元素不携带 id:跳过,不让整个回合崩(#31)
151
151
  const t = toolById.get(tc.id)
152
152
  if (t) {
153
153
  toolById.delete(tc.id)
@@ -11,7 +11,7 @@
11
11
  * 事件流规范:流以 response.completed / response.incomplete / response.failed 结束,
12
12
  * 没有 "data: [DONE]"。
13
13
  */
14
- import { effectiveFetchTimeoutMs } from "./core.mjs"
14
+ import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
15
15
  import { proxyFetch } from "../proxy.mjs"
16
16
  import { requestWithRetry } from "./retry.mjs"
17
17
  import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
@@ -244,8 +244,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
244
244
  headers,
245
245
  body: JSON.stringify(body),
246
246
  signal,
247
- _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
248
- _bodyIdleMs: 120_000,
247
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
248
+ _bodyIdleMs: effectiveBodyIdleMs(provider),
249
249
  }, provider.proxyUri),
250
250
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
251
251
  )
@@ -263,8 +263,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
263
263
  headers,
264
264
  body: JSON.stringify(fullBody),
265
265
  signal,
266
- _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
267
- _bodyIdleMs: 120_000,
266
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
267
+ _bodyIdleMs: effectiveBodyIdleMs(provider),
268
268
  }, provider.proxyUri),
269
269
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
270
270
  )
@@ -281,8 +281,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
281
281
  headers,
282
282
  body: JSON.stringify(fresh2.body),
283
283
  signal,
284
- _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
285
- _bodyIdleMs: 120_000,
284
+ _headerTimeoutMs: effectiveFetchTimeoutMs(provider),
285
+ _bodyIdleMs: effectiveBodyIdleMs(provider),
286
286
  }, provider.proxyUri),
287
287
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
288
288
  )
package/provider/sse.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { abortError, timeoutError } from "../abort-provenance.mjs"
6
6
  import { terminateBody } from "../stream-destroy.mjs"
7
+ import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
7
8
 
8
9
  /**
9
10
  * Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
@@ -89,7 +90,7 @@ function finalizeToolCalls(result) {
89
90
  }
90
91
  }
91
92
 
92
- export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
93
+ export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired, idleMs = DEFAULT_BODY_IDLE_MS }) {
93
94
  // Early intercept: non-SSE responses — either error bodies (HTTP >= 400) or
94
95
  // valid single-chunk JSON completions (some APIs return JSON despite stream:true).
95
96
  const contentType = response.headers.get("content-type") || ""
@@ -196,9 +197,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
196
197
  if (!response.body) throw new Error("No stream response body")
197
198
  // 2026-09-01 读侧 idle 超时(根因修复):原实现靠 fetch 层的 600s 绝对墙钟兜底——长上下文子代理
198
199
  // 单次生成(或上游排队)超 10 分钟即被腰斩("The operation was aborted due to timeout" 直透)。
199
- // 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续 READ_IDLE_MS 无新 chunk 才判死。
200
+ // 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续 `idleMs` 无新 chunk 才判死
201
+ // (`idleMs` 由 provider/core.mjs 的 effectiveBodyIdleMs 算好传入,缺省 DEFAULT_BODY_IDLE_MS)。
200
202
  // 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
201
- const READ_IDLE_MS = 120_000
202
203
  let idleTimer = null
203
204
  let idleError = null
204
205
  const armIdle = () => {
@@ -206,9 +207,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
206
207
  idleTimer = setTimeout(() => {
207
208
  // #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道,
208
209
  // proxy 形回落 destroyBody(原契约)。
209
- idleError = timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")
210
+ idleError = timeoutError(`SSE idle timeout: no data for ${idleMs / 1000}s`, "provider", "sse-idle")
210
211
  try { terminateBody(response, idleError) } catch { /* already gone */ }
211
- }, READ_IDLE_MS)
212
+ }, idleMs)
212
213
  idleTimer.unref?.()
213
214
  }
214
215
  armIdle()
@@ -15,6 +15,7 @@ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"
15
15
  import { abortError, timeoutError } from "./abort-provenance.mjs"
16
16
  import { createChunkedDecoder } from "./proxy-chunked.mjs"
17
17
  import { destroyBody } from "./stream-destroy.mjs"
18
+ import { DEFAULT_BODY_IDLE_MS } from "./config-defaults.mjs"
18
19
 
19
20
  export const FETCH_TIMEOUT = 15_000
20
21
 
@@ -35,10 +36,11 @@ const CE_DECODERS = { gzip: createGunzip, "x-gzip": createGunzip, deflate: creat
35
36
  * absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
36
37
  * 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
37
38
  * 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
38
- * bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
39
+ * bodyIdleMs 缺省 = DEFAULT_BODY_IDLE_MS(与 `agent.bodyIdleTimeoutMs` 同源);传 0 不是「禁用」——
40
+ * 定时器立即触发(本批不提供禁用开关)。
39
41
  * 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
40
42
  */
41
- export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
43
+ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = DEFAULT_BODY_IDLE_MS) {
42
44
  return new Promise((resolve, reject) => {
43
45
  const target = new URL(urlStr)
44
46
  const method = opts.method ?? "GET"
@@ -229,7 +231,7 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
229
231
  }
230
232
  const signal = opts?.signal
231
233
  const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
232
- const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
234
+ const bodyIdleMs = opts?._bodyIdleMs ?? DEFAULT_BODY_IDLE_MS
233
235
 
234
236
  if (signal?.aborted) return reject(abortError(signal, "provider", "proxy"))
235
237
 
package/proxy.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { URL } from "node:url"
7
7
  import { normalizeProxy } from "./config.mjs"
8
+ import { DEFAULT_BODY_IDLE_MS } from "./config-defaults.mjs"
8
9
  import { IDLE_ABORT } from "./stream-destroy.mjs"
9
10
  import { isLoopbackTarget, resolveProxyConfig, resolveWebProxy } from "./proxy-target.mjs"
10
11
  import { FETCH_TIMEOUT, streamHttpResponse, tcpConnectProxy, tunnelHttps } from "./proxy-transport.mjs"
@@ -48,6 +49,6 @@ export async function proxyFetch(urlStr, opts, proxyUri) {
48
49
  // http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
49
50
  const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
50
51
  const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : FETCH_TIMEOUT
51
- const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
52
+ const bodyIdleMs = opts?._bodyIdleMs ?? DEFAULT_BODY_IDLE_MS
52
53
  return streamHttpResponse(sock, urlStr, opts, headerTimeoutMs, true, bodyIdleMs)
53
54
  }
package/session-guard.mjs CHANGED
@@ -22,6 +22,7 @@ import { RECORD_DIR_SUFFIX } from "./session-store.mjs"
22
22
  // 本进程端名(创建端取值链缺省值)——静态环:本档 ↔ session-slots.mjs(经 session.mjs,同
23
23
  // 两档头注:函数体内运行时使用、求值期零顶层调用 ⇒ 环安全)。
24
24
  import { sessionEnd } from "./session-slots.mjs"
25
+ import { logEvent } from "./log.mjs" // 槽事件(docs/core/design/SESSION-OBSERVABILITY.md §3.1):guard 侧轮转同报 `via=guard`。
25
26
 
26
27
  /** 轮转时随迁记录存储(.bak 路径联动)——本会话活动绑定面跳过(防拔掉活动存储)。 */
27
28
  function moveSidecar(src, dst, agent) {
@@ -55,10 +56,12 @@ export function guardForeignSlotFile(agent, p, slot) {
55
56
  const myStart = agent._sessionStart ?? null
56
57
  const diskForeign = typeof disk?.cwd === "string" && disk.cwd.toLowerCase() !== agent.cwd.toLowerCase()
57
58
  if (diskIsNewer || diskForeign || (diskStart && diskStart !== myStart)) {
59
+ const reason = diskIsNewer ? "newer-version" : diskForeign ? "foreign-cwd" : "other-session"
58
60
  const bak = `${p}.bak-${Date.now()}`
59
61
  renameSync(p, bak)
60
62
  moveSidecar(p, bak, agent)
61
63
  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)}`)
64
+ logEvent("slot:rotate", { slot, reason, bak: true, via: "guard" })
62
65
  agent._slotMtime = { p, mtimeMs: st.mtimeMs }
63
66
  agent._slotCreatedBy = sessionEnd() // 轮转:现场已让位 ⇒ 本端将首物化新文件
64
67
  return bak
@@ -70,9 +73,12 @@ export function guardForeignSlotFile(agent, p, slot) {
70
73
  // 文件存在但不可读(损坏/半写):改名 .corrupted 保留现场(2026-09-01 advisor 🔵——
71
74
  // 与自身 loadSlotFile 的 .corrupted 约定 + VS Code saveSessionToSlot 对齐;.bak 保留
72
75
  // 给 F2 轮转路径,损坏现场不再混入轮转后缀)。
76
+ // 设计档 §3.1 / D13:catch 补 `preserved` 跟踪——否则 `unreadable` / `rotate-failed` 两值不可判。
77
+ let preserved = false
73
78
  if (existsSync(p)) {
74
- try { renameSync(p, `${p}.corrupted`) } catch {}
79
+ try { renameSync(p, `${p}.corrupted`); preserved = true } catch {}
75
80
  }
81
+ logEvent("slot:rotate", { slot, reason: preserved ? "unreadable" : "rotate-failed", bak: false, via: "guard" })
76
82
  agent._slotCreatedBy = sessionEnd() // 损坏改名:现场已让位 ⇒ 本端将首物化新文件
77
83
  return null
78
84
  }