thincoder 0.11.0 → 0.11.1

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/advisor.mjs +360 -72
  3. package/src/agent/helpers.mjs +7 -3
  4. package/src/agent/setup.mjs +2 -2
  5. package/src/agent-tools/advisor.mjs +36 -0
  6. package/src/agent-tools/plan.mjs +53 -2
  7. package/src/agent-tools/subagent.mjs +7 -1
  8. package/src/agent-tools/timer.mjs +1 -1
  9. package/src/agent-tools/verify.mjs +1 -0
  10. package/src/agent-tools.mjs +1 -0
  11. package/src/agent.mjs +73 -21
  12. package/src/auto-think.mjs +23 -5
  13. package/src/config.mjs +1 -1
  14. package/src/prompts/advisor-round1.md +23 -0
  15. package/src/prompts/advisor-round2.md +26 -0
  16. package/src/prompts/advisor-round3.md +24 -0
  17. package/src/prompts/coder.md +1 -0
  18. package/src/prompts/discipline.md +15 -1
  19. package/src/prompts/explore.md +2 -0
  20. package/src/prompts/plan.md +2 -0
  21. package/src/prompts/system.md +5 -1
  22. package/src/provider/anthropic.mjs +4 -4
  23. package/src/provider/core.mjs +6 -126
  24. package/src/provider/google.mjs +4 -2
  25. package/src/provider/sse.mjs +112 -0
  26. package/src/tools/bash.md +8 -0
  27. package/src/tools/codemode.mjs +5 -16
  28. package/src/tools/edit.md +8 -0
  29. package/src/tools/git.mjs +9 -6
  30. package/src/tools/read.md +7 -0
  31. package/src/tools/shared.mjs +16 -0
  32. package/src/tools/system.mjs +14 -10
  33. package/src/tools/web.mjs +21 -16
  34. package/src/tui/agent-turn.mjs +76 -64
  35. package/src/tui/cmd-advisor.mjs +119 -18
  36. package/src/tui/index.mjs +7 -187
  37. package/src/tui/key-handler.mjs +8 -2
  38. package/src/tui/layout.mjs +1 -1
  39. package/src/tui/render-conversation.mjs +92 -0
  40. package/src/tui/render-frame.mjs +27 -103
  41. package/src/tui/render-loop.mjs +181 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * provider/sse.mjs — SSE stream reader
3
+ * Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
4
+ */
5
+ export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
6
+ const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
7
+ const decoder = new TextDecoder()
8
+ let buffer = ""
9
+ let hasChoices = false
10
+ const firedPatterns = sharedFired ?? new Set()
11
+
12
+ const processLines = (lines) => {
13
+ for (const line of lines) {
14
+ if (!line.startsWith("data:")) continue
15
+ const data = line.slice(5).trim()
16
+ if (!data || data === "[DONE]") continue
17
+
18
+ let json
19
+ try { json = JSON.parse(data) } catch { continue }
20
+
21
+ if (json.usage) result.usage = json.usage
22
+ const choice = json.choices?.[0]
23
+ if (!choice) continue
24
+ hasChoices = true
25
+ if (choice.finish_reason) result.finishReason = choice.finish_reason
26
+
27
+ const delta = choice.delta ?? {}
28
+ if (delta.reasoning_content) {
29
+ result.reasoning += delta.reasoning_content
30
+ onReasoning?.(delta.reasoning_content)
31
+ }
32
+ if (delta.content) {
33
+ result.content += delta.content
34
+ onToken?.(delta.content)
35
+ }
36
+ for (const tc of delta.tool_calls ?? []) {
37
+ const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
38
+ if (tc.id) slot.id = tc.id
39
+ if (tc.function?.name && !slot.name) slot.name = tc.function.name
40
+ if (tc.function?.arguments) slot.arguments += tc.function.arguments
41
+ }
42
+ }
43
+ }
44
+
45
+ if (!response.body) throw new Error("No stream response body")
46
+ try {
47
+ for await (const chunk of response.body) {
48
+ if (signal?.aborted) {
49
+ const e = new DOMException("The operation was aborted", "AbortError")
50
+ e.reason = signal.reason
51
+ throw e
52
+ }
53
+ buffer += decoder.decode(chunk, { stream: true })
54
+ const lines = buffer.split("\n")
55
+ buffer = lines.pop()
56
+ processLines(lines)
57
+
58
+ if (rules?.length && result.content && !result.toolCalls.length) {
59
+ for (const rule of rules) {
60
+ if (rule.repeat === "once" && firedPatterns.has(rule.pattern)) continue
61
+ if (rule._regex.test(result.content)) {
62
+ if (rule.repeat === "once") firedPatterns.add(rule.pattern)
63
+ if (rule.action === "abort") {
64
+ result.ruleTriggered = true
65
+ result.ruleMessage = rule.message
66
+ result.ruleName = rule.name
67
+ return result
68
+ }
69
+ const existing = result._warnings ??= []
70
+ if (!existing.some(w => w.pattern === rule.pattern)) {
71
+ existing.push({ name: rule.name, pattern: rule.pattern, message: rule.message })
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ buffer += decoder.decode()
78
+ processLines(buffer.split("\n"))
79
+ } catch (e) {
80
+ if (e.name === "AbortError" && signal?.reason?.interrupt) {
81
+ result.interrupted = true
82
+ result.interruptMessage = signal.reason.message
83
+ return result
84
+ }
85
+ throw e
86
+ }
87
+
88
+ if (!hasChoices) {
89
+ const contentType = response.headers.get("content-type") || ""
90
+ let errorMsg = ""
91
+ try {
92
+ const raw = buffer.trim() || ""
93
+ if (raw) {
94
+ const parsed = JSON.parse(raw)
95
+ errorMsg = parsed?.error?.message
96
+ || parsed?.base_resp?.status_msg
97
+ || parsed?.detail
98
+ || parsed?.message
99
+ || parsed?.msg
100
+ || (typeof parsed.error === "string" ? parsed.error : "")
101
+ }
102
+ } catch { /* not JSON */ }
103
+ if (!errorMsg && !contentType.includes("event-stream")) {
104
+ errorMsg = `Response is not SSE (Content-Type: ${contentType || "unknown"})`
105
+ }
106
+ if (errorMsg) {
107
+ throw new Error(`API error: ${errorMsg}`)
108
+ }
109
+ }
110
+
111
+ return result
112
+ }
package/src/tools/bash.md CHANGED
@@ -1,5 +1,13 @@
1
1
  Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.
2
2
 
3
+ **Route to a dedicated tool instead of bash:**
4
+ - `cat file` / `head` / `tail` → `read`
5
+ - `ls` / `dir` → `ls`
6
+ - `find` / glob search → `glob`
7
+ - `grep` / `rg` → `grep`
8
+ - `echo >` / `sed` / `printf >` / `cat << EOF` → `write` / `edit` / `hashline_edit` / `apply_patch` (enforced: redirection is blocked)
9
+ - `git diff` / `git status` / `git log` → `git` tool
10
+
3
11
  Parameters:
4
12
  - command (required): Shell command to execute
5
13
  - timeout: Timeout in milliseconds (default 120000, max ~300000)
@@ -24,32 +24,21 @@
24
24
  import { Script, createContext } from "node:vm"
25
25
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
26
26
  import { join, dirname, relative, resolve } from "node:path"
27
- import { globToRegex, normalizeEOL } from "./shared.mjs"
27
+ import { globToRegex, normalizeEOL, isPrivateHost } from "./shared.mjs"
28
28
 
29
29
  const MAX_OUTPUT = 50_000
30
30
  const MAX_SCRIPT = 50_000
31
31
  const DEFAULT_TIMEOUT = 30_000
32
32
 
33
- /** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout */
33
+ /** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout. */
34
34
  async function sandboxFetch(url) {
35
35
  const parsed = new URL(url)
36
36
  if (!["http:", "https:"].includes(parsed.protocol)) {
37
37
  throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
38
38
  }
39
- // Block private/internal IPs
40
- const hostname = parsed.hostname.toLowerCase()
41
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ||
42
- hostname.startsWith("192.168.") || hostname.startsWith("10.") ||
43
- hostname.startsWith("172.16.") || hostname.startsWith("172.17.") ||
44
- hostname.startsWith("172.18.") || hostname.startsWith("172.19.") ||
45
- hostname.startsWith("172.20.") || hostname.startsWith("172.21.") ||
46
- hostname.startsWith("172.22.") || hostname.startsWith("172.23.") ||
47
- hostname.startsWith("172.24.") || hostname.startsWith("172.25.") ||
48
- hostname.startsWith("172.26.") || hostname.startsWith("172.27.") ||
49
- hostname.startsWith("172.28.") || hostname.startsWith("172.29.") ||
50
- hostname.startsWith("172.30.") || hostname.startsWith("172.31.") ||
51
- hostname === "0.0.0.0" || hostname.endsWith(".local")) {
52
- throw new Error(`CodeMode fetch: private/internal host not allowed: ${hostname}`)
39
+
40
+ if (isPrivateHost(parsed.hostname)) {
41
+ throw new Error(`CodeMode fetch: private/internal host not allowed: ${parsed.hostname}`)
53
42
  }
54
43
  const ctrl = new AbortController()
55
44
  const timer = setTimeout(() => ctrl.abort(), 10_000)
package/src/tools/edit.md CHANGED
@@ -1,5 +1,13 @@
1
1
  Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
2
2
 
3
+ **Routing — pick the right edit tool:**
4
+ - Precise line-targeted change → `hashline_edit` (hash-based, immune to whitespace/encoding drift — preferred)
5
+ - One exact-string swap → this tool
6
+ - Add a function/block after a known line → `insert_after`
7
+ - Same change across multiple files or many spots → `apply_patch`
8
+ - Rewrite an entire file → `write`
9
+ - Rename a symbol project-wide → `lsp` or `grep` first to map every caller
10
+
3
11
  Parameters:
4
12
  - path (required): File path
5
13
  - old_string (required): Exact text to find and replace
package/src/tools/git.mjs CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  truncate,
4
4
  runGit
5
5
  } from "./shared.mjs";
6
+ import { escapeXml } from "../agent/helpers.mjs";
6
7
  import { execFileSync } from "node:child_process";
7
8
  import { join } from "node:path";
8
9
 
@@ -35,7 +36,7 @@ export const gitTool = {
35
36
  switch (args.action) {
36
37
  case "diff": {
37
38
  const ref = args.ref ?? "HEAD"
38
- if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
39
+ if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
39
40
  const flags = args.staged ? ["--staged"] : []
40
41
  const paths = args.path ? [args.path] : []
41
42
  const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
@@ -120,11 +121,12 @@ export const gitTool = {
120
121
  return formatFileTree(cp)
121
122
  }
122
123
 
123
- // Overview: list of all snapshots
124
+ // Overview: list of all snapshots (file names are XML-escaped: they are
125
+ // untrusted input that flows back into the model's context)
124
126
  return cps.map((c) => {
125
127
  const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
126
- if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
127
- if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
128
+ if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.map(escapeXml).join(", ")}`)
129
+ if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.map(escapeXml).join(", ")}`)
128
130
  return parts.join(" ")
129
131
  }).join("\n")
130
132
  }
@@ -162,9 +164,10 @@ export const questionTool = {
162
164
 
163
165
  /** Format a checkpoint's file list as a directory tree (directories first, indented display) */
164
166
  function formatFileTree(cp) {
167
+ // File names are XML-escaped: untrusted input that flows back into the model's context
165
168
  const all = [
166
- ...(cp.tracked ?? []).map((f) => ({ path: f, type: "" })),
167
- ...(cp.untracked ?? []).map((f) => ({ path: f, type: " (untracked)" })),
169
+ ...(cp.tracked ?? []).map((f) => ({ path: escapeXml(f), type: "" })),
170
+ ...(cp.untracked ?? []).map((f) => ({ path: escapeXml(f), type: " (untracked)" })),
168
171
  ]
169
172
  if (all.length === 0) return "(empty checkpoint)"
170
173
 
package/src/tools/read.md CHANGED
@@ -1,4 +1,11 @@
1
1
  Read a text file. Returns numbered lines. Use offset/limit to page large files.
2
+
3
+ **Routing:**
4
+ - Don't know which file? → `repo_outline` / `code_search` / `glob` first
5
+ - Know the symbol but not the location? → `code_search` or `lsp definition`
6
+ - Know the file but not the lines? → `grep` to find line numbers, then read that range with offset/limit
7
+ - Reading an image? → `read_image` instead
8
+
2
9
  Parameters:
3
10
  - path (required): File path, relative to cwd or absolute (alias: filePath)
4
11
  - offset: 1-based line number to start reading from
@@ -20,6 +20,22 @@ 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
+ /** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs. */
24
+ export function isPrivateHost(hostname) {
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
28
+ if (h === "169.254.169.254" || h === "metadata.google.internal") return true
29
+ // 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
31
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
32
+ if (m) {
33
+ const [a, b] = [Number(m[1]), Number(m[2])]
34
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || a === 192 && b === 168 || a === 169 && b === 254 || a === 0) return true
35
+ }
36
+ return false
37
+ }
38
+
23
39
  /** Normalize Windows line endings to Unix: \r\n → \n.
24
40
  * Applied on every text-file read so that edit/hash matching
25
41
  * and hash computation are platform-consistent. */
@@ -119,16 +119,15 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
119
119
 
120
120
  child.stdout.on("data", (d) => {
121
121
  const s = sanitizeOutput(outDecoder(d))
122
- if (s) {
123
- onOutput?.(s)
124
- if (outBuf.length < MAX_STREAM_BUF) outBuf += s
125
- else if (!truncatedNote) truncatedNote =
126
- "\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
127
- }
122
+ if (s) onOutput?.(s)
123
+ if (outBuf.length < MAX_STREAM_BUF) outBuf += s
124
+ else if (!truncatedNote) truncatedNote =
125
+ "\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
128
126
  })
129
127
 
130
128
  child.stderr.on("data", (d) => {
131
129
  const s = sanitizeOutput(errDecoder(d))
130
+ if (s) onOutput?.(s)
132
131
  if (errBuf.length < MAX_STREAM_BUF) errBuf += s
133
132
  })
134
133
 
@@ -142,11 +141,16 @@ function runBash(command, cwd, { timeout, signal, onOutput }) {
142
141
 
143
142
  child.on("close", (code, exitSignal) => {
144
143
  clearTimeout(timer)
145
- // Flush decoder tails (trailing multi-byte sequences that incomplete buffers left pending)
146
- outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
147
- errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
144
+ // Flush decoder tails also push final bytes to panel
145
+ const outFlush = sanitizeOutput(outDecoder(Buffer.alloc(0), true))
146
+ const errFlush = sanitizeOutput(errDecoder(Buffer.alloc(0), true))
147
+ outBuf += outFlush
148
+ errBuf += errFlush
149
+ if (outFlush) onOutput?.(outFlush)
150
+ if (errFlush) onOutput?.(errFlush)
148
151
 
149
- const status = exitSignal
152
+ // Windows has no POSIX signals — check signal.aborted for user interrupts
153
+ const status = (exitSignal || signal?.aborted)
150
154
  ? `killed: ${signal?.aborted ? "user interrupted" : "timeout"}`
151
155
  : `exit code ${code}`
152
156
 
package/src/tools/web.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { DESC, truncate, stripTags, htmlToText } from "./shared.mjs";
1
+ import { DESC, truncate, stripTags, htmlToText, isPrivateHost } from "./shared.mjs";
2
2
  import { URL } from "node:url";
3
3
  import { resolveWebProxy, proxyFetch } from "../proxy.mjs";
4
4
 
@@ -9,18 +9,30 @@ const FETCH_TIMEOUT = 15_000
9
9
 
10
10
  function extractBing(html) {
11
11
  const results = []
12
- const blocks = html.split('<li class="b_algo"').slice(1)
13
- for (const block of blocks) {
14
- const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
15
- if (!link) continue
16
- const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
17
- results.push({ href: link[1], title: stripTags(link[2]), snippet: snippet ? stripTags(snippet[1]) : "" })
12
+ // RSS format: <item><title>..</title><link>..</link><description>..</description></item>
13
+ for (const m of html.matchAll(/<item>([\s\S]*?)<\/item>/gi)) {
14
+ const block = m[1]
15
+ const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ""
16
+ const href = block.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? ""
17
+ const snippet = block.match(/<description>([\s\S]*?)<\/description>/)?.[1] ?? ""
18
+ if (!href) continue
19
+ results.push({ href, title: stripTags(title), snippet: stripTags(snippet) })
20
+ }
21
+ // Fallback: HTML b_algo blocks (older server-rendered pages)
22
+ if (results.length === 0) {
23
+ const blocks = html.split('<li class="b_algo"').slice(1)
24
+ for (const block of blocks) {
25
+ const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
26
+ if (!link) continue
27
+ const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
28
+ results.push({ href: link[1], title: stripTags(link[2]), snippet: snippet ? stripTags(snippet[1]) : "" })
29
+ }
18
30
  }
19
31
  return results
20
32
  }
21
33
 
22
34
  function bingUrl(query, page) {
23
- let u = `https://www.bing.com/search?q=${encodeURIComponent(query)}&setlang=en&setmkt=en-US`
35
+ let u = `https://www.bing.com/search?q=${encodeURIComponent(query)}&format=rss&setlang=en`
24
36
  if (page > 1) u += `&first=${(page - 1) * 10 + 1}`
25
37
  return u
26
38
  }
@@ -91,14 +103,7 @@ export const websearchTool = {
91
103
 
92
104
  function isPrivateUrl(urlStr) {
93
105
  let u; try { u = new URL(urlStr) } catch { return true }
94
- const host = u.hostname.toLowerCase()
95
- if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
96
- if (host === "127.0.0.1" || host.startsWith("127.")) return false
97
- if (host === "169.254.169.254" || host === "metadata.google.internal") return true
98
- const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
99
- if (m) { const [a, b] = [Number(m[1]), Number(m[2])]; if (a === 10||a === 172&&b>=16&&b<=31||a === 192&&b===168||a === 169&&b===254||a===0) return true }
100
- if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
101
- return false
106
+ return isPrivateHost(u.hostname)
102
107
  }
103
108
 
104
109
  // proxyFetch returns a native Response (Headers object, needs .get()) without proxy,
@@ -136,12 +136,21 @@ export async function runAgentTurn(ctx, text) {
136
136
  const stream = state.toolStreams[name]
137
137
  const panel = state.outputPanels[name]
138
138
  if (panel) {
139
- // Tool with output panel: mark panel as done, conversation gets only summary
140
- panel.done = true
141
139
  delete state.toolStreams[name]
142
- const summary = formatPanelSummary(name, result)
143
- if (summary) pushLine(` ${summary}`, C.dim)
144
- // Clear panel after 3 seconds
140
+ panel._pendingDone = true // defer done until next render cycle flushes it
141
+ scheduleRender() // trigger one final render while panel is still alive
142
+ if (name === "advisor") {
143
+ const text = String(result ?? "")
144
+ const lines = text.split("\n")
145
+ const maxShow = Math.min(60, lines.length)
146
+ // Push as single multiline block so formatTables aligns MD table columns
147
+ const shown = lines.slice(0, maxShow).map((l) => ` ${l.slice(0, 200)}`).join("\n")
148
+ pushLine(shown, C.advisor)
149
+ if (lines.length > maxShow) pushLine(` ... (${lines.length - maxShow} more lines — call advisor again or scroll through the tool result for full output)`, C.dim)
150
+ } else {
151
+ const summary = formatPanelSummary(name, result)
152
+ if (summary) pushLine(` ${summary}`, C.dim)
153
+ }
145
154
  setTimeout(() => {
146
155
  delete state.outputPanels[name]
147
156
  if (state.processing) render()
@@ -167,6 +176,7 @@ export async function runAgentTurn(ctx, text) {
167
176
  }
168
177
  panel.text = (panel.text ?? "") + chunk
169
178
  if (panel.text.length > 4000) panel.text = panel.text.slice(-4000)
179
+ panel.seq = (panel.seq ?? 0) + 1 // render-loop cache key: survives the 4000-char cap
170
180
  scheduleRender()
171
181
  },
172
182
  onPermissionRequest: (name, args) => askPermission(name, args),
@@ -200,9 +210,6 @@ export async function runAgentTurn(ctx, text) {
200
210
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
201
211
  render()
202
212
  },
203
- onAdvisor: (note) => {
204
- pushLine(` [advisor] ${note.replace(/\n/g, "\n ")}`, C.advisor)
205
- },
206
213
  // Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
207
214
  onTurnEnd: (() => {
208
215
  let n = 0
@@ -213,70 +220,75 @@ export async function runAgentTurn(ctx, text) {
213
220
  })(),
214
221
  }
215
222
 
216
- for (let resume = false; ; resume = true) {
217
- try {
218
- await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume })
219
- flushStream()
220
- break // Normal completion, exit loop
221
- } catch (error) {
222
- flushStream()
223
- if (error.name === "AbortError" || state.controller?.signal.aborted) {
224
- // Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
225
- // may have already injected it into history, but the aborted signal prevents retry.
226
- // Recreate the controller and resume from the same context.
227
- if (state.controller?.signal?.reason?.interrupt) {
223
+ // try/finally: every exit path including an unexpected throw inside the catch
224
+ // block (e.g. the continue-permission UI) — must stop the ticker and reset state,
225
+ // otherwise the 1s render interval leaks and keeps firing forever.
226
+ try {
227
+ for (let resume = false; ; resume = true) {
228
+ try {
229
+ await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume })
230
+ flushStream()
231
+ break // Normal completion, exit loop
232
+ } catch (error) {
233
+ flushStream()
234
+ if (error.name === "AbortError" || state.controller?.signal.aborted) {
235
+ // Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
236
+ // may have already injected it into history, but the aborted signal prevents retry.
237
+ // Recreate the controller and resume from the same context.
238
+ if (state.controller?.signal?.reason?.interrupt) {
239
+ state.controller = new AbortController()
240
+ resume = true
241
+ continue
242
+ }
243
+ pushLine("[stopped]", C.warn)
244
+ break
245
+ }
246
+ if (error instanceof ContinueError) {
247
+ pushLabel(`❯ Continue`, ansi.bold + C.warn)
248
+ pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
249
+ // Pause to ask: reuse permission mechanism
250
+ const willContinue = await new Promise((resolve) => {
251
+ state.permission = {
252
+ name: "continue",
253
+ args: { turns: error.turn },
254
+ resolve,
255
+ }
256
+ state.status = `Continue after ${error.turn} turns?`
257
+ render()
258
+ })
259
+ state.permission = null
260
+ if (!willContinue) {
261
+ pushLine("[continue cancelled]", C.warn)
262
+ break
263
+ }
264
+ pushLine("[continuing…]", C.tool)
265
+ // Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
228
266
  state.controller = new AbortController()
229
- resume = true
230
267
  continue
231
268
  }
232
- pushLine("[stopped]", C.warn)
269
+ pushLine(`[error] ${error.message}`, C.error)
233
270
  break
234
271
  }
235
- if (error instanceof ContinueError) {
236
- pushLabel(`❯ Continue`, ansi.bold + C.warn)
237
- pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
238
- // Pause to ask: reuse permission mechanism
239
- const willContinue = await new Promise((resolve) => {
240
- state.permission = {
241
- name: "continue",
242
- args: { turns: error.turn },
243
- resolve,
244
- }
245
- state.status = `Continue after ${error.turn} turns?`
246
- render()
247
- })
248
- state.permission = null
249
- if (!willContinue) {
250
- pushLine("[continue cancelled]", C.warn)
251
- break
252
- }
253
- pushLine("[continuing…]", C.tool)
254
- // Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
255
- state.controller = new AbortController()
256
- continue
257
- }
258
- pushLine(`[error] ${error.message}`, C.error)
259
- break
260
272
  }
273
+ } finally {
274
+ clearInterval(ticker)
275
+ state.processing = false
276
+ state.subTasks = {}
277
+ state.controller = null
278
+ state.status = "Ready"
279
+ // Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
280
+ if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
281
+ state.tasks = []
282
+ }
283
+ // Save session after every turn (survives crashes)
284
+ try {
285
+ saveSessionImpl(agent, state.lines)
286
+ } catch {
287
+ // Save failure doesn't interrupt usage
288
+ }
289
+ render()
261
290
  }
262
291
 
263
- clearInterval(ticker)
264
- state.processing = false
265
- state.subTasks = {}
266
- state.controller = null
267
- state.status = "Ready"
268
- // Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
269
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
270
- state.tasks = []
271
- }
272
- // Save session after every turn (survives crashes)
273
- try {
274
- saveSessionImpl(agent, state.lines)
275
- } catch {
276
- // Save failure doesn't interrupt usage
277
- }
278
- render()
279
-
280
292
  // Queued messages: auto-process next one
281
293
  while (state.queue.length > 0 && !state.processing) {
282
294
  const next = state.queue.shift()