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.
Files changed (51) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +535 -72
  4. package/src/agent/helpers.mjs +18 -5
  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 +80 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +20 -0
  15. package/src/config.mjs +1 -1
  16. package/src/mcp/transport-stdio.mjs +4 -3
  17. package/src/mcp.mjs +1 -1
  18. package/src/prompts/advisor-round1.md +23 -0
  19. package/src/prompts/advisor-round2.md +26 -0
  20. package/src/prompts/advisor-round3.md +24 -0
  21. package/src/prompts/coder.md +1 -0
  22. package/src/prompts/discipline.md +15 -1
  23. package/src/prompts/explore.md +2 -0
  24. package/src/prompts/plan.md +2 -0
  25. package/src/prompts/system.md +5 -1
  26. package/src/provider/anthropic.mjs +4 -4
  27. package/src/provider/core.mjs +6 -126
  28. package/src/provider/google.mjs +4 -2
  29. package/src/provider/sse.mjs +112 -0
  30. package/src/skills.mjs +67 -31
  31. package/src/tools/bash.md +8 -0
  32. package/src/tools/codemode.mjs +5 -16
  33. package/src/tools/edit.md +8 -0
  34. package/src/tools/git.mjs +9 -6
  35. package/src/tools/read.md +7 -0
  36. package/src/tools/shared.mjs +43 -2
  37. package/src/tools/system.mjs +14 -10
  38. package/src/tools/web.mjs +21 -16
  39. package/src/tui/agent-turn.mjs +130 -73
  40. package/src/tui/cmd-advisor.mjs +237 -41
  41. package/src/tui/cmd-auto.mjs +6 -8
  42. package/src/tui/cmd-mcp.mjs +4 -2
  43. package/src/tui/cmd-plan.mjs +6 -8
  44. package/src/tui/cmd-think.mjs +93 -70
  45. package/src/tui/index.mjs +9 -188
  46. package/src/tui/interaction.mjs +2 -1
  47. package/src/tui/key-handler.mjs +8 -4
  48. package/src/tui/layout.mjs +3 -2
  49. package/src/tui/render-conversation.mjs +92 -0
  50. package/src/tui/render-frame.mjs +94 -168
  51. package/src/tui/render-loop.mjs +110 -0
@@ -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
- /** Detect shell output/input redirection (> >> < followed by filename) — not excluded inside quotes, conservative block */
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
- return /(^|[\s;&|])>{1,2}\s*\S/.test(command) || /(^|[\s;&|])<\s*\S/.test(command)
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) */
@@ -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,
@@ -105,7 +105,11 @@ export async function runAgentTurn(ctx, text) {
105
105
  flushStream()
106
106
  ensureAssistantLabel()
107
107
  state.currentTool = name
108
- pushLine(` [tool] ${name} ${summarize(args)}`, C.tool)
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
- const summary = formatPanelSummary(name, result)
143
- if (summary) pushLine(` ${summary}`, C.dim)
144
- // Clear panel after 3 seconds
145
- setTimeout(() => {
146
- delete state.outputPanels[name]
147
- if (state.processing) render()
148
- }, 3000)
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] = { text: "", done: false }
182
+ state.outputPanels[name] = { parts: [], len: 0, done: false }
166
183
  panel = state.outputPanels[name]
167
184
  }
168
- panel.text = (panel.text ?? "") + chunk
169
- if (panel.text.length > 4000) panel.text = panel.text.slice(-4000)
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] = { text: "", done: false }
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
- 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) {
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("[stopped]", C.warn)
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 = []