thincoder 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/advisor.mjs +535 -72
- package/src/agent/helpers.mjs +18 -5
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +80 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +20 -0
- package/src/config.mjs +1 -1
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.mjs +1 -1
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +1 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +5 -1
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +6 -126
- package/src/provider/google.mjs +4 -2
- package/src/provider/sse.mjs +112 -0
- package/src/skills.mjs +67 -31
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/git.mjs +9 -6
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +43 -2
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +21 -16
- package/src/tui/agent-turn.mjs +130 -73
- package/src/tui/cmd-advisor.mjs +237 -41
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +9 -188
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +8 -4
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +94 -168
- package/src/tui/render-loop.mjs +110 -0
package/src/tools/shared.mjs
CHANGED
|
@@ -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. */
|
|
@@ -187,9 +203,34 @@ export function shellSegments(command) {
|
|
|
187
203
|
return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
|
|
188
204
|
}
|
|
189
205
|
|
|
190
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Blank out quoted regions (single/double/backtick) with spaces, preserving length.
|
|
208
|
+
* Lets safety checks ignore shell metacharacters inside quoted script bodies —
|
|
209
|
+
* e.g. `node -e "if (a > b) …"` comparisons are not redirections.
|
|
210
|
+
*/
|
|
211
|
+
function blankQuoted(command) {
|
|
212
|
+
let out = ""
|
|
213
|
+
let quote = null
|
|
214
|
+
for (let i = 0; i < command.length; i++) {
|
|
215
|
+
const ch = command[i]
|
|
216
|
+
if (quote) {
|
|
217
|
+
if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
|
|
218
|
+
if (ch === quote) quote = null
|
|
219
|
+
out += " "
|
|
220
|
+
} else if (ch === "'" || ch === '"' || ch === "`") {
|
|
221
|
+
quote = ch
|
|
222
|
+
out += " "
|
|
223
|
+
} else {
|
|
224
|
+
out += ch
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions */
|
|
191
231
|
export function hasFileRedirection(command) {
|
|
192
|
-
|
|
232
|
+
const bare = blankQuoted(command)
|
|
233
|
+
return /(^|[\s;&|])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|])<\s*\S/.test(bare)
|
|
193
234
|
}
|
|
194
235
|
|
|
195
236
|
/** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
|
package/src/tools/system.mjs
CHANGED
|
@@ -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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
for (const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
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)}&
|
|
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
|
-
|
|
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,
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -105,7 +105,11 @@ export async function runAgentTurn(ctx, text) {
|
|
|
105
105
|
flushStream()
|
|
106
106
|
ensureAssistantLabel()
|
|
107
107
|
state.currentTool = name
|
|
108
|
-
|
|
108
|
+
// Advisor: tag the round in the tool title — the model's own "第N轮" narration
|
|
109
|
+
// is unreliable (it glues onto the previous line), so the round belongs here.
|
|
110
|
+
const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1})` : ""
|
|
111
|
+
const argSummary = summarize(args)
|
|
112
|
+
pushLine(` [tool] ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, C.tool)
|
|
109
113
|
},
|
|
110
114
|
onToolResult: (name, result) => {
|
|
111
115
|
state.currentTool = null
|
|
@@ -136,16 +140,27 @@ export async function runAgentTurn(ctx, text) {
|
|
|
136
140
|
const stream = state.toolStreams[name]
|
|
137
141
|
const panel = state.outputPanels[name]
|
|
138
142
|
if (panel) {
|
|
139
|
-
// Tool with output panel: mark panel as done, conversation gets only summary
|
|
140
|
-
panel.done = true
|
|
141
143
|
delete state.toolStreams[name]
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
// Keep the panel visible for a 3s grace period (layout filters by closeAt);
|
|
145
|
+
// the render loop prunes it once expired. No defer hacks needed — row-diff
|
|
146
|
+
// repaints whatever should be on screen.
|
|
147
|
+
panel.done = true
|
|
148
|
+
panel.closeAt = Date.now() + 3000
|
|
149
|
+
scheduleRender()
|
|
150
|
+
if (name === "advisor") {
|
|
151
|
+
const text = String(result ?? "")
|
|
152
|
+
const lines = text.split("\n")
|
|
153
|
+
const maxShow = Math.min(60, lines.length)
|
|
154
|
+
// Push as single multiline block so formatTables aligns MD table columns
|
|
155
|
+
const shown = lines.slice(0, maxShow).map((l) => ` ${l.slice(0, 200)}`).join("\n")
|
|
156
|
+
pushLine(shown, C.advisor)
|
|
157
|
+
if (lines.length > maxShow) pushLine(` ... (${lines.length - maxShow} more lines — call advisor again or scroll through the tool result for full output)`, C.dim)
|
|
158
|
+
} else {
|
|
159
|
+
const summary = formatPanelSummary(name, result)
|
|
160
|
+
if (summary) pushLine(` ${summary}`, C.dim)
|
|
161
|
+
}
|
|
162
|
+
// Trigger a repaint after the grace period so the pruned panel disappears
|
|
163
|
+
setTimeout(() => render(), 3000)
|
|
149
164
|
} else if (stream) {
|
|
150
165
|
const tail = stream.trimEnd().slice(-4000)
|
|
151
166
|
if (tail) pushLine(tail, C.dim)
|
|
@@ -158,21 +173,45 @@ export async function runAgentTurn(ctx, text) {
|
|
|
158
173
|
},
|
|
159
174
|
onToolOutput: (name, chunk) => {
|
|
160
175
|
// Route streaming output to a panel if one exists or was requested via outputPanel flag.
|
|
176
|
+
// Chunk may be a string or { kind, text } — kind ("think" | "text" | "tool") drives
|
|
177
|
+
// per-kind coloring in renderOutput so reasoning / answer / tool progress are distinct.
|
|
161
178
|
let panel = state.outputPanels[name]
|
|
162
179
|
if (!panel) {
|
|
163
180
|
// Lazy-create panel: defensive against race conditions where setupOutputPanel
|
|
164
181
|
// hasn't fired yet or the callbacks chain dropped it (subagent relay, reconnect, etc.)
|
|
165
|
-
state.outputPanels[name] = {
|
|
182
|
+
state.outputPanels[name] = { parts: [], len: 0, done: false }
|
|
166
183
|
panel = state.outputPanels[name]
|
|
167
184
|
}
|
|
168
|
-
|
|
169
|
-
|
|
185
|
+
const part = typeof chunk === "string"
|
|
186
|
+
? { kind: "text", text: chunk }
|
|
187
|
+
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "") }
|
|
188
|
+
if (!part.text) return
|
|
189
|
+
// Separate phase transitions with a newline — think → answer → tool progress
|
|
190
|
+
// would otherwise glue onto each other mid-line.
|
|
191
|
+
const last = panel.parts[panel.parts.length - 1]
|
|
192
|
+
if (last && last.kind !== part.kind && !last.text.endsWith("\n") && !part.text.startsWith("\n")) {
|
|
193
|
+
part.text = "\n" + part.text
|
|
194
|
+
}
|
|
195
|
+
panel.parts.push(part)
|
|
196
|
+
panel.len += part.text.length
|
|
197
|
+
// Cap at 4000 chars, trimming oldest parts first
|
|
198
|
+
while (panel.len > 4000 && panel.parts.length > 1) {
|
|
199
|
+
const first = panel.parts[0]
|
|
200
|
+
const excess = panel.len - 4000
|
|
201
|
+
if (first.text.length <= excess) {
|
|
202
|
+
panel.len -= first.text.length
|
|
203
|
+
panel.parts.shift()
|
|
204
|
+
} else {
|
|
205
|
+
first.text = first.text.slice(excess)
|
|
206
|
+
panel.len -= excess
|
|
207
|
+
}
|
|
208
|
+
}
|
|
170
209
|
scheduleRender()
|
|
171
210
|
},
|
|
172
211
|
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
173
212
|
onQuestion: (text, options) => askQuestion(text, options),
|
|
174
213
|
setupOutputPanel: (name) => {
|
|
175
|
-
state.outputPanels[name] = {
|
|
214
|
+
state.outputPanels[name] = { parts: [], len: 0, done: false }
|
|
176
215
|
scheduleRender()
|
|
177
216
|
},
|
|
178
217
|
onCompress: () => {
|
|
@@ -200,9 +239,6 @@ export async function runAgentTurn(ctx, text) {
|
|
|
200
239
|
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
201
240
|
render()
|
|
202
241
|
},
|
|
203
|
-
onAdvisor: (note) => {
|
|
204
|
-
pushLine(` [advisor] ${note.replace(/\n/g, "\n ")}`, C.advisor)
|
|
205
|
-
},
|
|
206
242
|
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
207
243
|
onTurnEnd: (() => {
|
|
208
244
|
let n = 0
|
|
@@ -213,70 +249,75 @@ export async function runAgentTurn(ctx, text) {
|
|
|
213
249
|
})(),
|
|
214
250
|
}
|
|
215
251
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (state.controller?.signal
|
|
252
|
+
// try/finally: every exit path — including an unexpected throw inside the catch
|
|
253
|
+
// block (e.g. the continue-permission UI) — must stop the ticker and reset state,
|
|
254
|
+
// otherwise the 1s render interval leaks and keeps firing forever.
|
|
255
|
+
try {
|
|
256
|
+
for (let resume = false; ; resume = true) {
|
|
257
|
+
try {
|
|
258
|
+
await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume })
|
|
259
|
+
flushStream()
|
|
260
|
+
break // Normal completion, exit loop
|
|
261
|
+
} catch (error) {
|
|
262
|
+
flushStream()
|
|
263
|
+
if (error.name === "AbortError" || state.controller?.signal.aborted) {
|
|
264
|
+
// Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
|
|
265
|
+
// may have already injected it into history, but the aborted signal prevents retry.
|
|
266
|
+
// Recreate the controller and resume from the same context.
|
|
267
|
+
if (state.controller?.signal?.reason?.interrupt) {
|
|
268
|
+
state.controller = new AbortController()
|
|
269
|
+
resume = true
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
pushLine("[stopped]", C.warn)
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
if (error instanceof ContinueError) {
|
|
276
|
+
pushLabel(`❯ Continue`, ansi.bold + C.warn)
|
|
277
|
+
pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
|
|
278
|
+
// Pause to ask: reuse permission mechanism
|
|
279
|
+
const willContinue = await new Promise((resolve) => {
|
|
280
|
+
state.permission = {
|
|
281
|
+
name: "continue",
|
|
282
|
+
args: { turns: error.turn },
|
|
283
|
+
resolve,
|
|
284
|
+
}
|
|
285
|
+
state.status = `Continue after ${error.turn} turns?`
|
|
286
|
+
render()
|
|
287
|
+
})
|
|
288
|
+
state.permission = null
|
|
289
|
+
if (!willContinue) {
|
|
290
|
+
pushLine("[continue cancelled]", C.warn)
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
pushLine("[continuing…]", C.tool)
|
|
294
|
+
// Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
|
|
228
295
|
state.controller = new AbortController()
|
|
229
|
-
resume = true
|
|
230
296
|
continue
|
|
231
297
|
}
|
|
232
|
-
pushLine(
|
|
298
|
+
pushLine(`[error] ${error.message}`, C.error)
|
|
233
299
|
break
|
|
234
300
|
}
|
|
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
301
|
}
|
|
302
|
+
} finally {
|
|
303
|
+
clearInterval(ticker)
|
|
304
|
+
state.processing = false
|
|
305
|
+
state.subTasks = {}
|
|
306
|
+
state.controller = null
|
|
307
|
+
state.status = "Ready"
|
|
308
|
+
// Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
|
|
309
|
+
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
310
|
+
state.tasks = []
|
|
311
|
+
}
|
|
312
|
+
// Save session after every turn (survives crashes)
|
|
313
|
+
try {
|
|
314
|
+
saveSessionImpl(agent, state.lines)
|
|
315
|
+
} catch {
|
|
316
|
+
// Save failure doesn't interrupt usage
|
|
317
|
+
}
|
|
318
|
+
render()
|
|
261
319
|
}
|
|
262
320
|
|
|
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
321
|
// Queued messages: auto-process next one
|
|
281
322
|
while (state.queue.length > 0 && !state.processing) {
|
|
282
323
|
const next = state.queue.shift()
|
|
@@ -295,11 +336,27 @@ export async function runAgentTurn(ctx, text) {
|
|
|
295
336
|
/** Extract a one-line summary from a panel tool's output */
|
|
296
337
|
function formatPanelSummary(name, result) {
|
|
297
338
|
if (name === "verify") return _verifySummary(result)
|
|
339
|
+
if (name === "bash") return _bashSummary(result)
|
|
298
340
|
// Default: first non-empty line
|
|
299
341
|
const first = result.split("\n").find((l) => l.trim())
|
|
300
342
|
return first ? `${name}: ${first.slice(0, 100)}` : null
|
|
301
343
|
}
|
|
302
344
|
|
|
345
|
+
/**
|
|
346
|
+
* bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
|
|
347
|
+
* The first non-empty line is always the "[stdout]:" marker — useless as a summary.
|
|
348
|
+
* Show the LAST output line (usually the meaningful tail) plus the exit status.
|
|
349
|
+
*/
|
|
350
|
+
function _bashSummary(result) {
|
|
351
|
+
const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
|
|
352
|
+
const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
|
|
353
|
+
const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
|
|
354
|
+
const parts = []
|
|
355
|
+
if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
|
|
356
|
+
if (status) parts.push(status)
|
|
357
|
+
return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
|
|
358
|
+
}
|
|
359
|
+
|
|
303
360
|
function _verifySummary(result) {
|
|
304
361
|
const lines = result.split("\n")
|
|
305
362
|
const summary = []
|