thincoder 0.12.52 → 0.12.53
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 +19 -0
- package/package.json +1 -1
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/mcp/helpers.mjs +14 -5
- package/src/mcp/transport-http.mjs +79 -27
- package/src/mcp/transport-stdio.mjs +57 -3
- package/src/mcp/transport-ws.mjs +46 -12
- package/src/mcp.mjs +197 -58
- package/src/prompts/discipline.md +43 -0
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +121 -36
- package/src/provider/google.mjs +41 -15
- package/src/provider/rate.mjs +5 -0
- package/src/provider/responses.mjs +498 -0
- package/src/provider/retry.mjs +125 -0
- package/src/provider/sse.mjs +58 -24
- package/src/proxy.mjs +36 -6
- package/src/tools/bash.md +2 -2
- package/src/tools/execute.md +1 -1
- package/src/tools/execute.mjs +3 -3
- package/src/tools/fetch.md +1 -0
- package/src/tools/file.mjs +136 -11
- package/src/tools/git.md +4 -2
- package/src/tools/git.mjs +35 -8
- package/src/tools/shared.mjs +5 -3
- package/src/tools/system.mjs +19 -1
- package/src/tools/web.mjs +44 -14
- package/src/tools/websearch.md +3 -1
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +56 -45
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/mouse.mjs +46 -7
- package/src/tui/render-conversation.mjs +225 -122
- package/src/tui/render-loop.mjs +10 -0
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/tool-args.mjs +4 -0
package/src/provider/sse.mjs
CHANGED
|
@@ -91,15 +91,17 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
91
91
|
throw new Error(`API error: HTTP ${response.status} — ${errorMsg}`)
|
|
92
92
|
}
|
|
93
93
|
// HTTP < 400 non-SSE: might be a valid single-chunk JSON response (e.g. proxy
|
|
94
|
-
// stripped SSE framing).
|
|
94
|
+
// stripped SSE framing). Parse `choice.message` (full chat.completion) OR
|
|
95
|
+
// `choice.delta` (chunk shape) — 2026-08-31: gateways downgrading stream:true
|
|
96
|
+
// to a complete completion used to return EMPTY content silently (message-only shape).
|
|
95
97
|
try {
|
|
96
98
|
const parsed = JSON.parse(body)
|
|
97
99
|
const choice = parsed.choices?.[0]
|
|
98
100
|
if (choice) {
|
|
99
101
|
const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
|
|
100
|
-
const delta = choice.delta ?? {}
|
|
102
|
+
const delta = choice.delta ?? choice.message ?? {}
|
|
101
103
|
result.content = delta.content ?? ""
|
|
102
|
-
result.reasoning = delta.reasoning_content ?? ""
|
|
104
|
+
result.reasoning = delta.reasoning_content ?? delta.reasoning ?? ""
|
|
103
105
|
result.finishReason = choice.finish_reason ?? null
|
|
104
106
|
mergeToolCalls(result, delta)
|
|
105
107
|
if (result.content) onToken?.(result.content)
|
|
@@ -118,32 +120,48 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
118
120
|
let hasChoices = false
|
|
119
121
|
const firedPatterns = sharedFired ?? new Set()
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
/** Process one complete SSE event (data lines joined with \n per the SSE spec).
|
|
124
|
+
* 2026-08-31: rows are buffered per event (multi-line data: support that the
|
|
125
|
+
* per-line JSON.parse used to crash on — single-line events behave identically). */
|
|
126
|
+
const handleEvent = (data) => {
|
|
127
|
+
if (!data || data === "[DONE]") return
|
|
128
|
+
let json
|
|
129
|
+
try { json = JSON.parse(data) } catch { return }
|
|
126
130
|
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
if (json.usage) result.usage = normalizeUsageCache(json.usage)
|
|
132
|
+
const choice = json.choices?.[0]
|
|
133
|
+
if (!choice) return
|
|
134
|
+
hasChoices = true
|
|
135
|
+
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
const delta = choice.delta ?? choice.message ?? {}
|
|
138
|
+
// reasoning 方言:DeepSeek/Kimi/GLM 用 reasoning_content,OpenAI o 系和部分
|
|
139
|
+
// 路由器用 reasoning —— 两个都认(2026-08-31 会诊 #9)
|
|
140
|
+
const rDelta = delta.reasoning_content ?? delta.reasoning
|
|
141
|
+
if (rDelta) {
|
|
142
|
+
result.reasoning += rDelta
|
|
143
|
+
onReasoning?.(rDelta)
|
|
144
|
+
}
|
|
145
|
+
if (delta.content) {
|
|
146
|
+
result.content += delta.content
|
|
147
|
+
onToken?.(delta.content)
|
|
148
|
+
}
|
|
149
|
+
mergeToolCalls(result, delta)
|
|
150
|
+
}
|
|
135
151
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
const processLines = (lines) => {
|
|
153
|
+
let currentData = ""
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (line.startsWith("data:")) {
|
|
156
|
+
const v = line.slice(5).trim()
|
|
157
|
+
if (v === "[DONE]") { currentData = ""; continue }
|
|
158
|
+
// multi-line data: joins with \n (SSE spec); single-line is the common case
|
|
159
|
+
currentData = currentData ? currentData + "\n" + v : v
|
|
160
|
+
continue
|
|
144
161
|
}
|
|
145
|
-
|
|
162
|
+
if (line === "" && currentData) { handleEvent(currentData); currentData = "" }
|
|
146
163
|
}
|
|
164
|
+
if (currentData) handleEvent(currentData)
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
if (!response.body) throw new Error("No stream response body")
|
|
@@ -155,6 +173,9 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
155
173
|
throw e
|
|
156
174
|
}
|
|
157
175
|
buffer += decoder.decode(chunk, { stream: true })
|
|
176
|
+
// UTF-8 BOM 剥除(2026-08-31 会诊 #12):某些网关/负载均衡在流首注入 \uFEFF,
|
|
177
|
+
// 首行 "data:" 前缀匹配失败会被静默丢弃(首个事件整体消失)。
|
|
178
|
+
if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
|
|
158
179
|
const lines = buffer.split("\n")
|
|
159
180
|
buffer = lines.pop()
|
|
160
181
|
processLines(lines)
|
|
@@ -186,6 +207,19 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
186
207
|
result.interruptMessage = signal.reason.message
|
|
187
208
|
return result
|
|
188
209
|
}
|
|
210
|
+
// 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
|
|
211
|
+
// proxy 断连)时若已解析出内容,把 partial 交回上层而不是整轮报废重试。
|
|
212
|
+
// 标记 partial:true + networkError(上层可决定续写/重试/展示部分结果)。
|
|
213
|
+
if (hasChoices && (result.content || result.toolCalls.length)) {
|
|
214
|
+
finalizeToolCalls(result)
|
|
215
|
+
result.partial = true
|
|
216
|
+
result.networkError = e.message ?? String(e)
|
|
217
|
+
const existing = result._warnings ??= []
|
|
218
|
+
if (!existing.some((w) => w.name === "network-partial")) {
|
|
219
|
+
existing.push({ name: "network-partial", message: `stream interrupted by network error after partial output: ${result.networkError}` })
|
|
220
|
+
}
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
189
223
|
throw e
|
|
190
224
|
}
|
|
191
225
|
|
package/src/proxy.mjs
CHANGED
|
@@ -64,9 +64,12 @@ function abortError(signal) {
|
|
|
64
64
|
* body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
|
|
65
65
|
* opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
|
|
66
66
|
* absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
|
|
67
|
+
* 2026-08-31 会诊 #4:timeout 只覆盖"响应头阶段";settle 后 body 空闲 > bodyIdleMs 即
|
|
68
|
+
* 断流(原实现头部 timer 到齐即清,流式中途停摆会无限挂起直到用户 Ctrl+C)。
|
|
69
|
+
* bodyIdleMs 默认 120s(provider 长生成用 0 即禁用,由调用方决定)。
|
|
67
70
|
* 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
|
|
68
71
|
*/
|
|
69
|
-
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false) {
|
|
72
|
+
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false, bodyIdleMs = 120_000) {
|
|
70
73
|
return new Promise((resolve, reject) => {
|
|
71
74
|
const target = new URL(urlStr)
|
|
72
75
|
const method = opts.method ?? "GET"
|
|
@@ -78,6 +81,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
78
81
|
const body = new PassThrough()
|
|
79
82
|
let settled = false
|
|
80
83
|
let headerBuf = ""
|
|
84
|
+
let idleTimer = null
|
|
81
85
|
|
|
82
86
|
const timer = setTimeout(() => fail(new Error("Response timeout")), timeout)
|
|
83
87
|
const onAbort = () => { sock.destroy(); fail(abortError(signal)) }
|
|
@@ -85,6 +89,7 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
85
89
|
|
|
86
90
|
function cleanup() {
|
|
87
91
|
clearTimeout(timer)
|
|
92
|
+
clearTimeout(idleTimer)
|
|
88
93
|
signal?.removeEventListener("abort", onAbort)
|
|
89
94
|
}
|
|
90
95
|
/** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
|
|
@@ -93,6 +98,11 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
93
98
|
if (!settled) { settled = true; reject(err) }
|
|
94
99
|
else body.destroy(err)
|
|
95
100
|
}
|
|
101
|
+
/** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
|
|
102
|
+
function armIdle() {
|
|
103
|
+
clearTimeout(idleTimer)
|
|
104
|
+
if (bodyIdleMs > 0) idleTimer = setTimeout(() => body.destroy(new Error("Response body timeout (idle)")), bodyIdleMs)
|
|
105
|
+
}
|
|
96
106
|
|
|
97
107
|
sock.on("data", (d) => {
|
|
98
108
|
if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
|
|
@@ -113,11 +123,20 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
113
123
|
sock.removeAllListeners("data")
|
|
114
124
|
settled = true
|
|
115
125
|
cleanup()
|
|
126
|
+
armIdle()
|
|
116
127
|
const remaining = headerBuf.slice(idx + 4)
|
|
117
128
|
if (remaining) body.write(Buffer.from(remaining, "utf8"))
|
|
118
129
|
sock.pipe(body)
|
|
119
|
-
//
|
|
120
|
-
|
|
130
|
+
// 空闲看门狗随数据到达重置。注意必须用 'readable' 而非 'data':
|
|
131
|
+
// 'data' 监听把流切成 flowing 模式,会抢走 readSSE/for-await 未来得及认领的
|
|
132
|
+
// 已写缓冲(2026-08-31 实测:分包响应头的 remaining 首段被吞,readSSE 等不到 ack)。
|
|
133
|
+
// 'readable' 不改变流模式,数据仍由消费方按需拉取。
|
|
134
|
+
body.on("readable", armIdle)
|
|
135
|
+
// body 结束后才移除 abort 监听(流式中途 abort 要能终止流);空闲看门狗一并清
|
|
136
|
+
body.on("close", () => {
|
|
137
|
+
clearTimeout(idleTimer)
|
|
138
|
+
signal?.removeEventListener("abort", onAbort)
|
|
139
|
+
})
|
|
121
140
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
122
141
|
|
|
123
142
|
resolve({
|
|
@@ -156,12 +175,20 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
156
175
|
/**
|
|
157
176
|
* HTTPS request through HTTP CONNECT proxy tunnel.
|
|
158
177
|
* CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
|
|
178
|
+
* 2026-08-31 会诊 #1/#4:
|
|
179
|
+
* - TLS 默认全量证书校验(rejectUnauthorized: true)——走代理的流量(含 API key)
|
|
180
|
+
* 不得在未验证链路上传输;确需自签/内网代理时 opts.insecureTls=true 显式放行。
|
|
181
|
+
* - CONNECT/TLS 阶段用 timeout(默认 15s,建隧道快);**响应头超时**用
|
|
182
|
+
* opts._headerTimeoutMs(默认 60s)——原实现共用 15s,DeepSeek 排队 TTFB>15s
|
|
183
|
+
* 即误报 "Response timeout",与直连 600s 语义割裂。
|
|
159
184
|
*/
|
|
160
185
|
export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
161
186
|
return new Promise((resolve, reject) => {
|
|
162
187
|
const target = new URL(urlStr)
|
|
163
188
|
const proxy = new URL(proxyUri)
|
|
164
189
|
const signal = opts?.signal
|
|
190
|
+
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : 60_000
|
|
191
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
165
192
|
|
|
166
193
|
if (signal?.aborted) return reject(abortError(signal))
|
|
167
194
|
|
|
@@ -181,12 +208,13 @@ export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
|
181
208
|
if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
|
|
182
209
|
sock.removeAllListeners("data"); clearTimeout(timer)
|
|
183
210
|
|
|
184
|
-
|
|
211
|
+
// 安全默认:TLS 全量校验。企业中间人代理场景显式 opts.insecureTls=true 才放行。
|
|
212
|
+
const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: opts?.insecureTls !== true })
|
|
185
213
|
if (buf) tlsSock.unshift(Buffer.from(buf))
|
|
186
214
|
tlsSock.on("secureConnect", () => {
|
|
187
215
|
// TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
|
|
188
216
|
signal?.removeEventListener("abort", onAbort)
|
|
189
|
-
streamHttpResponse(tlsSock, urlStr, opts,
|
|
217
|
+
streamHttpResponse(tlsSock, urlStr, opts, headerTimeoutMs, false, bodyIdleMs).then(resolve, reject)
|
|
190
218
|
})
|
|
191
219
|
tlsSock.on("error", e => { sock.destroy(); reject(e) })
|
|
192
220
|
})
|
|
@@ -232,5 +260,7 @@ export async function proxyFetch(urlStr, opts, proxyUri) {
|
|
|
232
260
|
if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
|
|
233
261
|
// http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
|
|
234
262
|
const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
|
|
235
|
-
|
|
263
|
+
const headerTimeoutMs = Number.isFinite(opts?._headerTimeoutMs) ? opts._headerTimeoutMs : FETCH_TIMEOUT
|
|
264
|
+
const bodyIdleMs = opts?._bodyIdleMs ?? 120_000
|
|
265
|
+
return streamHttpResponse(sock, urlStr, opts, headerTimeoutMs, true, bodyIdleMs)
|
|
236
266
|
}
|
package/src/tools/bash.md
CHANGED
|
@@ -27,9 +27,9 @@ Output format:
|
|
|
27
27
|
Notes:
|
|
28
28
|
- There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
|
|
29
29
|
- The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
|
|
30
|
-
- Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors.
|
|
30
|
+
- Output is capped at ~200K chars; if you need more, redirect to a file and read it. Truncated output ends with a `[... truncated: N chars omitted]` marker — the missing tail may contain errors, so read the saved log file's tail before trusting success. Use `filter` to narrow instead of hand-piping.
|
|
31
31
|
- Check `[stderr]` for error messages, warnings, and diagnostic output — it is separated from `[stdout]` so you can quickly identify problems.
|
|
32
|
-
- On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)
|
|
32
|
+
- On Windows the shell is **cmd.exe** (NOT Git Bash, NOT PowerShell): `&&`/`||` chaining works, use cmd built-ins (`del`, `dir`, `type`, `findstr`, `tasklist`) and `/dev/null`→`NUL`, `2>/dev/null`→`>nul 2>&1`. Bash-isms (`rm -rf`, `cp -r`, `head`, `$(...)`, `${VAR}`, single quotes, `;` separators) FAIL — the tool prepends a hint when it detects POSIX-only syntax, and still executes. For complex logic prefer the execute tool (node) over shell gymnastics.
|
|
33
33
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
34
34
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
35
35
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|
package/src/tools/execute.md
CHANGED
|
@@ -11,7 +11,7 @@ Parameters:
|
|
|
11
11
|
- nodeArgs: (scriptFile) extra node flags before the script, e.g. ["--test"], ["--check"]. Eval-like flags (--eval/--input-type/--inspect) are rejected.
|
|
12
12
|
- workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
|
|
13
13
|
- filter: optional — only return output lines matching this regex (case-insensitive)
|
|
14
|
-
- timeoutMs: Timeout in milliseconds (default 30000, max
|
|
14
|
+
- timeoutMs: Timeout in milliseconds (default 30000, max 600000 — covers slow `node --test` suites and long package scripts)
|
|
15
15
|
|
|
16
16
|
Notes:
|
|
17
17
|
- `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
|
package/src/tools/execute.mjs
CHANGED
|
@@ -161,8 +161,8 @@ export const executeTool = {
|
|
|
161
161
|
timeoutMs: {
|
|
162
162
|
type: "integer",
|
|
163
163
|
minimum: 1,
|
|
164
|
-
maximum:
|
|
165
|
-
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max
|
|
164
|
+
maximum: 600000,
|
|
165
|
+
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 600000)`,
|
|
166
166
|
},
|
|
167
167
|
},
|
|
168
168
|
required: [],
|
|
@@ -175,7 +175,7 @@ export const executeTool = {
|
|
|
175
175
|
catch (e) { return `Error: ${e.message}` }
|
|
176
176
|
|
|
177
177
|
const t = Number(args.timeoutMs)
|
|
178
|
-
const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t,
|
|
178
|
+
const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 600_000) : DEFAULT_TIMEOUT
|
|
179
179
|
|
|
180
180
|
let childArgs
|
|
181
181
|
if (args.scriptFile) {
|
package/src/tools/fetch.md
CHANGED
|
@@ -2,6 +2,7 @@ Fetch a URL and return its content as text. HTML pages are stripped to readable
|
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
4
|
- url (required): http/https URL
|
|
5
|
+
- proxy: http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling); pick per target (github/foreign sites need a proxy, gitee/domestic don't)
|
|
5
6
|
|
|
6
7
|
Notes:
|
|
7
8
|
- Follows redirects automatically
|
package/src/tools/file.mjs
CHANGED
|
@@ -38,6 +38,36 @@ export function markDirty(abs) { dirtyPaths.add(abs) }
|
|
|
38
38
|
export function clearDirty(abs) { dirtyPaths.delete(abs) }
|
|
39
39
|
export function isDirty(abs) { return dirtyPaths.has(abs) }
|
|
40
40
|
|
|
41
|
+
// 2026-08-31 工具顺手度优化(用户批准):写入工具记录受影响行范围——insert_after
|
|
42
|
+
// 精确判定:after_line 在未受影响区(< lastWrite.startLine)→ 行号未漂移 → 允许
|
|
43
|
+
// (消掉"我写的文件被当外部修改、必须重 read"的摩擦);受影响区内 → 拒绝(护栏保留);
|
|
44
|
+
// write 全文重写 → 全文件受影响,任何 after_line 拒绝。
|
|
45
|
+
const lastWrites = new Map() // abs → { type: 'write'|'edit'|'insert', startLine, shift }
|
|
46
|
+
export function recordWrite(abs, write) {
|
|
47
|
+
lastWrites.set(abs, write)
|
|
48
|
+
dirtyPaths.delete(abs) // 本 session 写入——等效于刚 read 过(快照在 lastWrites)
|
|
49
|
+
}
|
|
50
|
+
export function lastWriteOf(abs) { return lastWrites.get(abs) }
|
|
51
|
+
export function clearLastWrite(abs) { lastWrites.delete(abs) }
|
|
52
|
+
|
|
53
|
+
/** 2026-08-31 工具顺手度(用户批准"可以啊"):写入工具返回带上下文窗口——
|
|
54
|
+
* 模型拿到的不只是"inserted at L395",而是"L395 这行是什么内容"——下次再操作时
|
|
55
|
+
* 能自检"我的行号 vs 实际内容"是否匹配,匹配不上 = 行号漂了,先 read——
|
|
56
|
+
* 死循环就断了(根因:模型对行号锚点的"新鲜度"没有感知——数字本身不携带语义)。
|
|
57
|
+
* write 全文重写跳过(无行号锚点——模型刚写的知道内容)。 */
|
|
58
|
+
async function appendWriteContext(abs, writeLine, baseResult) {
|
|
59
|
+
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
60
|
+
const lines = content.split("\n")
|
|
61
|
+
const start = Math.max(1, writeLine - 3)
|
|
62
|
+
const end = Math.min(lines.length, writeLine + 3)
|
|
63
|
+
const ctxLines = []
|
|
64
|
+
for (let i = start; i <= end; i++) {
|
|
65
|
+
const marker = i === writeLine ? "→" : " "
|
|
66
|
+
ctxLines.push(`${marker} L${i}\t${lines[i - 1]}`)
|
|
67
|
+
}
|
|
68
|
+
return `${baseResult}\ncontext (L${start}-L${end}):\n${ctxLines.join("\n")}`
|
|
69
|
+
}
|
|
70
|
+
|
|
41
71
|
export const readTool = {
|
|
42
72
|
name: "read",
|
|
43
73
|
description: DESC("read"),
|
|
@@ -61,6 +91,7 @@ export const readTool = {
|
|
|
61
91
|
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
62
92
|
// A read refreshes the agent's view — line numbers are fresh again.
|
|
63
93
|
clearDirty(abs)
|
|
94
|
+
clearLastWrite(abs) // 2026-08-31:read 同时清写入快照(新视图以 read 为准)
|
|
64
95
|
const lines = content.split("\n")
|
|
65
96
|
const offset = Math.max(1, args.offset ?? 1)
|
|
66
97
|
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
@@ -171,7 +202,7 @@ export const writeTool = {
|
|
|
171
202
|
const eol = prev != null ? detectFileEol(prev) : majorityEol(dirname(abs))
|
|
172
203
|
const content = eol === "\r\n" ? normalizeEOL(args.content).replace(/\n/g, "\r\n") : args.content
|
|
173
204
|
await writeFile(abs, content, "utf8")
|
|
174
|
-
|
|
205
|
+
recordWrite(abs, { type: "write", startLine: 1, shift: 0 }) // 全文重写——全文件受影响
|
|
175
206
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
176
207
|
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
177
208
|
},
|
|
@@ -189,12 +220,78 @@ export const editTool = {
|
|
|
189
220
|
old_string: { type: "string", description: "Exact text to replace" },
|
|
190
221
|
new_string: { type: "string", description: "Replacement text" },
|
|
191
222
|
replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
|
|
223
|
+
edits: {
|
|
224
|
+
type: "array",
|
|
225
|
+
description: "2026-08-31 工具顺手度:一次多文件原子替换——任一失败全不写(先全量检查所有替换可执行)。与 path/old_string/new_string 互斥。",
|
|
226
|
+
items: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
path: { type: "string" },
|
|
230
|
+
old_string: { type: "string" },
|
|
231
|
+
new_string: { type: "string" },
|
|
232
|
+
replace_all: { type: "boolean" },
|
|
233
|
+
},
|
|
234
|
+
required: ["path", "old_string", "new_string"],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
192
237
|
},
|
|
193
|
-
required: [
|
|
238
|
+
required: [],
|
|
194
239
|
},
|
|
195
240
|
readonly: false,
|
|
196
|
-
touchedPaths(args) {
|
|
241
|
+
touchedPaths(args) {
|
|
242
|
+
if (args.edits) return args.edits.map((e) => e.path).filter(Boolean)
|
|
243
|
+
return args.path ? [args.path] : []
|
|
244
|
+
},
|
|
197
245
|
async execute(args, ctx) {
|
|
246
|
+
// 2026-08-31 工具顺手度(用户批准):数组形态——一次多文件原子替换
|
|
247
|
+
if (args.edits) {
|
|
248
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
249
|
+
throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
|
|
250
|
+
}
|
|
251
|
+
if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
|
|
252
|
+
throw new Error("edits array is mutually exclusive with path/old_string/new_string")
|
|
253
|
+
}
|
|
254
|
+
// 原子:先全量 read+match 检查(所有文件都能替换)——任一失败全不写
|
|
255
|
+
const prepared = []
|
|
256
|
+
for (const e of args.edits) {
|
|
257
|
+
if (!e.path) throw new Error("each edit must have a path")
|
|
258
|
+
if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
|
|
259
|
+
const abs = resolveInCwd(ctx, e.path)
|
|
260
|
+
const raw = await readFile(abs, "utf8")
|
|
261
|
+
const content = normalizeEOL(raw)
|
|
262
|
+
const occurrences = content.split(e.old_string).length - 1
|
|
263
|
+
if (occurrences === 0) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`edit aborted (atomic — no files written): old_string not found in ${e.path}\n` +
|
|
266
|
+
` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
if (occurrences > 1 && !e.replace_all) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${e.path}; ` +
|
|
272
|
+
`provide more context or set replace_all`
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
const updated = e.replace_all
|
|
276
|
+
? content.split(e.old_string).join(e.new_string)
|
|
277
|
+
: content.replace(e.old_string, () => e.new_string)
|
|
278
|
+
const matchIdx = content.indexOf(e.old_string)
|
|
279
|
+
const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
|
|
280
|
+
const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
|
|
281
|
+
prepared.push({ abs, path: e.path, raw, updated, editStartLine, lineShift, occurrences: e.replace_all ? occurrences : 1 })
|
|
282
|
+
}
|
|
283
|
+
// 全部检查通过——逐个写
|
|
284
|
+
const results = []
|
|
285
|
+
for (const p of prepared) {
|
|
286
|
+
await writeFile(p.abs, joinWithEol(normalizeEOL(p.updated).split("\n"), p.raw), "utf8")
|
|
287
|
+
recordWrite(p.abs, { type: "edit", startLine: p.editStartLine, shift: p.lineShift })
|
|
288
|
+
const withCtx = await appendWriteContext(p.abs, p.editStartLine, `Edited ${p.path}: replaced ${p.occurrences} occurrence(s)`)
|
|
289
|
+
results.push(withCtx)
|
|
290
|
+
}
|
|
291
|
+
return results.join("\n")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 单文件(现状路径)
|
|
198
295
|
const abs = resolveInCwd(ctx, args.path)
|
|
199
296
|
if (!args.old_string) {
|
|
200
297
|
throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
|
|
@@ -219,7 +316,11 @@ export const editTool = {
|
|
|
219
316
|
throw new Error(
|
|
220
317
|
`old_string not found in ${args.path}\n` +
|
|
221
318
|
` searched: "${preview}${args.old_string.length > 100 ? "…" : ""}"\n` +
|
|
222
|
-
|
|
319
|
+
(lastWriteOf(abs)?.type === "write"
|
|
320
|
+
? ` hints: this file was modified since your last read (write 全文重写后内容全变) — re-read it to refresh your copy of the content, then retry\n`
|
|
321
|
+
: isDirty(abs)
|
|
322
|
+
? ` hints: this file was modified since your last read (a prior write marked it dirty) — re-read it to refresh your copy of the content, then retry\n`
|
|
323
|
+
: ` hints: whitespace mismatch? file already changed? try reading the file first\n`) +
|
|
223
324
|
candText
|
|
224
325
|
)
|
|
225
326
|
}
|
|
@@ -235,9 +336,14 @@ export const editTool = {
|
|
|
235
336
|
// normalizeEOL first: new_string may carry \r\n (e.g. pasted from a raw CRLF
|
|
236
337
|
// read); without normalizing, split leaves stray \r and CRLF join makes \r\r\n.
|
|
237
338
|
await writeFile(abs, joinWithEol(normalizeEOL(updated).split("\n"), raw), "utf8")
|
|
238
|
-
|
|
339
|
+
// 2026-08-31 工具顺手度:记录受影响区(替换首行 + 行数差)——insert_after 精确判定
|
|
340
|
+
const matchIdx = content.indexOf(args.old_string)
|
|
341
|
+
const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
|
|
342
|
+
const lineShift = args.new_string.split("\n").length - args.old_string.split("\n").length
|
|
343
|
+
recordWrite(abs, { type: "edit", startLine: editStartLine, shift: lineShift })
|
|
239
344
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
240
|
-
|
|
345
|
+
const baseResult = `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
346
|
+
return await appendWriteContext(abs, editStartLine, baseResult)
|
|
241
347
|
},
|
|
242
348
|
}
|
|
243
349
|
|
|
@@ -265,7 +371,24 @@ export const insertAfterTool = {
|
|
|
265
371
|
// inserting at a drifted position (the failure mode that corrupted test
|
|
266
372
|
// structure repeatedly). after_regex callers get the same gate — a stale
|
|
267
373
|
// target line is just as wrong, and the rule is simpler to reason about.
|
|
268
|
-
|
|
374
|
+
// 2026-08-31 工具顺手度(用户批准):判定精确化——本 session 写入工具记录受影响区
|
|
375
|
+
// (lastWrite),after_line 在未受影响区(<= startLine)→ 行号未漂移 → 允许
|
|
376
|
+
// (消掉"我写的文件被当外部修改"的摩擦);受影响区内/write 全文重写 → 拒绝。
|
|
377
|
+
const lw = lastWriteOf(abs)
|
|
378
|
+
if (lw && args.after_line != null) {
|
|
379
|
+
if (lw.type === "write") {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`${args.path} 刚被 write 全文重写(was modified since your last read)——任何行号都可能漂移,必须重 read。`
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
if (args.after_line > lw.startLine) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`${args.path} 的 after_line ${args.after_line} 在上次写入(L${lw.startLine})之后——` +
|
|
387
|
+
`行号已漂移 ${lw.shift >= 0 ? "+" : ""}${lw.shift},请用新行号或先 read。`
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
// after_line <= startLine → 行号未漂移 → 允许
|
|
391
|
+
} else if (isDirty(abs)) {
|
|
269
392
|
throw new Error(
|
|
270
393
|
`${args.path} was modified since your last read — line numbers may be stale.\n` +
|
|
271
394
|
`Read the file again (read tool) to refresh line numbers, then retry insert_after.`
|
|
@@ -307,9 +430,10 @@ export const insertAfterTool = {
|
|
|
307
430
|
// edit — a CRLF file must not silently become LF here either).
|
|
308
431
|
const updated = joinWithEol(lines, raw)
|
|
309
432
|
await writeFile(abs, updated, "utf8")
|
|
310
|
-
|
|
433
|
+
recordWrite(abs, { type: "insert", startLine: targetLine, shift: normalizeEOL(args.content).split("\n").length })
|
|
311
434
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
312
|
-
|
|
435
|
+
const baseResult = `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
436
|
+
return await appendWriteContext(abs, targetLine + 1, baseResult)
|
|
313
437
|
},
|
|
314
438
|
}
|
|
315
439
|
|
|
@@ -399,9 +523,10 @@ export const hashlineEditTool = {
|
|
|
399
523
|
// Write back in the file's original EOL style (same rule as edit / apply_patch).
|
|
400
524
|
const updated = joinWithEol(lines, raw)
|
|
401
525
|
await writeFile(abs, updated, "utf8")
|
|
402
|
-
|
|
526
|
+
recordWrite(abs, { type: "edit", startLine: pos + 1, shift: newLines.length - target.length })
|
|
403
527
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
404
|
-
|
|
528
|
+
const baseResult = `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}${corrupted ? `\n${FFFD_WARNING}` : ""}`
|
|
529
|
+
return await appendWriteContext(abs, pos + 1, baseResult)
|
|
405
530
|
},
|
|
406
531
|
}
|
|
407
532
|
|
package/src/tools/git.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Run a git command. Only works inside a git repository.
|
|
2
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.
|
|
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, `git ls-remote`→ls-remote.
|
|
4
4
|
|
|
5
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
6
|
- action='status': working tree state — staged / unstaged / untracked / conflicts, categorized.
|
|
@@ -19,11 +19,13 @@ Run a git command. Only works inside a git repository.
|
|
|
19
19
|
- action='revert': revert a commit (safe). ref=<commit> (default HEAD).
|
|
20
20
|
- action='merge': merge ref=<branch/commit>; conflicts reported for you to resolve.
|
|
21
21
|
- action='cherry-pick': cherry-pick ref=<commit>.
|
|
22
|
+
- action='ls-remote': light remote-ref check — which refs a remote has (read-only, network). remote=<origin>, ref=<branch/tag> optional, config for proxy.
|
|
22
23
|
- action='checkpoint': git snapshots. checkpointAction=list/create/rewind/cat/versions; checkpointId required for rewind/cat.
|
|
23
24
|
|
|
24
25
|
Parameters:
|
|
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
|
+
- action (required): diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote
|
|
26
27
|
- workdir: run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd
|
|
28
|
+
- config: (network actions push/fetch/pull/ls-remote) git -c overrides, e.g. ["http.proxy=http://10.2.2.112:3128"] for blocked remotes
|
|
27
29
|
- path: (diff/log/add/commit/checkout/restore/rm) file or directory to scope / stage / restore
|
|
28
30
|
- 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
31
|
- name: (branch/tag) the branch or tag name
|
package/src/tools/git.mjs
CHANGED
|
@@ -22,9 +22,9 @@ function filterLines(output, filter) {
|
|
|
22
22
|
/** Run git PRESERVING per-line leading whitespace. runGit trims the WHOLE output, which
|
|
23
23
|
* strips a porcelain line's leading " " (the unstaged marker) and misclassifies an
|
|
24
24
|
* unstaged-only first line as staged. status uses this so the staged/unstaged column survives. */
|
|
25
|
-
function runGitRaw(cwd, cmdArgs) {
|
|
25
|
+
function runGitRaw(cwd, cmdArgs, config = []) {
|
|
26
26
|
try {
|
|
27
|
-
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
|
|
27
|
+
return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).replace(/\r/g, "").replace(/\n$/, "")
|
|
28
28
|
} catch (e) {
|
|
29
29
|
return String(e.stdout || "").replace(/\r/g, "")
|
|
30
30
|
}
|
|
@@ -32,9 +32,9 @@ function runGitRaw(cwd, cmdArgs) {
|
|
|
32
32
|
|
|
33
33
|
/** Run git and report failure (stderr + exit code) instead of swallowing it.
|
|
34
34
|
* Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
|
|
35
|
-
function runGitStrict(cwd, cmdArgs) {
|
|
35
|
+
function runGitStrict(cwd, cmdArgs, config = []) {
|
|
36
36
|
try {
|
|
37
|
-
const out = execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
|
|
37
|
+
const out = execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
|
|
38
38
|
return { ok: true, out }
|
|
39
39
|
} catch (e) {
|
|
40
40
|
return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
|
|
@@ -47,6 +47,19 @@ function validateRef(ref, what = "git ref") {
|
|
|
47
47
|
return ref
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** Normalize args.config into `-c key=value` pairs (git -c overrides, e.g. a proxy).
|
|
51
|
+
* Values are execFileSync array args (no shell injection) — still reject newlines/empty. */
|
|
52
|
+
function gitConfigArgs(config) {
|
|
53
|
+
if (config == null) return []
|
|
54
|
+
if (!Array.isArray(config)) throw new Error("config must be an array of \"key=value\" strings")
|
|
55
|
+
const out = []
|
|
56
|
+
for (const c of config) {
|
|
57
|
+
if (typeof c !== "string" || !c.trim() || c.includes("\n")) throw new Error(`invalid git -c config entry: ${String(c).slice(0, 60)}`)
|
|
58
|
+
out.push("-c", c)
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/** True when `abs` is inside `root` (handles `..` and cross-drive, which relative()
|
|
51
64
|
* returns as an absolute path on Windows). */
|
|
52
65
|
function isInside(root, abs) {
|
|
@@ -83,7 +96,7 @@ export const gitTool = {
|
|
|
83
96
|
parameters: {
|
|
84
97
|
type: "object",
|
|
85
98
|
properties: {
|
|
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" },
|
|
99
|
+
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", "ls-remote"], description: "diff / status / log / show / checkpoint / add / rm / commit / push / tag / branch / checkout / restore / stash / fetch / pull / reset / revert / merge / cherry-pick / ls-remote" },
|
|
87
100
|
// diff/log params
|
|
88
101
|
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
89
102
|
path: { type: "string", description: "(diff/log/add/commit/checkout/restore/checkpoint:cat/versions/rewind/rm) File or directory to scope to / stage / restore" },
|
|
@@ -96,6 +109,7 @@ export const gitTool = {
|
|
|
96
109
|
name: { type: "string", description: "(branch/tag) The branch or tag name (create/delete/switch)" },
|
|
97
110
|
remote: { type: "string", description: "(push/fetch/pull) Remote name (e.g. origin). Default: current upstream" },
|
|
98
111
|
workdir: { type: "string", description: "Run git in this workspace subdirectory (monorepo / multi-repo). Confined to the workspace. Default: cwd" },
|
|
112
|
+
config: { type: "array", items: { type: "string" }, description: "(network actions: push/fetch/pull/ls-remote) git -c overrides, e.g. [\"http.proxy=http://10.2.2.112:3128\"] for blocked remotes" },
|
|
99
113
|
tags: { type: "boolean", description: "(push) Also push all tags (--tags)" },
|
|
100
114
|
mode: { type: "string", enum: ["soft", "mixed", "hard"], description: "(reset) reset mode — hard snapshots the tree first + needs confirmation" },
|
|
101
115
|
tagAction: { type: "string", enum: ["list", "create", "delete"], description: "(tag) list tags / create one / delete one" },
|
|
@@ -112,6 +126,9 @@ export const gitTool = {
|
|
|
112
126
|
// workdir: run git in a workspace subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
|
|
113
127
|
// every action + snapshotBefore + checkpoint resolves against the workdir, confined to the workspace.
|
|
114
128
|
if (args.workdir) ctx = { ...ctx, cwd: resolveBaseDir(ctx.cwd, args.workdir) }
|
|
129
|
+
// git -c overrides (proxy etc.) — only network actions need them; passing to every
|
|
130
|
+
// action would be harmless but noisy. cfgArgs stays [] for local ops.
|
|
131
|
+
const cfgArgs = gitConfigArgs(args.config)
|
|
115
132
|
switch (args.action) {
|
|
116
133
|
case "diff": {
|
|
117
134
|
const ref = args.ref ?? "HEAD"
|
|
@@ -195,9 +212,19 @@ export const gitTool = {
|
|
|
195
212
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
196
213
|
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
197
214
|
if (args.tags) cmdArgs.push("--tags")
|
|
198
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
215
|
+
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
199
216
|
return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
|
|
200
217
|
}
|
|
218
|
+
case "ls-remote": {
|
|
219
|
+
// Lightweight remote-ref check (which refs a remote has) — network action,
|
|
220
|
+
// read-only, no snapshot. Config plumbing for blocked/gated remotes.
|
|
221
|
+
const cmdArgs = ["ls-remote"]
|
|
222
|
+
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
223
|
+
if (args.ref) for (const r of args.ref.split(/\s+/).filter(Boolean)) cmdArgs.push(validateRef(r, "ref"))
|
|
224
|
+
const out = runGit(ctx.cwd, cmdArgs, cfgArgs)
|
|
225
|
+
if (!out) return "(no refs / remote unreachable)"
|
|
226
|
+
return truncate(filterLines(out, args.filter))
|
|
227
|
+
}
|
|
201
228
|
case "add": {
|
|
202
229
|
// Granular staging: stage `path` when given, else all changes (add -A).
|
|
203
230
|
const cmdArgs = args.path ? ["add", "--", args.path] : ["add", "-A"]
|
|
@@ -293,14 +320,14 @@ export const gitTool = {
|
|
|
293
320
|
const cmdArgs = ["fetch"]
|
|
294
321
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
295
322
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
296
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
323
|
+
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
297
324
|
return r.ok ? truncate(r.out || "(fetch complete — no output)") : truncate(`git fetch failed: ${r.err || r.out}`)
|
|
298
325
|
}
|
|
299
326
|
case "pull": {
|
|
300
327
|
const cmdArgs = ["pull"]
|
|
301
328
|
if (args.remote) cmdArgs.push(validateRef(args.remote, "remote"))
|
|
302
329
|
if (args.ref) cmdArgs.push(validateRef(args.ref, "ref"))
|
|
303
|
-
const r = runGitStrict(ctx.cwd, cmdArgs)
|
|
330
|
+
const r = runGitStrict(ctx.cwd, cmdArgs, cfgArgs)
|
|
304
331
|
return r.ok ? truncate(r.out || "(pull complete — no output)") : truncate(`git pull failed: ${r.err || r.out}`)
|
|
305
332
|
}
|
|
306
333
|
case "reset": {
|