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 +26 -0
- package/package.json +1 -1
- package/src/acp/bridge.mjs +6 -1
- package/src/agent-tools/consult.mjs +2 -0
- package/src/agent-tools/escalate.mjs +2 -0
- package/src/agent-tools/subagent.mjs +4 -0
- package/src/prompts/discipline.md +9 -0
- package/src/prompts/engineering.md +56 -33
- package/src/tools/delete.md +2 -0
- package/src/tools/execute.md +8 -4
- package/src/tools/{codemode.mjs → execute.mjs} +52 -14
- package/src/tools/git.md +37 -19
- package/src/tools/git.mjs +207 -9
- package/src/tools/grep.md +2 -0
- package/src/tools/index.mjs +3 -3
- package/src/tools/ls.md +2 -0
- package/src/tools/read.md +2 -0
- package/src/tui/agent-turn.mjs +15 -3
- package/src/tui/render-frame.mjs +2 -2
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
package/src/acp/bridge.mjs
CHANGED
|
@@ -66,7 +66,12 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const callbacks = {
|
|
69
|
-
onToken: (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
|
|
8
|
-
|
|
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. **
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
the acceptance criteria from the design (run the
|
|
54
|
-
the changed files)
|
|
55
|
-
advisor(code
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
70
|
-
|
|
|
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
|
|
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
|
|
87
|
-
|
|
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 (
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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**:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
package/src/tools/delete.md
CHANGED
|
@@ -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)
|
package/src/tools/execute.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
Execute JavaScript code
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
66
|
+
function runNode(childArgs, baseDir, root, timeoutMs, signal) {
|
|
65
67
|
return new Promise((resolvePromise) => {
|
|
66
|
-
const
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
- action='
|
|
6
|
-
- action='
|
|
7
|
-
- action='
|
|
8
|
-
- action='
|
|
9
|
-
- 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
|
-
-
|
|
14
|
-
- path: (diff/log/
|
|
15
|
-
- ref: (show)
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
package/src/tools/index.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
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,
|
|
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`
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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 -
|
|
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}`)
|