thincoder 0.12.9 → 0.12.11

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/README.md CHANGED
@@ -20,7 +20,7 @@ Design philosophy (the entire meaning of the name): if the Node standard library
20
20
  - **Memory system**: three layers (personal/project/team), FTS5 + vector RRF hybrid retrieval, git-friendly markdown format
21
21
  - **Two-phase tool scheduling**: permission prompts serialized, read-only tools parallelized, side-effect tools serialized
22
22
  - **Session persistence** ⭐0.5.0: up to 5 archive slots, `/session` to switch anytime, tool results visible after restore. Process-level isolation — multiple instances in the same directory each get their own session slot
23
- - **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation
23
+ - **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation; per-subagent model override (`subagent` tool `model` arg or `agent.subagentModel` config — e.g. discuss with `glm-5.2`, let `deepseek-v4-flash` implement)
24
24
  - **Plan Mode**: read-only exploration + design, implement after user approval
25
25
  - **AUTO mode**: `/auto` full authorization, no confirmations on long tasks
26
26
  - **Task tracking**: `task` tool breaks down multi-step work, status bar ✓n/m live progress, auto-filters completed items
@@ -85,7 +85,7 @@ thincoder upgrade
85
85
 
86
86
  Running from source: replace `thincoder` above with `node bin/thincoder.mjs`.
87
87
 
88
- Slash commands in the TUI: `/help`, `/model` (two-level picker: first select provider, then model; `/model <provider>:<name>` switches directly), `/provider` (add/remove providers, set keys, custom endpoints), `/think` (thinking mode toggle and reasoning effort), `/config` (view config, `/config embedkey` for the embedding key, `/config set` for parameters), `/session` (list/switch archived sessions), `/reindex` (rebuild the index), `/extract` (extract knowledge from the current session), `/restore` (restore checkpoint), `/clear`, `/exit`. High-frequency commands support abbreviations: `/h` `/x` `/m` `/p` `/t` `/c` `/n`. Typing `/` shows live matching hints in the status bar. Model picker supports search/filter — type to narrow down results.
88
+ Slash commands in the TUI: `/help`, `/model` (two-level picker: first select provider, then model; `/model <provider>:<name>` switches directly), `/submodel` (subagent models per type — picker over global + explore/plan/coder/eng-coder slots, or `/submodel <type> <provider:model>` directly), `/shell` (platform-aware picker of available shells — e.g. `/shell` → pick Git Bash/pwsh, or `/shell "C:\Program Files\Git\bin\bash.exe"`, `/shell reset`; fixes win11 cmd encoding/command issues), `/provider` (add/remove providers, set keys, custom endpoints), `/think` (thinking mode toggle and reasoning effort), `/config` (view config, `/config embedkey` for the embedding key, `/config set` for parameters), `/session` (list/switch archived sessions), `/reindex` (rebuild the index), `/extract` (extract knowledge from the current session), `/restore` (restore checkpoint), `/clear`, `/exit`. High-frequency commands support abbreviations: `/h` `/x` `/m` `/p` `/t` `/c` `/n`. Typing `/` shows live matching hints in the status bar. Model picker supports search/filter — type to narrow down results.
89
89
 
90
90
  Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`), `THINCODER_BASE_URL`, `THINCODER_MODEL`, `SILICONFLOW_API_KEY`.
91
91
 
@@ -112,6 +112,7 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
112
112
  },
113
113
  ],
114
114
  "activeProvider": "deepseek", // currently active provider name
115
+ "shell": null, // bash tool shell (win11: e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default — cmd on Windows (UTF-8 forced per command), /bin/sh elsewhere. TUI: /shell
115
116
  "embedding": {
116
117
  // optional: without it, retrieval is pure FTS
117
118
  "baseURL": "https://api.siliconflow.cn/v1",
@@ -120,6 +121,8 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
120
121
  },
121
122
  "agent": {
122
123
  "maxTurns": 100, // tool-loop cap
124
+ "subagentModel": null, // default subagent provider/model override: "provider:model" | provider name | model name; null = inherit parent provider. Per-call: subagent tool `model` arg
125
+ "subagentModels": {}, // per-type override: { "explore": "...", "plan": "...", "coder": "...", "eng-coder": "..." }; priority: tool model arg > this > subagentModel > parent provider
123
126
  "compactThreshold": 100000, // context compaction threshold (approx. tokens)
124
127
  },
125
128
  "memory": {
@@ -209,6 +212,18 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
209
212
 
210
213
  ## Changelog
211
214
 
215
+ ### 0.12.11 (2026-08)
216
+ - **Subagent model per type** — `subagent` tool `model` arg, `config.agent.subagentModels` (per explore/plan/coder/eng-coder) and `config.agent.subagentModel` (global fallback); priority: tool arg > type > global > parent provider. `/submodel` TUI command: picker over 5 slots (global + 4 roles) with provider→model selection, or direct args (`/submodel coder deepseek:deepseek-v4-flash`).
217
+ - **Configurable bash shell** — `config.shell` or `/shell` TUI command: platform-aware picker (Windows: pwsh/Git Bash/WSL bash; POSIX: bash/zsh/fish — availability-detected, custom path supported). Windows default cmd now forces UTF-8 per command (`chcp 65001`) — fixes garbled Chinese output on win11.
218
+ - **Markdown table alignment fix** — `stringWidth` strips ANSI (zero display width) and rendered table rows are padded back to the computed width; inline markers (`` `code` ``, `**bold**`) no longer shift the borders.
219
+ - **sliceByWidth keeps ANSI sequences whole** — never slices mid-escape-sequence.
220
+
221
+ ### 0.12.10 (2026-08)
222
+ - **Code-quality pass (advisor subsystem):**
223
+ - **Drop 11 unused exports** — internal-use symbols no longer leak through the module API (advisor table headers/constants, plan reminders, token-UUID helper, shrinkOversized).
224
+ - **Advisor tool-timeout timer is now cleared** when the tool wins the race (no dangling timers); static import replaces a dynamic import on the hot path.
225
+ - **Advisor re-review no longer trusts git output** — the follow-up path previously injected a `git diff HEAD` snapshot: once fixes were committed the diff was empty and the model read "no changes" as "no fixes", misreporting fixed items as unfixed. The follow-up now injects **no git information at all** — verification is `read`-only, evidence must quote this round's read output (line numbers from the stale prior table are not evidence), and dead snapshot-dedup fields were removed.
226
+
212
227
  ### 0.12.9 (2026-08)
213
228
  - **Prompt-system quality pass (both CLI and VS Code extension, byte-identical sync):**
214
229
  - **Advisor-after-code rule moved from system.md to discipline.md** — engineering mode no longer receives the conflicting "call advisor after changing code" instruction (its review-timing rules say do not call unprompted). Standard mode behavior unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -6,8 +6,8 @@ import { join } from "node:path"
6
6
 
7
7
  export const ADVISOR_MD_PATH = ".thincoder/advisor.md"
8
8
  export const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
9
- export const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
10
- export const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
9
+ const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
10
+ const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
11
11
  export const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
12
12
 
13
13
  const DEFAULT_CRITERIA = `Review the code changes, focusing on:
@@ -57,7 +57,6 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
57
57
  }
58
58
 
59
59
  // Pre-collected changes — the design doc diff.
60
- // _advisorLastSnapshot is only consumed by code-review convergence — skip the write here.
61
60
  const snapshots = collectRepoSnapshots(repos, agent.cwd)
62
61
  if (snapshots.length > 0) {
63
62
  parts.push("## Design Document (git diff)")
@@ -96,7 +95,7 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
96
95
  return parts.join("\n")
97
96
  }
98
97
 
99
- // Convergence data (round 2+)
98
+ // Convergence data (round 2+). Design reviews returned above — only code reviews reach here.
100
99
  if (prior && (agent._advisorRound || 0) > 0) {
101
100
  const response = extractAgentResponseTable(agent.history, prior.sinceIdx)
102
101
  || "(Agent did not provide a response table — re-evaluate each issue)"
@@ -164,10 +163,11 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
164
163
  parts.push("## Instructions")
165
164
  parts.push("1. IMPORTANT: in the diff, `-` lines are REMOVED content (no longer in the file), `+` lines are ADDED. The prior issue table (if any) is HISTORY — always verify current file state with `read` before judging an item.")
166
165
  if (isReReview) {
167
- parts.push("2. STALE-CONTEXT WARNING: any diff embedded in earlier messages is a historical snapshot — treat it as expired. Only the \"Current Changes\" section above and fresh `read` results describe the current state. Never quote a `-` line from any diff as if it were live code.")
168
- parts.push("3. Do NOT re-read AGENTS.md / design docsconventions were established in round 1. Focus on verifying the prior issue table against the current diff.")
169
- parts.push("4. `read` only the files touched by the fixes. Batch independent reads/greps in a single reply.")
170
- parts.push("5. Produce your verification table. Do not re-read content you already have.")
166
+ parts.push("2. STALE-CONTEXT WARNING: any diff or file content embedded in earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state. Never quote a `-` line from an earlier diff as if it were live code.")
167
+ parts.push("3. Verify the prior issue table against the CURRENT FILE STATE use `read`, never `git diff` alone. Fixes may already be committed: an empty `git diff` does NOT mean nothing changed. `git log -3` shows recent commits.")
168
+ parts.push("4. `read` the files in the Review Scope in full — ALWAYS, regardless of what `git diff` shows. Batch reads/greps in a single reply.")
169
+ parts.push("5. Evidence rule: every 'Unfixed'/'New' finding MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.")
170
+ parts.push("6. Produce your verification table. Do not re-read content you already have.")
171
171
  } else {
172
172
  parts.push("2. Read `AGENTS.md` / design docs only if they exist (check once; do not re-probe with multiple patterns).")
173
173
  parts.push("3. `read` changed files for full context beyond the diff. Batch independent reads/greps in a single reply instead of one call per round-trip.")
@@ -5,8 +5,8 @@
5
5
  import { execFileSync } from "node:child_process"
6
6
  import { dirname, basename, resolve } from "node:path"
7
7
 
8
- export const GIT_TIMEOUT = 5_000
9
- export const MAX_EMBEDDED_DIFF = 50_000
8
+ const GIT_TIMEOUT = 5_000
9
+ const MAX_EMBEDDED_DIFF = 50_000
10
10
 
11
11
  /**
12
12
  * Find the git repository roots that contain the agent's touched files.
@@ -8,7 +8,7 @@ import { toOpenAISchema } from "../tools/index.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
9
  import { extractPriorIssueTable } from "../advisor/history.mjs"
10
10
 
11
- export const MAX_ADVISOR_TURNS = 100
11
+ const MAX_ADVISOR_TURNS = 100
12
12
  // Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
13
13
  // (full review, verify+fix cycles, strict verification). A 6th call means the
14
14
  // model is looping — refuse it instead of burning tokens on a review that cannot
@@ -172,18 +172,21 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
172
172
  if (!tool) {
173
173
  result = `Error: unknown tool "${tc.name}". Available: ${[...ADVISOR_TOOL_BY_NAME.keys()].join(", ")}`
174
174
  } else {
175
- // Execute with timeout
175
+ // Execute with timeout (clear the timer when the tool wins the race —
176
+ // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
176
177
  try {
177
- const toolPromise = tool.execute(args, {
178
- cwd,
179
- agent,
180
- onOutput,
181
- signal,
178
+ let timeoutId
179
+ const timeoutPromise = new Promise((_, reject) => {
180
+ timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
182
181
  })
183
- const timeoutPromise = new Promise((_, reject) =>
184
- setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
185
- )
186
- result = await Promise.race([toolPromise, timeoutPromise])
182
+ try {
183
+ result = await Promise.race([
184
+ tool.execute(args, { cwd, agent, onOutput, signal }),
185
+ timeoutPromise,
186
+ ])
187
+ } finally {
188
+ clearTimeout(timeoutId)
189
+ }
187
190
  } catch (e) {
188
191
  const errorType = e.message.includes("timeout") ? "timeout"
189
192
  : e.message.includes("ENOENT") ? "file_not_found"
@@ -224,7 +227,7 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
224
227
  }
225
228
 
226
229
  /** Resolve the advisor's provider: cfg.provider/model when set, otherwise the main agent's provider */
227
- export function resolveAdvisorProvider(agent) {
230
+ function resolveAdvisorProvider(agent) {
228
231
  const cfg = agent.config?.advisor
229
232
  if (cfg?.provider) {
230
233
  try {
package/src/advisor.mjs CHANGED
@@ -2,11 +2,13 @@
2
2
  * advisor.mjs — advisor system-prompt selection, follow-up building, session assembly.
3
3
  * User-message building lives in advisor/messages.mjs; execution (tool loop, provider
4
4
  * resolution, review entry) in advisor/run.mjs; git discovery/collection in
5
- * advisor/repos.mjs; history extraction in advisor/history.mjs.
5
+ * advisor/repos.mjs (design-review diffs only — code-review follow-ups deliberately
6
+ * inject NO git information); history extraction in advisor/history.mjs.
6
7
  *
7
8
  * The advisor runs as a read-only exploration sub-agent with tools
8
- * (read, glob, grep, ls, git, lsp, code_search). It discovers changes
9
- * via git diff, reads files for context, and traces callers via grep/lsp.
9
+ * (read, glob, grep, ls, git, lsp, code_search). Round 1 discovers changes
10
+ * via git diff and reads files for context; convergence rounds (2+) verify
11
+ * fix status with `read` only — no git output is injected or trusted.
10
12
  *
11
13
  * Config:
12
14
  * { advisor: { enabled: true, provider: "deepseek", model: "deepseek-chat" } }
@@ -37,8 +39,6 @@
37
39
  import { readFileSync } from "node:fs"
38
40
  import { join, dirname } from "node:path"
39
41
  import { fileURLToPath } from "node:url"
40
- import { createHash } from "node:crypto"
41
- import { findReviewRepos, collectRepoSnapshots } from "./advisor/repos.mjs"
42
42
  import { extractPriorIssueTable, extractAgentResponseTable } from "./advisor/history.mjs"
43
43
  import { buildAdvisorUserMessage } from "./advisor/messages.mjs"
44
44
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
@@ -88,7 +88,10 @@ export function buildAdvisorSystemPrompt(agent, _prior, reviewType) {
88
88
 
89
89
  /**
90
90
  * Build a follow-up user message for round 2+ — the agent's response table +
91
- * the refreshed diff, without re-sending the full round-1 context.
91
+ * round-aware instructions, without re-sending the full round-1 context.
92
+ * Deliberately NO git information injected (no diff snapshot, no git context):
93
+ * git output misled re-reviews — committed fixes never show in `git diff HEAD`,
94
+ * so the model read "no changes" as "no fixes". Verification is `read`-only.
92
95
  */
93
96
  export function buildAdvisorFollowUp(agent, _prior) {
94
97
  const prior = _prior ?? extractPriorIssueTable(agent.history)
@@ -111,31 +114,15 @@ export function buildAdvisorFollowUp(agent, _prior) {
111
114
  "## Instructions",
112
115
  round === 2
113
116
  ? "Verify each item in the prior table. Flag any obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style). Produce a verification table."
114
- : "Strictly verify ONLY the items in the prior table against the current diff. Do NOT look for new issues.",
117
+ : "Strictly verify ONLY the items in the prior table against the CURRENT FILE STATE (use `read` — an empty diff does not mean the fixes are absent). Do NOT look for new issues.",
115
118
  "",
116
- "IMPORTANT: in any embedded diff, `-` lines are REMOVED content (no longer in the file), `+` lines are ADDED. The prior issue table is HISTORY — always verify current file state with `read` before judging an item as fixed or unfixed.",
119
+ "IMPORTANT: the prior issue table is HISTORY — always verify current file state with `read` before judging an item as fixed or unfixed.",
117
120
  // Round-aware evidence rule: "New" entries only exist in round 2 (round 3+ forbids them).
118
- `STALE-CONTEXT WARNING: all diffs in earlier messages (including round 1) are historical snapshots files have changed since. Only THIS message's "Current Changes" section and fresh \`read\` results are authoritative. Any "Unfixed" entry${round === 2 ? ' (and any "New" entry)' : ""} MUST cite read-verified evidence (file:line from a \`read\` of the current file); uncited findings are unverified and will be ignored.`,
121
+ `STALE-CONTEXT WARNING: only fresh \`read\` results describe the current state never judge from earlier snapshots or from \`git diff\` (committed fixes never show in \`git diff HEAD\`). Read the files to verify. Any "Unfixed" entry${round === 2 ? ' (and any "New" entry)' : ""} MUST quote the exact line content from THIS round's \`read\` output (e.g. \`run.mjs:180: timeoutId = setTimeout(...)\`); line numbers alone are NOT evidence (they may come from the stale prior table). Uncited findings are unverified and will be ignored.`,
119
122
  "",
120
- "Do NOT re-read AGENTS.md / design docs or re-run git status/diff (current changes are below) you already have full context from previous rounds.",
123
+ "Do NOT re-read AGENTS.md / design docs. Verify fix status with `read` only — do not rely on git output: a clean working tree does not mean fixes are absent (they may be committed).",
121
124
  "",
122
125
  ]
123
- const snapshots = collectRepoSnapshots(findReviewRepos(agent), agent.cwd)
124
- const snapshotText = snapshots.join("\n")
125
- const snapshotHash = snapshotText ? createHash("sha1").update(snapshotText).digest("hex") : null
126
- // Skip re-pushing an identical diff (e.g. advisor re-run without any file changes) —
127
- // the previous snapshot is already in the conversation, duplicating it wastes tokens.
128
- if (snapshots.length === 0) {
129
- parts.push("## Current Changes", "(No git repository or no changes detected.)")
130
- } else if (snapshotHash && snapshotHash === agent._advisorLastSnapshotHash) {
131
- // Hash match → the snapshot from the previous round is still current.
132
- // Using hash instead of full-text comparison avoids keeping the entire diff
133
- // string in memory and handles edge cases (e.g. file changed then reverted).
134
- parts.push("## Current Changes", "(No changes since your previous review — the diff snapshot is identical, so the one from your last round remains valid for this round.)")
135
- } else {
136
- parts.push("## Current Changes (git status + git diff HEAD, refreshed)", ...snapshots)
137
- }
138
- agent._advisorLastSnapshotHash = snapshotHash
139
126
  return parts.join("\n")
140
127
  }
141
128
 
@@ -165,8 +152,6 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
165
152
  // a follow-up "Verify Prior Table" would be meaningless; start a fresh full review
166
153
  if (!prior) {
167
154
  agent._advisorSession = null
168
- agent._advisorLastSnapshotHash = null
169
- session = null
170
155
  // Only reset the round counter on a truly fresh start (no prior reviews at all).
171
156
  // If _advisorRound > 0, there WAS a prior review — it just passed (all-clear).
172
157
  if (!agent._advisorRound) agent._advisorRound = 0
@@ -188,7 +173,6 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
188
173
  // Fresh session. Only reset round if this is truly the first review.
189
174
  // If _advisorRound > 0, there was a prior review that passed (all-clear).
190
175
  if (!agent._advisorRound) agent._advisorRound = 0
191
- agent._advisorLastSnapshot = null
192
176
  if (!prior) {
193
177
  // Tell the advisor why no prior issue table is present
194
178
  session[1] = {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { randomUUID, createHmac } from "node:crypto"
7
7
  import { runAdvisorReview } from "../advisor/run.mjs"
8
+ import { isDocFile } from "../advisor/repos.mjs"
8
9
 
9
10
  const TOKEN_EXPIRY_MS = 3600000 // 1 hour
10
11
  const TOKEN_SECRET = process.env.THINCODER_TOKEN_SECRET || "thincoder-default-secret"
@@ -50,7 +51,7 @@ export function validateDesignToken(token) {
50
51
  }
51
52
 
52
53
  /** Extract UUID from signed token for regex matching */
53
- export function extractTokenUUID(token) {
54
+ function extractTokenUUID(token) {
54
55
  const parts = token.split(":")
55
56
  return parts.length >= 1 ? parts[0] : token
56
57
  }
@@ -78,7 +79,7 @@ export const advisorTool = {
78
79
  "Run an independent review on your work. " +
79
80
  "Use type='design' to review design documents before implementation — pass documents=[...] with the explicit list of doc paths to review; use documents in code review too (the task's Docs involved list). " +
80
81
  "Use type='code' (default) to review code changes after implementation — pass paths=[...] to specify which files or directories to review, or documents=[...] for acceptance criteria context. " +
81
- "The advisor is an independent read-only sub-agent that explores the codebase, runs git diff, " +
82
+ "The advisor is an independent read-only sub-agent that explores the codebase, " +
82
83
  "reads files, and traces callers via grep/lsp. " +
83
84
  "For code review: round 1 does a full review, round 2 verifies the prior table, " +
84
85
  "round 3+ strictly checks only the prior table — convergence, not divergence. " +
@@ -118,7 +119,6 @@ export const advisorTool = {
118
119
 
119
120
  // Design review: validate that documents are in docs/ or are recognized doc files
120
121
  if (reviewType === "design" && documents) {
121
- const { isDocFile } = await import("../advisor/repos.mjs")
122
122
  const invalidDocs = documents.filter((doc) => {
123
123
  // Allow docs/ directory and recognized doc files (METHODOLOGY.md, README.md, etc.)
124
124
  if (doc.startsWith("docs/") || doc.startsWith("docs\\")) return false
@@ -133,7 +133,6 @@ export const advisorTool = {
133
133
  if (reviewType === "design") {
134
134
  agent._advisorRound = 0
135
135
  agent._advisorSession = null
136
- agent._advisorLastSnapshotHash = null // stale diff dedup baseline must not leak into the next code review
137
136
  }
138
137
 
139
138
  // Generate the design token BEFORE the review and inject it into the advisor's prompt.
@@ -8,17 +8,17 @@
8
8
  * user sends a new message — so the constraint never fades from context.
9
9
  */
10
10
 
11
- export const PLAN_FULL_REMINDER =
11
+ const PLAN_FULL_REMINDER =
12
12
  "[System reminder: plan mode is ON. Workflow: (1) explore/read codebase with read-only tools, " +
13
13
  "(2) design a solution considering trade-offs, (3) present your plan by calling plan with action='exit' " +
14
14
  "so the user can approve it. Only read-only tools are allowed — do not write, edit, or run mutation commands. " +
15
15
  "Your turn must end with either a clarifying question to the user or a call to plan with action='exit'.]"
16
16
 
17
- export const PLAN_SPARSE_REMINDER =
17
+ const PLAN_SPARSE_REMINDER =
18
18
  "[System reminder: plan mode still active — read-only tools only (the current plan file exempt). " +
19
19
  "Design the solution, then call plan with action='exit' for user approval.]"
20
20
 
21
- export const PLAN_EXIT_REMINDER =
21
+ const PLAN_EXIT_REMINDER =
22
22
  "[System reminder: plan mode is now OFF. Start implementing your plan — edit files, run commands. " +
23
23
  "No need for a task list (plan already covered that) or further confirmation.]"
24
24
 
@@ -14,6 +14,48 @@ import { validateDesignToken } from "./advisor.mjs"
14
14
  * - parallel subagent calls via the parallel channel (parallel: true)
15
15
  * - non-recursive: child agents do not get the subagent tool (depth > 0 is not injected)
16
16
  */
17
+
18
+ /**
19
+ * Effective subagent model override for a role (CLI parity shared with VS Code):
20
+ * priority — subagent tool `model` arg > config.agent.subagentModels[role] > config.agent.subagentModel > null (inherit parent).
21
+ */
22
+ export function effectiveSubagentModel(parent, role, modelArg) {
23
+ if (modelArg) return modelArg
24
+ const cfg = parent.config?.agent ?? {}
25
+ return cfg.subagentModels?.[role] ?? cfg.subagentModel ?? null
26
+ }
27
+
28
+ /**
29
+ * Resolve the sub-agent's provider from a model override string (shared with the
30
+ * VS Code port). Forms accepted:
31
+ * "provider:model" → the named provider with the named model
32
+ * "provider" → the named provider's configured model
33
+ * "model" → same provider as the parent, different model
34
+ * null → parent's provider unchanged.
35
+ * API keys follow the config fallback order (provider.apiKey → THINCODER_API_KEY → provider-specific env).
36
+ */
37
+ export function resolveChildProvider(parent, modelArg) {
38
+ if (!modelArg) return { ...parent.provider }
39
+ const providers = parent.config?.providersList ?? []
40
+ const withKey = (p) => {
41
+ if (p.apiKey?.trim()) return { ...p, apiKey: p.apiKey.trim() }
42
+ if (process.env.THINCODER_API_KEY) return { ...p, apiKey: process.env.THINCODER_API_KEY }
43
+ const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
44
+ const keyVar = envMap[p.name]
45
+ if (keyVar && process.env[keyVar]) return { ...p, apiKey: process.env[keyVar] }
46
+ return { ...p }
47
+ }
48
+ if (modelArg.includes(":")) {
49
+ const [pname, mname] = modelArg.split(":")
50
+ const p = providers.find((x) => x.name === pname)
51
+ if (!p) throw new Error(`subagent model: unknown provider "${pname}" (available: ${providers.map((x) => x.name).join(", ") || "none"})`)
52
+ return { ...withKey(p), model: mname || p.model }
53
+ }
54
+ const byName = providers.find((x) => x.name === modelArg)
55
+ if (byName) return withKey(byName)
56
+ return { ...parent.provider, model: modelArg }
57
+ }
58
+
17
59
  export const subagentTool = {
18
60
  name: "subagent",
19
61
  description:
@@ -30,6 +72,7 @@ export const subagentTool = {
30
72
  task: { type: "string", description: "Self-contained task description for the sub-agent" },
31
73
  context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation)" },
32
74
  role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder"], description: "Sub-agent role: 'explore' (read-only search/analysis), 'plan' (read-only implementation planning), 'coder' (full implementation), 'eng-coder' (engineering-mode coder — strict methodology, design-driven). ENUM IS OVERRIDDEN IN setup.mjs PER ENGINEERING MODE." },
75
+ model: { type: "string", description: "Provider/model override for this sub-agent: 'provider:model', a provider name from config, or a model name on the parent's provider. Defaults to the agent.subagentModel config, then the parent's provider. Useful for offloading heavy work to a cheaper model." },
33
76
  designToken: { type: "string", description: "Required when role='eng-coder': the token returned by advisor(type='design') after the design review passed. Without a valid token, eng-coder cannot modify files." },
34
77
  },
35
78
  required: ["task"],
@@ -49,6 +92,9 @@ export const subagentTool = {
49
92
  throw new Error("Engineering mode is not active — use role='coder' for implementation tasks.")
50
93
  }
51
94
 
95
+ // Provider/model override: tool `model` arg > subagentModels[role] > subagentModel > parent provider
96
+ const childProvider = resolveChildProvider(parent, effectiveSubagentModel(parent, role, args.model))
97
+
52
98
  // eng-coder token gate: the design review must have passed and the caller must
53
99
  // present the exact token advisor issued — otherwise the child is not authorized to code.
54
100
  if (role === "eng-coder") {
@@ -97,7 +143,7 @@ export const subagentTool = {
97
143
  : parent.config
98
144
 
99
145
  const child = createAgent({
100
- provider: parent.provider,
146
+ provider: childProvider,
101
147
  tools,
102
148
  config: childConfig,
103
149
  cwd: parent.cwd,
@@ -180,11 +226,10 @@ export function mergeChildMutations(parent, child) {
180
226
  parent._verifiedThisRun = false
181
227
  parent._verifyPassed = undefined
182
228
  }
183
- // Fresh code → fresh convergence budget + stale session/diff cleanup.
229
+ // Fresh code → fresh convergence budget + stale session cleanup.
184
230
  // _advisorRound reset ensures new code gets a full round-1 review;
185
- // _advisorSession + _advisorLastSnapshotHash prevent cross-contamination.
231
+ // _advisorSession prevents cross-contamination between reviews.
186
232
  parent._advisorRound = 0
187
233
  parent._advisorSession = null
188
- parent._advisorLastSnapshotHash = null
189
234
  return true
190
235
  }
package/src/agent.mjs CHANGED
@@ -100,7 +100,7 @@ export function createAgent({
100
100
  _mutatedThisRun: false, _verifiedThisRun: false, _verifyPassed: undefined, _calledAdvisorThisRun: false,
101
101
  _engDesignReviewed: false, // eng-coder: design review gate passed (hard gate in dispatch.mjs)
102
102
  _engDesignToken: null, // issued by advisor(type="design"); required to spawn eng-coder
103
- _touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null, _advisorLastSnapshotHash: null,
103
+ _touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
104
104
  _lastEngState: false,
105
105
  _pendingReminders: [],
106
106
  _pendingTimers: [],
@@ -131,7 +131,6 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
131
131
  agent._verifyRetries = 0
132
132
  agent._advisorRound = 0
133
133
  agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
134
- agent._advisorLastSnapshotHash = null // dedup baseline is per-run too — stale snapshot could wrongly suppress a diff refresh
135
134
  agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
136
135
  agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
137
136
  }
package/src/config.mjs CHANGED
@@ -43,6 +43,8 @@ const DEFAULTS = {
43
43
  agent: {
44
44
  maxTurns: 100,
45
45
  subagentTurns: 100,
46
+ subagentModel: null, // default subagent model: "provider:model" | provider name | model name (parent provider); null = inherit parent provider
47
+ subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
46
48
  goalTurns: 200,
47
49
  compactThreshold: 100000,
48
50
  verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
@@ -56,6 +58,7 @@ const DEFAULTS = {
56
58
  projectDir: ".thincoder/memory",
57
59
  team: null,
58
60
  },
61
+ shell: null, // bash tool shell executable (e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default (cmd on Windows, /bin/sh elsewhere)
59
62
  embedding: {
60
63
  baseURL: "https://api.siliconflow.cn/v1",
61
64
  model: "BAAI/bge-m3",
package/src/context.mjs CHANGED
@@ -237,7 +237,7 @@ const OVERSIZE_CONTENT_LIMIT = 8_000
237
237
  * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400 risk.
238
238
  * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
239
239
  */
240
- export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
240
+ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
241
241
  let shrunk = false
242
242
  for (const m of agent.history) {
243
243
  if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") continue
@@ -5,21 +5,21 @@ You have read-only tools to explore the codebase.
5
5
  You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
6
6
 
7
7
  Review workflow:
8
- 1. The files to review are listed in the review scope — read them in full. The prior issue table is HISTORY from a previous review, not current state.
8
+ 1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
9
9
  2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
10
10
  3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a fix appears to contradict the task itself.
11
- 4. Read the specified files for full context. **Batch independent tool calls in one reply.** ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.
11
+ 4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** An empty `git diff` does NOT mean nothing changed: fixes may already be committed (`git log -3` shows recent commits) — `read` the files named in the prior table regardless of the diff. Batch independent tool calls in one reply.
12
12
  5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
13
13
  6. Produce your review table.
14
14
 
15
- Budget: read only the files affected by the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
15
+ Budget: read only the files named in the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
16
16
 
17
17
  Rules:
18
18
  - Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
19
19
  - Primarily check fix status of items in the prior issue table.
20
20
  - For items marked "fixed": verify they were actually fixed.
21
21
  - For items marked "not an issue": evaluate whether the reasoning is sound.
22
- - Every "Unfixed" or "New" entry MUST cite read-verified evidence — file:line from a `read` of the CURRENT file (e.g. `src/x.mjs:42`). Findings without such evidence are treated as unverified and will not be accepted.
22
+ - Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.
23
23
  - You may flag obvious new problems — but only if clearly visible in the reviewed files and would cause crashes, data loss, or logic errors.
24
24
  - Do NOT nitpick style or naming.
25
25
  - Output a Markdown table listing all remaining problems (old or new):
@@ -5,21 +5,21 @@ You have read-only tools to explore the codebase.
5
5
  You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
6
6
 
7
7
  Review workflow:
8
- 1. The files to review are listed in the review scope — read them in full. The prior issue table is HISTORY from a previous review, not current state.
8
+ 1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
9
9
  2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
10
10
  3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs.
11
- 4. Read the specified files for full context. **Batch independent tool calls in one reply.** ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.
11
+ 4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** An empty `git diff` does NOT mean nothing changed: fixes may already be committed (`git log -3` shows recent commits) — `read` the files named in the prior table regardless of the diff. Batch independent tool calls in one reply.
12
12
  5. Verify fix status of each item in the prior issue table.
13
13
  6. Produce your review table.
14
14
 
15
- Budget: read only the files affected by the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
15
+ Budget: read only the files named in the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
16
16
 
17
17
  Rules:
18
18
  - Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
19
19
  - Only check fix status of items in the prior issue table.
20
20
  - For items marked "fixed": verify they were actually fixed.
21
21
  - For items marked "not an issue": evaluate whether the reasoning is sound.
22
- - Every "Unfixed" entry MUST cite read-verified evidence — file:line from a `read` of the CURRENT file (e.g. `src/x.mjs:42`). Findings without such evidence are treated as unverified and will not be accepted.
22
+ - Every "Unfixed" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.
23
23
  - Output a Markdown table. Only list items that still have problems:
24
24
  | # | Orig# | File | Severity | Status | Notes |
25
25
  |---|-------|------|----------|--------|-------|
@@ -48,7 +48,7 @@ function checkBashSafety(command, cwd) {
48
48
  * Sets PYTHONIOENCODING on Windows to override GBK default for Python scripts.
49
49
  */
50
50
  function buildBashEnv() {
51
- const winCmd = process.platform === "win32"
51
+ const isWindows = process.platform === "win32"
52
52
  return {
53
53
  ...process.env,
54
54
  GIT_EDITOR: "true",
@@ -57,7 +57,7 @@ function buildBashEnv() {
57
57
  GIT_PAGER: "cat",
58
58
  PAGER: "cat",
59
59
  TERM: "dumb",
60
- ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
60
+ ...(isWindows ? { PYTHONIOENCODING: "utf-8" } : {}),
61
61
  }
62
62
  }
63
63
 
@@ -118,11 +118,18 @@ async function gitGuardSnapshot(command, cwd) {
118
118
  }
119
119
  }
120
120
 
121
- function runBash(command, cwd, { timeout, signal, onOutput }) {
121
+ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
122
122
  return new Promise((resolve) => {
123
- const child = spawn(command, {
123
+ // Windows + default cmd: force UTF-8 code page for this child process (each spawn
124
+ // is an independent cmd, so chcp has no side effects on other shells) — otherwise
125
+ // cmd emits GBK bytes that the UTF-8 decoder turns into mojibake, and the model
126
+ // fights encoding errors instead of the actual command (reported UX on win11).
127
+ const effectiveCommand = process.platform === "win32" && !shell
128
+ ? `chcp 65001 >nul && ${command}`
129
+ : command
130
+ const child = spawn(effectiveCommand, {
124
131
  cwd,
125
- shell: true,
132
+ shell: shell ?? true,
126
133
  windowsHide: true,
127
134
  detached: process.platform !== "win32",
128
135
  stdio: ["ignore", "pipe", "pipe"],
@@ -239,6 +246,7 @@ export const bashTool = {
239
246
  timeout: args.timeout ?? BASH_TIMEOUT_MS,
240
247
  signal: ctx.signal,
241
248
  onOutput: ctx.onOutput,
249
+ shell: ctx.agent?.config?.shell ?? null,
242
250
  })
243
251
  return guard ? `${guard.notice}\n\n${result}` : result
244
252
  },
@@ -0,0 +1,104 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync } from "node:fs"
3
+ import { C } from "./ansi.mjs"
4
+
5
+ /** Platform shell candidates: { name, value (config.shell payload), detect() }.
6
+ * detect() returns truthy when the shell is available (static check, never throws).
7
+ * The FULL filtered result (available shells) is cached at module level — shell
8
+ * availability does not change during a session, and re-running spawnSync on every
9
+ * /shell would freeze the TUI (up to 3 × timeout per open). */
10
+ let _shellCandidatesCache = null
11
+ function platformShellCandidates() {
12
+ if (_shellCandidatesCache) return _shellCandidatesCache
13
+ const win = process.platform === "win32"
14
+ const commandExists = (cmd) => {
15
+ try {
16
+ // 'command -v' is a POSIX shell builtin; sh -c runs it (Windows uses `where`)
17
+ const r = spawnSync(win ? "where" : "sh", win ? [cmd] : ["-c", `command -v ${cmd}`], { encoding: "utf8", timeout: 3000 })
18
+ return r.status === 0 && r.stdout.trim().length > 0
19
+ } catch { return false }
20
+ }
21
+ const GIT_BASH_PATHS = [
22
+ "C:\\Program Files\\Git\\bin\\bash.exe",
23
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
24
+ `${process.env.LOCALAPPDATA ?? ""}\\Programs\\Git\\bin\\bash.exe`,
25
+ ]
26
+ const candidates = []
27
+ // System default always first
28
+ candidates.push({ name: "System default", value: null, detect: () => true })
29
+ if (win) {
30
+ candidates.push({ name: "PowerShell (pwsh)", value: "pwsh", detect: () => commandExists("pwsh") })
31
+ candidates.push({ name: "Windows PowerShell (powershell)", value: "powershell", detect: () => commandExists("powershell") })
32
+ const gb = GIT_BASH_PATHS.find((p) => existsSync(p))
33
+ if (gb) candidates.push({ name: `Git Bash (${gb})`, value: gb, detect: () => true })
34
+ candidates.push({ name: "WSL bash (wsl)", value: "wsl", detect: () => commandExists("wsl") })
35
+ } else {
36
+ for (const sh of ["bash", "zsh", "fish"]) {
37
+ candidates.push({ name: sh, value: sh, detect: () => commandExists(sh) })
38
+ }
39
+ }
40
+ // Known limitation: POSIX detection requires `sh` in PATH (extremely minimal
41
+ // containers may lack it — then only "System default" is offered).
42
+ _shellCandidatesCache = candidates.filter((c) => c.detect())
43
+ return _shellCandidatesCache
44
+ }
45
+
46
+ /** /shell command: bash tool shell — platform-aware picker (default) or direct args.
47
+ * ctx: { agent, pushLine, showPicker, askQuestion, persistRaw }
48
+ * /shell → picker: available shells for this platform + custom path
49
+ * /shell <path|name> → set directly (quotes stripped)
50
+ * /shell reset → system default (case-insensitive) */
51
+ export async function handleShellCommand(ctx, args = []) {
52
+ const { agent, pushLine, showPicker, askQuestion, persistRaw } = ctx
53
+ // Strip surrounding quotes (slash args are whitespace-split; /shell "C:\Program Files\Git\bin\bash.exe"
54
+ // would otherwise persist the literal quote characters and spawn would fail)
55
+ const input = args.join(" ").trim().replace(/^["'](.+)["']$/, "$1")
56
+
57
+ const persist = async (value) => {
58
+ agent.config.shell = value
59
+ await persistRaw((raw) => { raw.shell = value }).catch((e) => pushLine(`[error] ${e.message}`, C.error))
60
+ }
61
+
62
+ // ── Direct args ──
63
+ if (input) {
64
+ if (input.toLowerCase() === "reset") {
65
+ await persist(null)
66
+ pushLine("Shell reset to system default.", C.text)
67
+ return
68
+ }
69
+ await persist(input)
70
+ pushLine(`Shell set to \`${input}\` — bash tool commands will run through it.`, C.text)
71
+ return
72
+ }
73
+
74
+ // ── Platform-aware picker ──
75
+ const available = platformShellCandidates() // cached — detection runs once per session
76
+ const current = agent.config?.shell ?? null
77
+ const entries = [
78
+ { type: "header", text: current ? `Current: ${current}` : "Current: (system default)" },
79
+ ...available.map((c) => ({
80
+ type: "item",
81
+ text: `${c.name}${c.value ? ` → ${c.value}` : " (cmd + UTF-8 on Windows / /bin/sh elsewhere)"}`,
82
+ action: "pick",
83
+ value: c.value,
84
+ marker: (c.value ?? null) === current ? "●" : "",
85
+ })),
86
+ { type: "header", text: "Other" },
87
+ { type: "item", text: "Custom path… (type any shell path/command)", action: "custom" },
88
+ ]
89
+ const defaultIndex = Math.max(0, available.findIndex((c) => (c.value ?? null) === current))
90
+ const picked = await showPicker("Shell", entries, { defaultIndex })
91
+ if (!picked) return // Esc
92
+
93
+ if (picked.action === "custom") {
94
+ const path = await askQuestion("Enter shell path or command (e.g. C:\\Program Files\\Git\\bin\\bash.exe, pwsh, cmd):")
95
+ if (!path) return
96
+ await persist(path.trim().replace(/^["'](.+)["']$/, "$1"))
97
+ pushLine(`Shell set to \`${path.trim()}\` — bash tool commands will run through it.`, C.text)
98
+ return
99
+ }
100
+ await persist(picked.value)
101
+ pushLine(picked.value === null
102
+ ? "Shell reset to system default."
103
+ : `Shell set to \`${picked.value}\` — bash tool commands will run through it.`, C.text)
104
+ }
@@ -0,0 +1,151 @@
1
+ import { ansi, C } from "./ansi.mjs"
2
+
3
+ /** Subagent role slots — each has an independent model override slot */
4
+ export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder"]
5
+
6
+ /** Human-readable effective display with inheritance source. role=null → global slot.
7
+ * source semantics: "type" = role-specific override (subagentModels[role]),
8
+ * "global" = falls back to subagentModel, "parent" = no override, inherits the
9
+ * parent agent's provider. */
10
+ function slotDisplay(agent, role) {
11
+ const cfg = agent.config?.agent ?? {}
12
+ if (role === null) {
13
+ return cfg.subagentModel ? { value: cfg.subagentModel, source: "global" } : { value: null, source: "parent" }
14
+ }
15
+ const type = cfg.subagentModels?.[role]
16
+ const global = cfg.subagentModel
17
+ if (type) return { value: type, source: "type" }
18
+ if (global) return { value: global, source: "global" }
19
+ return { value: null, source: "parent" }
20
+ }
21
+
22
+ /** /submodel command: subagent model config — picker menu (default) or direct args.
23
+ * ctx: { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot }
24
+ * /submodel → picker: global + 4 role slots
25
+ * /submodel <value> → set global default
26
+ * /submodel <type> <value> → set a role slot
27
+ * /submodel <type> → show a slot
28
+ * /submodel reset [type] → clear global (or a slot)
29
+ * value forms: provider:model | provider name | model name (same as subagent tool model arg) */
30
+ export async function handleSubmodelCommand(ctx, args = []) {
31
+ const { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot } = ctx
32
+ const input = args.join(" ").trim()
33
+
34
+ // Single write channel: mutate BOTH in-memory agent.config.agent and the disk raw
35
+ // with the same function (no divergent double-writes). Agent config is created
36
+ // lazily here — an Esc-cancelled picker never writes anything.
37
+ const persist = async (mutate) => {
38
+ agent.config.agent ??= {}
39
+ const a = agent.config.agent
40
+ mutate(a)
41
+ await persistRaw((raw) => {
42
+ raw.agent ??= {}
43
+ mutate(raw.agent)
44
+ }).catch((e) => pushLine(`[error] ${e.message}`, C.error))
45
+ }
46
+
47
+ // ── Direct args ──
48
+ if (input) {
49
+ const parts = input.split(/\s+/)
50
+ const first = parts[0].toLowerCase()
51
+ if (first === "reset") {
52
+ const type = parts[1]?.toLowerCase()
53
+ if (type) {
54
+ if (!SUBMODEL_SLOTS.includes(type)) {
55
+ pushLine(`Unknown subagent type: ${type} (available: ${SUBMODEL_SLOTS.join(", ")})`, C.error)
56
+ return
57
+ }
58
+ await persist((a) => { a.subagentModels ??= {}; delete a.subagentModels[type]; if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels })
59
+ pushLine(`Subagent type ${type}: reset to inherit (${slotDisplay(agent, type).source === "global" ? `global: ${slotDisplay(agent, type).value}` : "parent provider"}).`, C.text)
60
+ } else {
61
+ await persist((a) => { a.subagentModel = null })
62
+ pushLine("Subagent global model: reset to inherit parent provider.", C.text)
63
+ }
64
+ return
65
+ }
66
+ if (SUBMODEL_SLOTS.includes(first)) {
67
+ const value = parts.slice(1).join(" ")
68
+ if (!value) {
69
+ const d = slotDisplay(agent, first)
70
+ pushLine(`Subagent ${first}: ${d.value ? `\`${d.value}\` (${d.source} config)` : "(inherit: parent provider)"}`, C.text)
71
+ return
72
+ }
73
+ await persist((a) => { a.subagentModels ??= {}; a.subagentModels[first] = value })
74
+ pushLine(`Subagent ${first} model set to \`${value}\`.`, C.text)
75
+ return
76
+ }
77
+ if (parts.length >= 2) {
78
+ // Two+ tokens and the first is not a known slot → user probably meant a type
79
+ pushLine(`Unknown subagent type: ${first} (available: ${SUBMODEL_SLOTS.join(", ")})`, C.error)
80
+ return
81
+ }
82
+ // Global value (provider:model | provider | model)
83
+ await persist((a) => { a.subagentModel = input })
84
+ pushLine(`Subagent global model set to \`${input}\`.`, C.text)
85
+ return
86
+ }
87
+
88
+ // ── Picker menu: global + 4 role slots ──
89
+ // for(;;) loop relies on showPicker's async/Promise suspension — every iteration
90
+ // awaits a user choice; Esc (null) exits. Mirrors openModelPicker's menu-loop pattern.
91
+ for (;;) {
92
+ const g = slotDisplay(agent, null)
93
+ const entries = [
94
+ { type: "header", text: "Subagent models — pick a slot to edit (Esc exits)" },
95
+ { type: "item", text: `${g.value ? `Global default: ${g.value}` : "Global default: (inherit parent)"}`, action: "slot", slot: "global", marker: g.value ? "●" : "○" },
96
+ ...SUBMODEL_SLOTS.map((role) => {
97
+ const d = slotDisplay(agent, role)
98
+ const label = d.value ? `${role}: ${d.value}${d.source === "global" ? " (←global)" : ""}` : `${role}: (inherit: ${d.source === "global" ? "global" : "parent"})`
99
+ return { type: "item", text: label, action: "slot", slot: role, marker: d.source === "type" ? "●" : "○" }
100
+ }),
101
+ { type: "header", text: "Actions" },
102
+ { type: "item", text: "Reset all (inherit parent)", action: "resetall" },
103
+ ]
104
+ const picked = await showPicker("Subagent Models", entries)
105
+ if (!picked) return // Esc
106
+
107
+ if (picked.action === "resetall") {
108
+ await persist((a) => { a.subagentModel = null; delete a.subagentModels })
109
+ pushLine("All subagent model overrides cleared — inherit parent provider.", C.text)
110
+ continue
111
+ }
112
+
113
+ const role = picked.slot === "global" ? null : picked.slot
114
+ const cur = slotDisplay(agent, role)
115
+ const sub = await showPicker(`Subagent ${picked.slot}`, [
116
+ { type: "header", text: `Current: ${cur.value ? `\`${cur.value}\` (${cur.source})` : "(inherit)"}` },
117
+ { type: "item", text: "Set model… (provider → model picker)", action: "set" },
118
+ { type: "item", text: "Set to parent provider model", action: "parent" },
119
+ { type: "item", text: "Reset (inherit)", action: "reset" },
120
+ ])
121
+ if (!sub) continue // Esc → back to slots
122
+
123
+ if (sub.action === "set") {
124
+ const sel = await pickModelForSlot()
125
+ if (!sel) continue
126
+ const value = `${sel.provider}:${sel.model}`
127
+ if (role) {
128
+ await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
129
+ pushLine(`Subagent ${role} model set to \`${value}\`.`, C.text)
130
+ } else {
131
+ await persist((a) => { a.subagentModel = value })
132
+ pushLine(`Subagent global model set to \`${value}\`.`, C.text)
133
+ }
134
+ } else if (sub.action === "parent") {
135
+ const value = `${agent.activeProvider}:${agent.activeModel ?? agent.provider?.model}`
136
+ if (role) {
137
+ await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
138
+ } else {
139
+ await persist((a) => { a.subagentModel = value })
140
+ }
141
+ pushLine(`Subagent ${picked.slot} set to parent model \`${value}\`.`, C.text)
142
+ } else if (sub.action === "reset") {
143
+ if (role) {
144
+ await persist((a) => { a.subagentModels ??= {}; delete a.subagentModels[role]; if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels })
145
+ } else {
146
+ await persist((a) => { a.subagentModel = null })
147
+ }
148
+ pushLine(`Subagent ${picked.slot} reset to inherit.`, C.text)
149
+ }
150
+ }
151
+ }
package/src/tui/index.mjs CHANGED
@@ -291,7 +291,7 @@ export async function startTUI(agent, opts = {}) {
291
291
  // local TUI/agent config, never the in-flight turn); the rest are queued
292
292
  const cmd0 = text.split(/\s+/)[0].toLowerCase()
293
293
  const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
294
- const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
294
+ const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/submodel", "/shell", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
295
295
  if (safeDuringProcessing.has(resolved0)) {
296
296
  await handleSlash(text)
297
297
  render()
@@ -341,7 +341,7 @@ export async function startTUI(agent, opts = {}) {
341
341
  const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
342
342
 
343
343
  // Model picker + generic picker: implemented in pickers.mjs
344
- const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
344
+ const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot } = createPickers({
345
345
  agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
346
346
  })
347
347
 
@@ -363,6 +363,7 @@ export async function startTUI(agent, opts = {}) {
363
363
  openModelPicker: () => openModelPicker(),
364
364
  selectModel,
365
365
  setProviderKey,
366
+ pickModelForSlot,
366
367
  runDistill,
367
368
  exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
368
369
  })
@@ -40,6 +40,8 @@ export function createPickers(ctx) {
40
40
  closePicker()
41
41
  return new Promise((resolve) => {
42
42
  const itemCount = entries.filter((e) => e.type === "item").length
43
+ // No selectable items — resolve immediately instead of showing an empty picker
44
+ if (itemCount === 0) { resolve(null); return }
43
45
  const index = Math.max(0, Math.min(defaultIndex, Math.max(0, itemCount - 1)))
44
46
  state.picker = { title, entries, lines: [], index, scroll: 0, selectedLine: 0, filter: "", resolve }
45
47
  state.pickerStack.push(state.picker)
@@ -389,5 +391,34 @@ export function createPickers(ctx) {
389
391
  await persistRaw((raw) => { raw.providers = agent.providers })
390
392
  }
391
393
 
392
- return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey }
394
+
395
+ /** Slot-bound model picker: two-level provider → model selection that RETURNS
396
+ * { provider, model } instead of writing main-session state — used by /submodel
397
+ * to write into a subagent slot (global or per-role). Esc from the model list
398
+ * returns to the provider list (openModelPicker parity); Esc from the provider
399
+ * list exits → null. */
400
+ async function pickModelForSlot() {
401
+ for (;;) {
402
+ const providers = agent.providers
403
+ if (!providers.length) return null
404
+ const e = await showPicker("Select provider", providers.map((p) => ({
405
+ type: "item",
406
+ text: `${p.name.padEnd(12)} ${p.model}`,
407
+ action: "open-models",
408
+ provider: p.name,
409
+ })))
410
+ if (!e?.provider) return null
411
+ const providerConfig = providers.find((p) => p.name === e.provider)
412
+ if (!providerConfig) return null
413
+ const entries = buildModelEntriesForProvider(e.provider, providerConfig)
414
+ fetchModelsForProvider(e.provider, entries).catch((err) => {
415
+ pushLine(`[model] fetch models failed: ${err.message}`, C.error)
416
+ })
417
+ const me = await showPicker(`${e.provider} models`, entries)
418
+ if (!me?.model) continue // Esc from model list → back to provider list
419
+ return { provider: e.provider, model: me.model }
420
+ }
421
+ }
422
+
423
+ return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot }
393
424
  }
@@ -3,11 +3,26 @@
3
3
  * Extracted from render-frame.mjs.
4
4
  */
5
5
  import { ansi, C } from "./ansi.mjs"
6
- import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
6
+ import { formatTables, sanitizeDisplay, stringWidth, wrapText } from "./render.mjs"
7
7
  import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
8
8
 
9
9
  let _convCache = { key: "", cols: 0, lines: [] }
10
10
 
11
+ /**
12
+ * Render markdown markers to ANSI, then pad the line tail back to the pre-render
13
+ * display width. Markers (`` ` ``, `**`, `~~`) vanish on render — without the
14
+ * compensation, table rows containing them display shorter than the column widths
15
+ * computed by formatTables and the borders misalign (reported regression).
16
+ * @param {string} text — plain text line (no ANSI yet), already wrapped
17
+ * @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
18
+ */
19
+ function renderMarkdownPreservingWidth(text) {
20
+ const rendered = renderMarkdownInline(renderMarkdownHeading(text))
21
+ const diff = stringWidth(text) - stringWidth(rendered)
22
+ return diff > 0 ? rendered + " ".repeat(diff) : rendered
23
+ }
24
+
25
+
11
26
  export function convCacheKey(state) {
12
27
  const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
13
28
  // expandedBlocks participates: expanding/folding a block must invalidate the cache
@@ -87,7 +102,7 @@ function buildConvLines(state, cols) {
87
102
  for (const wrapped of wrapText(line, cols - 1)) {
88
103
  // Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
89
104
  // Runs AFTER wrapping so the ANSI it inserts never skews width math.
90
- block.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId, _src: i })
105
+ block.push({ text: renderMarkdownPreservingWidth(wrapped), color: l.color, _foldId: l._foldId, _src: i })
91
106
  }
92
107
  }
93
108
  if (folded && block.length > LONG_FOLD_LINES) {
@@ -135,7 +150,7 @@ function buildConvLines(state, cols) {
135
150
  if (state.streaming) {
136
151
  for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
137
152
  for (const wrapped of wrapText(line, cols - 1)) {
138
- convLines.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: C.text })
153
+ convLines.push({ text: renderMarkdownPreservingWidth(wrapped), color: C.text })
139
154
  }
140
155
  }
141
156
  }
@@ -31,20 +31,37 @@ export function charWidth(cp) {
31
31
 
32
32
  /** Compute the display width of a string (CJK characters count as 2) */
33
33
  export function stringWidth(text) {
34
+ // ANSI escape sequences occupy zero display width — strip them before counting.
35
+ // Without this, markdown-rendered text (ANSI inserted) was measured wider than it
36
+ // displays, and table lines with inline markers ended up shorter than the computed
37
+ // column widths (reported: table borders misaligned after `code`/`**bold**` cells).
34
38
  let w = 0
35
- for (const ch of text) w += charWidth(ch.codePointAt(0))
39
+ for (const part of text.split(ANSI_SEQUENCE_RE)) {
40
+ for (const ch of part) w += charWidth(ch.codePointAt(0))
41
+ }
36
42
  return w
37
43
  }
38
44
 
39
- /** Slice by display width */
45
+ /** Slice by display width — ANSI sequences count as zero width and are kept whole. */
40
46
  export function sliceByWidth(text, maxWidth) {
41
47
  let w = 0
42
48
  let out = ""
43
- for (const ch of text) {
44
- const cw = charWidth(ch.codePointAt(0))
49
+ let i = 0
50
+ while (i < text.length) {
51
+ // Copy any ANSI sequence verbatim (zero display width, never sliced mid-sequence)
52
+ const m = text.slice(i).match(ANSI_SEQUENCE)
53
+ if (m && m.index === 0) {
54
+ out += m[0]
55
+ i += m[0].length
56
+ continue
57
+ }
58
+ const cp = text.codePointAt(i)
59
+ const ch = String.fromCodePoint(cp)
60
+ const cw = charWidth(cp)
45
61
  if (w + cw > maxWidth) break
46
62
  w += cw
47
63
  out += ch
64
+ i += ch.length
48
65
  }
49
66
  return out
50
67
  }
@@ -186,7 +203,9 @@ export function layoutInput(chars, cursor, width) {
186
203
  * Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
187
204
  * are also cleaned during replay.
188
205
  */
189
- const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
206
+ const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
207
+ // Global variant for replace()/split(); the non-global one keeps match.index for slicing
208
+ const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
190
209
  export function sanitizeDisplay(s) {
191
210
  return s
192
211
  .replace(ANSI_SEQUENCE_RE, "")
@@ -25,6 +25,8 @@ import { handleAutoCommand } from "./cmd-auto.mjs"
25
25
  import { handleAdvisorCommand } from "./cmd-advisor.mjs"
26
26
  import { handleThinkCommand } from "./cmd-think.mjs"
27
27
  import { handleModelCommand } from "./cmd-model.mjs"
28
+ import { handleSubmodelCommand } from "./cmd-submodel.mjs"
29
+ import { handleShellCommand } from "./cmd-shell.mjs"
28
30
  import { handleConfigCommand } from "./cmd-config.mjs"
29
31
  import { handleExtractCommand } from "./cmd-extract.mjs"
30
32
  import { handleHelpCommand } from "./cmd-help.mjs"
@@ -39,6 +41,8 @@ export const SLASH_COMMANDS = [
39
41
  { name: "/eng", group: "Agent", desc: "toggle engineering mode — strict methodology enforcement" },
40
42
  { name: "/advisor", group: "Agent", desc: "advisor settings (toggle, model, thinking, guard)" },
41
43
  { name: "/model", group: "Agent", desc: "select model & manage providers" },
44
+ { name: "/submodel", group: "Agent", desc: "subagent model per type (explore/plan/coder/eng-coder)" },
45
+ { name: "/shell", group: "System", desc: "bash tool shell (git-bash/pwsh path; win11 cmd encoding fix)" },
42
46
  { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
43
47
  { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
44
48
  { name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
@@ -78,6 +82,8 @@ export const HANDLERS = {
78
82
  "/advisor": handleAdvisorCommand,
79
83
  "/think": handleThinkCommand,
80
84
  "/model": handleModelCommand,
85
+ "/submodel": handleSubmodelCommand,
86
+ "/shell": handleShellCommand,
81
87
  "/config": handleConfigCommand,
82
88
  "/upgrade": handleUpgradeCommand,
83
89
  "/fold": handleFoldCommand,
@@ -125,6 +131,10 @@ export function createSlashCommands(ctx) {
125
131
  const argIndex = parts.length - 2 // which parameter is being typed (0-based)
126
132
  const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
127
133
  if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
134
+ if (cmd === "/submodel") {
135
+ if (argIndex === 0) return match(["explore", "plan", "coder", "eng-coder", "reset"])
136
+ if (parts[1] && !["reset"].includes(parts[1]) && argIndex === 1) return match(agent.providers.map((p) => p.name))
137
+ }
128
138
  if (cmd === "/think") {
129
139
  if (argIndex === 0) return match(["on", "off", "effort"])
130
140
  if (argIndex === 1 && parts[1].toLowerCase() === "effort") {