thincoder 0.12.52 → 0.12.54

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 (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/src/acp.mjs +60 -18
  4. package/src/advisor/run.mjs +9 -11
  5. package/src/agent/dispatch.mjs +38 -13
  6. package/src/agent/setup.mjs +2 -1
  7. package/src/agent.mjs +34 -0
  8. package/src/cli/make-agent.mjs +11 -5
  9. package/src/escape.mjs +43 -8
  10. package/src/git/checkpoint.mjs +32 -6
  11. package/src/mcp/helpers.mjs +14 -5
  12. package/src/mcp/transport-http.mjs +79 -27
  13. package/src/mcp/transport-stdio.mjs +57 -3
  14. package/src/mcp/transport-ws.mjs +46 -12
  15. package/src/mcp.mjs +197 -58
  16. package/src/prompts/discipline.md +44 -1
  17. package/src/provider/anthropic.mjs +51 -18
  18. package/src/provider/core.mjs +163 -36
  19. package/src/provider/google.mjs +41 -15
  20. package/src/provider/rate.mjs +5 -0
  21. package/src/provider/responses.mjs +498 -0
  22. package/src/provider/retry.mjs +125 -0
  23. package/src/provider/sse.mjs +58 -24
  24. package/src/proxy.mjs +36 -6
  25. package/src/session-migrate.mjs +6 -0
  26. package/src/session-slots.mjs +361 -0
  27. package/src/session.mjs +267 -306
  28. package/src/tools/bash.md +2 -2
  29. package/src/tools/execute.md +1 -1
  30. package/src/tools/execute.mjs +3 -3
  31. package/src/tools/fetch.md +1 -0
  32. package/src/tools/file.mjs +136 -11
  33. package/src/tools/git-checkpoint.mjs +143 -0
  34. package/src/tools/git-ext.mjs +173 -0
  35. package/src/tools/git.md +21 -6
  36. package/src/tools/git.mjs +68 -155
  37. package/src/tools/shared.mjs +5 -3
  38. package/src/tools/system.mjs +19 -1
  39. package/src/tools/web.mjs +44 -14
  40. package/src/tools/websearch.md +3 -1
  41. package/src/tui/ansi.mjs +2 -0
  42. package/src/tui/cmd-new.mjs +6 -6
  43. package/src/tui/cmd-restore.mjs +27 -6
  44. package/src/tui/cmd-session.mjs +17 -4
  45. package/src/tui/fold-block.mjs +59 -11
  46. package/src/tui/index.mjs +59 -67
  47. package/src/tui/key-handler.mjs +3 -1
  48. package/src/tui/layout.mjs +81 -25
  49. package/src/tui/mouse.mjs +86 -8
  50. package/src/tui/render-conversation.mjs +260 -214
  51. package/src/tui/render-frame.mjs +22 -6
  52. package/src/tui/render-loop.mjs +11 -1
  53. package/src/tui/startup.mjs +1 -1
  54. package/src/tui/subagent-blocks.mjs +5 -1
  55. package/src/tui/subagent-panel.mjs +81 -0
  56. package/src/tui/tool-args.mjs +4 -0
  57. package/src/tui/tool-events.mjs +1 -1
  58. package/src/tui/tui-lifecycle.mjs +45 -0
@@ -91,15 +91,17 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
91
91
  throw new Error(`API error: HTTP ${response.status} — ${errorMsg}`)
92
92
  }
93
93
  // HTTP < 400 non-SSE: might be a valid single-chunk JSON response (e.g. proxy
94
- // stripped SSE framing). Try to parse as a chat.completion.chunk.
94
+ // stripped SSE framing). Parse `choice.message` (full chat.completion) OR
95
+ // `choice.delta` (chunk shape) — 2026-08-31: gateways downgrading stream:true
96
+ // to a complete completion used to return EMPTY content silently (message-only shape).
95
97
  try {
96
98
  const parsed = JSON.parse(body)
97
99
  const choice = parsed.choices?.[0]
98
100
  if (choice) {
99
101
  const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
100
- const delta = choice.delta ?? {}
102
+ const delta = choice.delta ?? choice.message ?? {}
101
103
  result.content = delta.content ?? ""
102
- result.reasoning = delta.reasoning_content ?? ""
104
+ result.reasoning = delta.reasoning_content ?? delta.reasoning ?? ""
103
105
  result.finishReason = choice.finish_reason ?? null
104
106
  mergeToolCalls(result, delta)
105
107
  if (result.content) onToken?.(result.content)
@@ -118,32 +120,48 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
118
120
  let hasChoices = false
119
121
  const firedPatterns = sharedFired ?? new Set()
120
122
 
121
- const processLines = (lines) => {
122
- for (const line of lines) {
123
- if (!line.startsWith("data:")) continue
124
- const data = line.slice(5).trim()
125
- if (!data || data === "[DONE]") continue
123
+ /** Process one complete SSE event (data lines joined with \n per the SSE spec).
124
+ * 2026-08-31: rows are buffered per event (multi-line data: support that the
125
+ * per-line JSON.parse used to crash on — single-line events behave identically). */
126
+ const handleEvent = (data) => {
127
+ if (!data || data === "[DONE]") return
128
+ let json
129
+ try { json = JSON.parse(data) } catch { return }
126
130
 
127
- let json
128
- try { json = JSON.parse(data) } catch { continue }
131
+ if (json.usage) result.usage = normalizeUsageCache(json.usage)
132
+ const choice = json.choices?.[0]
133
+ if (!choice) return
134
+ hasChoices = true
135
+ if (choice.finish_reason) result.finishReason = choice.finish_reason
129
136
 
130
- if (json.usage) result.usage = normalizeUsageCache(json.usage)
131
- const choice = json.choices?.[0]
132
- if (!choice) continue
133
- hasChoices = true
134
- if (choice.finish_reason) result.finishReason = choice.finish_reason
137
+ const delta = choice.delta ?? choice.message ?? {}
138
+ // reasoning 方言:DeepSeek/Kimi/GLM 用 reasoning_content,OpenAI o 系和部分
139
+ // 路由器用 reasoning —— 两个都认(2026-08-31 会诊 #9)
140
+ const rDelta = delta.reasoning_content ?? delta.reasoning
141
+ if (rDelta) {
142
+ result.reasoning += rDelta
143
+ onReasoning?.(rDelta)
144
+ }
145
+ if (delta.content) {
146
+ result.content += delta.content
147
+ onToken?.(delta.content)
148
+ }
149
+ mergeToolCalls(result, delta)
150
+ }
135
151
 
136
- const delta = choice.delta ?? {}
137
- if (delta.reasoning_content) {
138
- result.reasoning += delta.reasoning_content
139
- onReasoning?.(delta.reasoning_content)
140
- }
141
- if (delta.content) {
142
- result.content += delta.content
143
- onToken?.(delta.content)
152
+ const processLines = (lines) => {
153
+ let currentData = ""
154
+ for (const line of lines) {
155
+ if (line.startsWith("data:")) {
156
+ const v = line.slice(5).trim()
157
+ if (v === "[DONE]") { currentData = ""; continue }
158
+ // multi-line data: joins with \n (SSE spec); single-line is the common case
159
+ currentData = currentData ? currentData + "\n" + v : v
160
+ continue
144
161
  }
145
- mergeToolCalls(result, delta)
162
+ if (line === "" && currentData) { handleEvent(currentData); currentData = "" }
146
163
  }
164
+ if (currentData) handleEvent(currentData)
147
165
  }
148
166
 
149
167
  if (!response.body) throw new Error("No stream response body")
@@ -155,6 +173,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
155
173
  throw e
156
174
  }
157
175
  buffer += decoder.decode(chunk, { stream: true })
176
+ // UTF-8 BOM 剥除(2026-08-31 会诊 #12):某些网关/负载均衡在流首注入 \uFEFF,
177
+ // 首行 "data:" 前缀匹配失败会被静默丢弃(首个事件整体消失)。
178
+ if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
158
179
  const lines = buffer.split("\n")
159
180
  buffer = lines.pop()
160
181
  processLines(lines)
@@ -186,6 +207,19 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
186
207
  result.interruptMessage = signal.reason.message
187
208
  return result
188
209
  }
210
+ // 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
211
+ // proxy 断连)时若已解析出内容,把 partial 交回上层而不是整轮报废重试。
212
+ // 标记 partial:true + networkError(上层可决定续写/重试/展示部分结果)。
213
+ if (hasChoices && (result.content || result.toolCalls.length)) {
214
+ finalizeToolCalls(result)
215
+ result.partial = true
216
+ result.networkError = e.message ?? String(e)
217
+ const existing = result._warnings ??= []
218
+ if (!existing.some((w) => w.name === "network-partial")) {
219
+ existing.push({ name: "network-partial", message: `stream interrupted by network error after partial output: ${result.networkError}` })
220
+ }
221
+ return result
222
+ }
189
223
  throw e
190
224
  }
191
225
 
package/src/proxy.mjs CHANGED
@@ -64,9 +64,12 @@ function abortError(signal) {
64
64
  * body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
65
65
  * opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
66
66
  * absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
67
+ * 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
68
+ * 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
69
+ * bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
67
70
  * 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
68
71
  */
69
- export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false) {
72
+ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
70
73
  return new Promise((resolve, reject) => {
71
74
  const target = new URL(urlStr)
72
75
  const method = opts.method ?? "GET"
@@ -78,6 +81,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
78
81
  const body = new PassThrough()
79
82
  let settled = false
80
83
  let headerBuf = ""
84
+ let idleTimer = null
81
85
 
82
86
  const timer = setTimeout(() => fail(new Error("Response timeout")), timeout)
83
87
  const onAbort = () => { sock.destroy(); fail(abortError(signal)) }
@@ -85,6 +89,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
85
89
 
86
90
  function cleanup() {
87
91
  clearTimeout(timer)
92
+ clearTimeout(idleTimer)
88
93
  signal?.removeEventListener("abort", onAbort)
89
94
  }
90
95
  /** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
@@ -93,6 +98,11 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
93
98
  if (!settled) { settled = true; reject(err) }
94
99
  else body.destroy(err)
95
100
  }
101
+ /** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
102
+ function armIdle() {
103
+ clearTimeout(idleTimer)
104
+ if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(new Error("Response body timeout (idle)")), bodyIdleMs)
105
+ }
96
106
 
97
107
  sock.on("data", (d) => {
98
108
  if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
@@ -113,11 +123,20 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
113
123
  sock.removeAllListeners("data")
114
124
  settled = true
115
125
  cleanup()
126
+ armIdle()
116
127
  const remaining = headerBuf.slice(idx + 4)
117
128
  if (remaining) body.write(Buffer.from(remaining, "utf8"))
118
129
  sock.pipe(body)
119
- // body 结束后才移除 abort 监听(流式中途 abort 要能终止流)
120
- body.on("close", () => signal?.removeEventListener("abort", onAbort))
130
+ // 空闲看门狗随数据到达重置。注意必须用 'readable' 而非 'data':
131
+ // 'data' 监听把流切成 flowing 模式,会抢走 readSSE/for-await 未来得及认领的
132
+ // 已写缓冲(2026-08-31 实测:分包响应头的 remaining 首段被吞,readSSE 等不到 ack)。
133
+ // 'readable' 不改变流模式,数据仍由消费方按需拉取。
134
+ body.on("readable", armIdle)
135
+ // body 结束后才移除 abort 监听(流式中途 abort 要能终止流);空闲看门狗一并清
136
+ body.on("close", () => {
137
+ clearTimeout(idleTimer)
138
+ signal?.removeEventListener("abort", onAbort)
139
+ })
121
140
  signal?.addEventListener("abort", onAbort, { once: true })
122
141
 
123
142
  resolve({
@@ -156,12 +175,20 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
156
175
  /**
157
176
  * HTTPS request through HTTP CONNECT proxy tunnel.
158
177
  * CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
178
+ * 2026-08-31 会诊 #1/#4:
179
+ * - TLS 默认全量证书校验(rejectUnauthorized: true)——走代理的流量(含 API key)
180
+ * 不得在未验证链路上传输;确需自签/内网代理时 opts.insecureTls=true 显式放行。
181
+ * - CONNECT/TLS 阶段用 timeout(默认 15s,建隧道快);**响应头超时**用
182
+ * opts._headerTimeoutMs(默认 60s)——原实现共用 15s,DeepSeek 排队 TTFB>15s
183
+ * 即误报 "Response timeout",与直连 600s 语义割裂。
159
184
  */
160
185
  export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
161
186
  return new Promise((resolve, reject) => {
162
187
  const target = new URL(urlStr)
163
188
  const proxy = new URL(proxyUri)
164
189
  const signal = opts?.signal
190
+ const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
191
+ const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
165
192
 
166
193
  if (signal?.aborted) return reject(abortError(signal))
167
194
 
@@ -181,12 +208,13 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
181
208
  if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
182
209
  sock.removeAllListeners("data"); clearTimeout(timer)
183
210
 
184
- const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: false, timeout })
211
+ // 安全默认:TLS 全量校验。企业中间人代理场景显式 opts.insecureTls=true 才放行。
212
+ const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: opts?.insecureTls !== true })
185
213
  if (buf) tlsSock.unshift(Buffer.from(buf))
186
214
  tlsSock.on("secureConnect", () => {
187
215
  // TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
188
216
  signal?.removeEventListener("abort", onAbort)
189
- streamHttpResponse(tlsSock, urlStr, opts, timeout).then(resolve, reject)
217
+ streamHttpResponse(tlsSock, urlStr, opts, headerTimeoutMs, false, bodyIdleMs).then(resolve, reject)
190
218
  })
191
219
  tlsSock.on("error", e => { sock.destroy(); reject(e) })
192
220
  })
@@ -232,5 +260,7 @@ export async function proxyFetch(urlStr, opts, proxyUri) {
232
260
  if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
233
261
  // http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
234
262
  const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
235
- return streamHttpResponse(sock, urlStr, opts, FETCH_TIMEOUT, true)
263
+ const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : FETCH_TIMEOUT
264
+ const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
265
+ return streamHttpResponse(sock, urlStr, opts, headerTimeoutMs, true, bodyIdleMs)
236
266
  }
@@ -15,7 +15,12 @@ import { configDir } from "./config.mjs"
15
15
  * Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
16
16
  * Every combination is tried — a migration that only checks one candidate misses real
17
17
  * legacy files (drive-letter case differs between CLI and VS Code historical paths). */
18
+ /** 2026-09-01 advisor 🔵(VS Code 侧已修,CLI 对称补齐):已迁移/确认无 legacy 的 hash
19
+ * 记录在 Set 中短路——否则每次 sessionPath() 都重跑 5 候选 × 3 existsSync 的系统调用。 */
20
+ const migratedHashes = new Set() // full 40-char hash → migration already attempted (found none or done)
21
+
18
22
  export function migrateHashLength(cwd, fullHash) {
23
+ if (migratedHashes.has(fullHash)) return false
19
24
  const dir = join(configDir, "sessions")
20
25
  const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
21
26
  const candidates = [
@@ -38,5 +43,6 @@ export function migrateHashLength(cwd, fullHash) {
38
43
  }
39
44
  } catch { /* best-effort; leave files in place on failure */ }
40
45
  }
46
+ migratedHashes.add(fullHash)
41
47
  return migrated
42
48
  }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * session-slots.mjs — slot / manifest 管理(2026-08-31 advisor round1 🔴 拆分:
3
+ * session.mjs 曾超 500 行硬限;slot 所有权、认领、清单与核心读写分离,session.mjs
4
+ * re-export 全部导出以保持既有 import 兼容)。
5
+ *
6
+ * 模型:每个项目(cwd hash)拥有无限编号 slot;manifest 记录 active 指针 + 每个
7
+ * slot 的属主进程(slotSessions: slot → "pid-timestamp-random",CLI ↔ VS Code
8
+ * 共享 manifest 以互斥认领)。属主判定用 PID 存活探测(isProcessAlive)。
9
+ */
10
+
11
+ import { createHash } from "node:crypto"
12
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, statSync } from "node:fs"
13
+ import { join, dirname } from "node:path"
14
+ import { execSync } from "node:child_process"
15
+ import { configDir } from "./config.mjs"
16
+ import { migrateHashLength } from "./session-migrate.mjs"
17
+
18
+ let currentSessionId = null
19
+
20
+ /** Generate unique session ID for this process */
21
+ export function getSessionId() {
22
+ if (!currentSessionId) {
23
+ currentSessionId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
24
+ }
25
+ return currentSessionId
26
+ }
27
+
28
+ /** Normalize cwd for hashing: uppercase Windows drive letter so both ends
29
+ * (CLI's process.cwd() vs VS Code's uri.fsPath, which lowercases it) agree. */
30
+ export function normalizeCwd(cwd) {
31
+ return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
32
+ }
33
+
34
+ /** Full sha1 hex (40 chars), not truncated. Shared contract with the VS Code extension. */
35
+ function cwdHash(cwd) {
36
+ return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
37
+ }
38
+
39
+ /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
40
+ export function sessionPath(cwd) {
41
+ const hash = cwdHash(cwd)
42
+ migrateHashLength(cwd, hash)
43
+ return join(configDir, "sessions", `${hash}.json`)
44
+ }
45
+
46
+ export function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
47
+ export function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
48
+
49
+ /** Path to the active slot's file */
50
+ export function activePath(cwd) {
51
+ return slotPath(cwd, activeSlot(cwd))
52
+ }
53
+
54
+ /** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash. */
55
+ export function writeSessionFile(p, data) {
56
+ mkdirSync(dirname(p), { recursive: true })
57
+ const tmp = `${p}.tmp`
58
+ writeFileSync(tmp, JSON.stringify(data), "utf8")
59
+ try {
60
+ renameSync(tmp, p)
61
+ } catch {
62
+ try { unlinkSync(p) } catch {}
63
+ try {
64
+ renameSync(tmp, p)
65
+ try { unlinkSync(tmp) } catch {}
66
+ } catch {
67
+ writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
68
+ }
69
+ }
70
+ }
71
+
72
+ // ========== slot management ==========
73
+
74
+ /** Detect a genuine user message (excludes system-reminder injected messages) */
75
+ function isRealUserMsg(m) {
76
+ return m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:")
77
+ }
78
+
79
+ /** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
80
+ function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
81
+ const userMsgs = history.filter(isRealUserMsg)
82
+ const first = userMsgs[0]?.content ?? ""
83
+ return {
84
+ messageCount: history.length,
85
+ turnCount: userMsgs.length,
86
+ firstMessage: first.slice(0, 80),
87
+ activeProvider: activeProvider ?? "",
88
+ updatedAt: updatedAt ?? Date.now(),
89
+ title,
90
+ }
91
+ }
92
+
93
+ /** Extract preview summary from session data for manifest storage (with current timestamp) */
94
+ export function slotDigest(data) {
95
+ const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
96
+ return { ts: Date.now(), ...meta }
97
+ }
98
+
99
+ export function loadManifest(cwd) {
100
+ try {
101
+ const p = manifestPath(cwd)
102
+ if (!existsSync(p)) return { slots: {}, sessionId: null }
103
+ const m = JSON.parse(readFileSync(p, "utf8"))
104
+ if (!m.slots) m.slots = {} // 2026-09-01 会诊 deepseek 🔵:损坏的 {} manifest 不再让调用方抛 TypeError
105
+ if (!m.sessionId) m.sessionId = null
106
+ return m
107
+ } catch { return { slots: {}, sessionId: null } }
108
+ }
109
+
110
+ export function saveManifest(cwd, m, deletions = null, opts = {}) {
111
+ // 2026-08-31 会诊 kimi/deepseek 🟡:写前重读并按"条目级"合并——原实现把"读时快照"
112
+ // 整对象写回,另一进程在窗口内对 slots/slotSessions/active 的变更被覆盖抹除(被抹
113
+ // 认领的槽变"文件在、无属主"→ 第三方可认领 → 双属主 → F2 互旋)。无锁文件无法
114
+ // 完全原子,重读合并把丢失更新窗口缩到最小;删除意图经 deletions 参数显式表达
115
+ // (deleteSlot:{ slots: [n], slotSessions: [n] })。
116
+ // 2026-09-01 会诊 deepseek/kimi/glm 🟡:active 是单值——只有显式翻指针的调用方
117
+ // (ensureActive 分支、newSession、switchToSlot、deleteSlot 删到 active 时)传
118
+ // opts.setActive;其余调用方(saveSession/ACP 认领/死项清理)默认保留磁盘 fresh 的
119
+ // active,否则毫秒窗口内会把并发方刚翻的 active 回滚(F1 防漂移的反向变体)。
120
+ try {
121
+ const fresh = JSON.parse(readFileSync(manifestPath(cwd), "utf8"))
122
+ if (fresh && typeof fresh === "object" && fresh.slots && typeof fresh.slots === "object") {
123
+ const merged = { ...fresh }
124
+ if (opts.setActive) merged.active = m.active
125
+ merged.slots = { ...fresh.slots, ...(m.slots ?? {}) }
126
+ merged.slotSessions = { ...(fresh.slotSessions ?? {}), ...(m.slotSessions ?? {}) }
127
+ if (m.sessionId) merged.sessionId = m.sessionId
128
+ if (deletions) {
129
+ for (const [section, keys] of Object.entries(deletions)) {
130
+ for (const k of keys) delete merged[section]?.[k]
131
+ }
132
+ }
133
+ m = merged
134
+ }
135
+ } catch {
136
+ // 首次创建或 manifest 不可读:用传入对象。2026-09-01 advisor 🟡:解析失败时先改名
137
+ // 保留现场(与 loadSlotFile 对 slot 文件的 .corrupted 原则一致)——否则覆盖后全部
138
+ // 槽位元数据(digest/title/updatedAt)永久丢失,/session 列表变空。文件不存在时
139
+ // rename 抛错被吞,无害。
140
+ try { renameSync(manifestPath(cwd), `${manifestPath(cwd)}.corrupted`) } catch {}
141
+ }
142
+ m.sessionId = getSessionId()
143
+ writeSessionFile(manifestPath(cwd), m)
144
+ }
145
+
146
+ /**
147
+ * Claim a slot for this process and set it as active. Idempotent.
148
+ * Preference order:
149
+ * 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
150
+ * 2. Any slot that is unowned or owned by a dead process (reclaim).
151
+ * 3. A brand-new slot when all are owned by live processes.
152
+ * The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
153
+ * see which slots are taken and avoid them.
154
+ */
155
+ function ensureActive(cwd, m) {
156
+ const mySessionId = getSessionId()
157
+ if (!m.slotSessions) m.slotSessions = {}
158
+
159
+ // 2026-08-31 会诊 F4:顺手清理死主条目——owner 进程已死的 slot 标记不再占用
160
+ // (原实现死项永不清理,空闲 slot 越来越少 → 新号滥发;且每次 save 对每 slot 跑
161
+ // tasklist,延迟随 slot 数增长)。
162
+ // advisor round2 #10:不能加 "文件缺失即删" 的短路——活进程在"认领 → 首次保存"窗口
163
+ // (新项目首跑:启动认领 slot、回合末才落盘)文件暂缺,误删其条目会被另一进程当
164
+ // 空闲认领 → 双进程永久写同一槽(F2 轮转互旋)。死主判定必须跑 tasklist;
165
+ // ensureActive 因 F1 粘性每次进程只跑几次,全量 tasklist 成本可接受。
166
+ let cleaned = false
167
+ const deadSlots = []
168
+ for (const [slot, owner] of Object.entries(m.slotSessions)) {
169
+ if (owner && owner !== mySessionId) {
170
+ const pid = parseInt(owner.split("-")[0])
171
+ if (!pid || !isProcessAlive(pid)) {
172
+ delete m.slotSessions[slot]
173
+ deadSlots.push(slot)
174
+ cleaned = true
175
+ }
176
+ }
177
+ }
178
+
179
+ // 2026-09-01 会诊三家:deadSlots 里可能含分支 1/2 刚重新认领的槽(m.slotSessions[s]
180
+ // 已被覆盖为 mySessionId)——deletions 若删除它会把自己的认领抹掉(下次 ensureActive
181
+ // 重新认领,但窗口内属主真空)。过滤在每次调用时按当前 m 状态计算。
182
+ const deadParam = () => (cleaned ? { slotSessions: deadSlots.filter((s) => m.slotSessions[s] !== mySessionId) } : null)
183
+
184
+ // Already own the active slot — nothing to do.
185
+ // 2026-08-31 advisor round2 🔵:清理结果此时落盘(否则死项清理只在内存生效,早退
186
+ // 路径永不持久化——死条目一直滞留到其他路径保存才消失)。
187
+ // advisor round3 N1 + 2026-09-01 会诊 deepseek/kimi 🔴:必须传 deletions——saveManifest
188
+ // 的条目级合并({...fresh, ...m})会把磁盘上仍存在的死条目从 fresh 复活回写,仅传 m
189
+ // 等于没删。N1 当时只修了早退路径,分支 1/2/3 漏了(认领主路径上死项清理是死代码)。
190
+ if (m.active && m.slotSessions[m.active] === mySessionId) {
191
+ if (cleaned) saveManifest(cwd, m, deadParam())
192
+ return
193
+ }
194
+
195
+ const isFree = (slot) => {
196
+ const owner = m.slotSessions[slot]
197
+ if (!owner || owner === mySessionId) return true
198
+ return !isProcessAlive(parseInt(owner.split("-")[0]))
199
+ }
200
+
201
+ // 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
202
+ if (m.active && m.slots[m.active] && isFree(m.active)) {
203
+ m.slotSessions[m.active] = mySessionId
204
+ saveManifest(cwd, m, deadParam(), { setActive: true })
205
+ return
206
+ }
207
+
208
+ // 2. Reclaim a slot whose FILE does not exist (never held a session). 2026-08-31 会诊 F4:
209
+ // 原实现认领"编号最小的空闲 slot"——死主的旧 slot 文件仍在,新进程会 resume 进
210
+ // 陌生会话("会话乱了"实锤)且退出时覆盖它。只有文件缺失的空 slot 才允许回收。
211
+ const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
212
+ for (const slot of allSlots) {
213
+ if (isFree(slot) && !existsSync(slotPath(cwd, slot))) {
214
+ m.active = slot
215
+ m.slotSessions[slot] = mySessionId
216
+ saveManifest(cwd, m, deadParam(), { setActive: true })
217
+ return
218
+ }
219
+ }
220
+
221
+ // 3. All slots owned by live processes — allocate a new one (no limit).
222
+ // 2026-08-31 advisor round2 🟡:新号从 max+1 起逐号跳过"已被活进程认领但尚未落盘"
223
+ // 的号(认领→首次保存窗口:slotSessions 有条目、m.slots 无条目、文件不存在——
224
+ // 仅凭 m.slots/existsSync 查不到 → 双进程认领同一号 → 同槽双写/互旋)。
225
+ // 2026-09-01 会诊 kimi 🟡:同时跳过文件仍存在的号(与 newSession 对齐)——manifest
226
+ // 条目丢失/损坏时 max+1 会撞上孤儿槽文件 → F2 把真会话轮转成不可见的 .bak。
227
+ // 另:allSlots 已升序,取 max 用 allSlots[allSlots.length-1](数万槽位时 Math.max
228
+ // spread 有 RangeError 风险)。
229
+ const liveClaimed = (n) => {
230
+ const owner = m.slotSessions?.[n]
231
+ return !!(owner && owner !== mySessionId && isProcessAlive(parseInt(owner.split("-")[0])))
232
+ }
233
+ let newSlot = allSlots.length > 0 ? allSlots[allSlots.length - 1] + 1 : 1
234
+ while (liveClaimed(newSlot) || existsSync(slotPath(cwd, newSlot))) newSlot++
235
+ m.active = newSlot
236
+ m.slotSessions[newSlot] = mySessionId
237
+ saveManifest(cwd, m, deadParam(), { setActive: true })
238
+ }
239
+
240
+ /**
241
+ * Check if a process with given PID is still alive.
242
+ * Returns false if process doesn't exist or we can't determine.
243
+ */
244
+ export function isProcessAlive(pid) {
245
+ if (!pid || isNaN(pid)) return false
246
+ try {
247
+ // On Windows: tasklist /FI "PID eq <pid>" /NH
248
+ // On Unix: kill(pid, 0) or check /proc/<pid>
249
+ if (process.platform === 'win32') {
250
+ // 2026-08-31 会诊 F4 + advisor round1 🔵:/FI 已按 PID 过滤;用 CSV 格式解析 PID
251
+ // 列(第 2 列),避免旧 includes() 误报活、新行解析在罕见镜像名(含"数字+空格")
252
+ // 下误报死。
253
+ const output = execSync(`tasklist /FO CSV /FI "PID eq ${pid}" /NH`, { encoding: 'utf8', stdio: 'pipe' })
254
+ return output.split(/\r?\n/).some((line) => {
255
+ const m = line.match(/^"([^"]*)","(\d+)"/)
256
+ return m && m[2] === String(pid)
257
+ })
258
+ } else {
259
+ // Unix: try to send signal 0 (doesn't kill, just checks)
260
+ process.kill(pid, 0)
261
+ return true
262
+ }
263
+ } catch {
264
+ return false
265
+ }
266
+ }
267
+
268
+ /** Return the active slot number for this process, claiming one if necessary */
269
+ export function activeSlot(cwd) {
270
+ const m = loadManifest(cwd)
271
+ ensureActive(cwd, m)
272
+ return m.active
273
+ }
274
+
275
+ /** Lazy-load slot metadata from slot file (for old-format manifest entries that lack metadata) */
276
+ function loadSlotMeta(cwd, slot, v) {
277
+ if (typeof v === "object" && v !== null && "ts" in v) return v
278
+ const ts = typeof v === "number" ? v : 0
279
+ try {
280
+ const p = slotPath(cwd, slot)
281
+ if (!existsSync(p)) return { ts }
282
+ const data = JSON.parse(readFileSync(p, "utf8"))
283
+ const history = data.history ?? []
284
+ const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
285
+ return { ts, ...meta }
286
+ } catch {
287
+ return { ts }
288
+ }
289
+ }
290
+
291
+ /** List all slots, newest first. Includes isActive flag.
292
+ * 2026-09-01 会诊 🟢:只读操作不认领——原实现 active 缺失时调 activeSlot(写 manifest
293
+ * 副作用,ACP session/list 可触发)。m.active 缺失时全部 isActive=false,由下一次
294
+ * activeSlot 正常认领。 */
295
+ export function listSlots(cwd) {
296
+ const m = loadManifest(cwd)
297
+ const active = m.active ?? null
298
+ return Object.entries(m.slots)
299
+ .filter(([n]) => /^\d+$/.test(n))
300
+ .map(([n, v]) => {
301
+ const meta = loadSlotMeta(cwd, Number(n), v)
302
+ return {
303
+ slot: Number(n),
304
+ isActive: Number(n) === active,
305
+ timestamp: meta.ts,
306
+ date: new Date(meta.ts).toLocaleString(),
307
+ messageCount: meta.messageCount ?? 0,
308
+ turnCount: meta.turnCount ?? 0,
309
+ firstMessage: meta.firstMessage ?? "",
310
+ activeProvider: meta.activeProvider ?? "",
311
+ updatedAt: meta.updatedAt ?? meta.ts,
312
+ updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
313
+ title: meta.title ?? "",
314
+ }
315
+ })
316
+ .sort((a, b) => b.updatedAt - a.updatedAt)
317
+ }
318
+
319
+ /** Delete a slot: remove its file and manifest entry. Deleting the active slot
320
+ * resets the manifest active pointer (the next claim re-creates one). */
321
+ export function deleteSlot(cwd, slot) {
322
+ const n = Number(slot)
323
+ if (!Number.isInteger(n) || n < 1) return false
324
+ const m = loadManifest(cwd)
325
+ if (!m.slots[n]) return false
326
+ delete m.slots[n]
327
+ delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
328
+ try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
329
+ if (m.active === n) delete m.active
330
+ // setActive: true —— 显式表达"删到 active 时 active 置空"的意图(saveManifest 默认
331
+ // 保留 fresh.active,2026-09-01 会诊三家 🟡)
332
+ saveManifest(cwd, m, { slots: [n], slotSessions: [n] }, { setActive: true })
333
+ return true
334
+ }
335
+
336
+ /** Rename a slot: update the slot file's title + the manifest metadata (shared with VS Code).
337
+ * 2026-09-01 会诊 glm 🟡:写回前按 mtime 门控重读——原实现读全量→改 title→整文件写回,
338
+ * 窗口内并发方的最新保存会被旧数据覆盖(丢消息);mtime 变了即放弃本次重命名。 */
339
+ export function renameSlot(cwd, slot, title) {
340
+ const n = Number(slot)
341
+ if (!Number.isInteger(n) || n < 1) return false
342
+ const p = slotPath(cwd, n)
343
+ if (!existsSync(p)) return false
344
+ let data
345
+ try {
346
+ data = JSON.parse(readFileSync(p, "utf8"))
347
+ } catch {
348
+ return false
349
+ }
350
+ const t0 = statSync(p).mtimeMs
351
+ data.title = title
352
+ // 读与写之间文件被并发方改过 → 放弃(保留并发内容,重命名下次重试)
353
+ if (statSync(p).mtimeMs !== t0) return false
354
+ writeSessionFile(p, data)
355
+ const m = loadManifest(cwd)
356
+ if (m.slots[n]) {
357
+ m.slots[n] = slotDigest(data)
358
+ saveManifest(cwd, m)
359
+ }
360
+ return true
361
+ }