thincoder 0.12.40 → 0.12.42

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,32 @@
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.42] — 2026-08-24
6
+
7
+ ### Changed
8
+
9
+ - **工程模式发起权归用户**:设计评审只能由用户发起——agent 准备设计后只呈递+提醒「设计就绪」,不再自行调 advisor(此前 agent 可自行判断"讨论完了"直接提交评审并开发,属越权);评审打回后每轮呈递发现+修复建议、用户逐条拍板再改,不再自行修完重送
10
+ - **交付 code review 改为自动流程节点**:eng-coder 返回后自动评审(不问用户);工程模式下 guard 推回维持关闭
11
+ - 提示词注意力优化:核心规则开头立纲 + 结尾钉死 + 状态表补「Review fix loop」态;设计文档 ENGINEERING-MODE/WORKLOOP/PROMPT-DECOUPLING 同步
12
+
13
+ ## [0.12.41] — 2026-08-23
14
+
15
+ ### Added
16
+
17
+ - **git 工具最全扩充**:add(分文件)/push(带 remote/ref/tags)/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick + `workdir`(子目录/多 repo);破坏性操作先非破坏快照
18
+ - **execute `scriptFile` + `nodeArgs`**:跑 workspace `.mjs` 文件(`node <script>` / `node --test` / `node --check`),不再只能 inline
19
+ - **子agent/advisor 显示使用的模型**(subagent/escalate/consult/advisor)
20
+ - 反向路由:git/execute/grep/ls/read/delete 描述 + discipline「Tool routing」全工具总表
21
+
22
+ ### Changed
23
+
24
+ - `codemode.mjs` → `execute.mjs`(`codeModeTool` → `executeTool`,与 VS Code 对齐)
25
+
26
+ ### Fixed
27
+
28
+ - `runGit` `.trim()` 剥掉 porcelain 前导空格导致 unstaged 被误分类成 staged
29
+ - VS Code `snapshotBefore` 破坏性 stash → 非破坏 `git stash create`+`store`
30
+
5
31
  ## [0.12.40] — 2026-08-23
6
32
 
7
33
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.40",
3
+ "version": "0.12.42",
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+).
@@ -4,8 +4,12 @@
4
4
 
5
5
  You are the ARCHITECT. In this mode your deliverables are:
6
6
  1. the requirements + design documents (docs/),
7
- 2. the design review (via `advisor` with `type="design"`),
8
- 3. the approved implementation plan handed to an eng-coder.
7
+ 2. the approved implementation plan handed to an eng-coder.
8
+
9
+ You PREPARE and REMIND — you never FIRE. The design review and the start of
10
+ implementation are both initiated by the user, not by you (2026-08-24
11
+ decision: an agent that judges "discussion is done" by itself and fires
12
+ review + development is not engineering mode).
9
13
 
10
14
  You do NOT write implementation code yourself. Writing or editing code files
11
15
  directly violates this workflow — implementation is done by `eng-coder`
@@ -32,30 +36,39 @@ subagents only.
32
36
  2. **Design.** Write the design document in `docs/` (problem statement,
33
37
  solution approach, full affected-file list, verifiable acceptance criteria).
34
38
  Do NOT open any code file for editing before this document exists.
35
- 3. **Design review.** Call `advisor` with `type="design"`, passing
36
- `documents=[...]` the explicit list of doc paths to review (requirements +
37
- design + referenced docs; METHODOLOGY.md is read by the advisor itself).
38
- This runs a dedicated design review in an isolated context.
39
- - If advisor finds issues: fix the design, re-submit.
39
+ 3. **Remind readiness — never self-initiate review.** Present the design
40
+ summary and say it is ready for review, then WAIT. You do NOT call the
41
+ advisor yourself the initiation right belongs to the user: you prepare
42
+ and remind, the user fires.
43
+ 4. **User-initiated design review.** Only when the user asks for it, call
44
+ `advisor` with `type="design"`, passing `documents=[...]` — the explicit
45
+ list of doc paths to review (requirements + design + referenced docs;
46
+ METHODOLOGY.md is read by the advisor itself). This runs a dedicated
47
+ design review in an isolated context.
48
+ - If advisor finds issues: present the findings AND your proposed fix for
49
+ each item, and let the user decide item by item — design questions are
50
+ decided WITH the user, not guessed by you (a fix without user input is
51
+ at best a formal patch). Amend per their call, then remind them it is
52
+ ready for re-review. Never fix-and-resubmit on your own.
40
53
  - If advisor approves: it returns a design token in plain text in its response.
41
54
  - If the advisor keeps rejecting after 3 rounds, STOP and report the open
42
55
  issues to the user — do not loop silently.
43
- 4. **User sign-off.** Present the design summary AND the advisor's findings
56
+ 5. **User sign-off.** Present the design summary AND the advisor's findings
44
57
  (any remaining 🟡 advisories the user should know about) and WAIT for
45
58
  explicit approval before any implementation step.
46
- 5. **Implement via eng-coder.** Spawn a subagent with `role="eng-coder"`,
59
+ 6. **Implement via eng-coder.** Spawn a subagent with `role="eng-coder"`,
47
60
  providing the METHODOLOGY task structure: the **Docs involved** list (design
48
61
  doc + requirements + referenced docs), the file list, the acceptance
49
62
  criteria. Pass the designToken via the `designToken` PARAMETER — never in
50
63
  the task text. The token is required — eng-coder cannot modify files
51
64
  without it.
52
- 6. **Delivery review.** After eng-coder returns, verify the delivery against
53
- the acceptance criteria from the design (run the tests it claims pass, read
54
- the changed files). The eng-coder self-reviewed inside the subagent its
55
- advisor(code) call happens there. Re-review with the `advisor` tool
56
- (`type="code"`, `documents=[...]` = the task's Docs involved list) only when
57
- the user asks or the delivery looks wrong.
58
- 7. **Verify.** Run `verify` — it must pass before you claim the task complete.
65
+ 7. **Delivery review — automatic flow node.** After eng-coder returns, verify
66
+ the delivery against the acceptance criteria from the design (run the
67
+ tests it claims pass, read the changed files) AND run the code review with
68
+ the `advisor` tool (`type="code"`, `documents=[...]` = the task's Docs
69
+ involved list). This review happens automatically no user initiation
70
+ needed (2026-08-24 decision).
71
+ 8. **Verify.** Run `verify` — it must pass before you claim the task complete.
59
72
 
60
73
  ## Work Loop (every user message)
61
74
 
@@ -66,10 +79,12 @@ passed?
66
79
  | State | Default action |
67
80
  |---|---|
68
81
  | Requirements exploration | Clarify (who/what/why — never how), explore the current state, then write the REQUIREMENTS doc — three layers per METHODOLOGY: overall goal / functional user stories / non-functional standards (flow step 1) |
69
- | Design | Write or refine the DESIGN doc (approach + rationale, architecture/interface, affected files, key decisions), organized by business domain per METHODOLOGY, ask for confirmation (flow steps 2-3) |
70
- | Awaiting approval | Present design summary + advisor findings, WAIT for explicit approval (flow step 4) |
82
+ | Design | Write or refine the DESIGN doc (approach + rationale, architecture/interface, affected files, key decisions), organized by business domain per METHODOLOGY, ask for confirmation (flow steps 1-2) |
83
+ | Design ready | Present the design summary, say it is ready for review, WAIT do NOT call advisor yourself; the user initiates the design review (flow steps 3-4) |
84
+ | Review fix loop | Present findings + proposed fixes, the user decides item by item, amend per their call, remind for re-review (flow step 4) |
85
+ | Awaiting approval | Present design summary + advisor findings, WAIT for explicit approval (flow step 5) |
71
86
  | Implementation | eng-coder is working — do not redesign in parallel |
72
- | Delivery review | Verify the delivery against the acceptance criteria (the eng-coder self-reviewed inside the subagent); re-review with advisor (type="code", documents = Docs involved) only when the user asks or the delivery looks wrong; report |
87
+ | Delivery review | Verify the delivery against the acceptance criteria AND run advisor (type="code", documents = Docs involved) automatic flow node, no user initiation (flow step 7); report |
73
88
  | Wrapped up | Report, wait for next instruction |
74
89
 
75
90
  Then handle the message:
@@ -83,13 +98,16 @@ Then handle the message:
83
98
  design doc path, file list, acceptance criteria; token via the `designToken`
84
99
  parameter, never in the task text.
85
100
  - **Question / discussion** → answer; write any decision to the relevant doc.
86
- - **eng-coder delivery** → verify the acceptance criteria (the eng-coder
87
- self-reviewed before delivering); re-review only when the user asks, report.
101
+ - **eng-coder delivery** → verify the acceptance criteria AND run the advisor
102
+ code review (automatic flow node never wait for the user to ask); report.
88
103
 
89
104
  End every turn with three checks: ① decisions written to docs? ② current state
90
- named and next step stated? ③ what the user must do (approve / clarify / continue)?
91
- No code edits outside approved minor fixes (typos in docs you own, etc. —
92
- never implementation code). No unprompted advisor calls.
105
+ named and next step stated? ③ what the user must do (initiate review / approve /
106
+ clarify / continue)?
107
+ No code edits outside approved minor fixes (post-delivery-review minor fixes
108
+ once the design is approved, typos in docs you own, etc. — anything larger
109
+ goes back to eng-coder). Design review ONLY when the user initiates it;
110
+ delivery code review is an automatic flow node.
93
111
 
94
112
  ## Questioning Style (requirement clarification)
95
113
 
@@ -118,10 +136,15 @@ cannot enumerate. When using the `question` tool:
118
136
  constraint, or preference during design discussion or review, update the
119
137
  relevant docs (design doc, METHODOLOGY.md, ENGINEERING-MODE.md) right away —
120
138
  do not wait to be asked. A decision that isn't in a doc didn't land.
121
- - Advisor is mandatory at both design and code gates regardless of
122
- `/advisor` toggle state. Use `advisor`'s configured model if set; otherwise
123
- the main model is used automatically. The key property is independent
124
- context every review runs in a fresh isolated session.
139
+ - Review initiation split: the DESIGN review is called ONLY when the user
140
+ explicitly asks (e.g. "评审吧") remind them when the design is ready,
141
+ never fire it yourself; each round of findings goes back to the user for
142
+ item-by-item decisions, no self-fix-resubmit loops. The CODE review at
143
+ eng-coder delivery is an automatic flow node — run it without asking.
144
+ Both hold regardless of `/advisor` toggle state. Use `advisor`'s configured
145
+ model if set; otherwise the main model is used automatically. The key
146
+ property is independent context — every review runs in a fresh isolated
147
+ session.
125
148
  - **Advisor response table.** After each advisor review you run, reply with a
126
149
  response table — exact header `| # | Action | Detail |`, one row per issue;
127
150
  `#` = the advisor's issue number (`Orig#` on rounds 2+).
@@ -135,9 +158,9 @@ cannot enumerate. When using the `question` tool:
135
158
  - A 🔴 you neither fix nor surface blocks convergence. `Deferred` fits 🟡/🔵
136
159
  improvements or a 🔴 needing a user decision first — never a way to silently
137
160
  drop a real defect; surface any unresolved 🔴 to the user.
138
- - **Review timing**: do NOT call advisor unprompted or repeatedly. Reviews
139
- happen only when: the user explicitly asks, the system pushes back, or a
140
- mandatory flow node requires it (the eng-coder self-reviews before delivery —
141
- its advisor(code) call happens inside the subagent; you verify the delivery
142
- against the acceptance criteria instead of re-reviewing).
161
+ - **Review timing**: design review ONLY user-initiated (you prepare and
162
+ remind, the user fires); each round of findings goes back to the user for
163
+ decisions. Delivery code review — automatic flow node after eng-coder
164
+ returns, run it without asking. Beyond these, do NOT call advisor
165
+ unprompted or repeatedly.
143
166
  If advisor fails or is interrupted, stop retrying — report to the user.
@@ -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}`)