thincoder 0.12.63 → 0.12.65

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.63",
4
- "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
3
+ "version": "0.12.65",
4
+ "description": "Thin coding agent - zero third-party dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
7
7
  "agent",
@@ -20,7 +20,7 @@
20
20
  "node": ">=24"
21
21
  },
22
22
  "dependencies": {
23
- "@thincoder/core": "^0.9.1"
23
+ "@thincoder/core": "^0.9.3"
24
24
  },
25
25
  "files": [
26
26
  "bin/",
@@ -8,11 +8,22 @@
8
8
  * 受门 handler 首行求值,真 ⇒ 放行、假 ⇒ `-32000`。客户端可以不调 `authenticate`
9
9
  * (契约逐字「**May** return an `auth_required` error … **if** the agent requires
10
10
  * authentication」)。
11
+ *
12
+ * #168①(2026-09-22 · SESSION.md §6.15 / §6.16):`session/close` 入释放面——关闭会话 ⇒
13
+ * 释放该会话认领(保留集 = 本进程其余在存会话的活绑定槽,同 cwd;落盘判据三条沿用)。
11
14
  */
12
15
  import { resolve } from "node:path"
13
- import { normalizeCwd, bindRecordStore, newSession, slotPath } from "@thincoder/core/session.mjs"
16
+ import { loadManifest, normalizeCwd, bindRecordStore, newSession, saveManifest, slotPath } from "@thincoder/core/session.mjs"
17
+ // 认领释放谓词单源(SESSION.md §6.2 / §6.16——与 VSC 端壳 `releaseClaimsAll` 同源档;
18
+ // core `session.mjs` 未 re-export 该名——直引 manifest 档,先例 = `thincoder-vscode/src/extension/session-io.mjs`)。
19
+ import { staleClaims } from "@thincoder/core/session-slots-manifest.mjs"
20
+ import { PLAN_ENGINEERING_REFUSED } from "@thincoder/core/agent-tools/plan.mjs"
14
21
  import { ACP_ERRORS } from "./transport.mjs"
15
22
 
23
+ /** ENG-PLAN-EXCLUSION(FR31 ② · E7「命令面逐面钉定」):工程真值 = 核单源同键
24
+ * `agent.config.agent.engineering`。 */
25
+ const isEngineering = (agent) => agent?.config?.agent?.engineering === true
26
+
16
27
  /** Session-level config options exposed to clients — schema `SessionConfigOption.required = ["id","name"]`
17
28
  * (响应侧用 `id`;请求侧 `SetSessionConfigOptionRequest` 用 `configId`——不对称属契约本身 · §11.3 G2-8). */
18
29
  export const CONFIG_OPTIONS = [
@@ -102,6 +113,22 @@ export function createSessionHandlers(ctx) {
102
113
  return { session: s }
103
114
  }
104
115
 
116
+ /** #168①(SESSION.md §6.15 / §6.16——2026-09-22 定裁 · 台账 #168①):`session/close` ⇒
117
+ * 释放被关闭会话的槽认领。公式代入:释放 A ⟺ `slotSessions[A]` 为本进程 ∧ `A ∉ 保留集`;
118
+ * **保留集 = 本进程其余在存会话的活绑定槽**(同 cwd——ACP 单 cwd 模型)。取值点(实施轮定位报告):
119
+ * 会话记录容器 = `ctx.sessions`(`Map<id, session>`——`thincoder-cli/src/acp.mjs:98` 单容器);
120
+ * 槽值 = `session.agent._slot`(认领写入同源 = `handlers-slots.mjs:96-100` / `:151-155` /
121
+ * `handlers-session.mjs` 的 newSession 认领点)。落盘判据三条由核 `saveManifest` `opts.release`
122
+ * 自动继承(fresh 同次快照 / 值条件删除 / 内存认领表移除)——**零新增写盘路径**;本进程无残留认领
123
+ * ⇒ 零写早退(判据单源 `staleClaims`——同核 `releaseClaimsAll` 早退形)。 */
124
+ const releaseClosedSessionSlot = () => {
125
+ const cwd = getCwd()
126
+ const m = loadManifest(cwd)
127
+ const keep = [...sessions.values()].map((s) => s.agent?._slot).filter((slot) => slot != null)
128
+ if (staleClaims(m, keep).length === 0) return
129
+ saveManifest(cwd, m, null, { release: keep })
130
+ }
131
+
105
132
  return {
106
133
  "session/new": async (params) => {
107
134
  const gate = requireConfigured()
@@ -194,7 +221,9 @@ export function createSessionHandlers(ctx) {
194
221
  // Abort any in-flight turn first — the client is gone, the agent must
195
222
  // stop consuming LLM tokens and emitting notifications.
196
223
  found.session.cancel()
224
+ // 先出容器再释放(保留集 = 其余在存会话——被关闭者不留在保留集内)。
197
225
  sessions.delete(String(params.sessionId))
226
+ releaseClosedSessionSlot() // #168①:释放该会话认领(保留集 = 同 cwd 其余在存会话槽)
198
227
  log(`session ${params.sessionId} closed by client`)
199
228
  }
200
229
  return {}
@@ -209,6 +238,13 @@ export function createSessionHandlers(ctx) {
209
238
  if (!configId || value === undefined) {
210
239
  return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "set_config_option requires configId and value" } }
211
240
  }
241
+ // ENG-PLAN-EXCLUSION(FR31 ② / 评审轮 1 发现 #4 出口钉定):工程模式 ⇒ plan 面**前置判**
242
+ // ⇒ 携共用文案的 INVALID_PARAMS。`applyConfigOption` 布尔契约零改(`false` 仍 = unknown
243
+ // configId——工程拒绝不经该路径,下方 `:unknown configId` 误导文案对 mode=plan 不再可达);
244
+ // `mode:"normal"` 照常接受(唯一合法态,幂等)。
245
+ if (configId === "mode" && value === "plan" && isEngineering(found.session.agent)) {
246
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: PLAN_ENGINEERING_REFUSED } }
247
+ }
212
248
  const applied = applyConfigOption(found.session.agent, configId, value)
213
249
  if (!applied) {
214
250
  return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: `unknown configId: ${configId}` } }
@@ -229,6 +265,11 @@ export function createSessionHandlers(ctx) {
229
265
  if (params.mode !== "plan" && params.mode !== "normal") {
230
266
  return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: "mode must be plan or normal" } }
231
267
  }
268
+ // ENG-PLAN-EXCLUSION(FR31 ② / 同 #4):工程模式 + `mode:"plan"` ⇒ 前置判 ⇒ 携共用文案
269
+ // 的 INVALID_PARAMS(状态零变、零通知);`normal` 照常(幂等)。
270
+ if (params.mode === "plan" && isEngineering(found.session.agent)) {
271
+ return { error: { ...ACP_ERRORS.INVALID_PARAMS, message: PLAN_ENGINEERING_REFUSED } }
272
+ }
232
273
  found.session.agent.planMode = params.mode === "plan"
233
274
  notifyRef.current("session/update", {
234
275
  sessionId: String(params.sessionId),
@@ -29,7 +29,7 @@ export function createSlotsHandlers(ctx) {
29
29
 
30
30
  /** #41 装载后一行重估(F3 准翻列 / F4):`applySession` 已按槽订正 config ⇒ 工程模式会话
31
31
  * 就地判据(单源)+ 附着——缺档格 `writer:'main'` 建档;非工程模式零动作(零 manifest I/O)。
32
- * 判据不通过 ⇒ 保持不附着(装载已发生——本行是「装载后订正」位,与入口门槛两分)+ 留痕。 */
32
+ * 判据不通过 ⇒ 保持不附着(装载已发生——本行是「装载后订正」位;启动侧恒不拒,拒面 = 「翻转可否进」)+ 留痕。 */
33
33
  const reattach = (ag) => {
34
34
  if (ag?.config?.agent?.engineering !== true) return
35
35
  const r = resolveEngineeringManifest(getCwd(), { writer: "main" })
@@ -8,7 +8,7 @@ import { discoverRules } from "@thincoder/core/rules.mjs"
8
8
  // (静态表 ∪ memory / code_search / doc_search / repo_outline / settings / peer_instances 六面 ∪ 门控 read_image)。
9
9
  // 传 `model` 必需:漏传 ⇒ `read_image` 对所有模型静默消失(门判据 = specForModel(model)?.multimodal)。
10
10
  import { assembleBuiltinTools } from "@thincoder/core/tools/index.mjs"
11
- import { resolveEngineeringManifest } from "@thincoder/core/manifest.mjs"
11
+ import { resolveEngineeringManifest, projectView } from "@thincoder/core/manifest.mjs"
12
12
 
13
13
  /** 第 27 批 §12.3⑤(R-A1.1):装配期工具剔除——按 `name` 过滤的纯函数(机验锚)。
14
14
  * 恒等语义:空列表 / 零命中 → 原数组原样返回(零意外剔除——T15)。
@@ -123,9 +123,9 @@ export async function assembleAgent({ excludeTools = [], slotData } = {}) {
123
123
  agent.activeModel = provider.model ?? null
124
124
  agent._mcpWarnings = mcpWarnings
125
125
  // M1 装配钩子(会话起点①——装配期):判据 = 会话权威值(`slotData` 槽值优先 + config 回退);
126
- // 附着 agent.manifest 供下游 M2–M9 读面(普通会话 = null)。钩子后移的可观察后果(拒 / 建档
127
- // 晚于 memory sync / MCP 连接 / 工具装配——功能等价,仍在进入正常循环之前)见
128
- // docs/core/design/MANIFEST.md §2.2。
126
+ // 附着 agent.manifest 供下游 M2–M9 读面(普通会话 = null);非 ok 态不抛(启动零拒绝)。
127
+ // 钩子后移的可观察后果(报明 / 建档晚于 memory sync / MCP 连接 / 工具装配——功能等价,仍在
128
+ // 进入正常循环之前)见 docs/core/design/MANIFEST.md §2.2。
129
129
  attachManifest(agent, { cwd, slotData })
130
130
  // SESSION.md §6.8 D-S1:assembleAgent 后唯一校验点(TUI/chat 两路径同源)——不抛错不退出,
131
131
  // 标记由调用侧消费(TUI 弹重选 / headless 报错)。空 provider 由 TUI 路径在 startTUI 前清空。
@@ -145,20 +145,20 @@ export async function assembleAgent({ excludeTools = [], slotData } = {}) {
145
145
  * 「恢复槽带 `engineering` 字段 ? 槽值 : `agent.config.agent.engineering`」(槽字段在场判据
146
146
  * 与 `applySession` 同款——`thincoder-core/session.mjs:314-317`)。普通会话 → **装配钩子
147
147
  * 零 manifest I/O**(不读 / 不拒 / 不建档)+ `agent.manifest = null`(清残留附着——复用
148
- * agent 跨模式防陈旧);工程模式四态:合法档 → 附着;缺档 + 根可解析 → `initManifest`
149
- * 建档(拒进正常循环直到生成——E2 前置门槛);缺档 + 根不可解析 → 抛「工程模式启动拒绝」
150
- * **不自动建档**;非法档 → fail-closed 抛出。
148
+ * agent 跨模式防陈旧)。
149
+ *
150
+ * 工程模式分支 = 核单源决策树 `resolveEngineeringManifest`(KD-M1-20);**非 fatal**
151
+ * (KD-M1-25 / M1-29——**启动零拒绝**):档合法 / 梯②④⑤ 缺档(轻动作就地建档,`writer:'main'`)
152
+ * ⇒ 附着;歧义(≥2 候选)/ 档非法 / 建档失败 ⇒ **不抛**——`agent.manifest = null` + 记
153
+ * `agent._projectView`(形状 = `projectView` 返回面 + `created` 建档标记——§2.2 钩子段)。
154
+ * 需要项目参数者各自报明(情境行 §2.6 / 动作侧各自报错——§2.9 C)。
151
155
  *
152
156
  * `slotData` = 恢复槽记录,由调用方经**参数注入**(`bin` TUI 分支既有 `resumeSlot` 调用
153
157
  * 前移——KD-M1-15:钩子内不调 `resumeSlot`(该函数非纯读:GC / 认领 / 端标写);`chat` /
154
158
  * ACP 装配无会话 ⇒ 不传 ⇒ config 回退)。重估点不传(`applySession` 已按槽订正 config)。
155
- *
156
- * #41(薄包装——KD-M1-20 / AC-19):工程模式分支的决策树 = 核单源
157
- * `resolveEngineeringManifest`(非抛错形态);本函数只把它翻回「抛错」——四条出口
158
- * (附着 / 缺档建档 / 根不可解析抛 / 档非法抛)的**结果与文案逐字不变**(原句由决策树构造)。
159
- * @param {object} agent — createAgent 产物(读 agent.config;写 agent.manifest)
159
+ * @param {object} agent — createAgent 产物(读 agent.config;写 agent.manifest / agent._projectView)
160
160
  * @param {{cwd?: string, slotData?: object|null}} [opts]
161
- * @returns {object|null} 附着后的 manifest / 普通会话 null
161
+ * @returns {object|null} 附着后的 manifest / 非 ok 态与普通会话 null
162
162
  */
163
163
  export function attachManifest(agent, { cwd = process.cwd(), slotData } = {}) {
164
164
  const slotEng = slotData?.engineering
@@ -167,10 +167,11 @@ export function attachManifest(agent, { cwd = process.cwd(), slotData } = {}) {
167
167
  agent.manifest = null // 清残留附着(KD-M1-12)——普通会话不读 / 不拒 / 不建档
168
168
  return null
169
169
  }
170
- // 缺档 + 根可解析 → 就地建档(writer 显式 'main'——写门 fail-closed 缺省拒,KD-M1-3)。
170
+ // 决策树(writer 显式 'main'——写门 fail-closed 缺省拒,KD-M1-3;漏传 ⇒ 缺档格退化
171
+ // `init-failed`)。任何非 ok 态都不抛(KD-M1-25)。
171
172
  const r = resolveEngineeringManifest(cwd, { writer: "main" })
172
- if (!r.ok) throw new Error(r.message)
173
- agent.manifest = r.manifest
173
+ agent._projectView = { ...projectView(cwd), created: r.ok && r.created === true }
174
+ agent.manifest = r.ok ? r.manifest : null
174
175
  return agent.manifest
175
176
  }
176
177
 
@@ -21,6 +21,7 @@ import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
21
21
  import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 §17 段迁 suspension-drive.mjs
22
22
  import { ensureSessionTitle } from "@thincoder/core/generate-title.mjs"
23
23
  import { logEvent, errText } from "@thincoder/core/log.mjs"
24
+ import { t } from "@thincoder/core/i18n.mjs"
24
25
  import { suspensionSession, poolLive } from "./suspension-drive.mjs"
25
26
 
26
27
  /** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
@@ -191,11 +192,11 @@ async function runAgentTurnInner(ctx, text, opts) {
191
192
  // §2 统一规则自动 resume(无人值守授权);手动档静默拒绝(部分消化留在
192
193
  // 历史,会话回挂起——结果不丢,只是不再烧轮次)。
193
194
  if (agent.autoApprove) {
194
- pushLine("[auto-turn: continuing past turn cap…]", C.dim)
195
+ pushLine(t("digest.capAuto"), C.dim)
195
196
  state.controller = makeController()
196
197
  continue
197
198
  }
198
- pushLine(`[auto-turn stopped at ${error.turn} turns — partial digest; finished reports stay in history]`, C.warn)
199
+ pushLine(t("digest.capStop", { turns: error.turn }), C.warn)
199
200
  if (opts?._logOutcome) opts._logOutcome.result = "stopped"
200
201
  break
201
202
  }
@@ -339,6 +340,8 @@ async function runAgentTurnInner(ctx, text, opts) {
339
340
  // 承诺)——逐条直发;斜杠命令直接执行(保序);回合后的续发由递归层同循环续取。
340
341
  while (state.queue.length > 0 && !state.processing) {
341
342
  const head = state.queue.shift()
343
+ // F16(TUI.md §7.5):消费回执——queued dim 行(消费事实的可见锚;对位 [continuing…])
344
+ pushLine("[sending queued message]", C.tool)
342
345
  // 残项斜杠命令直接执行——保序、绝不合并
343
346
  if (head.text.startsWith("/")) {
344
347
  await handleSlash(head.text)
@@ -27,7 +27,7 @@ export async function handleAdvisorCommand(ctx) {
27
27
  // Other advisor keys (model/thinking/effort/timeout) stay config-scoped — persist() only.
28
28
  const persistGuard = async () => {
29
29
  try {
30
- const p = slotPath(agent.cwd, activeSlot(agent.cwd))
30
+ const p = slotPath(agent.cwd, agent._slot ?? activeSlot(agent.cwd))
31
31
  const data = JSON.parse(readFileSync(p, "utf8"))
32
32
  if (data && typeof data === "object" && Array.isArray(data.history)) {
33
33
  data.advisor = { ...(typeof data.advisor === "object" && data.advisor !== null ? data.advisor : {}), guard: cfg.guard === true }
@@ -12,6 +12,7 @@ import { purgeExpiredDesignTokens } from "@thincoder/core/token-ttl.mjs"
12
12
  import { resolveEngineeringManifest } from "@thincoder/core/manifest.mjs"
13
13
 
14
14
  import { ENG_OFF_REMINDER } from "@thincoder/core/agent.mjs"
15
+ import { clearPlanMode } from "@thincoder/core/agent-tools/plan.mjs"
15
16
 
16
17
  /** Atomic slot write (same shape as session.mjs writeSessionFile — kept local to avoid a
17
18
  * private-import; cmd-advisor's guard toggle shares this helper). */
@@ -48,6 +49,9 @@ export async function handleEngCommand(ctx) {
48
49
  agent.manifest = r.manifest
49
50
  }
50
51
  agent.config.agent.engineering = !agent.config.agent.engineering
52
+ // FR31 ③ / KD10(翻转点②·CLI `/eng`):ON ⇒ 清 plan 残留(内存位 + 未注入的 plan 提示语);
53
+ // OFF 方向不动(普通模式零改——FR31 边界)。提示行仅在真清到东西时出(不喧宾)。
54
+ const planReset = agent.config.agent.engineering ? clearPlanMode(agent) : false
51
55
  // §11.2 D-24b: per-review instances die with the mode (fresh convergence cycles
52
56
  // on the next toggle).
53
57
  agent._advisorRuns = new Map()
@@ -68,6 +72,9 @@ export async function handleEngCommand(ctx) {
68
72
  pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"} (session)`, C.tool)
69
73
  if (agent.config.agent.engineering) {
70
74
  pushLine(` → design-before-code enforced (design review + user approval before code)`, C.dim)
75
+ if (planReset) {
76
+ pushLine(` → plan mode reset (engineering mode excludes plan mode)`, C.dim)
77
+ }
71
78
  if (clearedExpired > 0) {
72
79
  pushLine(` → cleared ${clearedExpired} expired design token${clearedExpired === 1 ? "" : "s"}; valid tokens from prior reviews stay usable`, C.dim)
73
80
  }
@@ -80,14 +87,18 @@ export async function handleEngCommand(ctx) {
80
87
  * VS Code, per-session; config.json is just the initial default, no mirror write).
81
88
  * The in-memory agent.config.agent.engineering (already flipped) stays the live authority for
82
89
  * this process; saveSession also round-trips it on every turn-end write.
90
+ * 槽写也担 FR31 ③ 的槽位收正(T12):ON 时 `data.planMode = false`——否则下次装载/恢复
91
+ * 把 plan-active 推回 UI(半状态复活面;验收判据 = 「内存 + 槽位」双清零)。OFF 方向零改。
83
92
  */
84
93
  async function persistEngineering(agent) {
85
- const slot = activeSlot(agent.cwd)
94
+ // #167:绑定优先(先例 = cmd-session.mjs)——粘性槽优先于 activeSlot(后者有认领副作用)。
95
+ const slot = agent._slot ?? activeSlot(agent.cwd)
86
96
  try {
87
97
  const p = slotPath(agent.cwd, slot)
88
98
  const data = JSON.parse(readFileSync(p, "utf8"))
89
99
  if (data && typeof data === "object" && Array.isArray(data.history)) {
90
100
  data.engineering = agent.config.agent.engineering
101
+ if (agent.config.agent.engineering) data.planMode = false
91
102
  writeSessionFile(p, data)
92
103
  }
93
104
  } catch { /* slot missing/unreadable — in-memory flag already flipped; saveSession persists at turn end */ }
@@ -1,4 +1,6 @@
1
1
  import { C } from "./ansi.mjs"
2
+ // #58(hygiene-sweep 批):headers/env 现值脱敏判据 = 核 settings 同源单点(不再各自为政)。
3
+ import { isSensitiveKey } from "@thincoder/core/agent-tools/settings.mjs"
2
4
 
3
5
  /** MCP.md §5 v2(D-1):edit/add 统一字段 picker 表单机制。
4
6
  * fieldPicker 循环:picker 列字段行(label + 当前值打码)+ `✓ Save & test` 末行;
@@ -111,15 +113,19 @@ function formEntries(entry, transport, mode) {
111
113
  ]
112
114
  }
113
115
 
114
- /** 字段输入提示 `(current: …)`——token 打码(maskToken)、headers/env 列键值对、
116
+ /** #58(hygiene-sweep 批):headers/env 现值列示——敏感键名(谓词 = 核 settings 同源
117
+ * `isSensitiveKey`)的值位一律 `••••`(人读面不再明文);非敏感键名保可读(`k=v`)。 */
118
+ function currentPairs(v) {
119
+ const pairs = Object.entries(v ?? {}).map(([k, val]) => (isSensitiveKey(k) ? `${k}=••••` : `${k}=${val}`))
120
+ return pairs.length ? pairs.join(", ") : "none"
121
+ }
122
+
123
+ /** 字段输入提示 `(current: …)`——token 打码(maskToken)、headers/env 列键值对(敏感值位遮)、
115
124
  * args 空格串接;空值 → "none"。 */
116
125
  function currentText(entry, field) {
117
126
  const v = entry[field]
118
127
  if (field === "token") return v ? maskToken(v) : "none"
119
- if (field === "headers" || field === "env") {
120
- const pairs = Object.entries(v ?? {}).map(([k, val]) => `${k}=${val}`)
121
- return pairs.length ? pairs.join(", ") : "none"
122
- }
128
+ if (field === "headers" || field === "env") return currentPairs(v)
123
129
  if (field === "args") return (v ?? []).length ? v.join(" ") : "none"
124
130
  return String(v ?? "") || "none"
125
131
  }
@@ -8,7 +8,7 @@ export async function handleNewCommand(ctx) {
8
8
 
9
9
  // F-MI7:newSession = async(探测束)——doNewSession 随之为 async,调用点 await
10
10
  const doNewSession = async () => {
11
- const slot = await newSession(agent.cwd)
11
+ const slot = await newSession(agent.cwd, { releaseStale: true }) // F-CR1:/new 释放本进程残留认领(保留集 = {新槽});ACP 四点不传(F-CR3)
12
12
  // 2026-08-31 会诊 F3:resetSessionState 清全量会话态(_fullHistory/title/_sessionStart/
13
13
  // 多槽设计凭证(_engDesignTokens——单值镜像 _engDesignToken 已退役 D3)/压缩与验证计数
14
14
  // 等)——原实现只清 agent.history,新会话首次落盘把旧会话完整人类线 + 旧标题写进新
@@ -1,9 +1,18 @@
1
1
  /** /plan command: toggle plan mode (read-only explore → design → implement).
2
2
  * ctx: { agent, pushLine, pushLabel } */
3
3
  import { ansi, C } from "./ansi.mjs"
4
+ import { PLAN_ENGINEERING_REFUSED } from "@thincoder/core/agent-tools/plan.mjs"
4
5
 
5
6
  export async function handlePlanCommand(ctx) {
6
7
  const { agent, pushLine, pushLabel } = ctx
8
+ // ENG-PLAN-EXCLUSION(FR31 ② · 设计 `ENGINEERING-MODE-V2.md` §2.3 E7「命令面逐面钉定」):
9
+ // 工程模式 ⇒ 拒绝——**前置判**:零翻转(不写 `planMode`)+ 提示行(共用文案常量逐字,
10
+ // 与 ACP 两面同源);无成功标签(标签 = 成功回显——拒时零标签防假成功,同 `/eng` 先例)。
11
+ // 真值 = `agent.config.agent.engineering`(核单源同键);普通模式四路径全带宽(FR31 边界)。
12
+ if (agent.config?.agent?.engineering === true) {
13
+ pushLine(PLAN_ENGINEERING_REFUSED, C.warn)
14
+ return
15
+ }
7
16
  agent.planMode = !agent.planMode
8
17
  pushLabel("❯ Plan", ansi.bold + C.tool)
9
18
  pushLine(`Plan mode: ${agent.planMode ? "ON" : "OFF"}`, C.tool)
@@ -0,0 +1,62 @@
1
+ import { ansi, C } from "./ansi.mjs"
2
+ import { capLine, accountLine, accountAll, syncLineBudget } from "./display-budget.mjs"
3
+ import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
4
+
5
+ /** 对话写入面(2026-09-22 structure-debt §2.2 · #159):pushLine(行额度单点)/ pushLabel
6
+ * (消息标签 + 呼吸空行)/ ensureAssistantLabel(回合内单次 assistant 标签位)——三块自
7
+ * index.mjs startTUI 逐字搬移(仅去包裹)。
8
+ * `render` 由调用侧以惰性转发承接(renderLoop 于 startTUI 后段装配——TDZ 语义保持)。
9
+ * @param {{ state: object, render: Function }} ctx
10
+ * @returns {{ pushLine: Function, pushLabel: Function, ensureAssistantLabel: Function,
11
+ * assistantLabeled: boolean }} */
12
+ export function createConversationWriter({ state, render }) {
13
+ const pushLine = (text, color, kind) => {
14
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):行额度单点——行文本过 capLine
15
+ // (LINE_MAX_CHARS——单行巨内容/无换行巨 chunk 的堵口)+ 总量账 + 预算对账。
16
+ const line = { text: capLine(text), color, _kind: kind }
17
+ state.lines.push(line)
18
+ accountLine(state, line)
19
+ if (state.lines.length > 5000) {
20
+ state.lines.splice(0, 1000)
21
+ state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
22
+ // 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
23
+ // 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
24
+ // = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
25
+ shiftFreezeAnchors(state, 1000)
26
+ accountAll(state) // 行对象集合已变(splice/unshift 直写)——直算重对账
27
+ }
28
+ syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
29
+ render()
30
+ }
31
+
32
+ /** Message block label: blank line + label line. Breathing space between user/assistant messages */
33
+ const pushLabel = (text, color) => {
34
+ // 行额度同 pushLine(§15.3.1——恢复行/标签行同口径)
35
+ if (state.lines.length > 0) {
36
+ const blank = { text: "", color: C.dim }
37
+ state.lines.push(blank)
38
+ accountLine(state, blank)
39
+ }
40
+ const line = { text: capLine(text), color }
41
+ state.lines.push(line)
42
+ accountLine(state, line)
43
+ syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
44
+ render()
45
+ }
46
+
47
+ // Only emit the assistant label once per turn (on first token or first tool call)
48
+ let assistantLabeled = false
49
+ const ensureAssistantLabel = () => {
50
+ if (!assistantLabeled) {
51
+ assistantLabeled = true
52
+ pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
53
+ }
54
+ }
55
+ return {
56
+ pushLine,
57
+ pushLabel,
58
+ ensureAssistantLabel,
59
+ get assistantLabeled() { return assistantLabeled },
60
+ set assistantLabeled(v) { assistantLabeled = v },
61
+ }
62
+ }