thincoder 0.12.40 → 0.12.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  本文件记录 ThinCoder CLI 的发布历史。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),版本遵循[语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.12.41] — 2026-08-23
6
+
7
+ ### Added
8
+
9
+ - **git 工具最全扩充**:add(分文件)/push(带 remote/ref/tags)/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick + `workdir`(子目录/多 repo);破坏性操作先非破坏快照
10
+ - **execute `scriptFile` + `nodeArgs`**:跑 workspace `.mjs` 文件(`node <script>` / `node --test` / `node --check`),不再只能 inline
11
+ - **子agent/advisor 显示使用的模型**(subagent/escalate/consult/advisor)
12
+ - 反向路由:git/execute/grep/ls/read/delete 描述 + discipline「Tool routing」全工具总表
13
+
14
+ ### Changed
15
+
16
+ - `codemode.mjs` → `execute.mjs`(`codeModeTool` → `executeTool`,与 VS Code 对齐)
17
+
18
+ ### Fixed
19
+
20
+ - `runGit` `.trim()` 剥掉 porcelain 前导空格导致 unstaged 被误分类成 staged
21
+ - VS Code `snapshotBefore` 破坏性 stash → 非破坏 `git stash create`+`store`
22
+
5
23
  ## [0.12.40] — 2026-08-23
6
24
 
7
25
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.40",
3
+ "version": "0.12.41",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,12 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
66
66
  }
67
67
 
68
68
  const callbacks = {
69
- onToken: (text) => update("agent_message_chunk", { content: { type: "text", text } }),
69
+ onToken: (text) => {
70
+ // Strip the subagent `[model]` metadata token (role#id/[model]<name>) — it's a
71
+ // TUI/webview display signal, not conversation content, and must not reach ACP clients.
72
+ if (/^[\w-]+#\d+\/\[model\]/.test(text)) return
73
+ update("agent_message_chunk", { content: { type: "text", text } })
74
+ },
70
75
  onReasoning: (text) => update("agent_thought_chunk", { content: { type: "text", text } }),
71
76
  onUsage: (usage) => update("usage_update", { usage }),
72
77
  onWait: ({ phase, seconds }) => log(`[rate-limit] ${phase} waiting ~${seconds}s`),
@@ -177,6 +177,8 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
177
177
  agent._subAgentCounter = (agent._subAgentCounter ?? 0) + 1
178
178
  const subId = agent._subAgentCounter
179
179
  const relayPrefix = `consult#${subId}/`
180
+ // Report this consultant's model to the display layer (each consultant may use a different model).
181
+ ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (provider.model ?? ""))
180
182
  const childCallbacks = {
181
183
  onToken: ctx.callbacks?.onToken ? (t) => ctx.callbacks.onToken(`${relayPrefix}${t}`) : null,
182
184
  onReasoning: ctx.callbacks?.onReasoning ? (r) => ctx.callbacks.onReasoning(`${relayPrefix}${r}`) : null,
@@ -93,6 +93,8 @@ export const escalateTool = {
93
93
  const subId = parent._subAgentCounter
94
94
  const tag = label(pick)
95
95
  const relayPrefix = `escalate#${subId}/`
96
+ // Report the escalated model to the display layer (it may differ from the parent's).
97
+ ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (provider.model ?? tag))
96
98
 
97
99
  // No wall-clock watchdog — turn cap only, exactly like subagent (the verified write
98
100
  // path). Rationale (2026-08-16): a fixed wall-clock aborts NORMAL-but-slow surgery —
@@ -161,6 +161,10 @@ export const subagentTool = {
161
161
  parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
162
162
  const subId = parent._subAgentCounter
163
163
  const relayPrefix = `${role ?? "sub"}#${subId}/`
164
+ // Report the subagent's effective model to the display layer (it may differ from the
165
+ // parent's). Emitted as a `[model]` metadata token via the relay prefix — the TUI/webview
166
+ // parse it into the subagent block's header instead of showing it as content.
167
+ ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (childProvider.model ?? ""))
164
168
  const childOpts = {
165
169
  onPermissionRequest: childPermission,
166
170
  onToken: ctx.callbacks?.onToken
@@ -20,6 +20,15 @@ UI & interface design:
20
20
  - Free-text for a discrete value forces the user to guess the exact spelling, needs manual validation, and fails silently on typos. This has happened repeatedly (e.g. reasoning-effort levels typed by hand).
21
21
  - Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
22
22
 
23
+ Tool routing — use the dedicated tool, not bash:
24
+ - **git operations** → `git` tool (action=status/diff/log/show/add/commit/push/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick; `workdir` for sub-repos). Never run git via bash.
25
+ - **JavaScript** → `execute` (inline code; or `scriptFile`+`nodeArgs` for `node <file>` / `node --test` / `node --check`). Never `bash node -e`.
26
+ - **File reads/searches** → `read` / `grep` / `ls` / `glob` — never `cat` / `type` / `findstr` / `dir` / shell-grep.
27
+ - **File mutations** → `write` / `edit` / `apply_patch` / `hashline_edit` / `insert_after` / `file_ops` (move/copy/rename) / `delete`.
28
+ - **Process / time / sleep / tree** → the dedicated tools (never `tasklist`/`ps`/`date`/`tree` via bash).
29
+ - Each tool's description carries a "Route to X instead of bash" mapping.
30
+ - **bash IS correct for**: package-manager/CLI subprocesses (`npm`/`vsce`/`ovsx`, git-CLI-only flags the tool lacks), servers, interactive/TTY programs, and one-off shell pipelines no dedicated tool expresses.
31
+
23
32
  Review discipline (standard mode only — engineering mode has its own review timing rules):
24
33
  - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context).
25
34
  - **After each advisor review, reply with a response table** — exact header `| # | Action | Detail |` (the runtime extracts this header; keep it verbatim). One row per issue; `#` = the advisor's issue number (`Orig#` on rounds 2+).
@@ -1,5 +1,7 @@
1
1
  Delete a file. Use when the agent created a temporary or junk file that should be cleaned up, or when the user explicitly asks to delete something. Refuses to delete git-tracked files as a safety measure — tracked files should be edited or removed via bash with explicit user confirmation.
2
2
 
3
+ **Route to delete instead of bash:** `del file` / `rm file` → delete (single files). Use bash `rm -rf` only for directories (delete is single-file).
4
+
3
5
  Parameters:
4
6
  - path (required): File path, relative to cwd or absolute
5
7
  - force: Allow deleting git-tracked files (default false)
@@ -1,10 +1,14 @@
1
- Execute JavaScript code with full Node access. Runs in a real `node` process with top-level `await` and dynamic `import()` so you can load and call the project's own `.mjs` modules directly. Use this to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
1
+ Execute JavaScript — either inline `code` or a workspace `scriptFile`. Runs in a real `node` process with top-level `await` and dynamic `import()`. Use inline `code` to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
2
2
 
3
3
  **Route to execute instead of bash:**
4
- - `node -e "…"` → execute (top-level await + import() + console all work)
4
+ - `node -e "…"` → execute (inline code; top-level await + import() + console all work)
5
+ - `node <script.mjs>` → execute with scriptFile (runs the file in a child node process)
6
+ - `node --test <file>` / `node --check <file>` → execute with scriptFile + nodeArgs
5
7
 
6
8
  Parameters:
7
- - code (required): JavaScript to run. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import.
9
+ - code: JavaScript to run inline. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import. Use this OR scriptFile.
10
+ - scriptFile: run a workspace .mjs/.js file with node (self-contained — no prelude; the file imports what it needs). Path relative to workdir, confined to the workspace. Use this OR code.
11
+ - nodeArgs: (scriptFile) extra node flags before the script, e.g. ["--test"], ["--check"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.
8
12
  - workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
9
13
  - filter: optional — only return output lines matching this regex (case-insensitive)
10
14
  - timeoutMs: Timeout in milliseconds (default 30000, max 60000)
@@ -14,4 +18,4 @@ Notes:
14
18
  - File paths are confined to the workspace root (`..` traversal is denied) — but `require`/`process`/`import()` are full Node, same boundary as bash.
15
19
  - A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
16
20
  - Output capped at ~50KB; use `writeFile` to a file if you need more.
17
- - Use `write`/`edit`/`apply_patch` for source edits and `bash` for subprocess/CLI runs (`npm test`, `node --test`, servers) — execute is for in-process JS, not spawning programs.
21
+ - Use `write`/`edit`/`apply_patch` for source edits. Still use `bash` for package-manager/CLI subprocesses (`npm test`/`npm publish`/`vsce`), servers, and interactive/TTY programs — execute covers in-process JS and `node <script>`/`node --test`/`node --check`, not arbitrary CLI or long-running programs.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * tools/codemode.mjs — CodeMode: JavaScript execution tool
2
+ * tools/execute.mjs — JavaScript execution tool
3
3
  *
4
4
  * Gives the model an `execute` tool that runs JS in a child `node
5
5
  * --input-type=module --eval` process — NOT the in-process vm sandbox it used
@@ -15,7 +15,9 @@
15
15
  * process/import() is available — same boundary as bash, no fake sandbox.
16
16
  *
17
17
  * Parameters:
18
- * code — JS to run (top-level await and import() supported)
18
+ * code — JS to run inline (top-level await and import() supported). Use this OR scriptFile.
19
+ * scriptFile — run a workspace .mjs/.js file with node (self-contained, no prelude). Use this OR code.
20
+ * nodeArgs — (scriptFile) extra node flags before the script (e.g. --test, --check); eval-like flags rejected
19
21
  * workdir — run in this sub-directory (confined to the workspace)
20
22
  * filter — return only output lines matching this regex (case-insensitive)
21
23
  * timeoutMs — timeout (default 30s, max 60s)
@@ -59,12 +61,11 @@ function applyFilter(output, filter) {
59
61
  }
60
62
  }
61
63
 
62
- /** Spawn node, run code + prelude, capture stdout/stderr, enforce timeout/abort.
64
+ /** Spawn node with the given args, capture stdout/stderr, enforce timeout/abort.
63
65
  * Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
64
- function runNodeEval(code, baseDir, root, timeoutMs, signal) {
66
+ function runNode(childArgs, baseDir, root, timeoutMs, signal) {
65
67
  return new Promise((resolvePromise) => {
66
- const src = `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`
67
- const child = spawn(process.execPath, ["--input-type=module", "--eval", src], {
68
+ const child = spawn(process.execPath, childArgs, {
68
69
  cwd: baseDir,
69
70
  env: { ...process.env, THINCODER_EXEC_ROOT: root },
70
71
  stdio: ["ignore", "pipe", "pipe"],
@@ -118,7 +119,19 @@ function runNodeEval(code, baseDir, root, timeoutMs, signal) {
118
119
  })
119
120
  }
120
121
 
121
- export const codeModeTool = {
122
+ /** Validate nodeArgs (extra node flags for scriptFile mode, e.g. --test / --check). Forbids
123
+ * eval-like flags that would conflict with scriptFile mode or re-open inline injection. */
124
+ function validateNodeArgs(nodeArgs) {
125
+ if (!nodeArgs) return []
126
+ const arr = Array.isArray(nodeArgs) ? nodeArgs : String(nodeArgs).split(/\s+/).filter(Boolean)
127
+ const forbidden = /^(--eval|-e|--input-type|--print|-p|--inspect|--inspect-brk)(=|$)/i
128
+ for (const a of arr) {
129
+ if (forbidden.test(a)) throw new Error(`nodeArgs flag not allowed: ${a}`)
130
+ }
131
+ return arr
132
+ }
133
+
134
+ export const executeTool = {
122
135
  name: "execute",
123
136
  description: DESC("execute"),
124
137
  parameters: {
@@ -126,7 +139,16 @@ export const codeModeTool = {
126
139
  properties: {
127
140
  code: {
128
141
  type: "string",
129
- description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import.",
142
+ description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import. Use this OR scriptFile.",
143
+ },
144
+ scriptFile: {
145
+ type: "string",
146
+ description: "Run a workspace .mjs/.js file with node (self-contained, no prelude). Path relative to workdir, confined to the workspace. Use this OR code. For `node <script>` / `node --test <file>` / `node --check <file>`.",
147
+ },
148
+ nodeArgs: {
149
+ type: "array",
150
+ items: { type: "string" },
151
+ description: "(scriptFile) Extra node flags before the script, e.g. [\"--test\"], [\"--check\"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.",
130
152
  },
131
153
  workdir: {
132
154
  type: "string",
@@ -143,15 +165,11 @@ export const codeModeTool = {
143
165
  description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
144
166
  },
145
167
  },
146
- required: ["code"],
168
+ required: [],
147
169
  },
148
170
  readonly: false,
149
171
 
150
172
  async execute(args, ctx) {
151
- const code = args.code ?? ""
152
- if (code.length > MAX_SCRIPT) {
153
- return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
154
- }
155
173
  let baseDir
156
174
  try { baseDir = resolveBaseDir(ctx.cwd, args.workdir) }
157
175
  catch (e) { return `Error: ${e.message}` }
@@ -159,7 +177,27 @@ export const codeModeTool = {
159
177
  const t = Number(args.timeoutMs)
160
178
  const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 60_000) : DEFAULT_TIMEOUT
161
179
 
162
- const { text, ok } = await runNodeEval(code, baseDir, ctx.cwd, timeoutMs, ctx.signal)
180
+ let childArgs
181
+ if (args.scriptFile) {
182
+ if (args.code?.trim()) return "Error: pass code OR scriptFile, not both"
183
+ // scriptFile mode: run a workspace .mjs/.js file with node [nodeArgs...]. Self-contained —
184
+ // no prelude (a real node process imports what it needs). Confined to the workspace.
185
+ const scriptAbs = resolve(baseDir, args.scriptFile)
186
+ if (!isInside(ctx.cwd, scriptAbs)) return `Error: scriptFile escapes the workspace: ${args.scriptFile}`
187
+ let nodeArgs
188
+ try { nodeArgs = validateNodeArgs(args.nodeArgs) }
189
+ catch (e) { return `Error: ${e.message}` }
190
+ childArgs = [...nodeArgs, scriptAbs]
191
+ } else {
192
+ const code = args.code ?? ""
193
+ if (!code.trim()) return "Error: either code or scriptFile is required"
194
+ if (code.length > MAX_SCRIPT) {
195
+ return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
196
+ }
197
+ childArgs = ["--input-type=module", "--eval", `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`]
198
+ }
199
+
200
+ const { text, ok } = await runNode(childArgs, baseDir, ctx.cwd, timeoutMs, ctx.signal)
163
201
  // Only filter successful output — never swallow an error report behind a filter.
164
202
  if (!ok) return text
165
203
  return args.filter ? applyFilter(text, args.filter) : text
package/src/tools/git.md CHANGED
@@ -1,21 +1,39 @@
1
- Run a git command. Use this to see uncommitted changes, staged changes, diff against a ref, recent commits, or manage checkpoints. Only works inside a git repository.
2
- - action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.
3
- - action='status': Show working tree state staged, unstaged, untracked files, and conflicts. Returns categorized lists.
4
- - action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.
5
- - action='show': Show a commit's details (--stat). Set ref=<ref> to inspect a specific commit (default HEAD).
6
- - action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).
7
- - action='rm': Untrack a file/directory (git rm --cached keeps the file on disk). path is required.
8
- - action='commit': Stage all changes and commit. message is required. Confirms with the user (outward action).
9
- - action='push': Push the current branch to the remote. Confirms with the user (outward action).
1
+ Run a git command. Only works inside a git repository.
2
+
3
+ **Route to git instead of bash:** `git status`→status, `git log`→log, `git diff`→diff, `git show`→show, `git add`→add, `git rm`→rm, `git commit -m`→commit, `git push <remote> <branch> <tag>`→push, `git tag`→tag, `git branch`→branch, `git checkout`→checkout, `git restore`→restore, `git stash`→stash, `git fetch/pull`→fetch/pull, `git reset`→reset, `git revert`→revert, `git merge`→merge, `git cherry-pick`→cherry-pick.
4
+
5
+ - action='diff': unified diff what changed since last commit. staged=true for staged-only; ref=<ref> to compare a commit/branch; path=<dir> to scope.
6
+ - action='status': working tree state staged / unstaged / untracked / conflicts, categorized.
7
+ - action='log': recent commits. count (default 10), oneline=true compact, path=<file> for one file's history.
8
+ - action='show': a commit's details (--stat). ref=<ref> (default HEAD).
9
+ - action='add': stage files path=<file> (granular) or all changes when path omitted.
10
+ - action='commit': stage + commit. message required; path=<file> for granular staging.
11
+ - action='rm': untrack a file/dir (git rm --cached, kept on disk). path required.
12
+ - action='push'/'fetch'/'pull': sync with remote. remote=<origin>, ref=<branch or tag> (space-separated for multiple), tags=true for --tags.
13
+ - action='tag': manage tags. tagAction=list (optional filter) / create (name, optional ref) / delete (name; snapshots first).
14
+ - action='branch': manage branches. branchAction=list / create (name, optional ref) / switch (name) / delete (name; snapshots first).
15
+ - action='checkout': switch to ref=<branch/commit>, or restore a file path=<file> (discards its working-tree changes; snapshots first).
16
+ - action='restore': restore a file from index/HEAD. path required; staged=true restores the staged copy; snapshots first.
17
+ - action='stash': manage the stash. stashAction=list / push (message) / pop (snapshots first).
18
+ - action='reset': reset to ref (default HEAD). mode=soft/mixed/hard; hard snapshots the tree first (drops working-tree changes).
19
+ - action='revert': revert a commit (safe). ref=<commit> (default HEAD).
20
+ - action='merge': merge ref=<branch/commit>; conflicts reported for you to resolve.
21
+ - action='cherry-pick': cherry-pick ref=<commit>.
22
+ - action='checkpoint': git snapshots. checkpointAction=list/create/rewind/cat/versions; checkpointId required for rewind/cat.
10
23
 
11
24
  Parameters:
12
- - action (required): diff / status / log / show / checkpoint / rm / commit / push
13
- - staged: (diff) Show staged changes instead of working tree
14
- - path: (diff/log/checkpoint:cat/checkpoint:rewind/rm) File or directory to scope to
15
- - ref: (show) Commit ref to inspect (default HEAD)
16
- - count: (log) Number of commits (default 10)
17
- - oneline: (log) One-line-per-commit format
18
- - message: (commit) Commit message required for commit
19
- - filter: Optional — keep only status/diff/log output lines matching this regex (case-insensitive)
20
- - checkpointAction: (checkpoint) list snapshots / create one / restore by id / read file from snapshot
21
- - checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
25
+ - action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick
26
+ - workdir: run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd
27
+ - path: (diff/log/add/commit/checkout/restore/rm) file or directory to scope / stage / restore
28
+ - ref: (show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create) commit/branch/ref; (push/pull/fetch) the branch or tag (space-separated for multiple)
29
+ - name: (branch/tag) the branch or tag name
30
+ - remote: (push/fetch/pull) remote name (e.g. origin); default: current upstream
31
+ - tags: (push) also push all tags (--tags)
32
+ - staged: (diff) staged changes; (restore) the staged copy
33
+ - count: (log) number of commits (default 10)
34
+ - oneline: (log) one-line-per-commit
35
+ - message: (commit) commit message — required; (stash:push) stash message
36
+ - mode: (reset) soft / mixed / hard — hard snapshots the tree first + needs confirmation
37
+ - tagAction: (tag) list / create / delete — branchAction: (branch) list / create / delete / switch — stashAction: (stash) push / pop / list
38
+ - filter: (read-only actions) keep only output lines matching this regex (case-insensitive)
39
+ - checkpointAction: (checkpoint) list / create / rewind / cat / versions — checkpointId: snapshot id (rewind/cat)
package/src/tools/git.mjs CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  } from "./shared.mjs";
6
6
  import { escapeXml } from "../agent/helpers.mjs";
7
7
  import { execFileSync } from "node:child_process";
8
- import { join } from "node:path";
8
+ import { join, resolve, relative, isAbsolute, sep } from "node:path";
9
9
 
10
10
  /** Keep only output lines matching a regex (git filter, case-insensitive). */
11
11
  function filterLines(output, filter) {
@@ -19,6 +19,17 @@ function filterLines(output, filter) {
19
19
  }
20
20
  }
21
21
 
22
+ /** Run git PRESERVING per-line leading whitespace. runGit trims the WHOLE output, which
23
+ * strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
24
+ * unstaged-only first line as staged. status uses this so the staged/unstaged column survives. */
25
+ function runGitRaw(cwd, cmdArgs) {
26
+ try {
27
+ return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
28
+ } catch (e) {
29
+ return String(e.stdout || "").replace(/\r/g, "")
30
+ }
31
+ }
32
+
22
33
  /** Run git and report failure (stderr + exit code) instead of swallowing it.
23
34
  * Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
24
35
  function runGitStrict(cwd, cmdArgs) {
@@ -30,21 +41,66 @@ function runGitStrict(cwd, cmdArgs) {
30
41
  }
31
42
  }
32
43
 
44
+ /** Validate a git ref / branch / tag / remote name (no option injection, no whitespace). */
45
+ function validateRef(ref, what = "git ref") {
46
+ if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid ${what}: ${ref}`)
47
+ return ref
48
+ }
49
+
50
+ /** True when `abs` is inside `root` (handles `..` and cross-drive, which relative()
51
+ * returns as an absolute path on Windows). */
52
+ function isInside(root, abs) {
53
+ const rel = relative(root, abs)
54
+ if (isAbsolute(rel)) return false
55
+ return rel !== ".." && !rel.startsWith(".." + sep)
56
+ }
57
+
58
+ /** Resolve workdir relative to cwd, asserting it stays within the workspace. */
59
+ function resolveBaseDir(cwd, workdir) {
60
+ if (!workdir || typeof workdir !== "string") return cwd
61
+ const abs = resolve(cwd, workdir)
62
+ if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
63
+ return abs
64
+ }
65
+
66
+ /** Snapshot the working tree before a destructive op (reset --hard / checkout file / restore /
67
+ * stash pop / branch|tag delete). Best-effort — a snapshot failure must not block the op
68
+ * (the approval/permission layer is the real gate). Returns a note line or "". */
69
+ async function snapshotBefore(ctx, label) {
70
+ try {
71
+ const { createCheckpoint, isGitRepo } = await import("../git/checkpoint.mjs")
72
+ if (!isGitRepo(ctx.cwd)) return ""
73
+ const cp = await createCheckpoint(ctx.cwd)
74
+ return `[snapshot ${cp.id} created before ${label}]\n`
75
+ } catch {
76
+ return ""
77
+ }
78
+ }
79
+
33
80
  export const gitTool = {
34
81
  name: "git",
35
82
  description: DESC("git"),
36
83
  parameters: {
37
84
  type: "object",
38
85
  properties: {
39
- action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "rm", "commit", "push"], description: "diff / status / log / show / checkpoint / rm / commit / push" },
86
+ action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "add", "rm", "commit", "push", "tag", "branch", "checkout", "restore", "stash", "fetch", "pull", "reset", "revert", "merge", "cherry-pick"], description: "diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick" },
40
87
  // diff/log params
41
88
  staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
42
- path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind/rm) File or directory to scope to" },
43
- ref: { type: "string", description: "(show) Commit ref to show (default HEAD)" },
89
+ path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm) File or directory to scope to / stage / restore" },
90
+ ref: { type: "string", description: "(show/diff/checkout/reset/revert/merge/cherry-pick/tag:create/branch:create) Commit/branch/ref; (push/pull/fetch) the branch or tag to push/pull/fetch (space-separated for multiple)" },
44
91
  count: { type: "number", description: "(log) Number of commits (default 10)" },
45
92
  oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
46
- message: { type: "string", description: "(commit) Commit message — required for commit" },
93
+ message: { type: "string", description: "(commit) Commit message — required for commit; (stash:push) stash message" },
47
94
  filter: { type: "string", description: "Optional: keep only status/diff/log output lines matching this regex (case-insensitive)" },
95
+ // write-op params
96
+ name: { type: "string", description: "(branch/tag) The branch or tag name (create/delete/switch)" },
97
+ remote: { type: "string", description: "(push/fetch/pull) Remote name (e.g. origin). Default: current upstream" },
98
+ workdir: { type: "string", description: "Run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd" },
99
+ tags: { type: "boolean", description: "(push) Also push all tags (--tags)" },
100
+ mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation" },
101
+ tagAction: { type: "string", enum: ["list", "create", "delete"], description: "(tag) list tags / create one / delete one" },
102
+ branchAction: { type: "string", enum: ["list", "create", "delete", "switch"], description: "(branch) list branches / create / delete / switch to one" },
103
+ stashAction: { type: "string", enum: ["push", "pop", "list"], description: "(stash) push (stash now) / pop (apply+drop) / list" },
48
104
  // checkpoint params
49
105
  checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
50
106
  checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
@@ -53,6 +109,9 @@ export const gitTool = {
53
109
  },
54
110
  readonly: false,
55
111
  async execute(args, ctx) {
112
+ // workdir: run git in a workspace subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
113
+ // every action + snapshotBefore + checkpoint resolves against the workdir, confined to the workspace.
114
+ if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
56
115
  switch (args.action) {
57
116
  case "diff": {
58
117
  const ref = args.ref ?? "HEAD"
@@ -63,7 +122,9 @@ export const gitTool = {
63
122
  return truncate(filterLines(out || "(no changes)", args.filter))
64
123
  }
65
124
  case "status": {
66
- const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
125
+ // Preserve per-line leading whitespace — porcelain " M"/"M " staged/unstaged markers are
126
+ // significant (runGit trims the whole output's leading space, corrupting an unstaged-first-line).
127
+ const porcelain = runGitRaw(ctx.cwd, ["status", "--porcelain"])
67
128
  if (!porcelain) return "(clean — no changes)"
68
129
 
69
130
  const staged = []
@@ -119,7 +180,8 @@ export const gitTool = {
119
180
  }
120
181
  case "commit": {
121
182
  if (!args.message) return "Error: commit requires message"
122
- const add = runGitStrict(ctx.cwd, ["add", "-A"])
183
+ // Granular staging when path given (only stage these); otherwise stage all (add -A).
184
+ const add = runGitStrict(ctx.cwd, args.path ? ["add", "--", args.path] : ["add", "-A"])
123
185
  if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
124
186
  const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
125
187
  const parts = []
@@ -129,9 +191,145 @@ export const gitTool = {
129
191
  return truncate(parts.join("\n") || "(commit produced no output)")
130
192
  }
131
193
  case "push": {
132
- const r = runGitStrict(ctx.cwd, ["push"])
194
+ const cmdArgs = ["push"]
195
+ if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
196
+ if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
197
+ if (args.tags) cmdArgs.push("--tags")
198
+ const r = runGitStrict(ctx.cwd, cmdArgs)
133
199
  return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
134
200
  }
201
+ case "add": {
202
+ // Granular staging: stage `path` when given, else all changes (add -A).
203
+ const cmdArgs = args.path ? ["add", "--", args.path] : ["add", "-A"]
204
+ const r = runGitStrict(ctx.cwd, cmdArgs)
205
+ return r.ok ? truncate(r.out || `Staged ${args.path || "all changes"}`) : truncate(`git add failed: ${r.err || r.out}`)
206
+ }
207
+ case "tag": {
208
+ const sub = args.tagAction
209
+ if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["tag", "-l"]) || "(no tags)", args.filter))
210
+ if (sub === "create") {
211
+ if (!args.name) return "Error: tag create requires name"
212
+ validateRef(args.name, "tag")
213
+ const cmdArgs = ["tag", args.name]
214
+ if (args.ref) cmdArgs.push(validateRef(args.ref))
215
+ const r = runGitStrict(ctx.cwd, cmdArgs)
216
+ return r.ok ? `Tag ${args.name} created` : truncate(`git tag failed: ${r.err || r.out}`)
217
+ }
218
+ if (sub === "delete") {
219
+ if (!args.name) return "Error: tag delete requires name"
220
+ validateRef(args.name, "tag")
221
+ const snap = await snapshotBefore(ctx, `tag delete ${args.name}`)
222
+ const r = runGitStrict(ctx.cwd, ["tag", "-d", args.name])
223
+ return r.ok ? truncate(snap + `Tag ${args.name} deleted`) : truncate(`git tag -d failed: ${r.err || r.out}`)
224
+ }
225
+ return "Error: tag requires tagAction — use: list | create | delete"
226
+ }
227
+ case "branch": {
228
+ const sub = args.branchAction
229
+ if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["branch", "--all", "-vv"]) || "(no branches)", args.filter))
230
+ if (sub === "create") {
231
+ if (!args.name) return "Error: branch create requires name"
232
+ validateRef(args.name, "branch")
233
+ const cmdArgs = ["branch", args.name]
234
+ if (args.ref) cmdArgs.push(validateRef(args.ref))
235
+ const r = runGitStrict(ctx.cwd, cmdArgs)
236
+ return r.ok ? `Branch ${args.name} created` : truncate(`git branch failed: ${r.err || r.out}`)
237
+ }
238
+ if (sub === "switch") {
239
+ if (!args.name) return "Error: branch switch requires name"
240
+ validateRef(args.name, "branch")
241
+ const r = runGitStrict(ctx.cwd, ["checkout", args.name])
242
+ return r.ok ? `Switched to branch ${args.name}` : truncate(`git checkout ${args.name} failed: ${r.err || r.out}`)
243
+ }
244
+ if (sub === "delete") {
245
+ if (!args.name) return "Error: branch delete requires name"
246
+ validateRef(args.name, "branch")
247
+ const snap = await snapshotBefore(ctx, `branch delete ${args.name}`)
248
+ const r = runGitStrict(ctx.cwd, ["branch", "-d", args.name])
249
+ return r.ok ? truncate(snap + `Branch ${args.name} deleted`) : truncate(`git branch -d failed: ${r.err || r.out}`)
250
+ }
251
+ return "Error: branch requires branchAction — use: list | create | delete | switch"
252
+ }
253
+ case "checkout": {
254
+ if (args.path) {
255
+ // Restore file from index (discards working-tree changes to it) — destructive: snapshot first.
256
+ const snap = await snapshotBefore(ctx, `checkout -- ${args.path}`)
257
+ const r = runGitStrict(ctx.cwd, ["checkout", "--", args.path])
258
+ return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git checkout -- ${args.path} failed: ${r.err || r.out}`)
259
+ }
260
+ if (args.ref) {
261
+ validateRef(args.ref, "ref")
262
+ const r = runGitStrict(ctx.cwd, ["checkout", args.ref])
263
+ return r.ok ? truncate(r.out || `Checked out ${args.ref}`) : truncate(`git checkout ${args.ref} failed: ${r.err || r.out}`)
264
+ }
265
+ return "Error: checkout requires ref (branch/commit) or path (file to restore)"
266
+ }
267
+ case "restore": {
268
+ if (!args.path) return "Error: restore requires path"
269
+ const snap = await snapshotBefore(ctx, `restore ${args.path}`)
270
+ const cmdArgs = ["restore"]
271
+ if (args.staged) cmdArgs.push("--staged")
272
+ cmdArgs.push("--", args.path)
273
+ const r = runGitStrict(ctx.cwd, cmdArgs)
274
+ return r.ok ? truncate(snap + `Restored ${args.path}`) : truncate(`git restore failed: ${r.err || r.out}`)
275
+ }
276
+ case "stash": {
277
+ const sub = args.stashAction
278
+ if (sub === "list") return truncate(filterLines(runGit(ctx.cwd, ["stash", "list"]) || "(no stashes)", args.filter))
279
+ if (sub === "push") {
280
+ const cmdArgs = ["stash", "push"]
281
+ if (args.message) cmdArgs.push("-m", args.message)
282
+ const r = runGitStrict(ctx.cwd, cmdArgs)
283
+ return r.ok ? truncate(r.out || "Stashed") : truncate(`git stash push failed: ${r.err || r.out}`)
284
+ }
285
+ if (sub === "pop") {
286
+ const snap = await snapshotBefore(ctx, "stash pop")
287
+ const r = runGitStrict(ctx.cwd, ["stash", "pop"])
288
+ return r.ok ? truncate(snap + (r.out || "Popped")) : truncate(`git stash pop failed: ${r.err || r.out}`)
289
+ }
290
+ return "Error: stash requires stashAction — use: push | pop | list"
291
+ }
292
+ case "fetch": {
293
+ const cmdArgs = ["fetch"]
294
+ if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
295
+ if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
296
+ const r = runGitStrict(ctx.cwd, cmdArgs)
297
+ return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
298
+ }
299
+ case "pull": {
300
+ const cmdArgs = ["pull"]
301
+ if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
302
+ if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
303
+ const r = runGitStrict(ctx.cwd, cmdArgs)
304
+ return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
305
+ }
306
+ case "reset": {
307
+ const mode = args.mode ?? "mixed"
308
+ if (!["soft", "mixed", "hard"].includes(mode)) return "Error: reset mode must be soft | mixed | hard"
309
+ let snap = ""
310
+ if (mode === "hard") snap = await snapshotBefore(ctx, "reset --hard") // destructive: drops working-tree changes
311
+ const cmdArgs = ["reset", `--${mode}`]
312
+ if (args.ref) cmdArgs.push(validateRef(args.ref))
313
+ const r = runGitStrict(ctx.cwd, cmdArgs)
314
+ return r.ok ? truncate(snap + (r.out || `Reset (${mode}) complete`)) : truncate(`git reset failed: ${r.err || r.out}`)
315
+ }
316
+ case "revert": {
317
+ const ref = validateRef(args.ref ?? "HEAD")
318
+ const r = runGitStrict(ctx.cwd, ["revert", "--no-edit", ref])
319
+ return r.ok ? truncate(r.out || `Reverted ${ref}`) : truncate(`git revert failed: ${r.err || r.out}`)
320
+ }
321
+ case "merge": {
322
+ if (!args.ref) return "Error: merge requires ref (branch/commit to merge)"
323
+ validateRef(args.ref, "ref")
324
+ const r = runGitStrict(ctx.cwd, ["merge", "--no-edit", args.ref])
325
+ return r.ok ? truncate(r.out || `Merged ${args.ref}`) : truncate(`git merge failed: ${r.err || r.out} — resolve conflicts, then commit`)
326
+ }
327
+ case "cherry-pick": {
328
+ if (!args.ref) return "Error: cherry-pick requires ref (commit)"
329
+ validateRef(args.ref, "ref")
330
+ const r = runGitStrict(ctx.cwd, ["cherry-pick", args.ref])
331
+ return r.ok ? truncate(r.out || `Cherry-picked ${args.ref}`) : truncate(`git cherry-pick failed: ${r.err || r.out}`)
332
+ }
135
333
  case "checkpoint": {
136
334
  const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
137
335
  if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
@@ -191,7 +389,7 @@ export const gitTool = {
191
389
  throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
192
390
  }
193
391
  default:
194
- return `Unknown action '${args.action}'. Use: diff | status | log | show | checkpoint | rm | commit | push`
392
+ return `Unknown action '${args.action}'. Use: diff | status | log | show | checkpoint | add | rm | commit | push | tag | branch | checkout | restore | stash | fetch | pull | reset | revert | merge | cherry-pick`
195
393
  }
196
394
  },
197
395
  }
package/src/tools/grep.md CHANGED
@@ -1,5 +1,7 @@
1
1
  Search file contents with a regex. Returns matching lines as path:line: content.
2
2
 
3
+ **Route to grep instead of bash:** `findstr /c:"pat" file` / `grep -rn pat .` → grep. Searching file contents is a read — never shell out for it.
4
+
3
5
  Parameters:
4
6
  - pattern (required): JavaScript regular expression, or a literal string when literal=true
5
7
  - path: Directory or file to search (default cwd)
@@ -9,7 +9,7 @@ import { gitTool, questionTool } from "./git.mjs";
9
9
  import { checklistTool } from "./checklist.mjs";
10
10
  import { lintTool } from "./linter.mjs";
11
11
  import { lspTool } from "./lsp.mjs";
12
- import { codeModeTool } from "./codemode.mjs";
12
+ import { executeTool } from "./execute.mjs";
13
13
  import { fileOpsTool, processTool, getCurrentTimeTool, sleepTool } from "./ops.mjs";
14
14
  import { treeTool } from "./tree.mjs";
15
15
 
@@ -18,7 +18,7 @@ export const builtinTools = [
18
18
  readImageTool, bashTool, globTool, grepTool,
19
19
  websearchTool, lsTool, fetchTool, deleteTool,
20
20
  gitTool, questionTool,
21
- checklistTool, lintTool, lspTool, codeModeTool,
21
+ checklistTool, lintTool, lspTool, executeTool,
22
22
  fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
23
23
  treeTool,
24
24
  ];
@@ -28,7 +28,7 @@ export {
28
28
  readImageTool, bashTool, globTool, grepTool,
29
29
  websearchTool, lsTool, fetchTool, deleteTool,
30
30
  gitTool, questionTool,
31
- checklistTool, lintTool, lspTool, codeModeTool,
31
+ checklistTool, lintTool, lspTool, executeTool,
32
32
  fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
33
33
  treeTool,
34
34
  };
package/src/tools/ls.md CHANGED
@@ -1,5 +1,7 @@
1
1
  List directory contents with type, size, and modification time. Directories listed first. Use to see what a directory contains (glob only matches files).
2
2
 
3
+ **Route to ls instead of bash:** `dir /b` / `ls` / `dir` → ls. Listing a directory is a read — never shell out for it.
4
+
3
5
  Parameters:
4
6
  - path: Directory path (default cwd)
5
7
  - filter: Only list entries matching this glob (e.g. '*.mjs', '*test*') — a wildcard filter, not a full listing
package/src/tools/read.md CHANGED
@@ -1,5 +1,7 @@
1
1
  Read a text file. Returns numbered lines. Use offset/limit to page large files.
2
2
 
3
+ **Route to read instead of bash:** `cat file` / `type file` / `node -e "fs.readFileSync(...)"` → read. Reading a file is a read — never shell out for it.
4
+
3
5
  **Routing:**
4
6
  - Don't know which file? → `repo_outline` / `code_search` / `glob` first
5
7
  - Know the symbol but not the location? → `code_search` or `lsp definition`
@@ -3,7 +3,7 @@ import { saveSession } from "../session.mjs"
3
3
  import { sliceByWidth } from "./render.mjs"
4
4
  import { ansi, C } from "./ansi.mjs"
5
5
  import { formatToolSummary } from "./tool-summaries.mjs"
6
- import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
6
+ import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
7
7
 
8
8
  /** Tool execution start timestamps (performance.now ms), keyed by tool name. */
9
9
  const _toolTicks = Object.create(null)
@@ -101,6 +101,15 @@ export async function runAgentTurn(ctx, text) {
101
101
  if (!state.subTasks[key]) {
102
102
  state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
103
103
  }
104
+ // `[model]<name>` metadata token: record the subagent's model (may differ from the
105
+ // parent's) — shown in the subagent header, NOT appended to its content stream.
106
+ // Only treat as metadata when the model isn't set yet (it's always the FIRST token);
107
+ // a child content token that happens to start with "[model]" must not be swallowed.
108
+ if (payload.startsWith("[model]") && state.subTasks[key].model === undefined) {
109
+ state.subTasks[key].model = payload.slice(7)
110
+ scheduleRender()
111
+ return
112
+ }
104
113
  state.subTasks[key].text += payload
105
114
  if (state.subTasks[key].text.length > 2000) {
106
115
  state.subTasks[key].text = state.subTasks[key].text.slice(-2000)
@@ -149,6 +158,8 @@ export async function runAgentTurn(ctx, text) {
149
158
  flushStream()
150
159
  ensureAssistantLabel()
151
160
  state.currentTool = name
161
+ // Advisor's effective model (resolved once for the status line + inline title below).
162
+ const advModel = name === "advisor" ? (() => { try { return resolveAdvisorProvider(agent).model } catch { return null } })() : null
152
163
  // Update status bar with current tool and key arguments for user visibility
153
164
  if (name === "bash" && args.command) {
154
165
  const cmd = args.command.replace(/\s+/g, " ").trim()
@@ -162,13 +173,14 @@ export async function runAgentTurn(ctx, text) {
162
173
  } else if (name === "websearch" && args.query) {
163
174
  state.status = `search: ${args.query.length > 40 ? args.query.slice(0, 40) + "…" : args.query}`
164
175
  } else if (name === "advisor") {
165
- state.status = `advisor review (round ${(agent._advisorRound || 0) + 1})`
176
+ state.status = `advisor review (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})`
166
177
  } else {
167
178
  state.status = `tool: ${name}`
168
179
  }
169
180
  // Advisor: tag the round in the tool title — the model's own "第N轮" narration
170
181
  // is unreliable (it glues onto the previous line), so the round belongs here.
171
- const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1})` : ""
182
+ // Also show the advisor's effective model (it may differ from the main agent's).
183
+ const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
172
184
  const argSummary = summarize(args)
173
185
  // Inline block title — panel tools get both the title AND the
174
186
  // streaming output panel, complementary display.
@@ -62,7 +62,7 @@ export function renderSubagent(allSubs, W) {
62
62
  for (const s of subs.slice(0, MAX_SUB_LINES)) {
63
63
  const icon = s.done ? "✓" : "…"
64
64
  const color = s.done ? C.dim : C.tool
65
- const label = `[${s.role}]`.padEnd(10)
65
+ const label = `[${s.role}${s.model ? " · " + s.model : ""}]`.padEnd(10)
66
66
  let content
67
67
  if (s.done) {
68
68
  const elapsed = Math.floor((Date.now() - s.started) / 1000)
@@ -75,7 +75,7 @@ export function renderSubagent(allSubs, W) {
75
75
  } else {
76
76
  content = "thinking..."
77
77
  }
78
- out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, W - 14))}${ansi.reset}`)
78
+ out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, W - 4 - stringWidth(label)))}${ansi.reset}`)
79
79
  }
80
80
  if (subs.length > MAX_SUB_LINES) {
81
81
  out.push(`${C.dim} ... +${subs.length - MAX_SUB_LINES} more subagents${ansi.reset}`)