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.
@@ -1,4 +1,4 @@
1
- You are a code review advisor.
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.** An empty `git diff` does NOT mean nothing changed: fixes may already be committed (`git log -3` shows recent commits) `read` the files named in the prior table regardless of the diff. Batch independent tool calls in one reply.
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 come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.
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 a code review advisor.
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.** An empty `git diff` does NOT mean nothing changed: fixes may already be committed (`git log -3` shows recent commits) `read` the files named in the prior table regardless of the diff. Batch independent tool calls in one reply.
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 come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.
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]
@@ -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.
@@ -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
  }
@@ -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
- if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return false
27
- if (h === "127.0.0.1" || h.startsWith("127.")) return false
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
- if (h.includes(":") && (h === "::1" || h === "fe80::1" || h.startsWith("fc") || h.startsWith("fd"))) return true
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
- export function realpathNearest(abs) {
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
- export function realCwd(cwd) {
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
- export function assertInside(cwd, resolved, p) {
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
- out += " "
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 both recursive (-r/-R) and force (-f) flags: -rf / -fr / -r -f / -Rf etc.
240
- if (/\brm\b/.test(s) && /\s-\S*r/i.test(s) && /\s-\S*f/i.test(s)) return true
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
- /** Strip HTML tags */
280
+ /** Decode a numeric HTML entity to its code point — invalid/out-of-range
281
+ * values (e.g. &#999999999999;) 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(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
291
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
296
+ .replace(/&#0*(\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(/&nbsp;|&ensp;/g, " ")
293
299
  .replace(/&lt;/g, "<")
294
300
  .replace(/&gt;/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(/&#0*(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
312
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
317
+ .replace(/&#0*(\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(/&nbsp;|&ensp;/g, " ")
314
320
  .replace(/&lt;/g, "<")
315
321
  .replace(/&gt;/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
- // ERR_CHILD_PROCESS_STDIO_MAXBUFFER: e.stdout contains partial output, return first 200 lines
329
- if (e.stdout) return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
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
  }