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.
- package/package.json +1 -1
- package/src/advisor.mjs +360 -72
- package/src/agent/helpers.mjs +7 -3
- 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 +73 -21
- package/src/auto-think.mjs +23 -5
- package/src/config.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/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 +16 -0
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +21 -16
- package/src/tui/agent-turn.mjs +76 -64
- package/src/tui/cmd-advisor.mjs +119 -18
- package/src/tui/index.mjs +7 -187
- package/src/tui/key-handler.mjs +8 -2
- package/src/tui/layout.mjs +1 -1
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +27 -103
- 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)
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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._
|
|
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
|
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. */
|
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
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (state.controller?.signal
|
|
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(
|
|
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()
|