thincoder 0.10.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 (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +360 -72
  4. package/src/agent/helpers.mjs +7 -3
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +73 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +7 -0
  15. package/src/config.mjs +47 -20
  16. package/src/prompts/advisor-round1.md +23 -0
  17. package/src/prompts/advisor-round2.md +26 -0
  18. package/src/prompts/advisor-round3.md +24 -0
  19. package/src/prompts/coder.md +6 -2
  20. package/src/prompts/discipline.md +23 -6
  21. package/src/prompts/explore.md +2 -0
  22. package/src/prompts/plan.md +2 -0
  23. package/src/prompts/system.md +13 -6
  24. package/src/provider/anthropic.mjs +190 -0
  25. package/src/provider/core.mjs +42 -130
  26. package/src/provider/google.mjs +199 -0
  27. package/src/provider/sse.mjs +112 -0
  28. package/src/proxy.mjs +236 -0
  29. package/src/tools/bash.md +8 -0
  30. package/src/tools/codemode.mjs +5 -16
  31. package/src/tools/edit.md +8 -0
  32. package/src/tools/fetch.md +2 -1
  33. package/src/tools/git.mjs +125 -156
  34. package/src/tools/index.mjs +9 -9
  35. package/src/tools/linter.mjs +46 -32
  36. package/src/tools/read.md +7 -0
  37. package/src/tools/shared.mjs +16 -0
  38. package/src/tools/system.mjs +14 -10
  39. package/src/tools/web.mjs +115 -89
  40. package/src/tools/websearch.md +5 -3
  41. package/src/tui/agent-turn.mjs +86 -75
  42. package/src/tui/cmd-advisor.mjs +138 -49
  43. package/src/tui/cmd-clear.mjs +11 -17
  44. package/src/tui/cmd-config.mjs +226 -142
  45. package/src/tui/cmd-extract.mjs +1 -1
  46. package/src/tui/cmd-fold.mjs +2 -3
  47. package/src/tui/cmd-goal.mjs +58 -27
  48. package/src/tui/cmd-help.mjs +3 -1
  49. package/src/tui/cmd-mcp.mjs +178 -142
  50. package/src/tui/cmd-model.mjs +15 -4
  51. package/src/tui/cmd-new.mjs +7 -13
  52. package/src/tui/cmd-restore.mjs +12 -16
  53. package/src/tui/cmd-session.mjs +28 -32
  54. package/src/tui/cmd-think.mjs +75 -50
  55. package/src/tui/cmd-undo.mjs +19 -23
  56. package/src/tui/cmd-upgrade.mjs +22 -26
  57. package/src/tui/index.mjs +61 -215
  58. package/src/tui/key-handler.mjs +56 -20
  59. package/src/tui/layout.mjs +13 -3
  60. package/src/tui/pickers.mjs +151 -182
  61. package/src/tui/render-conversation.mjs +92 -0
  62. package/src/tui/render-frame.mjs +56 -114
  63. package/src/tui/render-loop.mjs +181 -0
  64. package/src/tui/slash-commands.mjs +26 -16
package/src/tools/web.mjs CHANGED
@@ -1,11 +1,60 @@
1
- import {
2
- DESC,
3
- truncate,
4
- readBodyText,
5
- stripTags,
6
- htmlToText
7
- } from "./shared.mjs";
8
- import { join } from "node:path";
1
+ import { DESC, truncate, stripTags, htmlToText, isPrivateHost } from "./shared.mjs";
2
+ import { URL } from "node:url";
3
+ import { resolveWebProxy, proxyFetch } from "../proxy.mjs";
4
+
5
+ export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
6
+ const FETCH_TIMEOUT = 15_000
7
+
8
+ // ── Web search (Bing; direct by default, through proxy when configured and web toggle on) ──
9
+
10
+ function extractBing(html) {
11
+ const results = []
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
+ }
30
+ }
31
+ return results
32
+ }
33
+
34
+ function bingUrl(query, page) {
35
+ let u = `https://www.bing.com/search?q=${encodeURIComponent(query)}&format=rss&setlang=en`
36
+ if (page > 1) u += `&first=${(page - 1) * 10 + 1}`
37
+ return u
38
+ }
39
+
40
+ const ENGINES = [{ name: "bing", label: "Bing", url: bingUrl, extract: extractBing, ua: UA }]
41
+ const ENGINE_NAMES = ENGINES.map(e => e.name)
42
+
43
+ async function fetchEngine(engine, query, page, ctx) {
44
+ const ctrl = new AbortController()
45
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
46
+ try {
47
+ const response = await proxyFetch(engine.url(query, page), {
48
+ headers: { "User-Agent": engine.ua, "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8" },
49
+ signal: ctrl.signal,
50
+ }, resolveWebProxy(ctx))
51
+ if (!response.ok) return null
52
+ const html = await response.text()
53
+ const results = engine.extract(html)
54
+ return { engine: engine.name, results }
55
+ } catch { return null }
56
+ finally { clearTimeout(timer) }
57
+ }
9
58
 
10
59
  export const websearchTool = {
11
60
  name: "websearch",
@@ -14,108 +63,85 @@ export const websearchTool = {
14
63
  type: "object",
15
64
  properties: {
16
65
  query: { type: "string", description: "Search query" },
17
- limit: { type: "number", description: "Max results (default 8)" },
66
+ limit: { type: "number", description: "Max results (default 8, max 20)" },
67
+ engine: { type: "string", enum: ENGINE_NAMES, description: "Specific engine — \"bing\" (Bing). Omit to search all engines concurrently." },
68
+ page: { type: "number", description: "Page number for pagination (1-based, default 1). Only used when engine is specified." },
18
69
  },
19
70
  required: ["query"],
20
71
  },
21
72
  readonly: true,
22
73
  async execute(args, ctx) {
23
- const limit = args.limit ?? 8
24
- const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
25
- let html
26
- try {
27
- const response = await fetch(url, {
28
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
29
- signal: ctx?.signal
30
- ? AbortSignal.any([ctx.signal, AbortSignal.timeout(15_000)])
31
- : AbortSignal.timeout(15_000),
32
- })
33
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
34
- html = await readBodyText(response)
35
- } catch (error) {
36
- throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
74
+ const limit = Math.min(args.limit ?? 8, 20)
75
+ const page = Math.max(1, args.page ?? 1)
76
+ if (args.engine) {
77
+ const engine = ENGINES.find(e => e.name === args.engine)
78
+ if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
79
+ const fetched = await fetchEngine(engine, args.query, page, ctx)
80
+ if (!fetched || fetched.results.length === 0) return `(no results from ${engine.label})`
81
+ return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
37
82
  }
38
-
39
- // Result block <li class="b_algo">: <h2><a href>title</a></h2> + <p>snippet</p>
40
- const blocks = html.split('<li class="b_algo"').slice(1)
41
- const results = []
42
- for (const block of blocks) {
43
- const link = block.match(/<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/)
44
- if (!link) continue
45
- const snippet = block.match(/<p[^>]*>([\s\S]*?)<\/p>/)
46
- results.push({
47
- href: link[1],
48
- title: stripTags(link[2]),
49
- snippet: snippet ? stripTags(snippet[1]) : "",
50
- })
51
- if (results.length >= limit) break
83
+ const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, ctx))
84
+ const fetched = (await Promise.all(promises)).filter(Boolean)
85
+ if (fetched.length === 0) return "(no results — all search engines failed)"
86
+ const merged = [], indexes = fetched.map(() => 0)
87
+ let done = false
88
+ while (!done && merged.length < limit) {
89
+ done = true
90
+ for (let i = 0; i < fetched.length; i++) {
91
+ if (indexes[i] < fetched[i].results.length) {
92
+ merged.push({ ...fetched[i].results[indexes[i]], _engine: fetched[i].engine })
93
+ indexes[i]++; done = false
94
+ if (merged.length >= limit) break
95
+ }
96
+ }
52
97
  }
53
- if (results.length === 0) return "(no results)"
54
- return truncate(
55
- results.map((r, i) => `${i + 1}. ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"),
56
- )
98
+ return truncate(merged.slice(0, limit).map((r, i) => `${i + 1}. [${r._engine}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
57
99
  },
58
100
  }
59
101
 
102
+ // ── Fetch tool (with proxy support) ──────
60
103
 
61
- // ---------------------------------------------------------------- ls
62
-
63
- /** SSRF protection: block internal private-network/metadata endpoints (localhost allowed — user's dev server, tests depend on it) */
64
104
  function isPrivateUrl(urlStr) {
65
- let u
66
- try { u = new URL(urlStr) } catch { return true }
67
- const host = u.hostname.toLowerCase()
68
- // localhost / 127.x allowed (user's dev server on local machine, test mock server)
69
- if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
70
- if (host === "127.0.0.1" || host.startsWith("127.")) return false
71
- // cloud metadata endpoint
72
- if (host === "169.254.169.254" || host === "metadata.google.internal") return true
73
- // IPv4 private ranges
74
- const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
75
- if (m) {
76
- const [a, b] = [Number(m[1]), Number(m[2])]
77
- if (a === 10) return true
78
- if (a === 172 && b >= 16 && b <= 31) return true
79
- if (a === 192 && b === 168) return true
80
- if (a === 169 && b === 254) return true
81
- if (a === 0) return true
82
- }
83
- // IPv6 loopback / link-local / unique local addresses
84
- if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
85
- return false
105
+ let u; try { u = new URL(urlStr) } catch { return true }
106
+ return isPrivateHost(u.hostname)
107
+ }
108
+
109
+ // proxyFetch returns a native Response (Headers object, needs .get()) without proxy,
110
+ // but a Response-like with a plain lowercase-keyed Record through the CONNECT tunnel — handle both.
111
+ function headerOf(res, name) {
112
+ const h = res.headers
113
+ if (!h) return null
114
+ if (typeof h.get === "function") return h.get(name)
115
+ return h[name.toLowerCase()] ?? null
86
116
  }
87
117
 
88
118
  export const fetchTool = {
89
119
  name: "fetch",
90
120
  description: DESC("fetch"),
91
- parameters: {
92
- type: "object",
93
- properties: {
94
- url: { type: "string", description: "http/https URL" },
95
- },
96
- required: ["url"],
97
- },
121
+ parameters: { type: "object", properties: { url: { type: "string", description: "http/https URL" } }, required: ["url"] },
98
122
  readonly: true,
99
123
  async execute(args, ctx) {
100
124
  if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
101
125
  if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
102
- let response
103
126
  try {
104
- response = await fetch(args.url, {
105
- headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
106
- redirect: "follow",
107
- signal: ctx?.signal
108
- ? AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)])
109
- : AbortSignal.timeout(20_000),
110
- })
111
- } catch (error) {
112
- throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)
113
- }
114
- if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status}`)
115
-
116
- const contentType = response.headers.get("content-type") ?? ""
117
- const body = await readBodyText(response)
118
- if (!contentType.includes("text/html")) return truncate(body)
119
- return truncate(htmlToText(body))
127
+ const proxyUri = resolveWebProxy(ctx)
128
+ const response = await proxyFetch(args.url, { headers: { "User-Agent": UA } }, proxyUri)
129
+ if (!response.ok) {
130
+ if ([301, 302, 307, 308].includes(response.status)) {
131
+ const loc = headerOf(response, "location")
132
+ if (loc) {
133
+ const r2 = await proxyFetch(loc, { headers: { "User-Agent": UA } }, proxyUri)
134
+ if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}`)
135
+ const ct2 = headerOf(r2, "content-type") ?? ""
136
+ const b2 = await r2.text()
137
+ return ct2.includes("text/html") ? truncate(htmlToText(b2)) : truncate(b2)
138
+ }
139
+ }
140
+ throw new Error(`fetch failed: HTTP ${response.status}`)
141
+ }
142
+ const ct = headerOf(response, "content-type") ?? ""
143
+ const body = await response.text()
144
+ return ct.includes("text/html") ? truncate(htmlToText(body)) : truncate(body)
145
+ } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`) }
120
146
  },
121
147
  }
@@ -1,11 +1,13 @@
1
- Search the web (Bing). Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
1
+ Search the web via Bing. Returns result titles, URLs, and snippets. Use for looking up current information, docs, error messages.
2
2
 
3
3
  Parameters:
4
4
  - query (required): Search query
5
- - limit: Max results (default 8)
5
+ - limit: Max results (default 8, max 20)
6
+ - engine: Specific engine to use — "bing" (Bing). Omit to search all engines concurrently.
7
+ - page: Page number for pagination (1-based, default 1). Only used when engine is specified.
6
8
 
7
9
  Notes:
8
10
  - Before searching the web, call `memory_search` first — you may already know the answer from a previous session. Only reach for websearch if memory comes up empty.
9
11
  - Use this for information that is NOT in the local codebase — current docs, error messages, API references
10
12
  - Follow up with `fetch` to read full pages from the results
11
- - Results are scraped from Bing HTML some formatting may be imperfect
13
+ - Proxy support: set `"proxy": {"uri": "http://host:port", "web": true}` in config.json
@@ -10,6 +10,9 @@ import { ansi, C } from "./ansi.mjs"
10
10
  * handleSlash, summarize } */
11
11
  export async function runAgentTurn(ctx, text) {
12
12
  const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash, summarize } = ctx
13
+ // 可注入覆盖(测试用);默认走真实实现
14
+ const runAgentImpl = ctx.runAgent ?? runAgent
15
+ const saveSessionImpl = ctx.saveSession ?? saveSession
13
16
  pushLabel(`❯ You:`, ansi.bold + C.user)
14
17
  pushLine(text, C.text)
15
18
 
@@ -133,12 +136,21 @@ export async function runAgentTurn(ctx, text) {
133
136
  const stream = state.toolStreams[name]
134
137
  const panel = state.outputPanels[name]
135
138
  if (panel) {
136
- // Tool with output panel: mark panel as done, conversation gets only summary
137
- panel.done = true
138
139
  delete state.toolStreams[name]
139
- const summary = formatPanelSummary(name, result)
140
- if (summary) pushLine(` ${summary}`, C.dim)
141
- // 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
+ }
142
154
  setTimeout(() => {
143
155
  delete state.outputPanels[name]
144
156
  if (state.processing) render()
@@ -164,6 +176,7 @@ export async function runAgentTurn(ctx, text) {
164
176
  }
165
177
  panel.text = (panel.text ?? "") + chunk
166
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
167
180
  scheduleRender()
168
181
  },
169
182
  onPermissionRequest: (name, args) => askPermission(name, args),
@@ -197,99 +210,97 @@ export async function runAgentTurn(ctx, text) {
197
210
  pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
198
211
  render()
199
212
  },
200
- onAdvisor: (note) => {
201
- pushLine(` [advisor] ${note.replace(/\n/g, "\n ")}`, C.advisor)
202
- },
203
213
  // Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
204
214
  onTurnEnd: (() => {
205
215
  let n = 0
206
216
  return () => {
207
217
  if (++n % 5 !== 0) return
208
- try { saveSession(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
218
+ try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
209
219
  }
210
220
  })(),
211
221
  }
212
222
 
213
- for (let resume = false; ; resume = true) {
214
- try {
215
- await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
216
- flushStream()
217
- break // Normal completion, exit loop
218
- } catch (error) {
219
- flushStream()
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) {
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)
225
266
  state.controller = new AbortController()
226
- resume = true
227
267
  continue
228
268
  }
229
- pushLine("[stopped]", C.warn)
269
+ pushLine(`[error] ${error.message}`, C.error)
230
270
  break
231
271
  }
232
- if (error instanceof ContinueError) {
233
- pushLabel(`❯ Continue`, ansi.bold + C.warn)
234
- pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
235
- // Pause to ask: reuse permission mechanism
236
- const willContinue = await new Promise((resolve) => {
237
- state.permission = {
238
- name: "continue",
239
- args: { turns: error.turn },
240
- resolve,
241
- }
242
- state.status = `Continue after ${error.turn} turns?`
243
- render()
244
- })
245
- state.permission = null
246
- if (!willContinue) {
247
- pushLine("[continue cancelled]", C.warn)
248
- break
249
- }
250
- pushLine("[continuing…]", C.tool)
251
- // Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
252
- state.controller = new AbortController()
253
- continue
254
- }
255
- pushLine(`[error] ${error.message}`, C.error)
256
- break
257
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()
258
290
  }
259
291
 
260
- clearInterval(ticker)
261
- state.processing = false
262
- state.subTasks = {}
263
- state.controller = null
264
- state.status = "Ready"
265
- // Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
266
- if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
267
- state.tasks = []
268
- }
269
- // Save session after every turn (survives crashes)
270
- try {
271
- saveSession(agent, state.lines)
272
- } catch {
273
- // Save failure doesn't interrupt usage
274
- }
275
- render()
276
-
277
292
  // Queued messages: auto-process next one
278
- if (state.queue.length > 0) {
293
+ while (state.queue.length > 0 && !state.processing) {
279
294
  const next = state.queue.shift()
280
- // Queued slash commands execute directly
295
+ // Queued slash commands execute directly — check every item, not just the first
281
296
  if (next.text.startsWith("/")) {
282
297
  await handleSlash(next.text)
283
298
  render()
284
- // After slash command completes, continue checking queue
285
- if (state.queue.length > 0 && !state.processing) {
286
- const next2 = state.queue.shift()
287
- await runAgentTurn(ctx, next2.text)
288
- }
289
- } else {
290
- pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
291
- await runAgentTurn(ctx, next.text)
299
+ continue
292
300
  }
301
+ pushLabel(`❯ You: (from queue)`, ansi.bold + C.user)
302
+ await runAgentTurn(ctx, next.text)
303
+ return
293
304
  }
294
305
  }
295
306