thincoder 0.8.2 → 0.8.4
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 +2 -1
- package/bin/thincoder.mjs +25 -25
- package/package.json +1 -1
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +12 -11
- package/src/agent-tools/plan.mjs +5 -6
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +7 -26
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +82 -92
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +56 -53
- package/src/context.mjs +59 -61
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +0 -6
- package/src/prompts/discipline.md +9 -0
- package/src/prompts/main.md +24 -24
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -42
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +20 -14
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/ls.md +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +55 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +5 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +3 -12
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +108 -23
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +11 -3
- package/src/tui/cmd-goal.mjs +3 -12
- package/src/tui/cmd-help.mjs +2 -2
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +38 -25
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +33 -13
- package/src/tui/cmd-plan.mjs +3 -12
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +4 -11
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +4 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +34 -26
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +22 -22
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/tools/system.mjs
CHANGED
|
@@ -13,14 +13,11 @@ import {
|
|
|
13
13
|
insideGitRepo,
|
|
14
14
|
globToRegex
|
|
15
15
|
} from "./shared.mjs";
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
|
-
import {
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
19
|
-
import { readdir } from "node:fs/promises";
|
|
20
|
-
import { stat, lstat } from "node:fs/promises";
|
|
16
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
17
|
+
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
21
18
|
import { join } from "node:path";
|
|
22
19
|
|
|
23
|
-
/**
|
|
20
|
+
/** Child process environment variable whitelist: only pass through safe variables, isolate sensitive info like API keys */
|
|
24
21
|
const SAFE_ENV_KEYS = new Set([
|
|
25
22
|
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
|
26
23
|
"TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
|
|
@@ -30,6 +27,152 @@ const SAFE_ENV_KEYS = new Set([
|
|
|
30
27
|
"GIT_PAGER", "PAGER", "TERM",
|
|
31
28
|
])
|
|
32
29
|
|
|
30
|
+
/** Maximum buffer size per stream (stdout / stderr) before truncation */
|
|
31
|
+
const MAX_STREAM_BUF = 2_000_000
|
|
32
|
+
|
|
33
|
+
// ====================================================================
|
|
34
|
+
// bash — command execution with safety gates
|
|
35
|
+
// ====================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pre-execution safety checks for bash commands.
|
|
39
|
+
* Three layers: file redirection → destructive commands → destructive git ops with uncommitted changes.
|
|
40
|
+
* Throws with actionable guidance on failure.
|
|
41
|
+
*/
|
|
42
|
+
function checkBashSafety(command, cwd) {
|
|
43
|
+
if (hasFileRedirection(command)) {
|
|
44
|
+
throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
|
|
45
|
+
}
|
|
46
|
+
if (shellSegments(command).some(isDestructiveCommand)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"Destructive command blocked — use specific tools or confirm with the user first. " +
|
|
49
|
+
"(If work was already destroyed, recover from auto-snapshot: checkpoint action=list then action=rewind.)"
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
if (shellSegments(command).some(isDestructiveGitSegment)) {
|
|
53
|
+
if (!insideGitRepo(cwd)) {
|
|
54
|
+
throw new Error(`Refusing destructive git command: not a git repository: ${cwd}`)
|
|
55
|
+
}
|
|
56
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
57
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
58
|
+
}).trim()
|
|
59
|
+
if (status) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Refusing destructive git command: uncommitted changes exist.\n` +
|
|
62
|
+
`First create a checkpoint (action=create) to protect current work, then commit or stash before the destructive operation.\n` +
|
|
63
|
+
`(If uncommitted work was already lost, recover from the latest auto-snapshot: checkpoint action=list, then action=rewind.)\n\n${status}`
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a sanitized environment for the child process.
|
|
71
|
+
* Whitelists safe variables, forces non-interactive defaults (EDITOR/PAGER/TERM),
|
|
72
|
+
* and sets PYTHONIOENCODING on Windows to override GBK default for Python scripts.
|
|
73
|
+
*/
|
|
74
|
+
function buildBashEnv() {
|
|
75
|
+
const winCmd = process.platform === "win32"
|
|
76
|
+
return {
|
|
77
|
+
...Object.fromEntries(
|
|
78
|
+
Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
|
|
79
|
+
),
|
|
80
|
+
GIT_EDITOR: "true",
|
|
81
|
+
EDITOR: "true",
|
|
82
|
+
VISUAL: "true",
|
|
83
|
+
GIT_PAGER: "cat",
|
|
84
|
+
PAGER: "cat",
|
|
85
|
+
TERM: "dumb",
|
|
86
|
+
...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Platform-aware process tree kill.
|
|
92
|
+
* POSIX: kill process group (spawned with detached=true).
|
|
93
|
+
* Windows: taskkill /T to reach grandchildren (npm test's subprocesses, etc.).
|
|
94
|
+
*/
|
|
95
|
+
function killProcessTree(child) {
|
|
96
|
+
if (process.platform === "win32") {
|
|
97
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
98
|
+
} else {
|
|
99
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
100
|
+
try { child.kill("SIGKILL") } catch {} // fallback: kill directly if group kill fails
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
|
|
106
|
+
* enforce timeout and signal abort, return formatted result.
|
|
107
|
+
*
|
|
108
|
+
* Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
|
|
109
|
+
*/
|
|
110
|
+
function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const child = spawn(command, {
|
|
113
|
+
cwd,
|
|
114
|
+
shell: true,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
detached: process.platform !== "win32",
|
|
117
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
118
|
+
env: buildBashEnv(),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const killTree = () => killProcessTree(child)
|
|
122
|
+
|
|
123
|
+
// Separate decoders for stdout / stderr (same encoding in practice, but separate
|
|
124
|
+
// collection is cleaner and lets the model locate errors via stderr quickly)
|
|
125
|
+
const outDecoder = makeDecoder()
|
|
126
|
+
const errDecoder = makeDecoder()
|
|
127
|
+
let outBuf = ""
|
|
128
|
+
let errBuf = ""
|
|
129
|
+
let truncatedNote = ""
|
|
130
|
+
|
|
131
|
+
child.stdout.on("data", (d) => {
|
|
132
|
+
const s = sanitizeOutput(outDecoder(d))
|
|
133
|
+
if (s) {
|
|
134
|
+
onOutput?.(s)
|
|
135
|
+
if (outBuf.length < MAX_STREAM_BUF) outBuf += s
|
|
136
|
+
else if (!truncatedNote) truncatedNote =
|
|
137
|
+
"\n[... output exceeded 2MB, remainder discarded — redirect to a file if you need the full output]"
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
child.stderr.on("data", (d) => {
|
|
142
|
+
const s = sanitizeOutput(errDecoder(d))
|
|
143
|
+
if (errBuf.length < MAX_STREAM_BUF) errBuf += s
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const timer = setTimeout(killTree, timeout)
|
|
147
|
+
if (signal) signal.addEventListener("abort", killTree, { once: true })
|
|
148
|
+
|
|
149
|
+
child.on("error", (error) => {
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
child.on("close", (code, exitSignal) => {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
// Flush decoder tails (trailing multi-byte sequences that incomplete buffers left pending)
|
|
157
|
+
outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
158
|
+
errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
159
|
+
|
|
160
|
+
const status = exitSignal
|
|
161
|
+
? `killed: ${signal?.aborted ? "user interrupted" : "timeout"}`
|
|
162
|
+
: `exit code ${code}`
|
|
163
|
+
|
|
164
|
+
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
165
|
+
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
166
|
+
parts.push(`(${status})`)
|
|
167
|
+
resolve(truncate(parts.join("\n\n") + truncatedNote))
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ====================================================================
|
|
173
|
+
// tool definitions
|
|
174
|
+
// ====================================================================
|
|
175
|
+
|
|
33
176
|
export const bashTool = {
|
|
34
177
|
name: "bash",
|
|
35
178
|
description: DESC("bash"),
|
|
@@ -42,111 +185,13 @@ export const bashTool = {
|
|
|
42
185
|
required: ["command"],
|
|
43
186
|
},
|
|
44
187
|
readonly: false,
|
|
188
|
+
outputPanel: true, // stream stdout/stderr to panel during execution, collapse to summary on completion
|
|
45
189
|
async execute(args, ctx) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (shellSegments(args.command).some(isDestructiveCommand)) {
|
|
52
|
-
throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
|
|
53
|
-
}
|
|
54
|
-
// 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
|
|
55
|
-
if (shellSegments(args.command).some(isDestructiveGitSegment)) {
|
|
56
|
-
if (!insideGitRepo(ctx.cwd)) {
|
|
57
|
-
throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
|
|
58
|
-
}
|
|
59
|
-
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
60
|
-
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
-
}).trim()
|
|
62
|
-
if (status) {
|
|
63
|
-
throw new Error(
|
|
64
|
-
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
|
|
65
|
-
`(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return new Promise((resolve) => {
|
|
71
|
-
// detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
|
|
72
|
-
// Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
|
|
73
|
-
const killTree = () => {
|
|
74
|
-
if (process.platform === "win32") {
|
|
75
|
-
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
76
|
-
} else {
|
|
77
|
-
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
78
|
-
try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
// Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
|
|
82
|
-
// chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
|
|
83
|
-
// 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
|
|
84
|
-
// 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
|
|
85
|
-
const winCmd = process.platform === "win32"
|
|
86
|
-
const child = spawn(args.command, {
|
|
87
|
-
cwd: ctx.cwd,
|
|
88
|
-
shell: true,
|
|
89
|
-
windowsHide: true,
|
|
90
|
-
detached: process.platform !== "win32",
|
|
91
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
-
env: {
|
|
93
|
-
...Object.fromEntries(
|
|
94
|
-
Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
|
|
95
|
-
),
|
|
96
|
-
GIT_EDITOR: "true",
|
|
97
|
-
EDITOR: "true",
|
|
98
|
-
VISUAL: "true",
|
|
99
|
-
GIT_PAGER: "cat",
|
|
100
|
-
PAGER: "cat",
|
|
101
|
-
TERM: "dumb",
|
|
102
|
-
...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
103
|
-
},
|
|
104
|
-
})
|
|
105
|
-
// stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
|
|
106
|
-
// 且允许模型按 stderr 快速定位错误)
|
|
107
|
-
const outDecoder = makeDecoder()
|
|
108
|
-
const errDecoder = makeDecoder()
|
|
109
|
-
let outBuf = ""
|
|
110
|
-
let errBuf = ""
|
|
111
|
-
let truncatedNote = ""
|
|
112
|
-
|
|
113
|
-
const onStdout = (d) => {
|
|
114
|
-
const s = sanitizeOutput(outDecoder(d))
|
|
115
|
-
if (s) {
|
|
116
|
-
ctx.onOutput?.(s)
|
|
117
|
-
if (outBuf.length < 2_000_000) outBuf += s
|
|
118
|
-
else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
const onStderr = (d) => {
|
|
122
|
-
const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
|
|
123
|
-
if (errBuf.length < 2_000_000) errBuf += s
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
child.stdout.on("data", onStdout)
|
|
127
|
-
child.stderr.on("data", onStderr)
|
|
128
|
-
|
|
129
|
-
const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
|
|
130
|
-
if (ctx.signal) {
|
|
131
|
-
ctx.signal.addEventListener("abort", killTree, { once: true })
|
|
132
|
-
}
|
|
133
|
-
child.on("error", (error) => {
|
|
134
|
-
clearTimeout(timer)
|
|
135
|
-
resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
136
|
-
})
|
|
137
|
-
child.on("close", (code, signal) => {
|
|
138
|
-
clearTimeout(timer)
|
|
139
|
-
// 冲刷解码器尾部
|
|
140
|
-
outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
141
|
-
errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
142
|
-
const status = signal
|
|
143
|
-
? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
|
|
144
|
-
: `exit code ${code}`
|
|
145
|
-
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
146
|
-
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
147
|
-
parts.push(`(${status})`)
|
|
148
|
-
resolve(truncate(parts.join("\n\n") + truncatedNote))
|
|
149
|
-
})
|
|
190
|
+
checkBashSafety(args.command, ctx.cwd)
|
|
191
|
+
return runBash(args.command, ctx.cwd, {
|
|
192
|
+
timeout: args.timeout ?? BASH_TIMEOUT_MS,
|
|
193
|
+
signal: ctx.signal,
|
|
194
|
+
onOutput: ctx.onOutput,
|
|
150
195
|
})
|
|
151
196
|
},
|
|
152
197
|
}
|
|
@@ -180,7 +225,7 @@ export const globTool = {
|
|
|
180
225
|
},
|
|
181
226
|
}
|
|
182
227
|
|
|
183
|
-
/**
|
|
228
|
+
/** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
|
|
184
229
|
async function* walkFiles(dir, rel = "") {
|
|
185
230
|
let entries
|
|
186
231
|
try {
|
|
@@ -199,7 +244,7 @@ async function* walkFiles(dir, rel = "") {
|
|
|
199
244
|
}
|
|
200
245
|
}
|
|
201
246
|
|
|
202
|
-
/**
|
|
247
|
+
/** Glob to regex: **\/ matches zero or more directory levels, ** crosses directories, * within segment, ? single char within segment */
|
|
203
248
|
|
|
204
249
|
// ---------------------------------------------------------------- grep
|
|
205
250
|
|
|
@@ -226,17 +271,17 @@ export const grepTool = {
|
|
|
226
271
|
const after = Math.max(0, Math.floor(args.after ?? 0))
|
|
227
272
|
const wantCtx = before > 0 || after > 0
|
|
228
273
|
const hits = [] // { file, line(1-based), text }
|
|
229
|
-
const fileLines = new Map() // file -> string[]
|
|
274
|
+
const fileLines = new Map() // file -> string[] (cached only when context lines requested)
|
|
230
275
|
|
|
231
276
|
async function search(file) {
|
|
232
277
|
let content
|
|
233
278
|
try {
|
|
234
|
-
//
|
|
279
|
+
// Large file guard: skip files over 10MB to prevent OOM
|
|
235
280
|
const fst = await stat(file)
|
|
236
281
|
if (fst.size > 10_000_000) return
|
|
237
282
|
content = await readFile(file, "utf8")
|
|
238
283
|
} catch {
|
|
239
|
-
return //
|
|
284
|
+
return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
|
|
240
285
|
}
|
|
241
286
|
const lines = content.split("\n")
|
|
242
287
|
if (wantCtx) fileLines.set(file, lines)
|
|
@@ -250,7 +295,7 @@ export const grepTool = {
|
|
|
250
295
|
|
|
251
296
|
async function walk(target) {
|
|
252
297
|
if (hits.length >= 200) return
|
|
253
|
-
//
|
|
298
|
+
// Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
|
|
254
299
|
let s
|
|
255
300
|
try { s = await lstat(target) } catch { return }
|
|
256
301
|
if (!s.isDirectory()) {
|
|
@@ -272,12 +317,12 @@ export const grepTool = {
|
|
|
272
317
|
await walk(base)
|
|
273
318
|
if (hits.length === 0) return "(no matches)"
|
|
274
319
|
|
|
275
|
-
//
|
|
320
|
+
// No context: keep original path:line: content format
|
|
276
321
|
if (!wantCtx) {
|
|
277
322
|
return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
|
|
278
323
|
}
|
|
279
324
|
|
|
280
|
-
//
|
|
325
|
+
// With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
|
|
281
326
|
const fileMatched = new Map() // file -> Set<line>
|
|
282
327
|
for (const h of hits) {
|
|
283
328
|
if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
|
package/src/tools/web.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export const websearchTool = {
|
|
|
36
36
|
throw new Error(`websearch request failed: ${error.cause?.code ?? error.message}`)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
39
|
+
// Result block <li class="b_algo">: <h2><a href>title</a></h2> + <p>snippet</p>
|
|
40
40
|
const blocks = html.split('<li class="b_algo"').slice(1)
|
|
41
41
|
const results = []
|
|
42
42
|
for (const block of blocks) {
|
|
@@ -60,17 +60,17 @@ export const websearchTool = {
|
|
|
60
60
|
|
|
61
61
|
// ---------------------------------------------------------------- ls
|
|
62
62
|
|
|
63
|
-
/** SSRF
|
|
63
|
+
/** SSRF protection: block internal private-network/metadata endpoints (localhost allowed — user's dev server, tests depend on it) */
|
|
64
64
|
function isPrivateUrl(urlStr) {
|
|
65
65
|
let u
|
|
66
66
|
try { u = new URL(urlStr) } catch { return true }
|
|
67
67
|
const host = u.hostname.toLowerCase()
|
|
68
|
-
// localhost / 127.x
|
|
68
|
+
// localhost / 127.x allowed (user's dev server on local machine, test mock server)
|
|
69
69
|
if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) return false
|
|
70
70
|
if (host === "127.0.0.1" || host.startsWith("127.")) return false
|
|
71
|
-
//
|
|
71
|
+
// cloud metadata endpoint
|
|
72
72
|
if (host === "169.254.169.254" || host === "metadata.google.internal") return true
|
|
73
|
-
// IPv4
|
|
73
|
+
// IPv4 private ranges
|
|
74
74
|
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
75
75
|
if (m) {
|
|
76
76
|
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
@@ -80,7 +80,7 @@ function isPrivateUrl(urlStr) {
|
|
|
80
80
|
if (a === 169 && b === 254) return true
|
|
81
81
|
if (a === 0) return true
|
|
82
82
|
}
|
|
83
|
-
// IPv6
|
|
83
|
+
// IPv6 loopback / link-local / unique local addresses
|
|
84
84
|
if (host === "::1" || host === "fe80::1" || host.startsWith("fc") || host.startsWith("fd")) return true
|
|
85
85
|
return false
|
|
86
86
|
}
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -3,8 +3,8 @@ import { saveSession } from "../session.mjs"
|
|
|
3
3
|
import { sliceByWidth } from "./render.mjs"
|
|
4
4
|
import { ansi, C } from "./ansi.mjs"
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
6
|
+
/** Execute one agent conversation turn (triggered by submit or queue).
|
|
7
|
+
* Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
|
|
8
8
|
* ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
|
|
9
9
|
* ensureAssistantLabel, askPermission, askQuestion,
|
|
10
10
|
* handleSlash, summarize } */
|
|
@@ -13,12 +13,12 @@ export async function runAgentTurn(ctx, text) {
|
|
|
13
13
|
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
14
14
|
pushLine(text, C.text)
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// Auto-checkpoint before task (git repo only; failure is silent, doesn't block the task)
|
|
17
17
|
try {
|
|
18
18
|
const { createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
19
19
|
await createCheckpoint(agent.cwd)
|
|
20
20
|
} catch {
|
|
21
|
-
//
|
|
21
|
+
// Checkpoint failure doesn't block the task
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
ctx.assistantLabeled = false
|
|
@@ -30,7 +30,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
30
30
|
state.currentTool = null
|
|
31
31
|
state.processingStarted = Date.now()
|
|
32
32
|
state.controller = new AbortController()
|
|
33
|
-
//
|
|
33
|
+
// Refresh status bar every second during processing (elapsed timer)
|
|
34
34
|
const ticker = setInterval(() => {
|
|
35
35
|
if (state.processing) render()
|
|
36
36
|
}, 1000)
|
|
@@ -49,7 +49,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
49
49
|
|
|
50
50
|
const callbacks = {
|
|
51
51
|
onToken: (t) => {
|
|
52
|
-
//
|
|
52
|
+
// Subagent streaming: prefix format role#id/ → extract id, update subTask streaming text
|
|
53
53
|
const subMatch = t.match(/^(\w+)#(\d+)\//)
|
|
54
54
|
if (subMatch) {
|
|
55
55
|
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
@@ -69,7 +69,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
69
69
|
scheduleRender()
|
|
70
70
|
},
|
|
71
71
|
onReasoning: (t) => {
|
|
72
|
-
//
|
|
72
|
+
// Subagent reasoning tokens also carry role#id/ prefix, go into subTasks panel
|
|
73
73
|
const subMatch = t.match(/^(\w+)#(\d+)\//)
|
|
74
74
|
if (subMatch) {
|
|
75
75
|
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
@@ -84,7 +84,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
84
84
|
scheduleRender()
|
|
85
85
|
},
|
|
86
86
|
onToolCall: (name, args) => {
|
|
87
|
-
//
|
|
87
|
+
// Subagent tool call: prefix role#id/toolName → update subTask current tool
|
|
88
88
|
const subMatch = name.match(/^(\w+)#(\d+)\//)
|
|
89
89
|
if (subMatch) {
|
|
90
90
|
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
@@ -105,7 +105,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
105
105
|
},
|
|
106
106
|
onToolResult: (name, result) => {
|
|
107
107
|
state.currentTool = null
|
|
108
|
-
//
|
|
108
|
+
// Subagent complete: mark earliest running subTask as done
|
|
109
109
|
const isSubagent = name === "subagent"
|
|
110
110
|
if (isSubagent) {
|
|
111
111
|
const running = Object.entries(state.subTasks)
|
|
@@ -116,12 +116,12 @@ export async function runAgentTurn(ctx, text) {
|
|
|
116
116
|
state.subTasks[key].done = true
|
|
117
117
|
state.subTasks[key].tool = null
|
|
118
118
|
}
|
|
119
|
-
//
|
|
119
|
+
// Subagent report preview (max 8 lines) displayed directly in conversation
|
|
120
120
|
const lines = result.split("\n")
|
|
121
121
|
const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
|
|
122
122
|
if (preview) pushLine(preview, C.dim)
|
|
123
123
|
if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
|
|
124
|
-
//
|
|
124
|
+
// Clear done entries from panel after 3 seconds
|
|
125
125
|
setTimeout(() => {
|
|
126
126
|
for (const key of Object.keys(state.subTasks)) {
|
|
127
127
|
if (state.subTasks[key].done) delete state.subTasks[key]
|
|
@@ -130,22 +130,47 @@ export async function runAgentTurn(ctx, text) {
|
|
|
130
130
|
}, 3000)
|
|
131
131
|
}
|
|
132
132
|
const stream = state.toolStreams[name]
|
|
133
|
-
|
|
133
|
+
const panel = state.outputPanels[name]
|
|
134
|
+
if (panel) {
|
|
135
|
+
// Tool with output panel: mark panel as done, conversation gets only summary
|
|
136
|
+
panel.done = true
|
|
137
|
+
delete state.toolStreams[name]
|
|
138
|
+
const summary = formatPanelSummary(name, result)
|
|
139
|
+
if (summary) pushLine(` ${summary}`, C.dim)
|
|
140
|
+
// Clear panel after 3 seconds
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
delete state.outputPanels[name]
|
|
143
|
+
if (state.processing) render()
|
|
144
|
+
}, 3000)
|
|
145
|
+
} else if (stream) {
|
|
134
146
|
const tail = stream.trimEnd().slice(-4000)
|
|
135
147
|
if (tail) pushLine(tail, C.dim)
|
|
136
148
|
delete state.toolStreams[name]
|
|
137
149
|
}
|
|
138
|
-
if (!isSubagent) {
|
|
150
|
+
if (!isSubagent && !panel) {
|
|
139
151
|
const first = result.split("\n")[0]
|
|
140
152
|
pushLine(` [done] ${name} → ${sliceByWidth(first, 100)}`, C.dim)
|
|
141
153
|
}
|
|
142
154
|
},
|
|
143
155
|
onToolOutput: (name, chunk) => {
|
|
144
|
-
|
|
156
|
+
// Route streaming output to a panel if one exists or was requested via outputPanel flag.
|
|
157
|
+
let panel = state.outputPanels[name]
|
|
158
|
+
if (!panel) {
|
|
159
|
+
// Lazy-create panel: defensive against race conditions where setupOutputPanel
|
|
160
|
+
// hasn't fired yet or the callbacks chain dropped it (subagent relay, reconnect, etc.)
|
|
161
|
+
state.outputPanels[name] = { text: "", done: false }
|
|
162
|
+
panel = state.outputPanels[name]
|
|
163
|
+
}
|
|
164
|
+
panel.text = (panel.text ?? "") + chunk
|
|
165
|
+
if (panel.text.length > 4000) panel.text = panel.text.slice(-4000)
|
|
145
166
|
scheduleRender()
|
|
146
167
|
},
|
|
147
168
|
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
148
169
|
onQuestion: (text, options) => askQuestion(text, options),
|
|
170
|
+
setupOutputPanel: (name) => {
|
|
171
|
+
state.outputPanels[name] = { text: "", done: false }
|
|
172
|
+
scheduleRender()
|
|
173
|
+
},
|
|
149
174
|
onCompress: () => {
|
|
150
175
|
pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
|
|
151
176
|
},
|
|
@@ -155,7 +180,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
155
180
|
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
156
181
|
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
157
182
|
},
|
|
158
|
-
//
|
|
183
|
+
// Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
|
|
159
184
|
onWait: ({ phase, seconds }) => {
|
|
160
185
|
state.status = phase === "gate" ? `TPM throttle wait ~${seconds}s` : `Rate-limited 429, retry in ${seconds}s`
|
|
161
186
|
render()
|
|
@@ -163,12 +188,12 @@ export async function runAgentTurn(ctx, text) {
|
|
|
163
188
|
onTaskUpdate: (items) => {
|
|
164
189
|
state.tasks = items
|
|
165
190
|
const done = items.filter((i) => i.status === "done").length
|
|
166
|
-
//
|
|
191
|
+
// Leave trace with current task title: reviewing history shows what was in progress
|
|
167
192
|
const current = items.find((i) => i.status === "in_progress")
|
|
168
193
|
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
169
194
|
render()
|
|
170
195
|
},
|
|
171
|
-
//
|
|
196
|
+
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
172
197
|
onTurnEnd: (() => {
|
|
173
198
|
let n = 0
|
|
174
199
|
return () => {
|
|
@@ -182,7 +207,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
182
207
|
try {
|
|
183
208
|
await runAgent(agent, text, callbacks, { signal: state.controller.signal, resume })
|
|
184
209
|
flushStream()
|
|
185
|
-
break //
|
|
210
|
+
break // Normal completion, exit loop
|
|
186
211
|
} catch (error) {
|
|
187
212
|
flushStream()
|
|
188
213
|
if (error.name === "AbortError" || state.controller?.signal.aborted) {
|
|
@@ -192,7 +217,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
192
217
|
if (error instanceof ContinueError) {
|
|
193
218
|
pushLabel(`❯ Continue`, ansi.bold + C.warn)
|
|
194
219
|
pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
|
|
195
|
-
//
|
|
220
|
+
// Pause to ask: reuse permission mechanism
|
|
196
221
|
const willContinue = await new Promise((resolve) => {
|
|
197
222
|
state.permission = {
|
|
198
223
|
name: "continue",
|
|
@@ -208,7 +233,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
208
233
|
break
|
|
209
234
|
}
|
|
210
235
|
pushLine("[continuing…]", C.tool)
|
|
211
|
-
//
|
|
236
|
+
// Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
|
|
212
237
|
state.controller = new AbortController()
|
|
213
238
|
continue
|
|
214
239
|
}
|
|
@@ -222,26 +247,26 @@ export async function runAgentTurn(ctx, text) {
|
|
|
222
247
|
state.subTasks = {}
|
|
223
248
|
state.controller = null
|
|
224
249
|
state.status = "Ready"
|
|
225
|
-
//
|
|
250
|
+
// Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
|
|
226
251
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
227
252
|
state.tasks = []
|
|
228
253
|
}
|
|
229
|
-
//
|
|
254
|
+
// Save session after every turn (survives crashes)
|
|
230
255
|
try {
|
|
231
256
|
saveSession(agent, state.lines)
|
|
232
257
|
} catch {
|
|
233
|
-
//
|
|
258
|
+
// Save failure doesn't interrupt usage
|
|
234
259
|
}
|
|
235
260
|
render()
|
|
236
261
|
|
|
237
|
-
//
|
|
262
|
+
// Queued messages: auto-process next one
|
|
238
263
|
if (state.queue.length > 0) {
|
|
239
264
|
const next = state.queue.shift()
|
|
240
|
-
//
|
|
265
|
+
// Queued slash commands execute directly
|
|
241
266
|
if (next.text.startsWith("/")) {
|
|
242
267
|
await handleSlash(next.text)
|
|
243
268
|
render()
|
|
244
|
-
//
|
|
269
|
+
// After slash command completes, continue checking queue
|
|
245
270
|
if (state.queue.length > 0 && !state.processing) {
|
|
246
271
|
const next2 = state.queue.shift()
|
|
247
272
|
await runAgentTurn(ctx, next2.text)
|
|
@@ -252,3 +277,34 @@ export async function runAgentTurn(ctx, text) {
|
|
|
252
277
|
}
|
|
253
278
|
}
|
|
254
279
|
}
|
|
280
|
+
|
|
281
|
+
/** Extract a one-line summary from a panel tool's output */
|
|
282
|
+
function formatPanelSummary(name, result) {
|
|
283
|
+
if (name === "verify") return _verifySummary(result)
|
|
284
|
+
// Default: first non-empty line
|
|
285
|
+
const first = result.split("\n").find((l) => l.trim())
|
|
286
|
+
return first ? `${name}: ${first.slice(0, 100)}` : null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function _verifySummary(result) {
|
|
290
|
+
const lines = result.split("\n")
|
|
291
|
+
const summary = []
|
|
292
|
+
// Changed files count
|
|
293
|
+
const changed = lines.find((l) => l.startsWith("Changed files:"))
|
|
294
|
+
if (changed) {
|
|
295
|
+
const m = changed.match(/files changed/) ? changed.replace(/^Changed files \(.*?\)/, "Changed files") : changed
|
|
296
|
+
summary.push(m)
|
|
297
|
+
}
|
|
298
|
+
// Syntax check results
|
|
299
|
+
const syntax = lines.filter((l) => l.startsWith(" ✗"))
|
|
300
|
+
if (syntax.length > 0) {
|
|
301
|
+
summary.push(`${syntax.length} syntax error(s)`)
|
|
302
|
+
}
|
|
303
|
+
// Test results
|
|
304
|
+
const testLine = lines.find((l) => l.startsWith("✓ Tests passed.") || l.startsWith("✗ Tests FAILED"))
|
|
305
|
+
if (testLine) summary.push(testLine.trim())
|
|
306
|
+
// Task list
|
|
307
|
+
const taskLine = lines.find((l) => l.startsWith("Task list:"))
|
|
308
|
+
if (taskLine) summary.push(taskLine)
|
|
309
|
+
return summary.length > 0 ? `verify: ${summary.join(" — ")}` : ""
|
|
310
|
+
}
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tui-ansi.mjs — ANSI
|
|
3
|
-
*
|
|
2
|
+
* tui-ansi.mjs — ANSI escape sequences and terminal color constants.
|
|
3
|
+
* Zero dependencies, pure constant exports.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const ESC = "\x1b"
|
|
@@ -11,6 +11,8 @@ export const ansi = {
|
|
|
11
11
|
mainBuffer: `${ESC}[?1049l`,
|
|
12
12
|
mouseOn: `${ESC}[?1000h${ESC}[?1006h`,
|
|
13
13
|
mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
|
|
14
|
+
bracketedPasteOn: `${ESC}[?2004h`,
|
|
15
|
+
bracketedPasteOff: `${ESC}[?2004l`,
|
|
14
16
|
home: `${ESC}[H`,
|
|
15
17
|
clearLine: `${ESC}[K`,
|
|
16
18
|
reset: `${ESC}[0m`,
|
|
@@ -24,7 +26,7 @@ export const C = {
|
|
|
24
26
|
user: ansi.fg(4),
|
|
25
27
|
assistant: ansi.fg(2),
|
|
26
28
|
text: ansi.fg(7),
|
|
27
|
-
reason: `${ESC}[2m
|
|
29
|
+
reason: `${ESC}[2m`,
|
|
28
30
|
tool: ansi.fg(6),
|
|
29
31
|
error: ansi.fg(1),
|
|
30
32
|
dim: ansi.gray,
|