thincoder 0.12.11 → 0.12.12
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/README.md +8 -0
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/history.mjs +25 -6
- package/src/advisor/messages.mjs +76 -22
- package/src/advisor/run.mjs +185 -91
- package/src/advisor.mjs +205 -83
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +11 -10
- package/src/agent-tools/subagent.mjs +26 -8
- package/src/agent.mjs +5 -5
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +6 -4
- package/src/prompts/advisor-round3.md +6 -4
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/index.mjs +1 -1
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/render-conversation.mjs +90 -39
- package/src/tui/tool-summaries.mjs +113 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
You are
|
|
1
|
+
You are an independent review advisor.
|
|
2
2
|
Verify the prior issue table (provided in the review context).
|
|
3
3
|
You may note obvious new issues introduced by the fixes.
|
|
4
4
|
You have read-only tools to explore the codebase.
|
|
@@ -7,8 +7,8 @@ You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 round
|
|
|
7
7
|
Review workflow:
|
|
8
8
|
1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
|
-
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a fix appears to contradict the task itself.
|
|
11
|
-
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.**
|
|
10
|
+
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-table item names them or a fix appears to contradict the task itself.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** Fixes may already be committed — `read` the files named in the prior table regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
|
|
12
12
|
5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
@@ -19,7 +19,9 @@ Rules:
|
|
|
19
19
|
- Primarily check fix status of items in the prior issue table.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may
|
|
22
|
+
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
|
+
- **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
24
|
+
- **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
|
|
23
25
|
- You may flag obvious new problems — but only if clearly visible in the reviewed files and would cause crashes, data loss, or logic errors.
|
|
24
26
|
- Do NOT nitpick style or naming.
|
|
25
27
|
- Output a Markdown table listing all remaining problems (old or new):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
You are
|
|
1
|
+
You are an independent review advisor.
|
|
2
2
|
Strictly verify only the prior issue table (provided in the review context).
|
|
3
3
|
Do NOT look for new issues.
|
|
4
4
|
You have read-only tools to explore the codebase.
|
|
@@ -7,8 +7,8 @@ You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 round
|
|
|
7
7
|
Review workflow:
|
|
8
8
|
1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
|
-
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs.
|
|
11
|
-
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.**
|
|
10
|
+
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a prior-table item names them or a fix appears to contradict the task itself.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** Fixes may already be committed — `read` the files named in the prior table regardless. (Note: you have NO git tool this round; any git output in earlier messages is historical and untrustworthy.) Batch independent tool calls in one reply.
|
|
12
12
|
5. Verify fix status of each item in the prior issue table.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
@@ -19,7 +19,9 @@ Rules:
|
|
|
19
19
|
- Only check fix status of items in the prior issue table.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may
|
|
22
|
+
- Every "Unfixed" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may be fabricated or stale. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
|
+
- **Host verification**: your `file:line: content` citations are mechanically checked against the CURRENT file state — quote exactly what `read` returned; a mismatch marks the finding unverified.
|
|
24
|
+
- **Fresh context**: this round's conversation contains NO read output from earlier rounds — every file must be re-read this round.
|
|
23
25
|
- Output a Markdown table. Only list items that still have problems:
|
|
24
26
|
| # | Orig# | File | Severity | Status | Notes |
|
|
25
27
|
|---|-------|------|----------|--------|-------|
|
|
@@ -12,4 +12,4 @@ Debugging strategy:
|
|
|
12
12
|
- Don't get stuck reading code — write tests, add logs. Trust the runtime over your theories.
|
|
13
13
|
|
|
14
14
|
Review discipline (standard mode only — engineering mode has its own review timing rules):
|
|
15
|
-
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies prior table.
|
|
15
|
+
- **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
|
package/src/session.mjs
CHANGED
|
@@ -282,6 +282,22 @@ export function switchToSlot(cwd, slot) {
|
|
|
282
282
|
return loadSession(cwd)
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** Delete a slot: remove its file and manifest entry. Deleting the active slot
|
|
286
|
+
* resets the manifest active pointer (the next claim re-creates one). */
|
|
287
|
+
export function deleteSlot(cwd, slot) {
|
|
288
|
+
const n = Number(slot)
|
|
289
|
+
if (!Number.isInteger(n) || n < 1) return false
|
|
290
|
+
const m = loadManifest(cwd)
|
|
291
|
+
if (!m.slots[n]) return false
|
|
292
|
+
delete m.slots[n]
|
|
293
|
+
delete m.slotSessions?.[n] // orphan session-id entries bloat the manifest forever
|
|
294
|
+
try { unlinkSync(slotPath(cwd, n)) } catch { /* missing file is fine */ }
|
|
295
|
+
if (m.active === n) delete m.active
|
|
296
|
+
saveManifest(cwd, m)
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
285
301
|
// ========== legacy transient prefix cleanup ==========
|
|
286
302
|
|
|
287
303
|
const LEGACY_TRANSIENT_PREFIXES = [
|
|
@@ -297,6 +313,8 @@ function isLegacyTransient(m) {
|
|
|
297
313
|
)
|
|
298
314
|
}
|
|
299
315
|
|
|
316
|
+
export { isLegacyTransient }
|
|
317
|
+
|
|
300
318
|
// ========== core read/write ==========
|
|
301
319
|
|
|
302
320
|
/** Save agent state and display lines to the active slot file (atomic write) */
|
|
@@ -396,6 +414,7 @@ export function applySession(agent, data) {
|
|
|
396
414
|
// (possibly compacted) machine line. Restore each line from its own source — the machine
|
|
397
415
|
// context keeps its compaction savings across resume. Legacy files without contextHistory
|
|
398
416
|
// fall back to seeding the machine line from the full history (it re-compacts when needed).
|
|
417
|
+
agent.config ??= {} // ACP test mocks may omit config; be defensive like the ??= below
|
|
399
418
|
const full = Array.isArray(data.history) ? data.history : []
|
|
400
419
|
const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
|
|
401
420
|
agent._fullHistory = [...full]
|
package/src/tools/file.mjs
CHANGED
|
@@ -20,6 +20,19 @@ import { join, relative, dirname } from "node:path";
|
|
|
20
20
|
const MAX_FILE_READ_BYTES = 10_000_000
|
|
21
21
|
const MAX_IMAGE_BYTES = 15_000_000
|
|
22
22
|
|
|
23
|
+
// ────────────────────────────────────────
|
|
24
|
+
// Dirty-file tracking (read-before-insert guard)
|
|
25
|
+
// ────────────────────────────────────────
|
|
26
|
+
// insert_after anchors on LINE NUMBERS — the most drift-prone addressing.
|
|
27
|
+
// Every write tool marks the file dirty; insert_after refuses to run on a
|
|
28
|
+
// dirty file until the agent reads it again (fresh line numbers). This turns
|
|
29
|
+
// the "read after edit" discipline into a structural guarantee: a stale
|
|
30
|
+
// after_line can never silently land in the wrong place again.
|
|
31
|
+
const dirtyPaths = new Set()
|
|
32
|
+
export function markDirty(abs) { dirtyPaths.add(abs) }
|
|
33
|
+
export function clearDirty(abs) { dirtyPaths.delete(abs) }
|
|
34
|
+
export function isDirty(abs) { return dirtyPaths.has(abs) }
|
|
35
|
+
|
|
23
36
|
export const readTool = {
|
|
24
37
|
name: "read",
|
|
25
38
|
description: DESC("read"),
|
|
@@ -41,6 +54,8 @@ export const readTool = {
|
|
|
41
54
|
const st = await stat(abs).catch(() => null)
|
|
42
55
|
if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
|
|
43
56
|
const content = normalizeEOL(await readFile(abs, "utf8"))
|
|
57
|
+
// A read refreshes the agent's view — line numbers are fresh again.
|
|
58
|
+
clearDirty(abs)
|
|
44
59
|
const lines = content.split("\n")
|
|
45
60
|
const offset = Math.max(1, args.offset ?? 1)
|
|
46
61
|
const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
|
@@ -129,6 +144,7 @@ export const writeTool = {
|
|
|
129
144
|
const st = await stat(abs).catch(() => null)
|
|
130
145
|
if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
|
|
131
146
|
await writeFile(abs, args.content, "utf8")
|
|
147
|
+
markDirty(abs)
|
|
132
148
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
133
149
|
return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
134
150
|
},
|
|
@@ -175,6 +191,7 @@ export const editTool = {
|
|
|
175
191
|
// Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
|
|
176
192
|
: content.replace(args.old_string, () => args.new_string)
|
|
177
193
|
await writeFile(abs, updated, "utf8")
|
|
194
|
+
markDirty(abs)
|
|
178
195
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
179
196
|
return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
180
197
|
},
|
|
@@ -199,6 +216,17 @@ export const insertAfterTool = {
|
|
|
199
216
|
touchedPaths(args) { return args.path ? [args.path] : [] },
|
|
200
217
|
async execute(args, ctx) {
|
|
201
218
|
const abs = resolveInCwd(ctx, args.path)
|
|
219
|
+
// Read-before-insert guard: after_line anchors are line numbers, and any
|
|
220
|
+
// write since the last read made them stale. Refuse instead of silently
|
|
221
|
+
// inserting at a drifted position (the failure mode that corrupted test
|
|
222
|
+
// structure repeatedly). after_regex callers get the same gate — a stale
|
|
223
|
+
// target line is just as wrong, and the rule is simpler to reason about.
|
|
224
|
+
if (isDirty(abs)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`${args.path} was modified since your last read — line numbers may be stale.\n` +
|
|
227
|
+
`Read the file again (read tool) to refresh line numbers, then retry insert_after.`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
202
230
|
const text = normalizeEOL(await readFile(abs, "utf8"))
|
|
203
231
|
const lines = text.split("\n")
|
|
204
232
|
|
|
@@ -232,6 +260,7 @@ export const insertAfterTool = {
|
|
|
232
260
|
lines.splice(targetLine, 0, args.content)
|
|
233
261
|
const updated = lines.join("\n")
|
|
234
262
|
await writeFile(abs, updated, "utf8")
|
|
263
|
+
markDirty(abs)
|
|
235
264
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
236
265
|
return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
237
266
|
},
|
|
@@ -317,6 +346,7 @@ export const hashlineEditTool = {
|
|
|
317
346
|
lines.splice(pos, target.length, ...newLines)
|
|
318
347
|
const updated = lines.join("\n")
|
|
319
348
|
await writeFile(abs, updated, "utf8")
|
|
349
|
+
markDirty(abs)
|
|
320
350
|
const diff = gitDiffOne(ctx.cwd, abs)
|
|
321
351
|
return `Edited ${args.path}: replaced ${target.length} line(s) at L${pos + 1} with ${newLines.length} line(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
|
|
322
352
|
},
|
|
@@ -11,3 +11,4 @@ Notes:
|
|
|
11
11
|
- Use this instead of `edit` when you're adding a new function, import, or block — no need to fabricate surrounding context for exact matching.
|
|
12
12
|
- The inserted content becomes its own line; it's equivalent to `lines.splice(targetLine, 0, content)`.
|
|
13
13
|
- Returns a diff of the change.
|
|
14
|
+
- **Read-before-insert guard**: if the file was modified by any write tool (write/edit/insert_after/hashline_edit/apply_patch/delete) since your last `read`, this tool REFUSES with an error — line numbers may be stale. Read the file again, then retry. This prevents after_line from silently landing at a drifted position.
|
package/src/tools/patch.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
autoSyntaxCheck,
|
|
4
4
|
resolveInCwd
|
|
5
5
|
} from "./shared.mjs";
|
|
6
|
+
import { markDirty } from "./file.mjs";
|
|
6
7
|
import { execFileSync } from "node:child_process";
|
|
7
8
|
import { mkdir } from "node:fs/promises";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
@@ -150,6 +151,8 @@ export const applyPatchTool = {
|
|
|
150
151
|
throw renameError
|
|
151
152
|
}
|
|
152
153
|
const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
|
|
154
|
+
// Mark every touched file dirty — insert_after must not run on stale line numbers.
|
|
155
|
+
for (const p of planned) markDirty(p.abs)
|
|
153
156
|
const syntaxChecks = await Promise.all(planned.map(async (p) => {
|
|
154
157
|
const r = await autoSyntaxCheck(p.abs)
|
|
155
158
|
return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
|
|
@@ -197,6 +200,7 @@ export const deleteTool = {
|
|
|
197
200
|
}
|
|
198
201
|
if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
|
|
199
202
|
await unlink(abs)
|
|
203
|
+
markDirty(abs)
|
|
200
204
|
return `Deleted ${args.path}`
|
|
201
205
|
},
|
|
202
206
|
}
|
package/src/tools/shared.mjs
CHANGED
|
@@ -17,19 +17,31 @@ export const MAX_OUTPUT_CHARS = 200_000
|
|
|
17
17
|
const ENCODING_DETECT_MAX_TRIM = 3
|
|
18
18
|
const SYNTAX_CHECK_TIMEOUT = 10000
|
|
19
19
|
export const BASH_TIMEOUT_MS = 120_000
|
|
20
|
-
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
21
20
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
22
21
|
|
|
23
|
-
/** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
|
|
22
|
+
/** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
|
|
23
|
+
* Returns TRUE for private hosts — callers block them. */
|
|
24
24
|
export function isPrivateHost(hostname) {
|
|
25
25
|
const h = hostname.toLowerCase()
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Local loopback + link-local names: BLOCK (true). These returned false
|
|
27
|
+
// before — the guard was inverted for the most common SSRF targets
|
|
28
|
+
// (localhost/127.x reach internal services unchecked).
|
|
29
|
+
if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return true
|
|
30
|
+
if (h === "127.0.0.1" || h.startsWith("127.")) return true
|
|
28
31
|
if (h === "169.254.169.254" || h === "metadata.google.internal") return true
|
|
29
32
|
// IPv6 private ranges — only check if host contains ":"
|
|
30
|
-
|
|
33
|
+
// fe80::/10 link-local covers fe80:…febf:… — startsWith("fe80:") is the
|
|
34
|
+
// practical subset (fe8/fe9/fea/feb all begin fe8/feb — full range regex
|
|
35
|
+
// would be /^fe[89ab][0-9a-f]:/; startsWith fe8 + fe9 + fea + feb covers it).
|
|
36
|
+
if (h.includes(":")) {
|
|
37
|
+
if (h === "::1" || h.startsWith("fc") || h.startsWith("fd")) return true
|
|
38
|
+
if (/^fe[89ab][0-9a-f]:/.test(h)) return true
|
|
39
|
+
}
|
|
31
40
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
32
41
|
if (m) {
|
|
42
|
+
// Octet range is NOT validated (999.10.0.1 parses but matches no private
|
|
43
|
+
// prefix → treated as public). Intentional: the guard checks known-private
|
|
44
|
+
// prefixes; invalid IPs are harmless false-negatives for SSRF purposes.
|
|
33
45
|
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
34
46
|
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || a === 192 && b === 168 || a === 169 && b === 254 || a === 0) return true
|
|
35
47
|
}
|
|
@@ -69,24 +81,10 @@ export function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
|
69
81
|
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
70
82
|
}
|
|
71
83
|
|
|
72
|
-
/** Read response body with a byte limit */
|
|
73
|
-
export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
74
|
-
if (!response.body) return ""
|
|
75
|
-
const reader = response.body.getReader()
|
|
76
|
-
const chunks = []
|
|
77
|
-
let total = 0
|
|
78
|
-
try {
|
|
79
|
-
for (;;) {
|
|
80
|
-
const { done, value } = await reader.read()
|
|
81
|
-
if (done) break
|
|
82
|
-
if (value) { chunks.push(value); total += value.length }
|
|
83
|
-
if (total >= limit) { await reader.cancel(); break }
|
|
84
|
-
}
|
|
85
|
-
} finally { reader.releaseLock() }
|
|
86
|
-
return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
84
|
/** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
|
|
85
|
+
* KNOWN LIMITATION (accepted): the fallback is hardcoded to GBK (Chinese) —
|
|
86
|
+
* Shift-JIS/EUC-KR pages decode as mojibake. Real-world usage is dominated
|
|
87
|
+
* by UTF-8; a charset-aware variant would need the Content-Type header.
|
|
90
88
|
* Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
|
|
91
89
|
export function makeDecoder() {
|
|
92
90
|
let decoder = null
|
|
@@ -154,7 +152,7 @@ export async function autoSyntaxCheck(abs) {
|
|
|
154
152
|
}
|
|
155
153
|
|
|
156
154
|
/** Resolve realpath by walking up the directory tree */
|
|
157
|
-
|
|
155
|
+
function realpathNearest(abs) {
|
|
158
156
|
let cur = abs
|
|
159
157
|
const tail = []
|
|
160
158
|
while (!existsSync(cur)) {
|
|
@@ -167,15 +165,20 @@ export function realpathNearest(abs) {
|
|
|
167
165
|
catch { return abs }
|
|
168
166
|
}
|
|
169
167
|
|
|
168
|
+
// cwd is effectively constant per CLI session — the cache never grows in
|
|
169
|
+
// practice. A long-running server with rotating cwds would leak; revisit with
|
|
170
|
+
// an LRU if that usage ever appears.
|
|
170
171
|
const realCwdCache = new Map()
|
|
171
172
|
/** Resolve cwd to realpath, cached */
|
|
172
|
-
|
|
173
|
+
function realCwd(cwd) {
|
|
173
174
|
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
174
175
|
return realCwdCache.get(cwd)
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
/** Assert that a resolved path is inside cwd; throws on escape */
|
|
178
|
-
|
|
179
|
+
function assertInside(cwd, resolved, p) {
|
|
180
|
+
// relative() returns platform-native separators; ".." + sep therefore
|
|
181
|
+
// matches both / and \ traversal on the respective platform.
|
|
179
182
|
const rel = relative(cwd, resolved)
|
|
180
183
|
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
181
184
|
throw new Error(`Access denied outside working directory: ${p}`)
|
|
@@ -215,8 +218,11 @@ function blankQuoted(command) {
|
|
|
215
218
|
const ch = command[i]
|
|
216
219
|
if (quote) {
|
|
217
220
|
if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
|
|
218
|
-
if (ch === quote) quote = null
|
|
219
|
-
|
|
221
|
+
if (ch === quote) { quote = null; out += " "; continue }
|
|
222
|
+
// Backticks are COMMAND SUBSTITUTION — the content executes, so it must
|
|
223
|
+
// stay visible to the redirection check (echo `cat > /tmp/x` writes a
|
|
224
|
+
// file). Only ' and " are literal regions.
|
|
225
|
+
out += quote === "`" ? ch : " "
|
|
220
226
|
} else if (ch === "'" || ch === '"' || ch === "`") {
|
|
221
227
|
quote = ch
|
|
222
228
|
out += " "
|
|
@@ -227,17 +233,22 @@ function blankQuoted(command) {
|
|
|
227
233
|
return out
|
|
228
234
|
}
|
|
229
235
|
|
|
230
|
-
/** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions
|
|
236
|
+
/** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions.
|
|
237
|
+
* Backtick contents count (command substitution executes); fd-prefixed forms
|
|
238
|
+
* (2> file, 1>> file) count too. */
|
|
231
239
|
export function hasFileRedirection(command) {
|
|
232
240
|
const bare = blankQuoted(command)
|
|
233
|
-
return /(^|[\s;&|])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|])<\s*\S/.test(bare)
|
|
241
|
+
return /(^|[\s;&|0-9])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|0-9])<\s*\S/.test(bare)
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
/** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
|
|
237
245
|
export function isDestructiveCommand(seg) {
|
|
238
246
|
const s = seg
|
|
239
|
-
// rm with
|
|
240
|
-
|
|
247
|
+
// rm with recursive (-r/-R/--recursive): destructive WITH or WITHOUT -f
|
|
248
|
+
// (recursive delete removes trees non-interactively in many setups; -rf is
|
|
249
|
+
// the classic case). Conservative: prefer blocking. The \s before the flag
|
|
250
|
+
// requires a separator — "rm-rf" is not a valid command (no such program).
|
|
251
|
+
if (/\brm\b/.test(s) && (/\s-\S*r/i.test(s) || /\s--recursive\b/i.test(s))) return true
|
|
241
252
|
if (/\brmdir\b/i.test(s)) return true
|
|
242
253
|
if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
|
|
243
254
|
if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
|
|
@@ -251,28 +262,11 @@ export function isDestructiveCommand(seg) {
|
|
|
251
262
|
return false
|
|
252
263
|
}
|
|
253
264
|
|
|
254
|
-
/** Whether a single command segment destroys uncommitted changes */
|
|
255
|
-
export function isDestructiveGitSegment(seg) {
|
|
256
|
-
if (!/^\s*git\s/.test(seg)) return false
|
|
257
|
-
if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
|
|
258
|
-
if (/\sreset\s+--hard\b/.test(seg)) return true
|
|
259
|
-
if (/\sclean\s+-\S*f/.test(seg)) return true
|
|
260
|
-
if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
|
|
261
|
-
return false
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Whether cwd is inside a git repository */
|
|
265
|
-
export function insideGitRepo(cwd) {
|
|
266
|
-
try {
|
|
267
|
-
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
268
|
-
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
269
|
-
})
|
|
270
|
-
return true
|
|
271
|
-
} catch { return false }
|
|
272
|
-
}
|
|
273
265
|
|
|
274
266
|
/** Convert glob pattern to regex */
|
|
275
267
|
export function globToRegex(pattern) {
|
|
268
|
+
// Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
|
|
269
|
+
// come from model output or the filesystem) — safe as **/ and ** placeholders.
|
|
276
270
|
const DS = "\u0001", DP = "\u0002"
|
|
277
271
|
const escaped = pattern
|
|
278
272
|
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
@@ -283,12 +277,24 @@ export function globToRegex(pattern) {
|
|
|
283
277
|
return new RegExp(`^${escaped}$`)
|
|
284
278
|
}
|
|
285
279
|
|
|
286
|
-
/**
|
|
280
|
+
/** Decode a numeric HTML entity to its code point — invalid/out-of-range
|
|
281
|
+
* values (e.g. �) must not throw RangeError; keep the source
|
|
282
|
+
* text as-is (display-only residue is acceptable). */
|
|
283
|
+
function decodeNumericEntity(_, digits) {
|
|
284
|
+
const n = Number(digits)
|
|
285
|
+
return Number.isSafeInteger(n) && n <= 0x10ffff ? String.fromCodePoint(n) : _
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Strip HTML tags. KNOWN LIMITATION (accepted): the `/<[^>]+>/g` regex treats
|
|
289
|
+
* the first `>` (or a `<` inside an attribute value) as the tag boundary —
|
|
290
|
+
* `<img alt="a > b">` truncates the match and leaves text residue. A full
|
|
291
|
+
* HTML parser is out of scope; real-world HTML with angle brackets in
|
|
292
|
+
* attributes is rare and the residue is display-only (never parsed). */
|
|
287
293
|
export function stripTags(html) {
|
|
288
294
|
return html
|
|
289
295
|
.replace(/<[^>]+>/g, "")
|
|
290
|
-
.replace(/�*(\d+);/g, (
|
|
291
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (
|
|
296
|
+
.replace(/�*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
|
|
297
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
|
|
292
298
|
.replace(/ | /g, " ")
|
|
293
299
|
.replace(/</g, "<")
|
|
294
300
|
.replace(/>/g, ">")
|
|
@@ -308,8 +314,8 @@ export function htmlToText(html) {
|
|
|
308
314
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
309
315
|
.replace(/<li[^>]*>/gi, "- ")
|
|
310
316
|
.replace(/<[^>]+>/g, "")
|
|
311
|
-
.replace(/�*(\d+);/g, (
|
|
312
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (
|
|
317
|
+
.replace(/�*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
|
|
318
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
|
|
313
319
|
.replace(/ | /g, " ")
|
|
314
320
|
.replace(/</g, "<")
|
|
315
321
|
.replace(/>/g, ">")
|
|
@@ -325,8 +331,13 @@ export function runGit(cwd, cmdArgs) {
|
|
|
325
331
|
try {
|
|
326
332
|
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
327
333
|
} catch (e) {
|
|
328
|
-
//
|
|
329
|
-
|
|
334
|
+
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
335
|
+
// (callers show "(truncated)"-style tails). ALL OTHER errors (non-git repo,
|
|
336
|
+
// permission, bad command) return "" — matching gitDiffOne's pattern: a
|
|
337
|
+
// failed git call must not masquerade as partial success.
|
|
338
|
+
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
339
|
+
return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
|
|
340
|
+
}
|
|
330
341
|
return ""
|
|
331
342
|
}
|
|
332
343
|
}
|