thincoder 0.12.68 → 0.12.70

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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * read-data.mjs — ACP read-only data interfaces (READ-DATA-INTERFACE.md §2.6 / §3.3 / §3.4):
3
+ * `ledger/list` · `ledger/count` · `batch/list` + change notifications
4
+ * (`ledger/changed` / `batch/changed`, params `{ cwd }` — 信号面,数据经拉取方法取).
5
+ *
6
+ * 核模块一律**动态 import**(W8 契约②——台账链静态入 node:sqlite;且装载期零开销)。
7
+ * 观察器 = 5s 指纹轮询(注入面 `intervalMs`;测试直驱 `watcher.check()`——零定时器依赖):
8
+ * 观察域 = 服务进程 cwd(每轮 `ctx.getCwd()` 求值——与 `params.cwd` 缺省同取值;非观察项目不通知);
9
+ * 首查立基线(静默);此后差分 ⇒ 对应通知;`start()` 挂 unref 定时器(先例 = 核 ledger-surface.mjs)。
10
+ * 凭据门 = `ctx.requireConfigured`(与既有四族同门——D-8;只读性不改通道门)。
11
+ */
12
+ import { statSync } from "node:fs"
13
+
14
+ import { ACP_ERRORS } from "./transport.mjs"
15
+
16
+ /** 观察周期(D-7——5s;注入面 = `createReadDataHandlers(ctx, { intervalMs })`)。 */
17
+ export const WATCH_INTERVAL_MS = 5000
18
+
19
+ /** 核模块惰性装载(单次——W8 契约②:消费侧动态 import)。 */
20
+ let corePromise = null
21
+ function core() {
22
+ corePromise ??= Promise.all([
23
+ import("@thincoder/core/ledger-read.mjs"),
24
+ import("@thincoder/core/ledger-cmd.mjs"),
25
+ import("@thincoder/core/ledger-db.mjs"),
26
+ import("@thincoder/core/agent-tools/batch-read.mjs"),
27
+ ]).then(([ledgerRead, ledgerCmd, ledgerDb, batchRead]) => ({ ledgerRead, ledgerCmd, ledgerDb, batchRead }))
28
+ return corePromise
29
+ }
30
+
31
+ const badParams = (message) => ({ error: { ...ACP_ERRORS.INVALID_PARAMS, message } })
32
+
33
+ /** `params.cwd` → 取值(缺省 = 服务进程 cwd,`ctx.getCwd()`);非字符串 ⇒ 错误对象。 */
34
+ function resolveCwd(params, ctx) {
35
+ const cwd = params?.cwd
36
+ if (cwd === undefined) return { cwd: ctx.getCwd() }
37
+ if (typeof cwd !== "string") return { error: badParams(`cwd must be a string (got ${typeof cwd})`) }
38
+ return { cwd }
39
+ }
40
+
41
+ /** 指纹(台账库单档 / 批次档逐档)——`exists:mtimeMs:size`;缺失 ⇒ `0`。 */
42
+ function fileSig(file) {
43
+ try { const st = statSync(file); return `1:${st.mtimeMs}:${st.size}` } catch { return "0" }
44
+ }
45
+
46
+ /**
47
+ * Build the read-data handlers + the change watcher. Shared state arrives via the single
48
+ * `ctx` (§3.5); `intervalMs` is the injection face for the watch period.
49
+ * @returns {{ handlers: Record<string, (params: object) => Promise<object>>,
50
+ * watcher: { check: () => Promise<string[]>, start: () => void, stop: () => void } }}
51
+ */
52
+ export function createReadDataHandlers(ctx, { intervalMs = WATCH_INTERVAL_MS } = {}) {
53
+ const { requireConfigured } = ctx
54
+
55
+ const handlers = {
56
+ "ledger/list": async (params) => {
57
+ const gate = requireConfigured()
58
+ if (gate.error) return gate
59
+ const c = resolveCwd(params, ctx)
60
+ if (c.error) return c.error
61
+ for (const f of ["family", "full"]) {
62
+ if (params?.[f] !== undefined && typeof params[f] !== "boolean") return badParams(`${f} must be a boolean (got ${typeof params[f]})`)
63
+ }
64
+ const { ledgerRead } = await core()
65
+ try {
66
+ return ledgerRead.ledgerExport({ cwd: c.cwd, family: params?.family === true, full: params?.full === true })
67
+ } catch (e) { return badParams(e?.message ?? String(e)) }
68
+ },
69
+
70
+ "ledger/count": async (params) => {
71
+ const gate = requireConfigured()
72
+ if (gate.error) return gate
73
+ const c = resolveCwd(params, ctx)
74
+ if (c.error) return c.error
75
+ const { ledgerCmd } = await core()
76
+ try { return { count: ledgerCmd.ledgerCount({ cwd: c.cwd }) } } catch (e) { return badParams(e?.message ?? String(e)) }
77
+ },
78
+
79
+ "batch/list": async (params) => {
80
+ const gate = requireConfigured()
81
+ if (gate.error) return gate
82
+ const c = resolveCwd(params, ctx)
83
+ if (c.error) return c.error
84
+ const { batchRead } = await core()
85
+ try { return batchRead.listBatchRecords({ cwd: c.cwd }) } catch (e) { return badParams(e?.message ?? String(e)) }
86
+ },
87
+ }
88
+
89
+ // ── 变更观察器(轮询指纹;起停 = runAcpServer 启、进程终灭)─────────────────
90
+ let last = null
91
+ let timer = null
92
+ let inflight = false // 重叠防衛(先例 = 核 ledger-surface.mjs):上一拍未落定 ⇒ 跳过本拍
93
+
94
+ async function check() {
95
+ if (inflight) return []
96
+ inflight = true
97
+ try {
98
+ const cwd = ctx.getCwd()
99
+ const { ledgerDb, batchRead } = await core()
100
+ const ledgerSig = fileSig(ledgerDb.ledgerDbPath(cwd))
101
+ const batchSig = batchRead.listBatchRecords({ cwd }).batches.map((b) => `${b.path}:${fileSig(b.path)}`).join("|")
102
+ if (!last || last.cwd !== cwd) { last = { cwd, ledgerSig, batchSig }; return [] } // 首查 / 观察域切换 ⇒ 立基线(静默)
103
+ const fired = []
104
+ if (ledgerSig !== last.ledgerSig) fired.push("ledger/changed")
105
+ if (batchSig !== last.batchSig) fired.push("batch/changed")
106
+ last = { cwd, ledgerSig, batchSig }
107
+ for (const method of fired) ctx.notifyRef.current(method, { cwd })
108
+ return fired
109
+ } finally { inflight = false }
110
+ }
111
+
112
+ /** 启动:立即首查(立基线——静默)+ 周期轮询;unref 定时器(不阻塞进程退出)。 */
113
+ function start() {
114
+ if (timer) return
115
+ void check().catch(() => {})
116
+ timer = setInterval(() => { void check().catch(() => {}) }, intervalMs)
117
+ timer.unref?.()
118
+ }
119
+
120
+ /** 停表(进程终灭即止的显式面——测试 / 收尾用)。 */
121
+ function stop() {
122
+ if (timer) clearInterval(timer)
123
+ timer = null
124
+ }
125
+
126
+ return { handlers, watcher: { check, start, stop } }
127
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * resource-link.mjs — `session/prompt` 的 `resource_link` 内容块(ACP v1 基线 MUST:
3
+ * 「All agents MUST support resource links in prompts」)。
4
+ *
5
+ * 判定树与验收 = `docs/cli/design/ACP-PROTOCOL-COMPLIANCE.md` §2.5(逐态可机检):
6
+ * uri 解析(file: ⇒ 解码 + UNC/盘符收正;非 file ⇒ `unsupported scheme`;解析失败 ⇒ 纯路径
7
+ * 按 cwd)→ 选区(`#L<a>` / `#L<a>-<b>` / `#L<a>:<b>`——1 基闭区间)→ 读面(`unreadable` /
8
+ * `not a regular file` / `too large (>10MB)`——文件级先于读取)→ NUL(`binary (NUL byte)`)→
9
+ * 载荷上限(`too many lines (>2000)` / `too long (>100000 chars)`)→ 成功形(`[File: …]` +
10
+ * 无语言标记围栏)。
11
+ *
12
+ * 语义裁定(设计档 §2.5 三条):**无 cwd confine**(用户显式 @ 引用可读任意绝对路径——与
13
+ * `read` 工具同界:confine 已撤除,读操作只读、不触发审批);**零能力门控**(resource_link 属
14
+ * 基线 MUST,非 `PromptCapabilities` 项——`embeddedContext` 维持 false);**上限量级与 `read`
15
+ * 同源**(行上限直引核 `MAX_READ_LINES`——零副本)。
16
+ *
17
+ * 降级标记 = 单行固定词表(11 词——机检面锚;块间独立,单块失败不影响他块)。
18
+ */
19
+ import { readFile, stat } from "node:fs/promises"
20
+ import { resolve as resolvePath } from "node:path"
21
+ // 行上限与 read 工具同源(设计档 §2.5「上限量级与 read 同源」——直引,零副本)。
22
+ import { MAX_READ_LINES } from "@thincoder/core/tools/shared.mjs"
23
+
24
+ /** 文件级字节闸(10MB——先于读取;D-9:字节闸 = 文件级 ∥ 行/字符闸 = 载荷级)。 */
25
+ export const MAX_INLINE_BYTES = 10 * 1024 * 1024
26
+ /** 载荷级行闸(直引 `read` 工具同源值)。 */
27
+ export const MAX_INLINE_LINES = MAX_READ_LINES
28
+ /** 载荷级字符闸(100k 字符)。 */
29
+ export const MAX_INLINE_CHARS = 100_000
30
+
31
+ /** 降级标记形:单行、无围栏——`<reason>` ∈ 固定词表(设计档 §2.5 第 7 段)。 */
32
+ const marker = (path, reason) => `[File reference: ${path} — ${reason}]`
33
+
34
+ /** 选区文法:`#L<a>` ∥ `#L<a>-<b>` ∥ `#L<a>:<b>`(`b` 可带 `L` 前缀——含 `#L5:15` / `#L10-L20` 两观察形)。 */
35
+ const SELECTION_RE = /^#L(\d+)(?:[-:]L?(\d+))?$/
36
+
37
+ /** 成功形:头行 + 无语言标记围栏(内容原样;缺尾换行时补一终止换行,围栏不粘连正文)。 */
38
+ function fenced(header, content) {
39
+ const body = content.endsWith("\n") ? content : `${content}\n`
40
+ return `${header}\n\`\`\`\n${body}\`\`\``
41
+ }
42
+
43
+ /** 单块 → 内联段或降级标记(块间独立)。 */
44
+ export async function resolveResourceLink(block, { cwd } = {}) {
45
+ // ① uri 非字符串 ⇒ missing uri(路径位 = block.name ?? "unknown")
46
+ const raw = block?.uri
47
+ if (typeof raw !== "string") return marker(block?.name ?? "unknown", "missing uri")
48
+
49
+ // ② uri 解析:file: ⇒ 解码 + UNC/盘符收正;非 file ⇒ unsupported scheme;解析失败 ⇒ 纯路径
50
+ let path = null
51
+ let hash = ""
52
+ let url = null
53
+ try { url = new URL(raw) } catch { url = null }
54
+ if (url) {
55
+ if (url.protocol !== "file:") return marker(raw, "unsupported scheme")
56
+ try { path = decodeURIComponent(url.pathname) } catch { return marker(raw, "invalid percent-encoding") }
57
+ if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}` // UNC 主机
58
+ else if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1) // Windows 盘符(/C:/… → C:/…)
59
+ hash = url.hash
60
+ } else {
61
+ try { path = decodeURIComponent(raw) } catch { return marker(raw, "invalid percent-encoding") }
62
+ }
63
+ path = resolvePath(cwd ?? process.cwd(), path) // 相对路径按 cwd;已在盘面形则归一平台原生分隔符
64
+
65
+ // ③ 选区解析(仅 url.hash;非选区哈希 = 锚点 ⇒ 整文)
66
+ let sel = null
67
+ if (hash) {
68
+ const m = SELECTION_RE.exec(hash)
69
+ if (m) {
70
+ const a = Number(m[1])
71
+ const b = m[2] === undefined ? a : Number(m[2])
72
+ if (a < 1 || b < 1 || b < a) return marker(path, "invalid selection")
73
+ sel = { a, b }
74
+ }
75
+ }
76
+
77
+ // ④ 读面:stat(unreadable / not a regular file / too large——文件级先于读取)→ 读取 → NUL
78
+ let st
79
+ try { st = await stat(path) } catch { return marker(path, "unreadable") }
80
+ if (!st.isFile()) return marker(path, "not a regular file")
81
+ if (st.size > MAX_INLINE_BYTES) return marker(path, "too large (>10MB)")
82
+ let buf
83
+ try { buf = await readFile(path) } catch { return marker(path, "unreadable") }
84
+ if (buf.includes(0)) return marker(path, "binary (NUL byte)")
85
+ const content = buf.toString("utf8")
86
+
87
+ // ⑤ 选区内容检查:a > 总行数 ⇒ lines out of range;b 超尾 ⇒ 截到总行数。
88
+ // 行模型 = `\n` 分界;文末换行符 = 终止符(不计行)——编辑器行号语义。
89
+ const lines = content.split("\n")
90
+ if (content.endsWith("\n")) lines.pop()
91
+ let slice = content
92
+ let lineCount = lines.length
93
+ let header = `[File: ${path}]`
94
+ if (sel) {
95
+ if (sel.a > lines.length) return marker(path, "lines out of range")
96
+ const b = Math.min(sel.b, lines.length)
97
+ slice = lines.slice(sel.a - 1, b).join("\n")
98
+ lineCount = b - sel.a + 1
99
+ header = `[File: ${path} lines ${sel.a}–${b}]`
100
+ }
101
+
102
+ // ⑥ 载荷级上限(选区时作用于选区切片,整文时作用于全篇)
103
+ if (lineCount > MAX_INLINE_LINES) return marker(path, "too many lines (>2000)")
104
+ if (slice.length > MAX_INLINE_CHARS) return marker(path, "too long (>100000 chars)")
105
+
106
+ // ⑦ 成功形
107
+ return fenced(header, slice)
108
+ }
109
+
110
+ /** 聚合(设计档 §2.5 目标形):文本段 = 首个 `type:"text"` 且 `text` 为字符串的块(首块策略;
111
+ * 「首块」判据 = 首个**合法** text 块——畸形首块不再吞掉后续合法块);资源段按数组序逐
112
+ * `resource_link` 块解析;段间 `\n\n` 连接;文本段在前、资源段随后(保序)。
113
+ * 空结果 ⇒ 调用方走既有 `-32602` 通道。 */
114
+ export async function buildPromptText(blocks, { cwd } = {}) {
115
+ const list = Array.isArray(blocks) ? blocks : []
116
+ const segments = []
117
+ const text = list.find((b) => b?.type === "text" && typeof b?.text === "string")?.text
118
+ if (text) segments.push(text)
119
+ for (const b of list) {
120
+ if (b?.type !== "resource_link") continue
121
+ segments.push(await resolveResourceLink(b, { cwd }))
122
+ }
123
+ return segments.join("\n\n")
124
+ }
@@ -19,7 +19,7 @@ import { buildAcpCallbacks } from "./bridge.mjs"
19
19
 
20
20
  export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent, save = saveSession, clientCaps = {} }) {
21
21
  let controller = new AbortController()
22
- const callbacks = buildAcpCallbacks({ sessionId: id, notify, request, log, clientCaps })
22
+ const callbacks = buildAcpCallbacks({ sessionId: id, agent, notify, request, log, clientCaps })
23
23
  let queue = Promise.resolve()
24
24
  let busy = false
25
25
 
package/src/acp.mjs CHANGED
@@ -3,15 +3,16 @@
3
3
  * Agent Client Protocol (schema v1) on stdio, so ACP clients (Zed, JetBrains
4
4
  * AI Chat, Paseo) can drive sessions directly.
5
5
  *
6
- * Handler bodies live in four modules (division + criteria = docs/cli/design/ACP-CLIENT.md
6
+ * Handler bodies live in five modules (division + criteria = docs/cli/design/ACP-CLIENT.md
7
7
  * §3.5 — the former 368-line single-function `buildAcpHandlers` split by method family):
8
8
  * acp/client-caps.mjs initialize / authenticate(版本协商 · 能力快照 · authMethods 构造)
9
9
  * acp/handlers-session.mjs session/{new,prompt,cancel,close,set_mode,set_config_option} + 门助手
10
10
  * acp/handlers-slots.mjs session/{list,load,resume,delete}(持久化槽族)
11
11
  * acp/ext.mjs checkpoint/* · memory/*
12
+ * acp/read-data.mjs ledger/list · ledger/count · batch/list + ledger/changed · batch/changed(只读数据接口)
12
13
  * acp/login.mjs `--login` terminal auth flow (non-stdio)
13
14
  * This file keeps constants, the injectable defaults and the assembly — the seam
14
- * `buildAcpHandlers(deps)` (signature + `{ handlers, sessions, notifyRef, requestRef }`)
15
+ * `buildAcpHandlers(deps)` (signature + `{ handlers, sessions, notifyRef, requestRef, watcher }`)
15
16
  * is the only external contract (both existing ACP test files drive it directly).
16
17
  *
17
18
  * #41(2026-09-18):`session/load` / `session/resume` 两路工程模式会话改「先判后装载」
@@ -29,9 +30,10 @@ import { assembleAgent } from "./cli/make-agent.mjs"
29
30
  import { createAcpServer } from "./acp/transport.mjs"
30
31
  import { createAcpSession } from "./acp/session.mjs"
31
32
  import { createCapsHandlers } from "./acp/client-caps.mjs"
32
- import { createRequireConfigured, createSessionHandlers } from "./acp/handlers-session.mjs"
33
+ import { createRequireConfigured, createSessionHandlers, createSlotReleaser } from "./acp/handlers-session.mjs"
33
34
  import { createSlotsHandlers } from "./acp/handlers-slots.mjs"
34
35
  import { createExtHandlers } from "./acp/ext.mjs"
36
+ import { createReadDataHandlers } from "./acp/read-data.mjs"
35
37
 
36
38
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
37
39
 
@@ -96,33 +98,39 @@ export function buildAcpHandlers({
96
98
  const notifyRef = { current: notify }
97
99
  const requestRef = { current: async () => { throw new Error("no request channel") } }
98
100
  const sessions = new Map()
99
- let nextId = 1
100
101
  /** §3.4 客户端能力快照:`initialize` 单次写入,之后只读(快照丢失 ⇒ 全取 false ⇒ 本地兜底)。 */
101
102
  const clientCaps = { current: {} }
102
- /** 共享态经单一 `ctx` 传入(§3.5)——id 分配器与槽族共享,命名空间不因拆分而裂。 */
103
+ /** 共享态经单一 `ctx` 传入(§3.5)——会话 id = 持久槽位号串(load/new 同命名空间——G5 收正);
104
+ * `releaseClosedSlot` = 认领释放单点(close ∥ load/resume 替换 三处同源消费)。 */
103
105
  const ctx = {
104
106
  getCwd: cwd,
105
107
  sessions,
106
- allocSessionId: () => String(nextId++),
107
108
  notifyRef,
108
109
  requestRef,
109
110
  // 能力位按「session/new 时刻取值」注入每个会话(§11.4——会话生命周期内不变)
110
111
  createSession: (opts) => createSession({ ...opts, clientCaps: clientCaps.current }),
111
112
  // 门助手由入口建一次、按引用传(§11.5——`session/new` 是门链首个触点)
112
113
  requireConfigured: createRequireConfigured({ isConfigured, providerStatus }),
114
+ // 认领释放单点由入口建一次、按引用传(§2.4——close ∥ load 替换 ∥ resume 替换同源)
115
+ releaseClosedSlot: createSlotReleaser({ getCwd: cwd, sessions }),
113
116
  log,
114
117
  }
115
118
 
119
+ // 只读数据接口族(read-data-interface 批):三方法 + 变更观察器(`runAcpServer` 启动)。
120
+ const readData = createReadDataHandlers(ctx)
121
+
116
122
  return {
117
123
  handlers: {
118
124
  ...createCapsHandlers({ version, clientCaps, requireConfigured: ctx.requireConfigured, log }),
119
125
  ...createSessionHandlers(ctx),
120
126
  ...createSlotsHandlers(ctx),
121
127
  ...createExtHandlers(ctx),
128
+ ...readData.handlers,
122
129
  },
123
130
  sessions,
124
131
  notifyRef,
125
132
  requestRef,
133
+ watcher: readData.watcher,
126
134
  }
127
135
  }
128
136
 
@@ -138,6 +146,7 @@ export async function runAcpServer() {
138
146
  const server = createAcpServer(built.handlers, { log })
139
147
  built.notifyRef.current = server.notify
140
148
  built.requestRef.current = server.request
149
+ built.watcher.start() // 变更观察器:首查立基线(静默)+ 5s 指纹轮询(进程终灭即止)
141
150
  log(`[acp] thincoder ${VERSION} — ACP v1 over stdio, waiting for initialize`)
142
151
  server.start()
143
152
  }
@@ -30,14 +30,23 @@ export async function chatCommand(args, ctx) {
30
30
  if (crashNotice) console.error(crashNotice)
31
31
 
32
32
  const agent = await assembleAgent()
33
- // SESSION.md §6.8 D-S4(F4)+ MODEL-MERGE-SESSION:headless 无 TUI —— 可读错误 + 退出码 1,
34
- // 不弹 UI、不崩溃;文案改引 defaultModel(F-6 引导 A headless 面——/config → 默认模型)
35
- if (agent._providerInvalid) {
36
- const dm = agent.config?.defaultModel
37
- console.error(`[error] 未配置有效模型(defaultModel "${dm ?? "(not set)"}":${agent._providerInvalidReason})。请运行 thincoder 进入 TUI 设置(/config → 默认模型;或 /model 选择后以会话槽生效),或编辑 ${configPath}`)
33
+ // SESSION.md §6.8 D-S4(#841 收正):headless 无 TUI——按**核统一态分流**(判据单源 = PROVIDER.md §6.22,
34
+ // 与 TUI 面同式):**invalid 类**(`state === "invalid"` ∨ `providerInvalidReason` 非空——无渠/key
35
+ // ⇒ 不可运行)⇒ 可读错误 + 退出码 1(不弹 UI、不崩溃);**fallback**(无有效 defaultModel 但可运行)
36
+ // ⇒ stderr 一行明示 + 继续运行(不退出——D-S2 同态同解)。条目结构不全不在本门(负向锁 =
37
+ // PROVIDER.md:437)——归运行期失败径(stderr + 非 0 退出)。
38
+ const headlessCfg = agent.config ?? {}
39
+ const invalidClass = headlessCfg.providerState === "invalid"
40
+ || (typeof headlessCfg.providerInvalidReason === "string" && headlessCfg.providerInvalidReason !== "")
41
+ if (invalidClass) {
42
+ console.error(`[error] 未配置有效渠道(${agent._providerInvalidReason ?? headlessCfg.providerStateReason ?? "provider unavailable"})。请运行 thincoder 进入 TUI 设置(/config 设置渠道与密钥;或 /model 选择后以会话槽生效),或编辑 ${configPath}`)
38
43
  exitSoon(1)
39
44
  return
40
45
  }
46
+ if (headlessCfg.providerState === "fallback" && agent.provider?.name) {
47
+ const m = typeof agent.provider.model === "string" && agent.provider.model ? `:${agent.provider.model}` : ""
48
+ console.error(`尚未设置默认模型:本次使用 \`${agent.provider.name}${m}\`——/config → 默认模型 设置一次(或 /model 选定即成为默认模型)`)
49
+ }
41
50
  if (!agent.provider.apiKey) {
42
51
  if (!process.stdin.isTTY) {
43
52
  console.error(noKeyMessage())
@@ -121,9 +130,10 @@ export async function tuiCommand(ctx) {
121
130
  // F-MI7:resumeSlot = async(探测束不阻塞事件循环)——此处必须 await
122
131
  const { slot, data } = await resumeSlot(process.cwd())
123
132
  const agent = await assembleAgent({ slotData: data })
124
- // SESSION.md §6.8 D-S1:TUI 路径在 startTUI 前清空无效 provider——空 provider 不流入 runAgent
125
- // (崩溃源:chat() 缺 model → 网关 400 或 fetch("undefined/...") TypeError)
126
- if (agent._providerInvalid) agent.provider = null
133
+ // SESSION.md §6.8 D-S1(#841 收正):TUI 路径**仅在 `state === "invalid"`**(无渠/key)于 startTUI
134
+ // 前置 `agent.provider = null`——空 provider 不流入 runAgent(崩溃源:chat() 缺 model → 网关 400
135
+ // 或 fetch("undefined/...") TypeError);`fallback` 态 provider 有效——不清(可运行 + 明示)。
136
+ if (agent.config?.providerState === "invalid") agent.provider = null
127
137
  const config = loadConfig()
128
138
  if (data) {
129
139
  // applySession 内部已按槽复合重算 compactThreshold(auto 时)——不再需要 switched 分支
@@ -159,7 +159,14 @@ export async function runCommandTable(command, args, ctx) {
159
159
  process.exitCode = await (args[0] === "migrate" ? m.runLedgerMigrate(args) : m.runLedgerAudit(args))
160
160
  break
161
161
  }
162
- console.error("Usage: thincoder ledger migrate --dry-run | --confirm [--from <key>] | thincoder ledger audit [--root <dir>]")
162
+ // 只读数据接口(read-data-interface 批):`ledger list --json [--full] [--family] [--cwd <dir>]`——
163
+ // 核内 runner(ledger-read.mjs)严格解析(缺 --json / 未知参 ⇒ usage + exit 1);壳侧只分发。
164
+ if (args[0] === "list") {
165
+ const m = await import("@thincoder/core/ledger-read.mjs")
166
+ process.exitCode = m.runLedgerList(args.slice(1))
167
+ break
168
+ }
169
+ console.error("Usage: thincoder ledger migrate --dry-run | --confirm [--from <key>] | thincoder ledger audit [--root <dir>] | thincoder ledger list --json [--full] [--family] [--cwd <dir>]")
163
170
  exitSoon(1)
164
171
  break
165
172
  }
@@ -24,9 +24,10 @@ export function printCompletion(shell) {
24
24
  sweep) COMPREPLY=( $(compgen -W "--origin= --dry-run --confirm" -- "$cur") ) ;;
25
25
  esac ;;
26
26
  ledger) case "$prev" in
27
- ledger) COMPREPLY=( $(compgen -W "migrate audit" -- "$cur") ) ;;
27
+ ledger) COMPREPLY=( $(compgen -W "migrate audit list" -- "$cur") ) ;;
28
28
  migrate) COMPREPLY=( $(compgen -W "--dry-run --confirm --from" -- "$cur") ) ;;
29
29
  audit) COMPREPLY=( $(compgen -W "--root" -- "$cur") ) ;;
30
+ list) COMPREPLY=( $(compgen -W "--json --full --family --cwd" -- "$cur") ) ;;
30
31
  esac ;;
31
32
  distill) COMPREPLY=( $(compgen -W "--yes --layer=" -- "$cur") ) ;;
32
33
  completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
@@ -59,7 +60,7 @@ _thincoder() {
59
60
  'upgrade[Update to latest version from npm]' \\
60
61
  'completion[Generate shell completion script]' \\
61
62
  'session[Session dir GC: session gc --dry-run|--confirm]' \\
62
- 'ledger[Ledger variants: migrate / audit]'
63
+ 'ledger[Ledger variants: migrate / audit / list]'
63
64
  ;;
64
65
  args)
65
66
  case "$words[1]" in
@@ -72,6 +73,7 @@ _thincoder() {
72
73
  esac ;;
73
74
  ledger) case "$words[2]" in
74
75
  migrate) _arguments '--dry-run[Report only]' '--confirm[Apply migration]' '--from:Source key:' ;;
76
+ list) _arguments '--json[Single-segment JSON output]' '--full[Include evidence]' '--family[Family scope]' '--cwd:Project anchor:' ;;
75
77
  audit) _arguments '--root:Scan root dir:' ;; esac ;;
76
78
  distill) _arguments '--yes[Skip confirmation]' '--layer=[Layer filter]' ;;
77
79
  completion) _values 'shell' 'bash' 'zsh' 'fish' ;;
@@ -94,7 +96,7 @@ complete -c thincoder -a distill -d 'Extract knowledge from session'
94
96
  complete -c thincoder -a upgrade -d 'Update to latest version'
95
97
  complete -c thincoder -a completion -d 'Shell completion'
96
98
  complete -c thincoder -a session -d 'Session dir GC (session gc --dry-run|--confirm)'
97
- complete -c thincoder -a ledger -d 'Ledger variants: migrate / audit'
99
+ complete -c thincoder -a ledger -d 'Ledger variants: migrate / audit / list'
98
100
  complete -c thincoder -a acp -d 'Agent Client Protocol server for IDEs'
99
101
 
100
102
  # Flags
@@ -133,8 +135,9 @@ complete -c thincoder -n '__fish_seen_subcommand_from distill' -l layer -d 'Laye
133
135
  complete -c thincoder -n '__fish_seen_subcommand_from completion' -a bash -d 'Bash completions'
134
136
  complete -c thincoder -n '__fish_seen_subcommand_from completion' -a zsh -d 'Zsh completions'
135
137
  complete -c thincoder -n '__fish_seen_subcommand_from completion' -a fish -d 'Fish completions'
136
- complete -c thincoder -n '__fish_seen_subcommand_from ledger' -a 'migrate audit' -d 'Ledger variants: migrate / audit'
138
+ complete -c thincoder -n '__fish_seen_subcommand_from ledger' -a 'migrate audit list' -d 'Ledger variants: migrate / audit / list'
137
139
  complete -c thincoder -n '__fish_seen_subcommand_from ledger' -l dry-run -l confirm -l from -l root -d 'Ledger flags'
140
+ complete -c thincoder -n '__fish_seen_subcommand_from ledger; and __fish_seen_subcommand_from list' -l json -l full -l family -l cwd -d 'Ledger list flags'
138
141
  `)
139
142
  }
140
143
  }
@@ -5,7 +5,8 @@ import { parseModelRef } from "@thincoder/core/config.mjs"
5
5
  * `/model <provider>:<model>`(MODEL-MERGE-SESSION——裁定③:裸 provider 拒——显式 p:m;
6
6
  * MODEL-SELECTION v2:显式复合一律放行——仅[空值/裸值/未知 provider]无效(M4),候选清单
7
7
  * 由运行期拉取决定——不再有成员校验)。
8
- * /model 是会话级操作(写槽——不写 config)——config 默认模型走 /config → 默认模型。
8
+ * /model 是会话级操作(写槽)——选定实变即同拍写回 config.defaultModel(判据句 6——新会话
9
+ * 起点随动)。config 默认模型显式设置走 /config → 默认模型。
9
10
  * ctx: { agent, openModelPicker, selectModel, pushLine } */
10
11
  export async function handleModelCommand(ctx, args = []) {
11
12
  const raw = args[0]?.trim()
@@ -18,7 +19,7 @@ export async function handleModelCommand(ctx, args = []) {
18
19
  const parsed = parseModelRef(raw, ctx.agent.providers)
19
20
  if (!parsed.ok) {
20
21
  ctx.pushLine(`[error] ${parsed.reason}`, C.error)
21
- ctx.pushLine(`/model 用法: /model provider:model(会话级——config 默认走 /config → 默认模型)`, C.dim)
22
+ ctx.pushLine(`/model 用法: /model provider:model(会话级——选定即成为默认模型)`, C.dim)
22
23
  return
23
24
  }
24
25
  await ctx.selectModel({ provider: parsed.provider.name, model: parsed.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
@@ -1,9 +1,19 @@
1
+ import { homedir } from "node:os"
2
+ import { resolve } from "node:path"
1
3
  import { C } from "./ansi.mjs"
2
4
 
5
+ /** home 根防护(#867 · `MEMORY.md` §6.14 L-④ · 同 startup.mjs):`cwd` = 用户主目录(resolve 后;win32 大小写不敏感)。 */
6
+ const isHomeDir = (dir) => typeof dir === "string" && dir !== "" &&
7
+ (process.platform === "win32" ? resolve(dir).toLowerCase() === resolve(homedir()).toLowerCase() : resolve(dir) === resolve(homedir()))
8
+
3
9
  /** /reindex command: rebuild memory index (files + code_chunks + doc_chunks).
4
10
  * ctx: { agent, distillOpts, pushLine } */
5
11
  export async function handleReindexCommand(ctx) {
6
12
  const { agent, distillOpts, pushLine } = ctx
13
+ if (isHomeDir(agent.cwd)) { // #867:home 根 ⇒ 跳过(零表删 ∥ 三 sync 零调用;含出路提示)
14
+ pushLine("[reindex] Skipped: working directory is the home directory — start in a project dir", C.warn)
15
+ return
16
+ }
7
17
  const { syncDir, codeSync, docSync } = await import("@thincoder/core/memory.mjs")
8
18
  pushLine("[reindex] Rebuilding index...", C.tool)
9
19
  agent.memory.db.prepare("DELETE FROM files").run()
@@ -1,46 +1,17 @@
1
1
  /**
2
2
  * cmd-undo.mjs — /undo command: revert recent file modifications
3
3
  *
4
- * Tracks write/edit/delete/hashline_edit/apply_patch operations in agent._undoStack.
5
- * /undo opens a picker to select and revert an operation.
4
+ * Reads the snapshot stack (`agent._undoStack`) — snapshot single point = `thincoder-core/undo-stack.mjs`
5
+ * (`snapshotForUndo`;CLI 侧旧副本已删 —— 单源 = 核档 · CORE-UNIFICATION §2.5 #149)。
6
+ * /undo opens a picker to select and revert an operation; **oversize 占位**(无快照 —— 备份超
7
+ * `MAX_UNDO_BYTES`)**列表可见 · 不可回退**(#863 · `docs/cli/design/TUI-COMMANDS.md` §5.3)。
6
8
  */
7
9
 
8
- import { existsSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"
10
+ import { existsSync, writeFileSync, unlinkSync } from "node:fs"
9
11
  import { isAbsolute, join } from "node:path"
12
+ import { MAX_UNDO_BYTES } from "@thincoder/core/undo-stack.mjs"
10
13
  import { ansi, C } from "./ansi.mjs"
11
14
 
12
- const MAX_UNDO = 50
13
-
14
- /**
15
- * Snapshot a file before a side-effect tool modifies it.
16
- * Called from dispatch.mjs before each write/edit/delete/apply_patch/hashline_edit.
17
- */
18
- export function snapshotForUndo(agent, toolName, args, cwd) {
19
- if (!agent._undoStack) agent._undoStack = []
20
- const path = args.path ?? args.file
21
- if (!path || typeof path !== "string") return
22
-
23
- // 绝对路径直接用(path.join 对绝对段不重置——Windows 反斜杠路径也不按 "/" 切分);相对路径整段 join(cwd)。
24
- const abs = isAbsolute(path) ? path : join(cwd, path)
25
- let backup = null
26
- try {
27
- if (existsSync(abs)) {
28
- backup = readFileSync(abs, "utf8")
29
- }
30
- } catch {
31
- // can't read — maybe binary, skip
32
- return
33
- }
34
-
35
- agent._undoStack.push({
36
- tool: toolName,
37
- path,
38
- backup,
39
- timestamp: Date.now(),
40
- })
41
- if (agent._undoStack.length > MAX_UNDO) agent._undoStack.shift()
42
- }
43
-
44
15
  export async function handleUndoCommand(ctx) {
45
16
  const { agent, pushLine, showPicker } = ctx
46
17
  const stack = agent._undoStack ?? []
@@ -55,9 +26,11 @@ export async function handleUndoCommand(ctx) {
55
26
  ...stack.map((item, i) => {
56
27
  const relIdx = stack.length - i
57
28
  const time = new Date(item.timestamp).toLocaleTimeString()
58
- const preview = item.backup === null
59
- ? "(was created — undo will delete)"
60
- : `(${item.backup.split("\n").length} lines — undo will restore)`
29
+ const preview = item.oversize === true
30
+ ? "(no snapshot — too large to undo)"
31
+ : item.backup === null
32
+ ? "(was created — undo will delete)"
33
+ : `(${item.backup.split("\n").length} lines — undo will restore)`
61
34
  return {
62
35
  type: "item",
63
36
  text: `#${relIdx} ${item.tool}: ${item.path} ${preview} — ${time}`,
@@ -69,6 +42,11 @@ export async function handleUndoCommand(ctx) {
69
42
  const e = await showPicker("Undo", entries)
70
43
  if (!e) return
71
44
  const item = stack[e.idx]
45
+ if (item.oversize === true) {
46
+ // #863:oversize 占位(无快照)——须先判 `oversize` 再判 `backup === null`,否则被当「文件创建」态误删档。
47
+ pushLine(`[undo] Cannot revert ${item.tool} ${item.path} — no snapshot (file larger than ${MAX_UNDO_BYTES / 1_000_000} MB)`, C.warn)
48
+ return
49
+ }
72
50
  const abs = isAbsolute(item.path) ? item.path : join(agent.cwd, item.path)
73
51
 
74
52
  try {
@@ -1,6 +1,7 @@
1
1
  /** persistRaw / syncProviderField / maskKey: config read/write helpers.
2
2
  * Extracted from index.mjs for shared use by slash-commands, wizard, and pickers.
3
- * createConfigHelpers(agent) returns { persistRaw, syncProviderField, maskKey }
3
+ * createConfigHelpers(agent) returns { persistRaw, syncProviderField, maskKey };模块级另导出
4
+ * carryoverDefaultModel(会话选定写回单点——SESSION.md §6.21 判据句 6;见下方定义)。
4
5
  * R10 F5(D-F5b):persistRaw 全链(磁盘新鲜读→mutate→写)收口进 config.mjs
5
6
  * writeConfigAtomic——mtime 门控 + .bak + 冲突放弃(见 MULTI-INSTANCE-COLLAB.md §2a.2)。
6
7
  * createConfigHelpers(agent, opts):opts.configPath 为测试注入缝(默认 configPath——
@@ -44,3 +45,35 @@ export function createConfigHelpers(agent, opts = {}) {
44
45
 
45
46
  return { persistRaw, syncProviderField, maskKey }
46
47
  }
48
+
49
+ /** 会话选定写回单点(SESSION.md §6.21 判据句 6——用户显式选定 ⇒ 同拍写回 config.defaultModel
50
+ * = "<provider>:<model>";新会话起点随用户最后一次显式选择)。门内聚于本单点(KD-883-1——
51
+ * call-site 零判据、零复合串):
52
+ * ① 槽面实变门:slotBefore 复合串 = 选定复合 ⇒ 零写(回声 ∕ 重选——系统同步不劫持全局默认);
53
+ * ② 等值门:现值 `loadConfig().defaultModel` 同串 ⇒ 零写(防盘面抖动 ∥ 探针空转)。
54
+ * 写经核 `writeConfigAtomic` + `_configPath()`(mtime 门控 + `.bak` 现场);失败不反扑会话写、
55
+ * 绝不抛出——畸形档 ∥ 写错误 ⇒ `{ ok:false, reason }`。返回 `{ ok:true, written:boolean }`。 */
56
+ export async function carryoverDefaultModel({ provider, model, slotBefore = null } = {}) {
57
+ const composite = compositeOf(provider, model)
58
+ if (composite === null) return { ok: false, reason: "invalid model reference — expected provider:model" }
59
+ try {
60
+ const { loadConfig } = await import("@thincoder/core/config.mjs")
61
+ const { writeConfigAtomic, _configPath } = await import("@thincoder/core/config-io.mjs")
62
+ const before = compositeOf(slotBefore?.activeProvider, slotBefore?.activeModel)
63
+ if (before === composite) return { ok: true, written: false } // ① 槽面实变门(档缺 ⇒ null ⇒ 判真)
64
+ if (loadConfig().defaultModel === composite) return { ok: true, written: false } // ② 等值门
65
+ const r = writeConfigAtomic(_configPath(), (raw) => { raw.defaultModel = composite })
66
+ if (!r.ok) return { ok: false, reason: r.reason }
67
+ return { ok: true, written: true }
68
+ } catch (error) {
69
+ return { ok: false, reason: error?.message ?? String(error) }
70
+ }
71
+ }
72
+
73
+ /** 复合串(比较单元构造单源——首冒号分割语义 = parseModelRef 同族):双段非空 ⇒
74
+ * "provider:model";档缺 ∕ 半缺 ⇒ null(无基线——不参与相等判定)。 */
75
+ function compositeOf(provider, model) {
76
+ const p = typeof provider === "string" && provider ? provider : null
77
+ const m = typeof model === "string" && model ? model : null
78
+ return p && m ? `${p}:${m}` : null
79
+ }