thincoder 0.12.39 → 0.12.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +2 -2
- 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/agent.mjs +1 -5
- package/src/context.mjs +17 -8
- package/src/prompts/discipline.md +9 -0
- 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.41] — 2026-08-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **git 工具最全扩充**:add(分文件)/push(带 remote/ref/tags)/tag/branch/checkout/restore/stash/fetch/pull/reset/revert/merge/cherry-pick + `workdir`(子目录/多 repo);破坏性操作先非破坏快照
|
|
10
|
+
- **execute `scriptFile` + `nodeArgs`**:跑 workspace `.mjs` 文件(`node <script>` / `node --test` / `node --check`),不再只能 inline
|
|
11
|
+
- **子agent/advisor 显示使用的模型**(subagent/escalate/consult/advisor)
|
|
12
|
+
- 反向路由:git/execute/grep/ls/read/delete 描述 + discipline「Tool routing」全工具总表
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `codemode.mjs` → `execute.mjs`(`codeModeTool` → `executeTool`,与 VS Code 对齐)
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `runGit` `.trim()` 剥掉 porcelain 前导空格导致 unstaged 被误分类成 staged
|
|
21
|
+
- VS Code `snapshotBefore` 破坏性 stash → 非破坏 `git stash create`+`store`
|
|
22
|
+
|
|
23
|
+
## [0.12.40] — 2026-08-23
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`shrinkOversized` 数据丢失**:原地改共享消息对象会污染持久化的人读线(巨型粘贴内容被永久截断)——改为复制-on-write(机器线替换、人读线不动)
|
|
28
|
+
- 轮末探索蒸馏边界只在压缩**真重建**时重置(shrink 路径不再误重置)
|
|
29
|
+
- `createAgent` 补 `_emptyRetries` 初始化;删 advisor 分支无用 JSON.parse
|
|
30
|
+
|
|
5
31
|
## [0.12.39] — 2026-08-23
|
|
6
32
|
|
|
7
33
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thincoder",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.41",
|
|
4
4
|
"description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"repository": {
|
|
30
30
|
"type": "git",
|
|
31
|
-
"url": "https://github.com/xinbo-tech/thincoder.git"
|
|
31
|
+
"url": "git+https://github.com/xinbo-tech/thincoder.git"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test \"test/*.mjs\"",
|
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
|
package/src/agent.mjs
CHANGED
|
@@ -95,6 +95,7 @@ export function createAgent({
|
|
|
95
95
|
_sessionStart: sessionStart,
|
|
96
96
|
_lastPromptTokens: null, _usageAtLen: null,
|
|
97
97
|
_compressFailures: 0,
|
|
98
|
+
_emptyRetries: 0, // empty-response retry budget (per-run; reset on a fresh user turn)
|
|
98
99
|
_runStartHistoryLen: 0, // machine-line length at the start of the current run — end-of-run exploration distillation slices from here
|
|
99
100
|
_currentTurn: 0, _maxTurns: 100, // turn counter for status bar display
|
|
100
101
|
}
|
|
@@ -418,11 +419,6 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
418
419
|
// how many reviews have run (round 1→2→3→4→5), not how many succeeded.
|
|
419
420
|
// A failed/interrupted review is still a review attempt and should use
|
|
420
421
|
// the next round's prompt on retry.
|
|
421
|
-
try {
|
|
422
|
-
JSON.parse(toolCall.arguments || "{}")
|
|
423
|
-
} catch {
|
|
424
|
-
/* arguments unparseable — still counts as a review attempt */
|
|
425
|
-
}
|
|
426
422
|
agent._advisorRound++
|
|
427
423
|
}
|
|
428
424
|
if (FILE_MUTATORS.has(toolCall.name)) {
|
package/src/context.mjs
CHANGED
|
@@ -272,19 +272,28 @@ const OVERSIZE_CONTENT_LIMIT = 8_000
|
|
|
272
272
|
*/
|
|
273
273
|
function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
|
|
274
274
|
let shrunk = false
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
// Copy-on-write: build a NEW array and replace only truncated entries. pushReal stores the SAME
|
|
276
|
+
// message object in both `agent.history` (machine line) and `agent._fullHistory` (human/persistence
|
|
277
|
+
// line), so in-place `m.content = ...` would ALSO truncate the never-compacted human line and lose
|
|
278
|
+
// the original pasted content on session persist (session.mjs persists _fullHistory). VS Code port
|
|
279
|
+
// already copies (`history.map(m => ({ ...m }))`); this brings CLI to parity.
|
|
280
|
+
const next = agent.history.map((m) => {
|
|
281
|
+
if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") return m
|
|
282
|
+
if (m.content.length <= limit) return m
|
|
278
283
|
// Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
|
|
279
284
|
const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
|
|
280
285
|
const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
|
|
281
|
-
m.content =
|
|
282
|
-
m.content.slice(0, keepHead) +
|
|
283
|
-
`\n[... ${m.content.length - keepHead - keepTail} chars truncated — single message too large for context window ...]\n` +
|
|
284
|
-
m.content.slice(-keepTail)
|
|
285
286
|
shrunk = true
|
|
286
|
-
|
|
287
|
+
return {
|
|
288
|
+
...m,
|
|
289
|
+
content:
|
|
290
|
+
m.content.slice(0, keepHead) +
|
|
291
|
+
`\n[... ${m.content.length - keepHead - keepTail} chars truncated — single message too large for context window ...]\n` +
|
|
292
|
+
m.content.slice(-keepTail),
|
|
293
|
+
}
|
|
294
|
+
})
|
|
287
295
|
if (shrunk) {
|
|
296
|
+
agent.history = next
|
|
288
297
|
// Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
|
|
289
298
|
agent._lastPromptTokens = null
|
|
290
299
|
agent._usageAtLen = null
|
|
@@ -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+).
|
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}`)
|