thincoder 0.12.59 → 0.12.61

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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -1,13 +1,14 @@
1
1
  /** /eng command: toggle engineering mode.
2
- * Requires METHODOLOGY.md in project root. Offers to create one if missing.
3
- * ctx: { agent, pushLine, pushLabel, persistRaw, showPicker } */
4
- import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"
5
- import { join, dirname } from "node:path"
6
- import { fileURLToPath } from "node:url"
2
+ * No prerequisites (FR11): the mode works on any project no methodology file,
3
+ * no docs tree, no git. The retired gate demanded a methodology file and offered
4
+ * to create it from a template that no longer ships — that option crashed
5
+ * (PORTABILITY P14). ctx: { agent, pushLine, pushLabel } */
6
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"
7
+ import { dirname } from "node:path"
7
8
  import { ansi, C } from "./ansi.mjs"
8
9
  import { activeSlot, slotPath } from "../session.mjs"
10
+ import { purgeExpiredDesignTokens } from "../token-ttl.mjs"
9
11
 
10
- const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
11
12
  import { ENG_OFF_REMINDER } from "../agent.mjs"
12
13
 
13
14
  /** Atomic slot write (same shape as session.mjs writeSessionFile — kept local to avoid a
@@ -25,53 +26,46 @@ export function writeSessionFile(p, data) {
25
26
  }
26
27
 
27
28
  export async function handleEngCommand(ctx) {
28
- const { agent, pushLine, pushLabel, persistRaw, showPicker } = ctx
29
+ const { agent, pushLine, pushLabel } = ctx
29
30
  agent.config.agent ??= {}
30
- const methodologyPath = join(agent.cwd, "METHODOLOGY.md")
31
-
32
- // Toggle on: check METHODOLOGY.md exists
33
- if (!agent.config.agent.engineering) {
34
- if (!existsSync(methodologyPath)) {
35
- pushLabel("❯ Eng", ansi.bold + C.tool)
36
- pushLine("METHODOLOGY.md not found in project root.", C.warn)
37
- const choice = await showPicker("Create METHODOLOGY.md?", [
38
- { type: "header", text: "Engineering mode requires a methodology file" },
39
- { type: "item", text: "Yes, create from template", action: "create" },
40
- { type: "item", text: "No, cancel", action: "cancel" },
41
- ])
42
- if (!choice || choice.action !== "create") return
43
- const src = join(templateDir, "methodology-template.md")
44
- copyFileSync(src, methodologyPath)
45
- pushLine(`Created METHODOLOGY.md (from template) → edit it to fit your project`, C.tool)
46
- }
47
- }
48
31
 
32
+ // Toggle on/off — no prerequisite check (FR11: engineering mode depends on no
33
+ // project file at all; a missing docs tree or git repo must not block it).
49
34
  agent.config.agent.engineering = !agent.config.agent.engineering
35
+ // §11.2 D-24b: per-review instances die with the mode (fresh convergence cycles
36
+ // on the next toggle).
37
+ agent._advisorRuns = new Map()
50
38
  if (!agent.config.agent.engineering) {
51
- agent._engDesignToken = null // invalidate stale token
52
- agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
39
+ // R16 (2026-09-06 F-R16a): OFF 不清 token——有效 token 跨模式存活(仅 TTL 过期
40
+ // 在三清理时机删:恢复过滤 / 开模式清过期 / spawn 门禁拒时删槽)。
53
41
  // OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
54
42
  // mode flip is invisible to the agent otherwise. (ON needs none here: the injector
55
43
  // in agent.mjs already announces ON transitions on the next turn.)
56
44
  agent._pendingReminders = agent._pendingReminders ?? []
57
45
  agent._pendingReminders.push(ENG_OFF_REMINDER)
58
46
  }
59
- await persistEngineering(ctx, agent)
47
+ // 开工程模式(真实 OFF→ON 转换)→ 清过期 token(有效保留——用户裁定"打开工程
48
+ // 模式时应该清理"——F-R16b ②——与 eng tool enter 同语义)。
49
+ const clearedExpired = agent.config.agent.engineering ? purgeExpiredDesignTokens(agent) : 0
50
+ await persistEngineering(agent)
60
51
  pushLabel("❯ Eng", ansi.bold + C.tool)
61
52
  pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"} (session)`, C.tool)
62
53
  if (agent.config.agent.engineering) {
63
- pushLine(` → strictly following ${methodologyPath}`, C.dim)
54
+ pushLine(` → design-before-code enforced (design review + user approval before code)`, C.dim)
55
+ if (clearedExpired > 0) {
56
+ pushLine(` → cleared ${clearedExpired} expired design token${clearedExpired === 1 ? "" : "s"}; valid tokens from prior reviews stay usable`, C.dim)
57
+ }
64
58
  }
65
59
  }
66
60
 
67
61
  /**
68
- * Dual persistence (2026-08-29engineering is session-level): write the flipped flag into
69
- * the CURRENT session slot first (slot authority shared with VS Code, per-session), then
70
- * the config.json mirror (CLI visibility/compat; no longer the cross-session source of truth).
62
+ * Slot-only persistence (2026-09-08 — ENG-SESSION-PROVIDER-CLEANUP D1.1): the flipped flag
63
+ * goes into the CURRENT session slot only the slot is the sole authority (shared with
64
+ * VS Code, per-session; config.json is just the initial default, no mirror write).
71
65
  * The in-memory agent.config.agent.engineering (already flipped) stays the live authority for
72
66
  * this process; saveSession also round-trips it on every turn-end write.
73
67
  */
74
- async function persistEngineering(ctx, agent) {
68
+ async function persistEngineering(agent) {
75
69
  const slot = activeSlot(agent.cwd)
76
70
  try {
77
71
  const p = slotPath(agent.cwd, slot)
@@ -80,11 +74,5 @@ async function persistEngineering(ctx, agent) {
80
74
  data.engineering = agent.config.agent.engineering
81
75
  writeSessionFile(p, data)
82
76
  }
83
- } catch { /* slot missing/unreadable — config mirror still written */ }
84
- if (ctx.persistRaw) {
85
- await ctx.persistRaw((raw) => {
86
- raw.agent ??= {}
87
- raw.agent.engineering = agent.config.agent.engineering
88
- })
89
- }
77
+ } catch { /* slot missing/unreadable — in-memory flag already flipped; saveSession persists at turn end */ }
90
78
  }
@@ -1,9 +1,7 @@
1
- /** /exit command: exit TUI (same path as Ctrl+C).
2
- * Direct synchronous process.exit — prevents the post-handler render()
3
- * from redrawing the TUI over the cleaned terminal. The actual cleanup
4
- * (saveSession + closeMcp + terminal reset) runs once
5
- * via the process.on("exit") handler registered in index.mjs.
6
- */
7
- export async function handleExitCommand(_ctx) {
8
- process.exit(0)
1
+ /** /exit command: exit TUI via ctx.exit(F-2——RESIZE-MOUSE-LEAK-FIX——与 Ctrl+C 同退出
2
+ * 形态:cleanup 同步执行 + 延迟 exit——非同派发路径)。不再直调 process.exit(0)(零提前量——
3
+ * 清理押 'exit' 事件);handleSlash 返回后的无条件 render() render-loop tuiActive 守卫
4
+ * 抑制(cleanup 已清活动态——不把 TUI 帧重绘到已恢复的主屏)。 */
5
+ export async function handleExitCommand(ctx) {
6
+ ctx.exit?.() // ctx.exit 由 index.mjs 装配(cleanup + 100ms 延迟 exit)
9
7
  }
@@ -82,9 +82,15 @@ export async function handleMcpCommand(ctx, args = []) {
82
82
  }
83
83
 
84
84
  async function removeServer(name) {
85
+ // D-F5a(#7)先盘后存:磁盘 fresh raw.mcp.servers 上只删目标条目——不整节写回内存
86
+ // agent.config.mcp.servers(内存含 reloadMcpFromDisk 的 keptConnected 尾巴——
87
+ // 整节写回会把对端磁盘上其他 server 的新改动一起抹掉);冲突放弃不留内存 ghost
88
+ await persistRaw((raw) => {
89
+ raw.mcp ??= { servers: [] }
90
+ if (!Array.isArray(raw.mcp.servers)) return
91
+ raw.mcp.servers = raw.mcp.servers.filter((s) => s?.name !== name)
92
+ })
85
93
  agent.config.mcp.servers = getServers().filter((s) => s.name !== name)
86
- // 评审 #1:磁盘无 mcp 段时(T23 场景——mcp 段被整体删除而连接保留)raw.mcp 为 undefined → 先建段再写
87
- await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = agent.config.mcp.servers })
88
94
  // Remove from tool list
89
95
  const { removeMcpTools } = await import("../mcp.mjs")
90
96
  removeMcpTools(agent, name)
@@ -1,23 +1,25 @@
1
1
  import { C } from "./ansi.mjs"
2
+ import { parseModelRef } from "../config.mjs"
2
3
 
3
- /** /model command: open model picker, or switch provider directly via `/model <provider>[:model]`.
4
+ /** /model command: open the session model picker, or switch the SESSION model directly via
5
+ * `/model <provider>:<model>`(MODEL-MERGE-SESSION——裁定③:裸 provider 拒——显式 p:m;
6
+ * MODEL-SELECTION v2:显式复合一律放行——仅[空值/裸值/未知 provider]无效(M4),候选清单
7
+ * 由运行期拉取决定——不再有成员校验)。
8
+ * /model 是会话级操作(写槽——不写 config)——config 默认模型走 /config → 默认模型。
4
9
  * ctx: { agent, openModelPicker, selectModel, pushLine } */
5
10
  export async function handleModelCommand(ctx, args = []) {
6
- const raw = args[0]?.toLowerCase()
11
+ const raw = args[0]?.trim()
7
12
  if (!raw) {
8
13
  ctx.openModelPicker().catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
9
14
  return
10
15
  }
11
- // Parse "provider:model" syntax
12
- const colonIdx = raw.indexOf(":")
13
- const providerName = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw
14
- const modelName = colonIdx >= 0 ? raw.slice(colonIdx + 1) : null
15
-
16
- const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === providerName)
17
- if (!target) {
18
- const available = ctx.agent.providers.map((p) => p.name).join(", ")
19
- ctx.pushLine(`Unknown provider: ${providerName} (available: ${available})`, C.error)
16
+ // 首冒号复合解析(parseModelRef——裸 provider / 未知 provider / 空模型段 → ok:false;
17
+ // 候选外/多冒号一律放行——M4)
18
+ const parsed = parseModelRef(raw, ctx.agent.providers)
19
+ if (!parsed.ok) {
20
+ ctx.pushLine(`[error] ${parsed.reason}`, C.error)
21
+ ctx.pushLine(`/model 用法: /model provider:model(会话级——config 默认走 /config → 默认模型)`, C.dim)
20
22
  return
21
23
  }
22
- await ctx.selectModel({ provider: target.name, model: modelName || target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
24
+ await ctx.selectModel({ provider: parsed.provider.name, model: parsed.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
23
25
  }
@@ -9,8 +9,9 @@ export async function handleNewCommand(ctx) {
9
9
  const doNewSession = () => {
10
10
  const slot = newSession(agent.cwd)
11
11
  // 2026-08-31 会诊 F3:resetSessionState 清全量会话态(_fullHistory/title/_sessionStart/
12
- // _engDesignToken/压缩与验证计数等)——原实现只清 agent.history,新会话首次落盘把旧
13
- // 会话完整人类线 + 旧标题写进新 slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
12
+ // 多槽设计凭证(_engDesignTokens——单值镜像 _engDesignToken 已退役 D3)/压缩与验证计数
13
+ // 等)——原实现只清 agent.history,新会话首次落盘把旧会话完整人类线 + 旧标题写进新
14
+ // slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
14
15
  resetSessionState(agent)
15
16
  agent._slot = slot
16
17
  state.tasks = []
@@ -40,5 +40,12 @@ export async function handleReindexCommand(ctx) {
40
40
  ])
41
41
  pushLine(` code: ${cr.total} files, +${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
42
42
  pushLine(` doc: ${dr.total} files, +${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
43
+ // Unlisted extensions (PORTABILITY PO-9): files the index skipped because no
44
+ // index claims their extension — visible here + declarable in the project.
45
+ const unlisted = cr?.unlistedExts?.count ? cr.unlistedExts : dr?.unlistedExts
46
+ if (unlisted?.count > 0) {
47
+ const sample = unlisted.exts.map((e) => e.ext).join(" ")
48
+ pushLine(` ${unlisted.count} file(s) with unlisted extension(s) skipped (${sample}) — declare them in .thincoder/conventions.json (index.codeExtensions / index.docExtensions) to index them`, C.warn)
49
+ }
43
50
  pushLine(`[reindex] Done, ${total} entries total. Vectors will be lazily generated on next search.`, C.tool)
44
51
  }
@@ -1,4 +1,4 @@
1
- import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy } from "../session.mjs"
1
+ import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy, readEndMarker } from "../session.mjs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
3
  import { restoreLines } from "./startup.mjs"
4
4
  import { stringWidth, sliceByWidth } from "./render.mjs"
@@ -19,8 +19,16 @@ export async function handleRenameCommand(ctx, args) {
19
19
  pushLine(`Title too long (max 80 chars)`, C.error)
20
20
  return
21
21
  }
22
- if (!renameSlot(agent.cwd, slot, title)) {
23
- pushLine(`Rename failed active session (slot ${slot}) not found`, C.error)
22
+ // §12.2.5:renameSlot 契约 { ok, reason }——失败原因对用户可见(F3)
23
+ const r = renameSlot(agent.cwd, slot, title)
24
+ if (!r.ok) {
25
+ const why = {
26
+ "file-missing": `active session (slot ${slot}) not found`,
27
+ "parse-failure": `session file (slot ${slot}) is corrupted`,
28
+ "mtime-conflict": "session changed on disk concurrently — retry",
29
+ "invalid-slot": `invalid slot ${slot}`,
30
+ }[r.reason] ?? r.reason
31
+ pushLine(`Rename failed — ${why}`, C.error)
24
32
  return
25
33
  }
26
34
  agent.title = title
@@ -37,6 +45,13 @@ export async function handleSessionCommand(ctx) {
37
45
  pushLine("No sessions (use /new to start a new session)", C.dim)
38
46
  return
39
47
  }
48
+ // 2026-09-05 §10 D-5:本端高亮按端记录(● = 记录槽 ∈ 列表 ? 记录槽 : manifest active
49
+ // 回退——含"记录槽已被对端删除"的守卫);listSlots 的 manifest active 语义不变
50
+ // (ACP session/list 零变化——manifest active 保留为列表回退高亮)
51
+ const rec = readEndMarker(agent.cwd)
52
+ const highlightSlot = (rec?.slot != null && slots.some((s) => s.slot === rec.slot))
53
+ ? rec.slot
54
+ : (slots.find((s) => s.isActive)?.slot ?? null)
40
55
  const shortDate = (d) => {
41
56
  const dt = new Date(d)
42
57
  return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
@@ -53,7 +68,7 @@ export async function handleSessionCommand(ctx) {
53
68
  const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
54
69
  const when = shortDate(s.updatedAt)
55
70
  const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
56
- const marker = s.isActive ? " ●" : ""
71
+ const marker = s.slot === highlightSlot ? " ●" : ""
57
72
  return {
58
73
  type: "item",
59
74
  text: `Slot ${s.slot} │ ${turns} │ ${when} │ ${label}${model}${marker}`,
@@ -1,7 +1,7 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
 
3
3
  /** Subagent role slots — each has an independent model override slot */
4
- export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder"]
4
+ export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder", "eng-designer"]
5
5
 
6
6
  /** Human-readable effective display with inheritance source. role=null → global slot.
7
7
  * source semantics: "type" = role-specific override (subagentModels[role]),
@@ -21,12 +21,14 @@ function slotDisplay(agent, role) {
21
21
 
22
22
  /** /submodel command: subagent model config — picker menu (default) or direct args.
23
23
  * ctx: { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot }
24
- * /submodel → picker: global + 4 role slots
24
+ * /submodel → picker: global + 5 role slots
25
25
  * /submodel <value> → set global default
26
26
  * /submodel <type> <value> → set a role slot
27
27
  * /submodel <type> → show a slot
28
28
  * /submodel reset [type] → clear global (or a slot)
29
- * value forms: provider:model | provider name | model name (same as subagent tool model arg) */
29
+ * value forms: provider:model | provider name | model name (same as subagent tool model arg)
30
+ * MODEL-MERGE-SESSION:会话模型 = provider:model 复合(agent.activeProvider/activeModel 双字段
31
+ * 恒非空——无渠道默认字段——父会话模型引用一律复合值)。 */
30
32
  export async function handleSubmodelCommand(ctx, args = []) {
31
33
  const { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot } = ctx
32
34
  const input = args.join(" ").trim()
@@ -85,7 +87,7 @@ export async function handleSubmodelCommand(ctx, args = []) {
85
87
  return
86
88
  }
87
89
 
88
- // ── Picker menu: global + 4 role slots ──
90
+ // ── Picker menu: global + 5 role slots ──
89
91
  // for(;;) loop relies on showPicker's async/Promise suspension — every iteration
90
92
  // awaits a user choice; Esc (null) exits. Mirrors openModelPicker's menu-loop pattern.
91
93
  for (;;) {
@@ -132,7 +134,8 @@ export async function handleSubmodelCommand(ctx, args = []) {
132
134
  pushLine(`Subagent global model set to \`${value}\`.`, C.text)
133
135
  }
134
136
  } else if (sub.action === "parent") {
135
- const value = `${agent.activeProvider}:${agent.activeModel ?? agent.provider?.model}`
137
+ // 会话复合值语义(MODEL-MERGE-SESSION——activeModel 恒非空——不再回退渠道字段)
138
+ const value = `${agent.activeProvider}:${agent.activeModel ?? ""}`
136
139
  if (role) {
137
140
  await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
138
141
  } else {
@@ -97,16 +97,16 @@ async function applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCus
97
97
  // 开 auto = 要思考:清显式 off 标记(thinking:null,NF1 约定;交付评审 #1)——残留 null
98
98
  // 会让 auto 每轮写入的 reasoning_effort 与 enable_thinking:false 矛盾同发(F2 违约)。
99
99
  // 仅清 null:thinking-type 模型的 {type:"disabled"} 不在评审 #1 范围,保持既有语义。
100
- if (cur.thinking === null) { delete cur.thinking; await syncProviderField("thinking", undefined) }
100
+ if (cur.thinking === null) { delete cur.thinking; await syncProviderField(agent.activeProvider, "thinking", undefined) }
101
101
  delete cur.reasoningEffort
102
- await syncProviderField("reasoningEffort", undefined)
102
+ await syncProviderField(agent.activeProvider, "reasoningEffort", undefined)
103
103
  }
104
104
  } else if (e.action === "effort") {
105
105
  // 选档位 = 要思考:清显式 off 标记(thinking:null,NF1 约定;交付评审 #1)——残留 null 会让
106
106
  // enable_thinking:false 与 reasoning_effort 矛盾同发(F2 违约)。仅清 null(同上注释)。
107
- if (cur.thinking === null) { delete cur.thinking; await syncProviderField("thinking", undefined) }
107
+ if (cur.thinking === null) { delete cur.thinking; await syncProviderField(agent.activeProvider, "thinking", undefined) }
108
108
  cur.reasoningEffort = e.level
109
- await syncProviderField("reasoningEffort", e.level)
109
+ await syncProviderField(agent.activeProvider, "reasoningEffort", e.level)
110
110
  } else {
111
111
  const enable = e.action === "on"
112
112
  if (isEffortOnly) {
@@ -116,9 +116,9 @@ async function applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCus
116
116
  // "on" 默认 effort 取 spec 枚举首值(交付评审 #2):硬编码 "high" 对 qwen3.8-max
117
117
  // (enum xhigh/medium/low)无效,会被 core.mjs 枚举校验 throw(400 前置)
118
118
  else { delete cur.thinking; if (!cur.reasoningEffort) cur.reasoningEffort = spec.reasoningEffortEnum?.[0] ?? "high" }
119
- await syncProviderField("thinking", cur.thinking)
120
- if (!enable) await syncProviderField("reasoningEffort", undefined)
121
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
119
+ await syncProviderField(agent.activeProvider, "thinking", cur.thinking)
120
+ if (!enable) await syncProviderField(agent.activeProvider, "reasoningEffort", undefined)
121
+ else await syncProviderField(agent.activeProvider, "reasoningEffort", cur.reasoningEffort)
122
122
  } else {
123
123
  if (enable) {
124
124
  cur.thinking = { type: thinkOnValue }
@@ -127,11 +127,11 @@ async function applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCus
127
127
  cur.thinking = isCustomThink ? undefined : { type: "disabled" }
128
128
  delete cur.reasoningEffort
129
129
  }
130
- await syncProviderField("thinking", cur.thinking)
130
+ await syncProviderField(agent.activeProvider, "thinking", cur.thinking)
131
131
  if (enable) {
132
- await syncProviderField("reasoningEffort", cur.reasoningEffort)
132
+ await syncProviderField(agent.activeProvider, "reasoningEffort", cur.reasoningEffort)
133
133
  } else {
134
- await syncProviderField("reasoningEffort", undefined)
134
+ await syncProviderField(agent.activeProvider, "reasoningEffort", undefined)
135
135
  }
136
136
  }
137
137
  }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { existsSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"
9
- import { join } from "node:path"
9
+ import { isAbsolute, join } from "node:path"
10
10
  import { ansi, C } from "./ansi.mjs"
11
11
 
12
12
  const MAX_UNDO = 50
@@ -20,7 +20,8 @@ export function snapshotForUndo(agent, toolName, args, cwd) {
20
20
  const path = args.path ?? args.file
21
21
  if (!path || typeof path !== "string") return
22
22
 
23
- const abs = join(cwd, ...path.split("/"))
23
+ // 绝对路径直接用(path.join 对绝对段不重置——Windows 反斜杠路径也不按 "/" 切分);相对路径整段 join(cwd)
24
+ const abs = isAbsolute(path) ? path : join(cwd, path)
24
25
  let backup = null
25
26
  try {
26
27
  if (existsSync(abs)) {
@@ -68,7 +69,7 @@ export async function handleUndoCommand(ctx) {
68
69
  const e = await showPicker("Undo", entries)
69
70
  if (!e) return
70
71
  const item = stack[e.idx]
71
- const abs = join(agent.cwd, ...item.path.split("/"))
72
+ const abs = isAbsolute(item.path) ? item.path : join(agent.cwd, item.path)
72
73
 
73
74
  try {
74
75
  if (item.backup === null) {
@@ -1,9 +1,10 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
+ import { upgradeFailureText } from "./update-notice.mjs"
2
3
 
3
4
  /** /upgrade command: check for updates and optionally upgrade.
4
5
  * ctx: { agent, pushLine, pushLabel, showPicker } */
5
6
  export async function handleUpgradeCommand(ctx) {
6
- const { pushLine, pushLabel, showPicker } = ctx
7
+ const { pushLine, pushLabel, showPicker, render } = ctx
7
8
  const { checkForUpdate } = await import("../upgrade.mjs")
8
9
  const { readFileSync } = await import("node:fs")
9
10
 
@@ -31,13 +32,27 @@ export async function handleUpgradeCommand(ctx) {
31
32
  pushLine(`Upgrading to ${result.latest}...`, C.tool)
32
33
  const { exec } = await import("node:child_process")
33
34
  const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
34
- cp.stdout?.on("data", () => {})
35
- cp.stderr?.on("data", () => {})
35
+ let stdout = ""
36
+ let done = false
37
+ cp.stdout?.on("data", (d) => { stdout += d })
38
+ cp.stderr?.on("data", (d) => { stdout += d })
39
+ // 2026-09-05(advisor 🟡#5/#7):异步回调必须显式 render(pushLine 只写 lines 不绘制——
40
+ // handleSlash 返回后的统一 render 先于 exec 完成,结论行要等下次输入才可见);失败文案
41
+ // 复用 upgradeFailureText(附 npm 输出尾 ≤3 行——与 update-notice 后台路径同诊断能力)
42
+ cp.on("error", (e) => {
43
+ if (done) return
44
+ done = true
45
+ pushLine(`✗ Upgrade failed (${e.message}). Run \`thincoder upgrade\` manually.`, C.error)
46
+ render?.()
47
+ })
36
48
  cp.on("close", (code) => {
49
+ if (done) return
50
+ done = true
37
51
  if (code === 0) {
38
52
  pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
39
53
  } else {
40
- pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
54
+ pushLine(upgradeFailureText(code, stdout), C.error)
41
55
  }
56
+ render?.()
42
57
  })
43
58
  }
@@ -1,26 +1,38 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
-
3
1
  /** persistRaw / syncProviderField / maskKey: config read/write helpers.
4
2
  * Extracted from index.mjs for shared use by slash-commands, wizard, and pickers.
5
- * createConfigHelpers(agent) returns { persistRaw, syncProviderField, maskKey } */
6
- export function createConfigHelpers(agent) {
7
- /** Read config.json mutate persist to disk */
3
+ * createConfigHelpers(agent) returns { persistRaw, syncProviderField, maskKey }
4
+ * R10 F5(D-F5b):persistRaw 全链(磁盘新鲜读→mutate→写)收口进 config.mjs
5
+ * writeConfigAtomic——mtime 门控 + .bak + 冲突放弃(见 MULTI-INSTANCE-COLLAB.md §2a.2)。
6
+ * createConfigHelpers(agent, opts):opts.configPath 为测试注入缝(默认 configPath——
7
+ * 生产 index.mjs 单参调用零变化)。 */
8
+ export function createConfigHelpers(agent, opts = {}) {
9
+ const cfgPath = opts.configPath ?? null // null → 默认 configPath(动态 import 解)
10
+
11
+ /** 磁盘新鲜读 → mutate(单操作)→ mtime 门控写;冲突时 throw(提示语见下——TUI
12
+ * slash 分发层统一 [error] 行展示;调用方若自带 try/catch 展示 Save failed 同文案)。 */
8
13
  async function persistRaw(mutate) {
9
- const { saveConfig, configPath } = await import("../config.mjs")
10
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
11
- mutate(raw)
12
- saveConfig(raw)
14
+ const { writeConfigAtomic, configPath } = await import("../config.mjs")
15
+ const r = writeConfigAtomic(cfgPath ?? configPath, mutate)
16
+ if (!r.ok) throw new Error("config changed on disk concurrently — retry")
13
17
  }
14
18
 
15
- /** Sync a field of the current provider to the providers array and persist to disk */
16
- async function syncProviderField(field, value) {
17
- const target = agent.providers.find((p) => p.name === agent.activeProvider)
18
- if (!target) return
19
- if (value === undefined) delete target[field]
20
- else target[field] = value
19
+ /** Sync a field of a provider (by name) to the providers array and persist to disk.
20
+ * D-F5a(#1):磁盘 fresh raw.providers 上单字段补丁——不取 agent.providers 整数组
21
+ * (长跑进程的内存 providers 快照不得整体写回——会抹掉对端对磁盘其他改动的保留)。
22
+ * 内存 agent.providers 目标项同步镜像(旧行为:改内存 + 整节写盘——镜像保留内存
23
+ * 一致性,磁盘写只剩单字段)。value === undefined 删除该字段。 */
24
+ async function syncProviderField(name, field, value) {
25
+ const mem = agent.providers?.find((p) => p?.name === name)
26
+ if (!mem) return // 内存无此 provider(旧语义 early return——不产生空写)
21
27
  await persistRaw((raw) => {
22
- raw.providers = agent.providers
28
+ raw.providers ??= []
29
+ const target = raw.providers.find((p) => p?.name === name)
30
+ if (!target) return // 磁盘目标已被对端删除 → 只做内存镜像,不落盘
31
+ if (value === undefined) delete target[field]
32
+ else target[field] = value
23
33
  })
34
+ if (value === undefined) delete mem[field]
35
+ else mem[field] = value
24
36
  }
25
37
 
26
38
  /** Mask API key for display */
@@ -22,7 +22,7 @@ export async function runDistill(ctx) {
22
22
  }
23
23
  let saved = 0
24
24
  for (const c of candidates) {
25
- pushLine(`── Candidate [${c.type}] ${c.title} (scope: ${c.scope ?? "personal"})`, C.warn)
25
+ pushLine(`── Candidate [${c.type}] ${c.title} (layer: ${c.layer ?? "personal"})`, C.warn)
26
26
  for (const line of c.content.split("\n").slice(0, 6)) pushLine(` ${line}`, C.dim)
27
27
  if (c.type === "rule") pushLine(" (rule type — consider writing manually; press y to extract)", C.warn)
28
28
  const accept = await askPermission("distill-save", { title: c.title })