thincoder 0.12.69 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +11 -3
- package/bin/node-version-gate.cjs +28 -0
- package/bin/thincoder.cjs +2 -0
- package/bin/thincoder.mjs +2 -0
- package/package.json +2 -2
- package/src/acp/bridge.mjs +50 -12
- package/src/acp/handlers-session.mjs +95 -44
- package/src/acp/handlers-slots.mjs +41 -14
- package/src/acp/read-data.mjs +127 -0
- package/src/acp/resource-link.mjs +124 -0
- package/src/acp/session.mjs +1 -1
- package/src/acp.mjs +15 -6
- package/src/command-interactive.mjs +1 -1
- package/src/command-table.mjs +8 -1
- package/src/completions.mjs +7 -4
- package/src/tui/cmd-model.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +10 -0
- package/src/tui/cmd-undo.mjs +16 -38
- package/src/tui/config-helpers.mjs +34 -1
- package/src/tui/index.mjs +5 -5
- package/src/tui/ledger-surface.mjs +5 -7
- package/src/tui/model-picker.mjs +15 -6
- package/src/tui/startup.mjs +12 -2
- package/src/tui/wizard.mjs +1 -1
|
@@ -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
|
+
}
|
package/src/acp/session.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
|
@@ -45,7 +45,7 @@ export async function chatCommand(args, ctx) {
|
|
|
45
45
|
}
|
|
46
46
|
if (headlessCfg.providerState === "fallback" && agent.provider?.name) {
|
|
47
47
|
const m = typeof agent.provider.model === "string" && agent.provider.model ? `:${agent.provider.model}` : ""
|
|
48
|
-
console.error(`尚未设置默认模型:本次使用 \`${agent.provider.name}${m}\`——/config → 默认模型
|
|
48
|
+
console.error(`尚未设置默认模型:本次使用 \`${agent.provider.name}${m}\`——/config → 默认模型 设置一次(或 /model 选定即成为默认模型)`)
|
|
49
49
|
}
|
|
50
50
|
if (!agent.provider.apiKey) {
|
|
51
51
|
if (!process.stdin.isTTY) {
|
package/src/command-table.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/completions.mjs
CHANGED
|
@@ -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
|
}
|
package/src/tui/cmd-model.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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))
|
package/src/tui/cmd-reindex.mjs
CHANGED
|
@@ -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()
|
package/src/tui/cmd-undo.mjs
CHANGED
|
@@ -1,46 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* cmd-undo.mjs — /undo command: revert recent file modifications
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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.
|
|
59
|
-
? "(
|
|
60
|
-
:
|
|
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
|
+
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export async function promptProviderIfInvalid(agent, openModelPicker, pushLine)
|
|
|
68
68
|
await openModelPicker()
|
|
69
69
|
if (!agent.provider) {
|
|
70
70
|
// D-S2 取消提示(#841 措辞收正 = 渠道 ∕ 密钥——配置入口 /config)
|
|
71
|
-
pushLine("未配置有效渠道(渠道 ∥ 密钥缺位):/config 设置渠道与密钥;/model
|
|
71
|
+
pushLine("未配置有效渠道(渠道 ∥ 密钥缺位):/config 设置渠道与密钥;/model 选定后本会话生效并成为默认模型", C.warn)
|
|
72
72
|
}
|
|
73
73
|
return true
|
|
74
74
|
}
|
|
@@ -76,7 +76,7 @@ export async function promptProviderIfInvalid(agent, openModelPicker, pushLine)
|
|
|
76
76
|
const name = agent.provider?.name
|
|
77
77
|
if (name) {
|
|
78
78
|
const model = typeof agent.provider.model === "string" && agent.provider.model ? agent.provider.model : null
|
|
79
|
-
pushLine(`尚未设置默认模型:本次使用 \`${name}${model ? `:${model}` : ""}\`——/config → 默认模型
|
|
79
|
+
pushLine(`尚未设置默认模型:本次使用 \`${name}${model ? `:${model}` : ""}\`——/config → 默认模型 设置一次(或 /model 选定即成为默认模型)`, C.warn)
|
|
80
80
|
}
|
|
81
81
|
return false
|
|
82
82
|
}
|
|
@@ -242,12 +242,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
242
242
|
await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
|
|
243
243
|
|
|
244
244
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
245
|
-
// LEDGER-SURFACE
|
|
246
|
-
const ledgerSurface = startLedgerSurface({ state, agent,
|
|
245
|
+
// LEDGER-SURFACE:台账可见面——首扫(setImmediate)+ 周期;dispose 挂进程退出(同 cleanup 先例)
|
|
246
|
+
const ledgerSurface = startLedgerSurface({ state, agent, render })
|
|
247
247
|
// 端壳契约 = **恒同步返回 `{ dispose }`**(K-LX3 归核后与核形一致——核句柄异步就绪后桥接;
|
|
248
248
|
// 载入失败 ⇒ 空句柄)⇒ 退钩同步 `.dispose()`(零 TypeError)。
|
|
249
249
|
process.on("exit", () => ledgerSurface.dispose())
|
|
250
|
-
backgroundIndex({ agent, state, render })
|
|
250
|
+
backgroundIndex({ agent, state, render, pushLine })
|
|
251
251
|
|
|
252
252
|
// Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
|
|
253
253
|
// (D-S1c):有 picker 打开时不硬抢——挂到 state.pendingNotice,picker 全部关闭后由
|