@thincoder/core 0.10.2 → 0.10.3

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 +33 -0
  2. package/advisor/citations.mjs +13 -3
  3. package/advisor/convergence.mjs +1 -1
  4. package/advisor/messages.mjs +3 -3
  5. package/agent/dispatch-run.mjs +29 -12
  6. package/agent/record-results.mjs +6 -1
  7. package/agent/spawn-child.mjs +2 -1
  8. package/agent/subagent-event.mjs +71 -0
  9. package/agent/suspension.mjs +35 -9
  10. package/agent/turn-loop.mjs +6 -0
  11. package/agent-tools/advisor-async.mjs +11 -1
  12. package/agent-tools/advisor-settle.mjs +7 -9
  13. package/agent-tools/batch-lifecycle.mjs +3 -2
  14. package/agent-tools/batch-read.mjs +36 -0
  15. package/agent-tools/batch-skeleton.mjs +28 -0
  16. package/agent-tools/batch.mjs +5 -3
  17. package/agent-tools/design-token.mjs +56 -16
  18. package/agent-tools/subagent-async.mjs +6 -1
  19. package/agent-tools/subagent-panel.mjs +119 -36
  20. package/agent-tools/subagent.mjs +4 -2
  21. package/agent-tools/verify.mjs +5 -0
  22. package/auto-think.mjs +26 -2
  23. package/config-presets.mjs +8 -0
  24. package/config.mjs +30 -0
  25. package/declaration.mjs +26 -2
  26. package/embedding.mjs +35 -3
  27. package/ledger-cmd.mjs +13 -21
  28. package/ledger-db.mjs +34 -1
  29. package/ledger-migrate.mjs +4 -3
  30. package/ledger-read.mjs +94 -0
  31. package/ledger-surface.mjs +15 -46
  32. package/ledger.mjs +36 -62
  33. package/manifest-schema.mjs +13 -3
  34. package/manifest.mjs +27 -8
  35. package/mcp/transport-http.mjs +52 -4
  36. package/mcp/transport-stdio.mjs +33 -8
  37. package/mcp.mjs +13 -5
  38. package/memory/code-search.mjs +5 -3
  39. package/memory/core.mjs +6 -4
  40. package/memory/docs.mjs +5 -3
  41. package/memory/schema.mjs +2 -0
  42. package/package.json +1 -1
  43. package/prompts/advisor-design.md +3 -2
  44. package/prompts/advisor-round1.md +1 -0
  45. package/prompts/advisor-round2.md +8 -3
  46. package/prompts/advisor-round3.md +7 -2
  47. package/prompts/discipline-normal.md +2 -1
  48. package/prompts/persona-engineering.md +3 -1
  49. package/provider/core.mjs +10 -4
  50. package/provider/google.mjs +18 -2
  51. package/provider/normalize.mjs +57 -0
  52. package/provider/sse.mjs +45 -13
  53. package/proxy.mjs +13 -3
  54. package/session.mjs +13 -2
  55. package/stream-destroy.mjs +62 -0
  56. package/tool-docs/verify.md +1 -1
  57. package/tools/edit-batch.mjs +7 -2
  58. package/undo-stack.mjs +23 -9
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * 沿革:初由 agent-tools/advisor.mjs 迁至 advisor-async.mjs(sync wrapper 与 async
7
7
  * settle 共享一套实现——无 wrapper↔runner 环),再由 advisor-async.mjs 迁入本文件。
8
- * 既有 import 面不变:advisor-async.mjs re-export 全部 7 个导出(advisor.mjs 与测试
9
- * 的 import 路径原样保留——advisor.mjs 再转发 validateDesignToken 给 spawn 门禁)。
8
+ * 既有 import 面不变:advisor-async.mjs re-export 原 7 个导出(advisor.mjs 与测试
9
+ * 的 import 路径原样保留——advisor.mjs 再转发 validateDesignToken 给 spawn 门禁);
10
+ * 本批 §5.1 新助手(stripDesignTokenEcho / makeDesignTokenPrefixRegex)为新增直连面
11
+ * (设计档 ENG-TOKEN-BINDING.md §5.1——不经 advisor-async re-export)。
10
12
  */
11
13
 
12
14
  import { randomUUID } from "node:crypto"
@@ -64,15 +66,46 @@ export function makeDesignTokenRegex(token, flags = "") {
64
66
  )
65
67
  }
66
68
 
69
+ /** Build a [DESIGN-TOKEN:...] regex matching the token's uuid PREFIX form — the
70
+ * truncated echo (tail missing/inexact, §5.1③; same boundary discipline as the full regex). */
71
+ export function makeDesignTokenPrefixRegex(token, flags = "") {
72
+ const uuid = String(token).split(":")[0]
73
+ const escaped = uuid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
74
+ return new RegExp(
75
+ `(?:^|\\s|\`|\\*)\\[DESIGN-TOKEN:\\s*${escaped}[^\\]\\s]*\\s*\\](?:\\s|$|\`|\\*)`,
76
+ flags + "ms"
77
+ )
78
+ }
79
+
80
+ /** §5.1③ stripping single source: strip every echo form of THIS token — the full
81
+ * [DESIGN-TOKEN:uuid:expiresAt] form, the truncated prefix form, and the bare
82
+ * uuid value (token-unique random ⇒ zero collateral; empty uuid → bare pass
83
+ * skipped — defensive). Other tokens / other uuids pass through untouched. */
84
+ export function stripDesignTokenEcho(text, designToken) {
85
+ if (typeof text !== "string" || !designToken) return text
86
+ const uuid = String(designToken).split(":")[0]
87
+ let out = text.replace(makeDesignTokenRegex(designToken, "g"), "")
88
+ out = out.replace(makeDesignTokenPrefixRegex(designToken, "g"), "")
89
+ if (uuid) out = out.split(uuid).join("")
90
+ return out
91
+ }
92
+
93
+ /** §5.1② constant settlement markers (verbatim — machine-greppable; M1 is
94
+ * emitted even when the review body is empty). */
95
+ const NO_VALID_ECHO_MARK = "未回显有效 token——未签发 (no valid token echo — no design token was issued)"
96
+ const TRUNCATED_ECHO_MARK = "注:回显被截断(uuid 之后缺失或不符)——已按批准结算;全串 token 已签发 (note: truncated echo — the part after the uuid was missing or inexact; settled as approved; the full token was issued)"
97
+
67
98
  /**
68
99
  * Shared design-review settlement (sync wrapper + async settle): the token echo
69
- * IS the verdict — the advisor echoes it only on approval. On echo: slot the
70
- * token under designId (+ eng-coder gate flag; single-value mirror retired per
71
- * DESIGN-TOKEN-SETTLEMENT D3) and return
72
- * the clean output with the Approved suffix; the review instance CLOSES (a
73
- * later review of the same doc-set starts a fresh full review). On non-echo:
74
- * strip every dead token occurrence and return the findings text — slots stay
75
- * untouched (方案 ②: a failed re-review revokes nothing).
100
+ * IS the verdict — the advisor echoes it only on approval. On echo (full string
101
+ * OR truncated — uuid present; §5.1①): slot the token under designId (always the
102
+ * FULL token; + eng-coder gate flag; single-value mirror retired per
103
+ * DESIGN-TOKEN-SETTLEMENT D3) and return the clean output with the Approved
104
+ * suffix (truncated echo: the M2 marker sits before the suffix); the review
105
+ * instance CLOSES (a later review of the same doc-set starts a fresh full
106
+ * review). On non-echo: strip every echo form (single source §5.1③) and return
107
+ * the findings text + the constant M1 marker — slots stay untouched
108
+ * (方案 ②: a failed re-review revokes nothing).
76
109
  * @param {Object} [opts] — 第 11 批(A/F11):`opts.incomplete` = 宿主尾族判定 kind
77
110
  * (单谓词 `advisorIncompleteMarker`——结算方透传)。非空 ⇒ **一律不签发**(无论评审文本
78
111
  * 是否回显 token):剥除全部 token 回显 + 追加未签发提示(原因 + 恢复指引),不写槽、
@@ -86,16 +119,20 @@ export function settleDesignReview(agent, run, designToken, rawResult, opts = {}
86
119
  // A(F11/ADVISOR-GUARDS.md §1 消费点 1):未完成即不签发——判据与 code 完成守卫同源(单谓词)。
87
120
  const incomplete = opts?.incomplete ?? null
88
121
  if (incomplete) {
89
- const stripped = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
122
+ const stripped = stripDesignTokenEcho(rawResult, designToken).trim()
90
123
  return {
91
124
  passed: false,
92
125
  output: `${stripped}\n\n评审未完成——token 未签发 (review incomplete — no design token issued; reason: ${incomplete})\n以更小范围重跑设计评审(逐档 / 逐节拆分,或拆到两次评审),或调大 agent.advisor.timeoutMs 后重试;补充检查未完成的部分不得按已核处理。`.trim(),
93
126
  }
94
127
  }
95
- const tokenPattern = makeDesignTokenRegex(designToken)
96
- if (!tokenPattern.test(rawResult)) {
97
- const stripped = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
98
- return { passed: false, output: stripped || "Advisor: design review did not pass." }
128
+ // §5.1① 截断容忍:全串匹配 ∨ 截断形(本 token 的 uuid 值出现——空 uuid 不构成「值」)
129
+ // ⇒ 认 pass;两者皆无 ⇒ fail + 恒定 M1(含正文为空——N7 零静默)。
130
+ const uuid = String(designToken).split(":")[0]
131
+ const fullEcho = makeDesignTokenRegex(designToken).test(rawResult)
132
+ const truncatedEcho = !fullEcho && uuid.length > 0 && rawResult.includes(uuid)
133
+ if (!fullEcho && !truncatedEcho) {
134
+ const stripped = stripDesignTokenEcho(rawResult, designToken).trim()
135
+ return { passed: false, output: [stripped, NO_VALID_ECHO_MARK].filter(Boolean).join("\n\n") }
99
136
  }
100
137
  // Echoed the token → review passed. Issue it to the parent for eng-coder.
101
138
  agent._engDesignTokens ??= new Map()
@@ -106,12 +143,15 @@ export function settleDesignReview(agent, run, designToken, rawResult, opts = {}
106
143
  // see the sync wrapper's note: unreachable today, kept for parity).
107
144
  if (agent._role === "eng-coder") agent._engDesignReviewed = true
108
145
  run.open = false // approval closes this doc-set instance — next review is fresh
109
- const clean = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
146
+ run.priorOutput = null // #863 关闭点轻量化:审批关同拍释放(closed 实例零消费面——续跑仅取 open)
147
+ const clean = stripDesignTokenEcho(rawResult, designToken).trim()
110
148
  // F2c: id echo + omission guidance + point-in-time slot snapshot — the
111
149
  // same suffix the F2e prior stores strip with (stored for the exact truncation).
112
150
  run.approvedSuffix = buildApprovedSuffix(designToken, run.designId, agent._engDesignTokens.size)
113
151
  return {
114
152
  passed: true,
115
- output: `${clean}\n\n${run.approvedSuffix}`,
153
+ // §5.1② 标记位置不变式:M2 恒居 approvedSuffix 之前、suffix 恒居文末
154
+ // (stripApprovedSuffix 的精确截断依赖此形——尾部追加改动不得破坏)。
155
+ output: [clean, truncatedEcho ? TRUNCATED_ECHO_MARK : null, run.approvedSuffix].filter(Boolean).join("\n\n"),
116
156
  }
117
157
  }
@@ -139,7 +139,12 @@ export function runningPoolCount(parent, pool) {
139
139
  * API keys come from config.json only (env vars are not a key source).
140
140
  */
141
141
  export function resolveChildProvider(parent, modelArg) {
142
- if (!modelArg) return { ...parent.provider }
142
+ if (modelArg == null || modelArg === "") return { ...parent.provider } // null ∕ undefined ∕ 空串 = 不覆盖(既有等价语义)
143
+ // #861(AGENT-LOOP-SUBAGENT.md §6.7.1 ②)运行期防御——四消费链汇点(config ∥ tool model 参数 ∥
144
+ // consult ∥ vision-reader):其余非字符串(对象 ∕ 数组 ∕ 数字 ∕ 布尔)明确报错(不裸 TypeError——可自纠)。
145
+ if (typeof modelArg !== "string") {
146
+ throw new Error(`subagent model override must be a string ("provider:model" | provider | model); got ${Array.isArray(modelArg) ? "array" : typeof modelArg}`)
147
+ }
143
148
  // "default" alias (2026-09-05 user ruling — ARCHITECTURE.md 子 agent 模型指定):
144
149
  // the literal "default", matched case-insensitively, declares "no override →
145
150
  // inherit the default model" — equivalent to null/omission (parent provider
@@ -9,6 +9,11 @@
9
9
  * computePanelBlocks(ctx.state)(ctx.state = agent._tuiState——TUI 装配处接线——单账本);
10
10
  * 未挂载(headless/VSC/子代理)→ 现算返 null → 降级/报不可用照旧
11
11
  * (T-P5 语义零变)。门控语义零动(F-4)——仅数据源从镜像换现算。
12
+ * **子代理面板批(2026-10-04 · `docs/desktop/design/PANEL-READBACK.md`)——回读源链**:数据源不再单源现算——
13
+ * `readPanelSource(ctx)` = `ctx.readout()`(桌面渲染面实况快照——上报缓存读面)优先 ⇒ `ctx.state` 现算 ⇒
14
+ * 二者皆空报不可用;视图面快照非空 ⇒ `{ source:"renderer", asOf, ageMs, panel[] }`(awaitingDigest 条读时
15
+ * 交叉核池/pending 标 `digested`——与 CLI 面同判据);freeze 门控两源同判(桌面块 `awaitingDigest`/`region` 为
16
+ * 独立字段),键统一规范化(`sub:` 前缀剥除——**有意放宽且两端同宽**;发射键 = 规范化键,relay 文法不吃 `:`)。
12
17
  * subagent-actions.mjs 尾部 re-export executePanelAction 保 subagent.mjs 既有 import 面。
13
18
  */
14
19
  import { getAsyncPool } from "./async-settle.mjs"
@@ -41,67 +46,129 @@ function consultChildUndigested(agent, key) {
41
46
  return false
42
47
  }
43
48
 
49
+ /**
50
+ * 回读源链(子代理面板批 · 2026-10-04 · `PANEL-READBACK.md` §2.1/§2.3):渲染面快照
51
+ * (`ctx.readout()`——桌面实况回读上报;`{ blocks, receivedAt }`,块形 = 判别六键 + `role`/`id` 随行)优先
52
+ * ∥ CLI 现算(`computePanelBlocks(ctx.state)`——F-3 单账本)∥ 二者皆空 ⇒ `null`(拒因照旧)。
53
+ * 返回 `{ blocks, readout }`:`readout` 非空 = 桌面快照源(`awaitingDigest` / `region` 为块独立字段)。
54
+ */
55
+ function readPanelSource(ctx) {
56
+ const snapshot = typeof ctx.readout === "function" ? ctx.readout() : null
57
+ if (snapshot && Array.isArray(snapshot.blocks)) return { blocks: snapshot.blocks, readout: snapshot }
58
+ const snap = computePanelBlocks(ctx.state)
59
+ return Array.isArray(snap) ? { blocks: snap, readout: null } : null
60
+ }
61
+
62
+ /** 键归一(**有意放宽且两端同宽**——§2.3):`sub:` 前缀剥除后比对(桌面块键 `sub:role#id` ∥ CLI 块键
63
+ * `role#id` 两写法皆接受);命中后比对与发射全用规范化键(relay 前缀文法 `^\w-+#\d+/` 不吃 `:`)。 */
64
+ function normalizePanelKey(key) {
65
+ const k = String(key ?? "")
66
+ return k.startsWith("sub:") ? k.slice("sub:".length) : k
67
+ }
68
+
69
+ /** 两源同判——等待消化:CLI 块 = 现算 status 位;桌面块 = 判别键 `awaitingDigest`。 */
70
+ function awaitingOf(src, block) {
71
+ return src.readout ? block.awaitingDigest === true : block.status === "awaitingDigest"
72
+ }
73
+
74
+ /** 活态标签(live 列表 ∥ 拒因文案共用):桌面块把 awaiting 态折进 status 位(与 CLI 现算同形)。 */
75
+ function liveStatusOf(src, block) {
76
+ return awaitingOf(src, block) ? "awaitingDigest" : String(block.status ?? "unknown")
77
+ }
78
+
79
+ /** 桌面回读条目(视图面——判别六键 + `role`/`id` 随行;awaitingDigest 条读时交叉核池/pending 标 `digested`
80
+ * —— 判据与 CLI 面同源;consult 子块按消费判据——键经归一后入判)。 */
81
+ function readoutPanelEntry(agent, block) {
82
+ const key = normalizePanelKey(block.key)
83
+ const out = {
84
+ key: block.key,
85
+ role: block.role ?? null,
86
+ id: block.id ?? null,
87
+ status: block.status ?? null,
88
+ frozen: block.frozen === true,
89
+ awaitingDigest: block.awaitingDigest === true,
90
+ region: block.region === "flow" ? "flow" : "activity",
91
+ dom: block.dom === true,
92
+ }
93
+ if (out.awaitingDigest) {
94
+ out.digested = consultChildKey(key)
95
+ ? !consultChildUndigested(agent, key)
96
+ : !blockKeyIn(agent._pendingAsyncResults, key)
97
+ && !blockKeyIn(getAsyncPool(agent, "subagent"), key) && !blockKeyIn(getAsyncPool(agent, "advisor"), key)
98
+ }
99
+ return out
100
+ }
101
+
44
102
  /**
45
103
  * AGENT-LOOP-SUBAGENT.md §6.7.2 D-P3 冻结门控(安全):仅允许冻结 awaitingDigest 且池(_asyncSubagents/
46
104
  * _asyncAdvisors——经 getAsyncPool accessor——D1)无对应运行条目 + pending 单容器
47
105
  * (_pendingAsyncResults——D2)无对应条目的块(= 已消化驻留块——报告已入模型上下文
48
106
  * ——pending 已消费——状态滞后——补发冻结不破坏任何顺序)。
107
+ * - 数据源 = **回读源链**(子代理面板批 · 2026-10-04):`ctx.readout`(桌面渲染面快照——块判别键
108
+ * `awaitingDigest`/`region` 独立字段)优先 ∥ `ctx.state` 现算;命中后比对与发射全用**规范化键**
109
+ * (`sub:` 前缀剥除——有意放宽且两端同宽,§2.3)。
49
110
  * - pending 仍有对应(报告未达模型)→ 拒绝(提前回收破坏消化顺序——T-P3)
50
111
  * - 不存在的 key / 仍 running / done 的块 → 拒绝(T-P4——running 块 settle 时自冻)
51
- * - 无面板(现算返 null)→ 拒绝(headless/VS Code——freeze 不可用——T-P5)
112
+ * - 无面板(两源皆空)→ 拒绝(headless/VS Code——freeze 不可用——T-P5)
113
+ * - 已入流(桌面快照 `region:"flow"`——已归档)→ 追加拒因(无卡可收;CLI 源无该字段 ⇒ 零改)
52
114
  * - consult 子块(#748):会话在跑 ∥ 会话条目在 pending(`childIds` 携该号)→ 拒绝(同判据——与回收 ∥ 补发同源)
53
- * 错误信息明确(模型可解释 + 自助修正)。返回 { ok:true } 或 { err }。
115
+ * 错误信息明确(模型可解释 + 自助修正)。返回 `{ ok:true, renderer }` 或 `{ err }`。
54
116
  */
55
117
  function panelFreezeGate(ctx, key) {
56
- // F-3(2026-09-10):镜像退役——经 ctx.state 读时现算(CLI-ACTIVITY-DEBLOAT)。
57
- // 门控语义零动(F-4):awaitingDigest 限定/池归属查/pending 查逐字保留——仅数据源换现算。
58
- const snap = computePanelBlocks(ctx.state)
59
- if (!Array.isArray(snap)) {
118
+ const src = readPanelSource(ctx)
119
+ if (src === null) {
60
120
  return { err: "panel unavailable — no CLI TUI panel in this session (headless / VS Code / subagent contexts — freeze unavailable; panel is CLI-TUI-only, AC-P4)" }
61
121
  }
62
- const block = snap.find((b) => b.key === key)
122
+ const want = normalizePanelKey(key)
123
+ const block = src.blocks.find((b) => normalizePanelKey(b.key) === want)
63
124
  if (!block) {
64
- const live = snap.map((b) => `${b.key}(${b.status})`).join(", ")
125
+ const live = src.blocks.map((b) => `${b.key}(${liveStatusOf(src, b)})`).join(", ")
65
126
  return { err: `unknown panel block key: ${key} — the live panel holds: ${live || "(no blocks)"}` }
66
127
  }
67
- if (block.status !== "awaitingDigest") {
68
- if (block.status === "done") {
69
- return { err: `block ${key} is already done — nothing to freeze; it was (or is about to be) reclaimed into the conversation by the freeze sweep at the turn end / settle (only digested-stuck awaitingDigest blocks need a manual freeze)` }
128
+ // 已入流 = 已归档(桌面快照判别键)——无卡可收(CLI 源无 region 字段 ⇒ 零改)。
129
+ if (src.readout && block.region === "flow") {
130
+ return { err: `block ${want} is already in the conversation flow (region "flow") — it has been archived; there is nothing left to freeze` }
131
+ }
132
+ if (!awaitingOf(src, block)) {
133
+ const status = String(block.status ?? "unknown")
134
+ if (status === "done") {
135
+ return { err: `block ${want} is already done — nothing to freeze; it was (or is about to be) reclaimed into the conversation by the freeze sweep at the turn end / settle (only digested-stuck awaitingDigest blocks need a manual freeze)` }
70
136
  }
71
- if (block.status === "running") {
72
- return { err: `block ${key} is still running — freeze only reclaims awaitingDigest blocks whose report is already digested; a running block freezes on its own settle (or stop it with action:'cancel' if it is a background async child)` }
137
+ if (status === "running") {
138
+ return { err: `block ${want} is still running — freeze only reclaims awaitingDigest blocks whose report is already digested; a running block freezes on its own settle (or stop it with action:'cancel' if it is a background async child)` }
73
139
  }
74
- return { err: `block ${key} is in state ${block.status} — freeze only reclaims awaitingDigest blocks whose report is already digested` }
140
+ return { err: `block ${want} is in state ${status} — freeze only reclaims awaitingDigest blocks whose report is already digested` }
75
141
  }
76
- if (blockKeyIn(getAsyncPool(ctx.agent, "subagent"), key) || blockKeyIn(getAsyncPool(ctx.agent, "advisor"), key)) {
77
- return { err: `block ${key} still has a live pool entry — it is NOT a digested-stuck block (freeze refused; status action shows the pool)` }
142
+ if (blockKeyIn(getAsyncPool(ctx.agent, "subagent"), want) || blockKeyIn(getAsyncPool(ctx.agent, "advisor"), want)) {
143
+ return { err: `block ${want} still has a live pool entry — it is NOT a digested-stuck block (freeze refused; status action shows the pool)` }
78
144
  }
79
145
  // ASYNC-RESULT-CONTAINER.md D2:pending 单容器(四族统一——原 escalate/consult
80
146
  // 独立族退役)。#748:consult 子块键不经裸键比对(会话条目键 `consult#<sessionId>` 与
81
147
  // 子块键可数字撞键)——改判消费判据(会话在跑 ∥ 会话条目在 pending——与回收 ∥ 补发同源)。
82
- if (!consultChildKey(key) && blockKeyIn(ctx.agent._pendingAsyncResults, key)) {
83
- return { err: `block ${key} is still genuinely awaiting digestion — its report is still pending and has NOT reached the model yet; freezing now would break the digestion order (wait for the digest run, which reclaims it automatically)` }
148
+ if (!consultChildKey(want) && blockKeyIn(ctx.agent._pendingAsyncResults, want)) {
149
+ return { err: `block ${want} is still genuinely awaiting digestion — its report is still pending and has NOT reached the model yet; freezing now would break the digestion order (wait for the digest run, which reclaims it automatically)` }
84
150
  }
85
- if (consultChildUndigested(ctx.agent, key)) {
86
- return { err: `block ${key} belongs to a consultation whose digest has not been consumed — the session is still running, or its session entry is still pending (freeze refused; the block is reclaimed at the consumption window)` }
151
+ if (consultChildUndigested(ctx.agent, want)) {
152
+ return { err: `block ${want} belongs to a consultation whose digest has not been consumed — the session is still running, or its session entry is still pending (freeze refused; the block is reclaimed at the consumption window)` }
87
153
  }
88
- return { ok: true }
154
+ return { ok: true, renderer: src.readout !== null }
89
155
  }
90
156
 
91
157
  /**
92
158
  * AGENT-LOOP-SUBAGENT.md §6.7.2 subagent action:"panel"(D-P2——readonly 视图面 + 门控干预面——单动作双参,
93
159
  * freeze 优先):
94
- * - view(缺省——返回面板块列表):ctx.state(= agent._tuiState——CLI TUI 装配)经
95
- * computePanelBlocks **读时现算**(F-3 单账本——与用户所见一致)。awaitingDigest 条目
96
- * **读时交叉**
97
- * _pendingAsyncResults/池 标注 digested(round1 #3——digested:true
98
- * = 报告已消化但块仍驻留——异常块——freeze 候选;模型可定位解释 UI 怪相)。
99
- * - freeze:key(D-P3 门控通过 → 发 key + "/" + ⟦ev⟧done 哨兵字面 token——
100
- * onToken——TUI routeSubToken 冻结回收——落位复用 sub._freezeAt settle 锚点
101
- * splice,无锚点尾推兜底——AGENT-LOOP-ASYNC-POOL.md §6.8 同口径——round1 #2)。
102
- * 无 TUI state(headless/VS Code——D-P2 round1 #1:webview 无 state.subTasks 对应物——
103
- * 7.2.3.2 #8 先例)→ view 恒降级池视图(双池经 getAsyncPool + pending 单容器
104
- * 合成)+ no panel 注;freeze 报不可用。CLI-only 完整能力(AC-P4)。
160
+ * - view(缺省——返回面板块列表):**回读源链**(子代理面板批 · 2026-10-04):渲染面快照非空 ⇒
161
+ * `{ source:"renderer", asOf, ageMs, panel[] }`(判别六键 + role/id 随行;awaitingDigest 条读时交叉
162
+ * 核池/pending 标 `digested`)∥ 快照空 ⇒ 现链回落——ctx.state(= agent._tuiState——CLI TUI 装配)经
163
+ * computePanelBlocks **读时现算**(F-3 单账本——与用户所见一致),awaitingDigest 条目**读时交叉**
164
+ * _pendingAsyncResults/池 标注 digested(round1 #3——digested:true = 报告已消化但块仍驻留——异常块——
165
+ * freeze 候选;模型可定位解释 UI 怪相)。
166
+ * - freeze:key(D-P3 门控通过 → 发 key + "/" + ⟦ev⟧done 哨兵字面 token——onToken——TUI routeSubToken
167
+ * 冻结回收 ∥ 桌面经桥 relay ⇒ 渲染面归档机入流 + record——同字面、零新消费者;发射 = 规范化键。
168
+ * 落位复用 sub._freezeAt settle 锚点 splice,无锚点尾推兜底——AGENT-LOOP-ASYNC-POOL.md §6.8 同口径——round1 #2)。
169
+ * 两源皆空(headless/VS Code——D-P2 round1 #1:webview 无 state.subTasks 对应物——7.2.3.2 #8 先例)
170
+ * → view 恒降级池视图(双池经 getAsyncPool + pending 单容器合成)+ no panel 注;freeze 报不可用。
171
+ * CLI-only 完整能力(AC-P4)。
105
172
  */
106
173
  export function executePanelAction(args, ctx) {
107
174
  const agent = ctx.agent
@@ -115,17 +182,22 @@ export function executePanelAction(args, ctx) {
115
182
  }
116
183
  const gate = panelFreezeGate(ctx, freezeKey)
117
184
  if (gate.err) return JSON.stringify({ status: "error", error: gate.err })
185
+ const panelKey = normalizePanelKey(freezeKey)
118
186
  if (!ctx.callbacks?.onToken) {
119
- return JSON.stringify({ status: "error", error: `panel present but no token relay in this context — the freeze of ${freezeKey} cannot reach the TUI` })
187
+ return JSON.stringify({ status: "error", error: `panel present but no token relay in this context — the freeze of ${panelKey} cannot reach the TUI` })
120
188
  }
121
189
  // 门控通过 → 发 done 冻结事件(settle 同机制字面格式——TUI routeSubToken done
122
190
  // 分支冻结回收——落位 _freezeAt settle 锚点 splice;无锚点(旧会话残留)时
123
191
  // freezeSubTaskLines 尾推兜底——注明两种落位,模型不被误导(advisor 🟡1)。
124
- ctx.callbacks.onToken(`${freezeKey}/⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
192
+ // 桌面(回读源):同字面经桥 relay 分流 ⇒ 渲染面归档机(归档入流 + record)——零新消费者;
193
+ // 发射键 = 规范化键(`sub:` 前缀不入 relay 文法——§2.3)。
194
+ ctx.callbacks.onToken(`${panelKey}/⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
125
195
  return JSON.stringify({
126
- key: freezeKey,
196
+ key: panelKey,
127
197
  status: "frozen",
128
- note: "done freeze event issued — the TUI reclaimed the block into the conversation (spliced at its settle anchor when one is recorded, else appended at the current stream end — same-rule position)",
198
+ note: gate.renderer
199
+ ? "done freeze event issued — the desktop renderer reclaimed the block into the conversation flow (archived there, record appended)"
200
+ : "done freeze event issued — the TUI reclaimed the block into the conversation (spliced at its settle anchor when one is recorded, else appended at the current stream end — same-rule position)",
129
201
  })
130
202
  }
131
203
  // ── view 面(缺省——readonly)──
@@ -133,6 +205,17 @@ export function executePanelAction(args, ctx) {
133
205
  if (args?.view === false) {
134
206
  return JSON.stringify({ status: "error", error: "panel has nothing to do — view:false with no freeze key; pass freeze:'role#N' to reclaim a digested-stuck block, or omit view (defaults to true)" })
135
207
  }
208
+ // 回读源链(子代理面板批 · 2026-10-04 · `PANEL-READBACK.md` §2.1):渲染面快照非空 ⇒ 实况回读面
209
+ // (判别六键 + role/id 随行 + awaitingDigest 读时交叉;asOf/ageMs 携龄——不设截断,卡留块状态恒定)。
210
+ const readout = typeof ctx.readout === "function" ? ctx.readout() : null
211
+ if (readout && Array.isArray(readout.blocks)) {
212
+ return JSON.stringify({
213
+ source: "renderer",
214
+ asOf: readout.receivedAt ?? null,
215
+ ageMs: typeof readout.receivedAt === "number" ? Math.max(0, Date.now() - readout.receivedAt) : null,
216
+ panel: readout.blocks.map((b) => readoutPanelEntry(agent, b)),
217
+ })
218
+ }
136
219
  const snap = computePanelBlocks(ctx.state)
137
220
  if (!Array.isArray(snap)) {
138
221
  // F-P3 降级:无 TUI state(headless/VS Code/子代理上下文——CLI TUI-only 完整能力)→
@@ -132,7 +132,7 @@ export const subagentTool = {
132
132
  properties: {
133
133
  action: { type: "string", enum: ["spawn", "status", "escalate", "cancel", "panel", "consume-design", "observe", "send"], description: "Which action — spawn (default) / status / observe / send / escalate / cancel / panel / consume-design; semantics per action in the tool description." },
134
134
  view: { type: "boolean", description: "action:'panel' only: true (default) = the live panel blocks as the user sees them; false without freeze = error. Mutually exclusive with freeze (freeze wins)." },
135
- freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck block — reclaims it into the conversation; refused for running/done/unknown blocks or a still-pending report; requires the CLI TUI panel mirror." },
135
+ freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck block — reclaims it into the conversation; refused for running/done/unknown blocks or a still-pending report; works on the CLI TUI panel and on the desktop renderer panel (live readback of the blocks as the user sees them)." },
136
136
  task: { type: "string", description: "Required for action:'spawn' (the self-contained task brief) and action:'escalate' (goal, constraints, entry files, acceptance criteria)." },
137
137
  context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
138
138
  role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder", "eng-designer"], description: "The sub-agent role — see the role matrix in the tool description. Exact spelling required. action:'spawn' only (escalate spawns its own expert internally)." },
@@ -188,7 +188,9 @@ export const subagentTool = {
188
188
  // CLI-ACTIVITY-DEBLOAT F-3(2026-09-10)接线:executePanelAction 经 ctx.state
189
189
  // (= agent._tuiState——startTUI 反向挂载)读时现算面板块(computePanelBlocks)——
190
190
  // 手工面板镜像已退役。headless/VSC 无挂载 → 现算返 null → 降级照旧。
191
- if (action === "panel") return executePanelAction(args, { ...ctx, state: ctx.agent?._tuiState })
191
+ // 子代理面板批(2026-10-04):另携 ctx.readout(= agent._panelReadout——桌面渲染面实况回读
192
+ // 上报缓存读面;agent-host 装配挂)——核内读源链 readout → state → 降级(PANEL-READBACK.md §2.1)。
193
+ if (action === "panel") return executePanelAction(args, { ...ctx, state: ctx.agent?._tuiState, readout: ctx.agent?._panelReadout })
192
194
  // SUBAGENT-OBSERVE-SEND:observe = readonly 查询(同 status——digest/planMode 放行);
193
195
  // send = 控制类豁免(同 cancel——父回合内显式调用即授权)。深度门在各自执行器内。
194
196
  if (action === "observe") return executeObserveAction(args, ctx)
@@ -87,6 +87,7 @@ export const verifyTool = {
87
87
  status: { type: "string", enum: ["passed", "failed", "skipped"], description: "The verification outcome you declare: 'passed' → allowed; 'failed' → blocked; 'skipped' → allowed but requires a summary reason." },
88
88
  command: { type: "string", description: "Optional: the verification command you ran (self-reported)." },
89
89
  summary: { type: "string", description: "Optional: the result. Required when status='skipped' — a concrete reason (e.g. \"project has no automated tests — verified by manual review\")." },
90
+ basis: { type: "string", description: "Optional: what the conclusion stands on — a judgment source, a read file:line coordinate, or an explicit \"unverified\" (declaration-only: no format is enforced or machine-checked). A passed/skipped declaration without it gets one advisory hint line in the report — never blocking, never changing the gate." },
90
91
  },
91
92
  required: ["status"],
92
93
  },
@@ -239,12 +240,16 @@ export const verifyTool = {
239
240
  lines.push("Verification declared passed.")
240
241
  if (v.command) lines.push(` command: ${v.command}`)
241
242
  if (v.summary) lines.push(` summary: ${v.summary}`)
243
+ if (v.basis) lines.push(` basis: ${v.basis}`)
244
+ else lines.push(" basis: not declared (advisory — does not affect the gate) — note what the conclusion stands on: a judgment source, a read file:line, or an explicit \"unverified\".")
242
245
  ctx.agent._verifyPassed = true
243
246
  } else if (status === "skipped") {
244
247
  const reason = (v.summary ?? "").trim()
245
248
  if (reason) {
246
249
  lines.push("")
247
250
  lines.push(`Verification skipped with reason: ${reason}`)
251
+ if (v.basis) lines.push(` basis: ${v.basis}`)
252
+ else lines.push(" basis: not declared (advisory — does not affect the gate) — note what the conclusion stands on: a judgment source, a read file:line, or an explicit \"unverified\".")
248
253
  ctx.agent._verifyPassed = true
249
254
  } else {
250
255
  lines.push("")
package/auto-think.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { chat } from "./provider/core.mjs"
18
18
  import { specForModel } from "./config.mjs"
19
+ // #860(AGENT-LOOP.md §6.1 ∥ §7 D-AL26):off 形族的单一实现源(写面取形 + 有效 off 路径判据)
20
+ import { thinkOffPath, thinkOffShape } from "./think-off.mjs"
19
21
 
20
22
  const CLASSIFY_PROMPT = `Classify this coding task's difficulty: low, medium, or high.
21
23
 
@@ -31,6 +33,10 @@ const EFFORT_MAP = {
31
33
  high: ["max", "max", "xhigh", "max"],
32
34
  }
33
35
 
36
+ /** #860(D-AL26):不可关思考的跳过警示 ∕ 分类失败——进程级一次性(按 model ∕ 错误签名去重,防逐轮刷屏)。 */
37
+ const warnedThinkGuard = new Set()
38
+ const warnedClassifyFailure = new Set()
39
+
34
40
  /**
35
41
  * Build classifier input from history: the latest real user message (reminders and
36
42
  * interrupt injections excluded), plus the previous user message as context when the
@@ -67,13 +73,25 @@ export async function classifyAndApply(agent, turn) {
67
73
  const validEfforts = spec.reasoningEffortEnum
68
74
  if (!validEfforts) return null // Model doesn't support reasoning effort
69
75
 
76
+ // #860 守卫(D-AL26):无法关思考的模型(thinkAlwaysOn ∕ 枚举无 none)⇒ 分类调用必然失败
77
+ //(思考吃光小预算 ∕ 400)——不调用;一次可见警示(按 model 去重),语义回退 null。
78
+ if (!thinkOffPath(spec)) {
79
+ if (!warnedThinkGuard.has(agent.provider.model)) {
80
+ warnedThinkGuard.add(agent.provider.model)
81
+ console.warn(`[auto-think] model "${agent.provider.model}" cannot disable thinking — difficulty classification skipped (reasoning effort unchanged)`)
82
+ }
83
+ return null
84
+ }
85
+
70
86
  const prompt = buildClassifierInput(agent.history)
71
87
  if (prompt == null) return null
72
88
 
73
89
  // Classification call: use same provider, minimal tokens, no tools, no streaming
74
90
  let level
75
91
  try {
76
- const classifierProvider = { ...agent.provider, maxTokens: 10 }
92
+ // #860:关思考形(族别 off 形)+ 小预算 32(原 10 token 被思考吃光)+ reasoningEffort 清空
93
+ //(防父档外溢;effort 族 off 形由载荷门补发 reasoning_effort:"none")。
94
+ const classifierProvider = { ...agent.provider, maxTokens: 32, thinking: thinkOffShape(spec), reasoningEffort: null }
77
95
  const response = await chat(classifierProvider, {
78
96
  messages: [
79
97
  { role: "system", content: CLASSIFY_PROMPT },
@@ -101,7 +119,13 @@ export async function classifyAndApply(agent, turn) {
101
119
  else if (word.startsWith("medium") || word.startsWith("med")) level = "medium"
102
120
  else if (word.startsWith("high")) level = "high"
103
121
  else return null // Unparseable
104
- } catch {
122
+ } catch (e) {
123
+ // #860(D-AL26):失败一次可见(按错误签名进程级去重——不再静默);回退 null 语义不变。
124
+ const sig = e?.message ?? String(e)
125
+ if (!warnedClassifyFailure.has(sig)) {
126
+ warnedClassifyFailure.add(sig)
127
+ console.warn(`[auto-think] classification failed: ${sig}`)
128
+ }
105
129
  return null // Classification failure → fall back to current setting
106
130
  }
107
131
 
@@ -42,6 +42,14 @@ export const PROVIDER_PRESETS = {
42
42
  siliconflow: { baseURL: "https://api.siliconflow.cn/v1", model: "deepseek-ai/DeepSeek-V3", maxTokens: 32_000, desc: "SiliconFlow (硅基流动)" },
43
43
  openrouter: { baseURL: "https://openrouter.ai/api/v1", model: "anthropic/claude-sonnet-4", maxTokens: 32_000, desc: "OpenRouter" },
44
44
  groq: { baseURL: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile", maxTokens: 32_000, desc: "Groq" },
45
+ // OpenCode Go 订阅网关(opencode.ai/zen 取 sk- key ∥ 无 OAuth)——官方档(go.mdx)双协议混装 ⇒ 拆双预设
46
+ // (PROVIDER.md §6.11):本键 = OpenAI 兼容 `/chat/completions` 侧;`opencode-go-anthropic` = Anthropic
47
+ // `/messages` 侧(同 key——模型按侧选)。字段集 = 最小面:`thinking` / `reasoningEffort` 不设 = 不发
48
+ // (D-13 同口径);`maxTokens` 仅 anthropic 侧设 `65536`(该 transport `max_tokens` 必发——不设即落规格
49
+ // 行值 131072,超渠道口径)。**待验(无 key——未实拉 `/models` ∕ 载荷未实测;接入后按实拉复核)**;
50
+ // `x-opencode-session` 头 = 可选遥测——**不发**(亦不动 `providers[].headers` 面)。
51
+ "opencode-go": { baseURL: "https://opencode.ai/zen/go/v1", model: "glm-5.2", desc: "OpenCode Go subscription (sk- key from opencode.ai/zen; OpenAI-compatible models — MiniMax/Qwen via opencode-go-anthropic)" },
52
+ "opencode-go-anthropic": { baseURL: "https://opencode.ai/zen/go/v1", model: "qwen3.7-max", format: "anthropic", maxTokens: 65536, desc: "OpenCode Go — Anthropic Messages side (same sk- key as opencode-go; MiniMax/Qwen models)" },
45
53
  }
46
54
 
47
55
  /** Build the stored provider entry from a preset — strip the display field, keep the rest
package/config.mjs CHANGED
@@ -188,6 +188,31 @@ function warnConsultModelsFiltered(dropped, path) {
188
188
  `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
189
189
  }
190
190
 
191
+ /** #861(AGENT-LOOP-SUBAGENT.md §6.7.1):subagent 模型覆盖加载期清洗——单源纯函数(核 loadConfig ∥ VSC 端壳 raw 读点同消费);非法形态不 throw(`subagentModel` 非法 ⇒ null、`subagentModels` 非对象 ⇒ {}、值非法 ⇒ 剔除该键)。 */
192
+ export function sanitizeSubagentModel(v) {
193
+ const ok = typeof v === "string" && v.trim()
194
+ const dropped = ok || v == null ? [] : [`agent.subagentModel must be a non-empty string ("provider:model" | provider | model) — got ${Array.isArray(v) ? "array" : typeof v} — ignored (inherits the parent provider)`]
195
+ return { value: ok ? v : null, dropped }
196
+ }
197
+ export function sanitizeSubagentModels(v) {
198
+ if (v == null) return { value: {}, dropped: [] }
199
+ if (typeof v !== "object" || Array.isArray(v)) return { value: {}, dropped: [`agent.subagentModels must be an object mapping role → model override — got ${Array.isArray(v) ? "array" : typeof v} — ignored`] }
200
+ const value = {}, dropped = []
201
+ for (const [role, val] of Object.entries(v)) {
202
+ if (typeof val === "string" && val.trim()) { value[role] = val; continue }
203
+ dropped.push(`agent.subagentModels.${role} must be a non-empty string ("provider:model" | provider | model) — got ${Array.isArray(val) ? "array" : typeof val} — dropped`)
204
+ }
205
+ return { value, dropped }
206
+ }
207
+
208
+ let warnedSubagentModels = false // #861:清洗警告——进程级一次性(同 consultModels 先例)
209
+ function warnSubagentModelsFiltered(dropped, path) {
210
+ if (warnedSubagentModels || dropped.length === 0) return
211
+ warnedSubagentModels = true
212
+ console.warn(`[config] agent.subagentModel/subagentModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
213
+ `${dropped.map((d) => ` - ${d}`).join("\n")}\n Fix: clean it in ${path} — "provider:model" | provider | model.`)
214
+ }
215
+
191
216
  /**
192
217
  * Find provider by name in providers[].
193
218
  * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -311,6 +336,11 @@ export function loadConfig() {
311
336
  merged.agent.consultModels = cmClean.keep
312
337
  if (cmClean.dropped.length) merged.agent.consultModelsFiltered = cmClean.dropped
313
338
 
339
+ const smClean = sanitizeSubagentModel(merged.agent.subagentModel), smsClean = sanitizeSubagentModels(merged.agent.subagentModels) // #861(§6.7.1):加载期清洗——非法形态不采纳 + 一次性警告(不 throw——同 F-4 范式)
340
+ warnSubagentModelsFiltered([...smClean.dropped, ...smsClean.dropped], path)
341
+ merged.agent.subagentModel = smClean.value
342
+ merged.agent.subagentModels = smsClean.value
343
+
314
344
  // Backward compatibility: promote root-level config fields to agent sub-object
315
345
  if (config.verifyGuard !== undefined) {
316
346
  merged.agent.verifyGuard = config.verifyGuard
package/declaration.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * name / shape / defaults = schema 面 `docs/core/design/MANIFEST.md` §2.2)。迁出块逐字;
6
6
  * 原档 `conventions.mjs` 经 `export { … } from` 转口保名(消费面 / 批内件 import 面零改)。
7
7
  */
8
- import { existsSync } from "node:fs"
9
- import { dirname, isAbsolute, join, relative, resolve } from "node:path"
8
+ import { existsSync, statSync } from "node:fs"
9
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
10
10
  import { DEFAULT_MANIFEST, manifestFilePath, readManifest } from "./manifest.mjs"
11
11
  import { logEvent } from "./log.mjs"
12
12
 
@@ -140,6 +140,30 @@ export function declaredPublicRoots(cwd) {
140
140
  return out
141
141
  }
142
142
 
143
+ /** 引用解析单点(§6.1 口径 4/5——写门 ∥ 迁移旗同消费,不得二写):① 仓根解析 `resolve(base, part)`
144
+ * 为存在的档 ⇒ 命中;② 声明源前缀解析序:首段命中声明公共仓目录名(消费既有 `declaredPublicRoots`
145
+ * ——同名多仓 ⇒ 声明序首者)⇒ 于该声明根解析余段;未声明 ∥ 声明根缺位 ∥ 目标档缺 ⇒ `{ ok: false, abs }`。
146
+ * @param {string} base 仓根基准(写门口径 2 同一子表达式)
147
+ * @param {string} part task_book 文件部分(首个 `§` 前子串,trim——调用面已判缺位)
148
+ * @returns {{ok: boolean, abs: string}}
149
+ */
150
+ export function resolveDeclaredRef(base, part) {
151
+ const abs = resolve(base, part)
152
+ if (isFile(abs)) return { ok: true, abs }
153
+ const segs = String(part).replace(/\\/g, "/").split("/").filter(Boolean)
154
+ for (const root of declaredPublicRoots(base)) {
155
+ if (segs[0] !== basename(root)) continue
156
+ if (segs.length > 1) {
157
+ const hit = resolve(root, segs.slice(1).join("/"))
158
+ if (isFile(hit)) return { ok: true, abs: hit }
159
+ }
160
+ break
161
+ }
162
+ return { ok: false, abs }
163
+ }
164
+
165
+ const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
166
+
143
167
  const _cache = new Map()
144
168
 
145
169
  /** Drop the per-data-file-path cache (test seam — declarations are read once per path). */