thincoder 0.8.12 → 0.8.13

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 CHANGED
@@ -205,6 +205,22 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
205
205
 
206
206
  ## Changelog
207
207
 
208
+ ### 0.8.13 (2026-07)
209
+ - **TUI: incremental rendering** — panel-level cache (`panelCache`) with sync-update bracketing (`DECSET 2026`). Only redraws changed panels, eliminating flicker. `saveCursor`/`restoreCursor` for efficient cursor positioning. Panel order reorganized: `header → conversation → subagent → output → todo → picker → permission → queue → input → status`.
210
+ - **Ctrl+I inject resume** — Ctrl+I (or Tab during processing) now properly interrupts, injects the message, and *resumes* the agent loop. Controller is recreated after abort. Added active signal check in SSE read loop for faster abort on Windows.
211
+ - **Processing hints** — Input box shows "Ctrl+U clear" hint during processing. Tab during processing treated as Ctrl+I. Slash commands re-render the frame.
212
+ - **Compression visibility** — `compressIfNeeded` now forwards `onToken`/`onReasoning` callbacks, making compression activity visible in the TUI.
213
+ - **Session: data-preserving fallback** — when atomic rename fails during session save, fall back to direct write instead of losing data.
214
+ - **Distill: balanced-bracket JSON extraction** — handles nested arrays in LLM output (e.g. `"tags": ["a", "b"]`), replacing the broken non-greedy regex approach.
215
+ - **File tools: EOL normalization** — `normalizeEOL` (`\r\n` → `\n`) applied on all reads (`read`, `edit`, `hashline_edit`, `insert_after`, `grep`, `repomap`), making hash computation and string matching platform-consistent.
216
+ - **hashline_edit: multiple-match detection** — when a hash sequence matches multiple positions, reports all with surrounding context instead of silently picking one.
217
+ - **delete: symlink-safe** — uses `lstat` instead of `stat` to correctly identify symlinks (not directories even if pointing to one).
218
+ - **repomap: large file guard** — skip files >10MB in dependency outline builds to prevent OOM.
219
+ - **Improved error messages** — `grep` and `insert_after` now catch invalid regex patterns at validation time with clear error messages.
220
+ - **Advisor session persistence** — advisor config saved/restored across sessions.
221
+ - **Timeout hardening** — auto-think uses `AbortSignal.timeout(5s)`, embedding requests add 60s timeout, MCP HTTP connect uses `INIT_TIMEOUT_MS`, fetch timeout extended to 10 minutes.
222
+ - **ClearScreen on exit** — terminal restored with `clearScreen` ANSI on TUI cleanup.
223
+
208
224
  ### 0.8.12 (2026-07)
209
225
  - **Indexing: git-repo-only** — `codeSync` and `docSync` now only index inside git worktrees (via `git ls-files`), respecting `.gitignore`. Non-git directories get empty indexes. Prevents 2.9GB memory.db from accidentally indexing entire user profiles (AppData, browser extensions, Office add-ins, Program Files)
210
226
  - **Indexing: file-size caps** — code files >1MB and doc files >512KB are skipped during bulk indexing (minified bundles, test fixtures, generated code)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
3
3
  */
4
- import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
5
4
  import { search as memorySearch, docSearch } from "../memory.mjs"
6
5
  import { toOpenAISchema } from "../tools/index.mjs"
7
6
  import { loadSkills, formatSkillListing } from "../skills.mjs"
8
- import { specForModel } from "../config.mjs"
9
- import { join } from "node:path"
10
7
  import {
11
8
  escapeXml, repairHistory, listWorkDir, readonlyToolNames,
12
9
  collectGitContext, loadProjectInstructions, OUTLINE_INJECT_PREFIX,
package/src/agent.mjs CHANGED
@@ -92,7 +92,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
92
92
  const lastRole = agent.history.at(-1)?.role
93
93
  if (lastRole === "user" || lastRole === "tool") {
94
94
  try {
95
- if (await compressIfNeeded(agent, threshold)) {
95
+ if (await compressIfNeeded(agent, threshold, callbacks)) {
96
96
  agent._compressFailures = 0
97
97
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
98
98
  callbacks.onCompress?.()
@@ -118,7 +118,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
118
118
  // Runs only on turn 0 of user input; failure is silent — falls back to current setting.
119
119
  if (agent.config?.agent?.autoThink && turn === 0) {
120
120
  const { classifyAndApply } = await import("./auto-think.mjs")
121
- classifyAndApply(agent, turn).catch(() => {})
121
+ await classifyAndApply(agent, turn).catch(() => {})
122
122
  }
123
123
 
124
124
  try {
@@ -132,14 +132,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
132
132
  })
133
133
  } catch (e) {
134
134
  // User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
135
- // The abort may fire before the SSE stream starts (during rate gate or HTTP request).
136
- // Inject the user's message into history and retry from the same context.
135
+ // Inject the message into history and let the outer loop recreate the controller.
137
136
  if (e.name === "AbortError" && signal?.reason?.interrupt) {
138
137
  agent.history.push({
139
138
  role: "user",
140
139
  content: `[User interrupt: ${signal.reason.message}]`,
141
140
  })
142
- continue
143
141
  }
144
142
  throw e
145
143
  }
@@ -170,7 +168,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
170
168
  }
171
169
 
172
170
  // User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
173
- // was partially generated. Commit partial output + inject user message, then retry.
171
+ // was partially generated. Commit partial output + inject user message, then signal
172
+ // the outer loop to recreate the controller and resume.
174
173
  if (response.interrupted) {
175
174
  if (response.content) {
176
175
  agent.history.push({ role: "assistant", content: response.content })
@@ -179,7 +178,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
179
178
  role: "user",
180
179
  content: `[User interrupt: ${response.interruptMessage}]`,
181
180
  })
182
- continue
181
+ throw Object.assign(new Error("User interrupted"), { name: "AbortError" })
183
182
  }
184
183
 
185
184
  if (response.usage) {
@@ -62,7 +62,7 @@ export async function classifyAndApply(agent, turn) {
62
62
  { role: "user", content: prompt.slice(0, 2000) },
63
63
  ],
64
64
  tools: [],
65
- signal: new AbortController().signal,
65
+ signal: AbortSignal.timeout(5_000),
66
66
  })
67
67
  const word = (response.content ?? "").trim().toLowerCase()
68
68
  if (word.startsWith("low")) level = "low"
package/src/context.mjs CHANGED
@@ -143,7 +143,7 @@ function applyCompression(agent, headEnd, tailStart, note) {
143
143
  * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
144
144
  * Automatically re-injects task list state after compaction.
145
145
  */
146
- export async function compressIfNeeded(agent, threshold) {
146
+ export async function compressIfNeeded(agent, threshold, callbacks) {
147
147
  const history = agent.history
148
148
  // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
149
149
  // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
@@ -174,6 +174,8 @@ export async function compressIfNeeded(agent, threshold) {
174
174
  // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
175
175
  const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
176
176
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
177
+ onToken: callbacks?.onToken,
178
+ onReasoning: callbacks?.onReasoning,
177
179
  })
178
180
 
179
181
  applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
package/src/distill.mjs CHANGED
@@ -47,11 +47,26 @@ export async function extractCandidates(provider, transcript) {
47
47
  const res = await chat(provider, {
48
48
  messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
49
49
  })
50
- // Non-greedy match first JSON array (greedy [\s\S]* would eat across multiple arrays including interstitial text)
51
- const match = res.content.match(/\[[\s\S]*?\]/)
52
- if (!match) return []
50
+ // Balanced-bracket extraction: find the first '[' and track depth through nested
51
+ // brackets (tags arrays, nested objects, etc.) until the matching ']'.
52
+ // Non-greedy regex (/\[[\s\S]*?\]/) stops at the FIRST ']', which is wrong when
53
+ // LLM output contains nested arrays like `"tags": ["a", "b"]`.
54
+ const start = res.content.indexOf("[")
55
+ if (start === -1) return []
56
+ let depth = 0
57
+ let end = -1
58
+ for (let i = start; i < res.content.length; i++) {
59
+ const ch = res.content[i]
60
+ if (ch === "[" && (i === start || res.content[i - 1] !== "\\")) depth++
61
+ else if (ch === "]" && res.content[i - 1] !== "\\") {
62
+ depth--
63
+ if (depth === 0) { end = i + 1; break }
64
+ }
65
+ }
66
+ if (end === -1) return []
67
+ const jsonText = res.content.slice(start, end)
53
68
  try {
54
- const parsed = JSON.parse(match[0])
69
+ const parsed = JSON.parse(jsonText)
55
70
  if (!Array.isArray(parsed)) return []
56
71
  return parsed.filter((c) => c?.type && c?.title && c?.content)
57
72
  } catch {
package/src/embedding.mjs CHANGED
@@ -84,7 +84,9 @@ async function requestWithRetry(embedder, input, signal) {
84
84
  Authorization: `Bearer ${embedder.apiKey}`,
85
85
  },
86
86
  body: JSON.stringify({ model: embedder.model, input }),
87
- signal,
87
+ signal: signal
88
+ ? AbortSignal.any([signal, AbortSignal.timeout(60_000)])
89
+ : AbortSignal.timeout(60_000),
88
90
  })
89
91
  } catch (error) {
90
92
  if (error.name === "AbortError") throw error
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * mcp/transport-http.mjs — MCP HTTP + SSE transport (Streamable HTTP)
3
3
  */
4
- import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
4
+ import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, INIT_TIMEOUT_MS, withTimeout } from "./helpers.mjs"
5
5
 
6
6
  /** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
7
7
  export function httpTransport(baseURL, extraHeaders = {}) {
@@ -56,10 +56,11 @@ export function httpTransport(baseURL, extraHeaders = {}) {
56
56
  if (closed) return
57
57
  abortController?.abort()
58
58
  abortController = new AbortController()
59
+ const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(INIT_TIMEOUT_MS)])
59
60
  const resp = await fetch(url, {
60
61
  method: "GET",
61
62
  headers: { Accept: "text/event-stream", ...extraHeaders },
62
- signal: abortController.signal,
63
+ signal,
63
64
  })
64
65
  if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
65
66
  eventSource = parseSSE(resp)
@@ -7,12 +7,11 @@ import { join } from "node:path"
7
7
  import { embed, cosine, toBlob, fromBlob } from "../embedding.mjs"
8
8
  import { commitAndPush } from "../git/gitmem.mjs"
9
9
  import { DOC_EXTS, SKIP_DIRS, MAX_DOC_FILE_BYTES } from "./schema.mjs"
10
- import { buildFtsQuery, put, search, putMarkdown } from "./core.mjs"
10
+ import { buildFtsQuery, put, search, putMarkdown, EMBED_TEXT_MAX_LEN } from "./core.mjs"
11
11
  import { _upsertDocFile, yieldTick } from "./code-index.mjs"
12
12
  import { markIndexedCommit, listProjectFiles } from "./code-sync.mjs"
13
13
 
14
14
  const DOC_EMBED_BATCH = 64
15
- const EMBED_TEXT_MAX_LEN = 2000
16
15
 
17
16
  /**
18
17
  * Sync doc index: scan all .md/.mdc/.txt/.rst/.adoc under dir → chunk → upsert into doc_chunks.
@@ -10,7 +10,7 @@ import {
10
10
  estimateRequestTokens, rateGate, recordRate,
11
11
  } from "./rate.mjs"
12
12
 
13
- const FETCH_TIMEOUT_MS = 120000
13
+ const FETCH_TIMEOUT_MS = 600_000
14
14
 
15
15
  /** Create a validated provider config object from raw config */
16
16
  export function createProvider(config) {
@@ -315,6 +315,13 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal })
315
315
  if (!response.body) throw new Error("No stream response body")
316
316
  try {
317
317
  for await (const chunk of response.body) {
318
+ // Active signal check: Ctrl+I abort should halt stream immediately, not wait for
319
+ // the underlying fetch stream to propagate the abort (delayed on Windows).
320
+ if (signal?.aborted) {
321
+ const e = new DOMException("The operation was aborted", "AbortError")
322
+ e.reason = signal.reason
323
+ throw e
324
+ }
318
325
  buffer += decoder.decode(chunk, { stream: true })
319
326
  const lines = buffer.split("\n")
320
327
  buffer = lines.pop()
package/src/session.mjs CHANGED
@@ -40,7 +40,9 @@ function writeSessionFile(p, data) {
40
40
  // rename succeeded: clean up temp file
41
41
  try { unlinkSync(tmp) } catch {}
42
42
  } catch {
43
- // rename still failed: keep tmp as fallback data (next read prefers main file; if missing, tmp is at least there)
43
+ // rename still failed fall back to direct write (non-atomic but data-preserving)
44
+ // p was deleted above; avoid losing both old and new data
45
+ writeFileSync(p, readFileSync(tmp, "utf8"), "utf8")
44
46
  }
45
47
  }
46
48
  }
@@ -161,6 +163,7 @@ export function saveSession(agent, display) {
161
163
  planMode: agent.planMode ?? false,
162
164
  autoApprove: agent.autoApprove ?? false,
163
165
  goal: agent.goal ?? null,
166
+ advisor: agent.config?.advisor ?? null,
164
167
  pendingReminders: agent._pendingReminders ?? [],
165
168
  sessionStart: agent._sessionStart ?? null,
166
169
  }
@@ -214,6 +217,9 @@ export function applySession(agent, data) {
214
217
  agent.goal = data.goal ?? null
215
218
  agent._pendingReminders = data.pendingReminders ?? []
216
219
  agent._sessionStart = data.sessionStart ?? null
220
+ if (data.advisor) {
221
+ agent.config.advisor = { ...data.advisor }
222
+ }
217
223
  // Reset stall/compaction state on session switch
218
224
  agent._compressFailures = 0
219
225
  agent._verifyRetries = 0
@@ -234,7 +240,7 @@ export function clearSession(cwd) {
234
240
  try {
235
241
  archiveCurrent(cwd)
236
242
  const p = sessionPath(cwd)
237
- writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
243
+ writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null })
238
244
  } catch {
239
245
  // Can't clear, oh well — next save will overwrite
240
246
  }
@@ -6,6 +6,7 @@ import {
6
6
  autoSyntaxCheck,
7
7
  resolveInCwd,
8
8
  resolveExternal,
9
+ normalizeEOL,
9
10
  } from "./shared.mjs";
10
11
  import { specForModel } from "../config.mjs";
11
12
  import { createHash } from "node:crypto";
@@ -39,7 +40,7 @@ export const readTool = {
39
40
  // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
40
41
  const st = await stat(abs).catch(() => null)
41
42
  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.`)
42
- const content = await readFile(abs, "utf8")
43
+ const content = normalizeEOL(await readFile(abs, "utf8"))
43
44
  const lines = content.split("\n")
44
45
  const offset = Math.max(1, args.offset ?? 1)
45
46
  const limit = Math.min(args.limit ?? MAX_READ_LINES, MAX_READ_LINES)
@@ -153,7 +154,7 @@ export const editTool = {
153
154
  if (!args.old_string) {
154
155
  throw new Error("old_string must not be empty (empty string matches everywhere and would corrupt the file)")
155
156
  }
156
- const content = await readFile(abs, "utf8")
157
+ const content = normalizeEOL(await readFile(abs, "utf8"))
157
158
  const occurrences = content.split(args.old_string).length - 1
158
159
  if (occurrences === 0) {
159
160
  // Give clues to help the model locate: first-line preview + common causes
@@ -195,7 +196,7 @@ export const insertAfterTool = {
195
196
  readonly: false,
196
197
  async execute(args, ctx) {
197
198
  const abs = resolveInCwd(ctx, args.path)
198
- const text = await readFile(abs, "utf8")
199
+ const text = normalizeEOL(await readFile(abs, "utf8"))
199
200
  const lines = text.split("\n")
200
201
 
201
202
  let targetLine
@@ -208,7 +209,12 @@ export const insertAfterTool = {
208
209
  throw new Error(`after_line ${targetLine} out of range (file has ${lines.length} lines)`)
209
210
  }
210
211
  } else if (args.after_regex) {
211
- const regex = new RegExp(args.after_regex)
212
+ let regex
213
+ try {
214
+ regex = new RegExp(args.after_regex)
215
+ } catch (e) {
216
+ throw new Error(`after_regex /${args.after_regex}/ is not a valid JavaScript regex: ${e.message}`)
217
+ }
212
218
  const matches = []
213
219
  for (let i = 0; i < lines.length; i++) {
214
220
  if (regex.test(lines[i])) matches.push(i + 1)
@@ -254,22 +260,24 @@ export const hashlineEditTool = {
254
260
  async execute(args, ctx) {
255
261
  const abs = resolveInCwd(ctx, args.path)
256
262
  if (!args.old_hashes?.length) throw new Error("old_hashes must not be empty — read the file with hashes=true to get line hashes")
257
- const content = await readFile(abs, "utf8")
263
+ const content = normalizeEOL(await readFile(abs, "utf8"))
258
264
  const lines = content.split("\n")
259
265
  const fileHashes = lines.map((l) => hashLine(l))
260
266
  const target = args.old_hashes
261
267
 
262
- // Sliding-window match: find the exact sequence of hashes
263
- let pos = -1
268
+ // Sliding-window match: find all occurrences of the hash sequence.
269
+ // When multiple matches are found (e.g. empty lines), report positions so the
270
+ // model can include more context lines (adjacent lines with unique hashes).
271
+ const matches = []
264
272
  for (let i = 0; i <= fileHashes.length - target.length; i++) {
265
273
  let match = true
266
274
  for (let j = 0; j < target.length; j++) {
267
275
  if (fileHashes[i + j] !== target[j]) { match = false; break }
268
276
  }
269
- if (match) { pos = i; break }
277
+ if (match) matches.push(i)
270
278
  }
271
279
 
272
- if (pos === -1) {
280
+ if (matches.length === 0) {
273
281
  // Help the model recover: show the current file hashes for context
274
282
  const maxShow = Math.min(fileHashes.length, 50)
275
283
  const hashDump = fileHashes.slice(0, maxShow).map((h, i) => `${h} L${i + 1}: ${lines[i].slice(0, 80)}`).join("\n")
@@ -280,6 +288,26 @@ export const hashlineEditTool = {
280
288
  )
281
289
  }
282
290
 
291
+ if (matches.length > 1) {
292
+ const ctx = 2 // lines of surrounding context
293
+ const detail = matches.map((m) => {
294
+ const start = Math.max(0, m - ctx)
295
+ const end = Math.min(lines.length, m + target.length + ctx)
296
+ const preview = lines.slice(start, end).map((l, i) => {
297
+ const ln = start + i + 1
298
+ const marker = m <= ln - 1 && ln - 1 < m + target.length ? ">" : " "
299
+ return `${marker} L${ln}: ${l.slice(0, 80)}`
300
+ }).join("\n")
301
+ return ` Match at line ${m + 1} (${target.length} line(s)):\n${preview}`
302
+ }).join("\n\n")
303
+ throw new Error(
304
+ `Hash sequence matches ${matches.length} positions in ${args.path} — ambiguous.\n` +
305
+ `Include more surrounding lines (unique-hash lines before/after the target) to disambiguate.\n\n` +
306
+ `All matches with surrounding context:\n\n${detail}`
307
+ )
308
+ }
309
+
310
+ const pos = matches[0]
283
311
  // Replace: remove old lines, insert new lines at the same position
284
312
  const newLines = args.new_content.split("\n")
285
313
  lines.splice(pos, target.length, ...newLines)
@@ -6,7 +6,7 @@ import {
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { mkdir } from "node:fs/promises";
8
8
  import { readFile } from "node:fs/promises";
9
- import { stat } from "node:fs/promises";
9
+ import { stat, lstat } from "node:fs/promises";
10
10
  import { writeFile } from "node:fs/promises";
11
11
  import { unlink } from "node:fs/promises";
12
12
  import { existsSync } from "node:fs";
@@ -206,8 +206,12 @@ export const deleteTool = {
206
206
  readonly: false,
207
207
  async execute(args, ctx) {
208
208
  const abs = resolveInCwd(ctx, args.path)
209
- if (!existsSync(abs)) throw new Error(`File not found: ${args.path}`)
210
- const s = await stat(abs)
209
+ let s
210
+ try {
211
+ s = await lstat(abs)
212
+ } catch {
213
+ throw new Error(`File not found: ${args.path}`)
214
+ }
211
215
  if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
212
216
  // git-tracked files: refuse direct deletion (safety net); untracked: allow
213
217
  // Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
@@ -1,11 +1,12 @@
1
1
  /**
2
- * repomap.mjs — repo dependency outline (zero dependencies, pure regex)
2
+ * repomap.mjs — repo dependency outline
3
3
  * Real-time import/export parsing, generates compact text for LLMs to understand code structure.
4
4
  * No index stored — reads and parses files on each call, ~50ms.
5
5
  */
6
6
  import { existsSync } from "node:fs"
7
- import { readFile } from "node:fs/promises"
7
+ import { readFile, stat } from "node:fs/promises"
8
8
  import { join } from "node:path"
9
+ import { normalizeEOL } from "./shared.mjs"
9
10
 
10
11
  /** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
11
12
  function parseImports(lines, ext) {
@@ -125,8 +126,11 @@ async function _buildDepGraph(db, cwd) {
125
126
  const rel = allFiles[i]
126
127
  const abs = join(cwd, ...rel.split("/"))
127
128
  if (!existsSync(abs)) continue
129
+ // Large file guard: skip files over 10MB to prevent OOM
130
+ const fst = await stat(abs).catch(() => null)
131
+ if (fst && fst.size > 10_000_000) continue
128
132
  let text
129
- try { text = await readFile(abs, "utf8") } catch { continue }
133
+ try { text = normalizeEOL(await readFile(abs, "utf8")) } catch { continue }
130
134
  const lines = text.split("\n")
131
135
  const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
132
136
 
@@ -20,6 +20,13 @@ export const BASH_TIMEOUT_MS = 120_000
20
20
  export const MAX_RESPONSE_BODY_BYTES = 5_000_000
21
21
  export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
22
22
 
23
+ /** Normalize Windows line endings to Unix: \r\n → \n.
24
+ * Applied on every text-file read so that edit/hash matching
25
+ * and hash computation are platform-consistent. */
26
+ export function normalizeEOL(text) {
27
+ return text.replace(/\r\n/g, "\n")
28
+ }
29
+
23
30
  /** Convert to OpenAI tools parameter format */
24
31
  export function toOpenAISchema(tool) {
25
32
  return {
@@ -11,7 +11,8 @@ import {
11
11
  isDestructiveCommand,
12
12
  hasFileRedirection,
13
13
  insideGitRepo,
14
- globToRegex
14
+ globToRegex,
15
+ normalizeEOL,
15
16
  } from "./shared.mjs";
16
17
  import { spawn, execFileSync } from "node:child_process";
17
18
  import { readFile, readdir, stat, lstat } from "node:fs/promises";
@@ -222,6 +223,8 @@ async function* walkFiles(dir, rel = "") {
222
223
  return
223
224
  }
224
225
  for (const e of entries) {
226
+ // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
227
+ if (e.isSymbolicLink()) continue
225
228
  if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
226
229
  const relPath = rel ? `${rel}/${e.name}` : e.name
227
230
  if (e.isDirectory()) {
@@ -253,7 +256,12 @@ export const grepTool = {
253
256
  readonly: true,
254
257
  async execute(args, ctx) {
255
258
  const base = resolveInCwd(ctx, args.path ?? ".")
256
- const regex = new RegExp(args.pattern)
259
+ let regex
260
+ try {
261
+ regex = new RegExp(args.pattern)
262
+ } catch (e) {
263
+ throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
264
+ }
257
265
  const fileFilter = args.glob ? globToRegex(args.glob) : null
258
266
  const before = Math.max(0, Math.floor(args.before ?? 0))
259
267
  const after = Math.max(0, Math.floor(args.after ?? 0))
@@ -267,7 +275,7 @@ export const grepTool = {
267
275
  // Large file guard: skip files over 10MB to prevent OOM
268
276
  const fst = await stat(file)
269
277
  if (fst.size > 10_000_000) return
270
- content = await readFile(file, "utf8")
278
+ content = normalizeEOL(await readFile(file, "utf8"))
271
279
  } catch {
272
280
  return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
273
281
  }
@@ -346,7 +354,13 @@ export const lsTool = {
346
354
  readonly: true,
347
355
  async execute(args, ctx) {
348
356
  const abs = resolveInCwd(ctx, args.path ?? ".")
349
- const entries = await readdir(abs, { withFileTypes: true })
357
+ let entries
358
+ try {
359
+ entries = await readdir(abs, { withFileTypes: true })
360
+ } catch (e) {
361
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`)
362
+ throw e
363
+ }
350
364
  const rows = await Promise.all(
351
365
  entries.slice(0, 500).map(async (e) => {
352
366
  const s = await stat(join(abs, e.name)).catch(() => null)
@@ -218,6 +218,14 @@ export async function runAgentTurn(ctx, text) {
218
218
  } catch (error) {
219
219
  flushStream()
220
220
  if (error.name === "AbortError" || state.controller?.signal.aborted) {
221
+ // Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
222
+ // may have already injected it into history, but the aborted signal prevents retry.
223
+ // Recreate the controller and resume from the same context.
224
+ if (state.controller?.signal?.reason?.interrupt) {
225
+ state.controller = new AbortController()
226
+ resume = true
227
+ continue
228
+ }
221
229
  pushLine("[stopped]", C.warn)
222
230
  break
223
231
  }
package/src/tui/ansi.mjs CHANGED
@@ -17,6 +17,10 @@ export const ansi = {
17
17
  clearLine: `${ESC}[K`,
18
18
  clearToEnd: `${ESC}[J`,
19
19
  clearScreen: `${ESC}[2J`,
20
+ saveCursor: `${ESC}7`, // DECSC — save cursor position
21
+ restoreCursor: `${ESC}8`, // DECRC — restore cursor position
22
+ syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
23
+ syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
20
24
  reset: `${ESC}[0m`,
21
25
  dim: `${ESC}[2m`,
22
26
  bold: `${ESC}[1m`,
@@ -56,11 +56,11 @@ async function openAdvisorModelPicker(ctx) {
56
56
  // Same as main — clear override (use main pool)
57
57
  delete cfg.provider
58
58
  delete cfg.model
59
- pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`)
59
+ pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
60
60
  } else {
61
61
  cfg.provider = e.provider
62
62
  cfg.model = e.model
63
- pushLine(`Advisor: ${e.provider}/${e.model}`)
63
+ pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
64
64
  }
65
65
  }
66
66
  },