thincoder 0.12.37 → 0.12.38
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/agent-tools/consult.mjs +48 -9
- package/src/agent-tools/verify.mjs +13 -8
- package/src/config.mjs +16 -4
- package/src/tools/bash.md +1 -0
- package/src/tools/codemode.mjs +125 -134
- package/src/tools/exec-prelude.mjs +84 -0
- package/src/tools/execute.md +14 -2
- package/src/tools/file_ops.md +16 -0
- package/src/tools/get_current_time.md +6 -0
- package/src/tools/git.md +10 -4
- package/src/tools/git.mjs +60 -8
- package/src/tools/grep.md +3 -1
- package/src/tools/index.mjs +7 -1
- package/src/tools/ls.md +1 -0
- package/src/tools/ops.mjs +142 -0
- package/src/tools/process.md +10 -0
- package/src/tools/sleep.md +5 -0
- package/src/tools/system.mjs +28 -6
- package/src/tools/tree.md +13 -0
- package/src/tools/tree.mjs +66 -0
package/package.json
CHANGED
|
@@ -19,6 +19,36 @@ function consultLabel(m) {
|
|
|
19
19
|
return `${m.provider}:${m.model}`
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Narrow the configured consultModels pool to a requested subset.
|
|
23
|
+
* Each selector is "provider:model", a bare provider name, or a bare model name
|
|
24
|
+
* (case-insensitive). Returns { models, error } — error set when a selector matches
|
|
25
|
+
* nothing (surface the typo rather than silently dropping it). Absent/empty selectors
|
|
26
|
+
* → the full pool. */
|
|
27
|
+
function selectConsultModels(pool, selectors) {
|
|
28
|
+
if (selectors == null || (Array.isArray(selectors) && selectors.length === 0)) return { models: pool, error: null }
|
|
29
|
+
const list = Array.isArray(selectors) ? selectors : [selectors] // coerce a bare string → [string]
|
|
30
|
+
const selected = []
|
|
31
|
+
const seen = new Set()
|
|
32
|
+
const unknowns = []
|
|
33
|
+
for (const raw of list) {
|
|
34
|
+
const s = String(raw).trim().toLowerCase()
|
|
35
|
+
const matches = pool.filter((m) =>
|
|
36
|
+
consultLabel(m).toLowerCase() === s ||
|
|
37
|
+
String(m.provider ?? "").toLowerCase() === s ||
|
|
38
|
+
String(m.model ?? "").toLowerCase() === s,
|
|
39
|
+
)
|
|
40
|
+
if (matches.length === 0) unknowns.push(String(raw))
|
|
41
|
+
else for (const m of matches) {
|
|
42
|
+
const key = consultLabel(m)
|
|
43
|
+
if (!seen.has(key)) { seen.add(key); selected.push(m) }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (unknowns.length > 0) {
|
|
47
|
+
return { models: null, error: `unknown consult model selector(s): ${unknowns.join(", ")} — choose from: ${pool.map(consultLabel).join(", ")}` }
|
|
48
|
+
}
|
|
49
|
+
return { models: selected, error: null }
|
|
50
|
+
}
|
|
51
|
+
|
|
22
52
|
/** Read-only tool injected into consultation children (via createAgent's tools).
|
|
23
53
|
* Lets the consultant pull the main agent's conversation history on demand —
|
|
24
54
|
* the failure trail is first-class evidence, not a retelling. */
|
|
@@ -225,31 +255,40 @@ export const consultStartTool = {
|
|
|
225
255
|
"arrives, judge/verify it yourself with your own tools, and call consult_stop(id) once a reply is good enough.\n" +
|
|
226
256
|
"Parameters:\n" +
|
|
227
257
|
"- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
|
|
228
|
-
"Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool
|
|
258
|
+
"Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
|
|
259
|
+
"- models (optional): subset of agent.consultModels to run — an array of \"provider:model\", bare provider, or bare model names (case-insensitive). Omit to run all.",
|
|
229
260
|
parameters: {
|
|
230
261
|
type: "object",
|
|
231
|
-
properties: {
|
|
262
|
+
properties: {
|
|
263
|
+
problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" },
|
|
264
|
+
models: { type: "array", items: { type: "string" }, description: 'Optional subset of agent.consultModels to run (default: all). Each entry is "provider:model", a bare provider name, or a bare model name (case-insensitive).' },
|
|
265
|
+
},
|
|
232
266
|
required: ["problem"],
|
|
233
267
|
},
|
|
234
|
-
async execute({ problem }, ctx) {
|
|
268
|
+
async execute({ problem, models }, ctx) {
|
|
235
269
|
if (typeof problem !== "string" || !problem.trim()) return "Error: problem is required and must be a non-empty string"
|
|
236
270
|
const agent = ctx.agent
|
|
237
271
|
if (!agent) return "Error: consult requires an agent context"
|
|
238
|
-
const
|
|
239
|
-
if (!Array.isArray(
|
|
272
|
+
const pool = agent.config?.agent?.consultModels ?? []
|
|
273
|
+
if (!Array.isArray(pool) || pool.length === 0)
|
|
240
274
|
return "Consultation is not configured — add agent.consultModels ([{ provider, model }], up to 5) to ~/.thincoder/config.json"
|
|
241
|
-
if (
|
|
275
|
+
if (pool.length > 5) return `Error: consultModels supports at most 5 models (got ${pool.length})`
|
|
276
|
+
|
|
277
|
+
// `models` (optional) narrows the pool to a subset; absent/empty → run the whole pool.
|
|
278
|
+
const picked = selectConsultModels(pool, models)
|
|
279
|
+
if (picked.error) return picked.error
|
|
280
|
+
const run = picked.models
|
|
242
281
|
|
|
243
282
|
agent._consultSessions ??= new Map()
|
|
244
283
|
const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
|
|
245
284
|
const session = {
|
|
246
285
|
id, controllers: [], replies: [], pending: 0, waiters: [],
|
|
247
|
-
failed: 0, terminated: 0, stopped: false, received: 0, total:
|
|
248
|
-
models:
|
|
286
|
+
failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
|
|
287
|
+
models: run.map(consultLabel),
|
|
249
288
|
}
|
|
250
289
|
agent._consultSessions.set(id, session)
|
|
251
290
|
|
|
252
|
-
for (const m of
|
|
291
|
+
for (const m of run) {
|
|
253
292
|
session.pending++
|
|
254
293
|
const ctrl = new AbortController()
|
|
255
294
|
session.controllers.push(ctrl)
|
|
@@ -2,7 +2,7 @@ import { repairHistory, listWorkDir } from "../agent.mjs"
|
|
|
2
2
|
import { isDocFile } from "../advisor/repos.mjs"
|
|
3
3
|
import { execSync, spawn, spawnSync } from "node:child_process"
|
|
4
4
|
import { readFileSync, existsSync } from "node:fs"
|
|
5
|
-
import { join } from "node:path"
|
|
5
|
+
import { join, resolve } from "node:path"
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Source module → test file mapping. Heuristic: the FIRST path component
|
|
@@ -58,12 +58,17 @@ export const verifyTool = {
|
|
|
58
58
|
type: "object",
|
|
59
59
|
properties: {
|
|
60
60
|
full: { type: "boolean", description: "Run the full test suite (npm test) instead of just related tests. Default false — use sparingly, per the testing discipline rules." },
|
|
61
|
+
workdir: { type: "string", description: "Optional: run verify in this subdirectory (relative to cwd or absolute) — for monorepos" },
|
|
62
|
+
filter: { type: "string", description: "Optional: limit the test run to matching test names (node --test-name-pattern / npm test -- --test-name-pattern)" },
|
|
61
63
|
},
|
|
62
64
|
},
|
|
63
65
|
readonly: true,
|
|
64
66
|
outputPanel: true, // stream test output to a panel instead of inline
|
|
65
67
|
async execute(args, ctx) {
|
|
66
68
|
const cwd = ctx.agent.cwd
|
|
69
|
+
// workdir only relocates WHERE tests (and package.json) live — changed-file
|
|
70
|
+
// resolution (git diff) stays anchored to the project root.
|
|
71
|
+
const testCwd = args.workdir ? resolve(cwd, args.workdir) : cwd
|
|
67
72
|
const lines = []
|
|
68
73
|
lines.push("=== VERIFICATION REPORT ===")
|
|
69
74
|
lines.push("")
|
|
@@ -130,7 +135,7 @@ export const verifyTool = {
|
|
|
130
135
|
const relatedTests = [...new Set(modules.map((m) => MODULE_TO_TEST[m]).filter(Boolean))]
|
|
131
136
|
|
|
132
137
|
// 4. Run tests
|
|
133
|
-
const pkgPath = join(
|
|
138
|
+
const pkgPath = join(testCwd, "package.json")
|
|
134
139
|
const hasTestScript = existsSync(pkgPath) && (() => { try { return !!JSON.parse(readFileSync(pkgPath, "utf8")).scripts?.test } catch { return false } })()
|
|
135
140
|
|
|
136
141
|
if (args.full) {
|
|
@@ -138,7 +143,7 @@ export const verifyTool = {
|
|
|
138
143
|
if (hasTestScript) {
|
|
139
144
|
lines.push("")
|
|
140
145
|
lines.push("Tests (full suite):")
|
|
141
|
-
const result = await runTestSuite(
|
|
146
|
+
const result = await runTestSuite(testCwd, ctx, args.filter)
|
|
142
147
|
if (result.passed) {
|
|
143
148
|
lines.push("✓ All tests passed.")
|
|
144
149
|
ctx.agent._verifyPassed = !syntaxFailed
|
|
@@ -163,7 +168,7 @@ export const verifyTool = {
|
|
|
163
168
|
continue
|
|
164
169
|
}
|
|
165
170
|
try {
|
|
166
|
-
const result = await runTestFile(cwd, testFile, ctx)
|
|
171
|
+
const result = await runTestFile(cwd, testFile, ctx, args.filter)
|
|
167
172
|
if (result.passed) {
|
|
168
173
|
lines.push(` ✓ ${testFile}`)
|
|
169
174
|
} else {
|
|
@@ -248,9 +253,9 @@ export const verifyTool = {
|
|
|
248
253
|
* Run a single test file with node --test, no maxBuffer limit.
|
|
249
254
|
* Returns { passed: boolean, tail: string } — the last few lines of output.
|
|
250
255
|
*/
|
|
251
|
-
function runTestFile(cwd, testPath, ctx) {
|
|
256
|
+
function runTestFile(cwd, testPath, ctx, filter) {
|
|
252
257
|
return new Promise((resolve, reject) => {
|
|
253
|
-
const child = spawn("node", ["--test", testPath], {
|
|
258
|
+
const child = spawn("node", filter ? ["--test", "--test-name-pattern", filter, testPath] : ["--test", testPath], {
|
|
254
259
|
cwd, stdio: ["ignore", "pipe", "pipe"],
|
|
255
260
|
env: { ...process.env, FORCE_COLOR: "0" },
|
|
256
261
|
})
|
|
@@ -288,9 +293,9 @@ function runTestFile(cwd, testPath, ctx) {
|
|
|
288
293
|
* Test output is streamed through ctx.callbacks.onToolOutput (TUI can display progress in real time).
|
|
289
294
|
* Returns { passed: boolean, tail: string }.
|
|
290
295
|
*/
|
|
291
|
-
function runTestSuite(cwd, ctx) {
|
|
296
|
+
function runTestSuite(cwd, ctx, filter) {
|
|
292
297
|
return new Promise((resolve, reject) => {
|
|
293
|
-
const child = spawn("npm", ["test"], {
|
|
298
|
+
const child = spawn("npm", filter ? ["test", "--", `--test-name-pattern=${filter}`] : ["test"], {
|
|
294
299
|
cwd, shell: true, stdio: ["ignore", "pipe", "pipe"],
|
|
295
300
|
env: { ...process.env, FORCE_COLOR: "0" },
|
|
296
301
|
})
|
package/src/config.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export const DEFAULTS = {
|
|
|
87
87
|
* multimodal: whether multimodal (image/vision input supported)
|
|
88
88
|
* cacheMode: context caching mode: "auto"=automatic / "prompt"=needs explicit / "none"=unsupported
|
|
89
89
|
* thinkApi: thinking API type: "type"=thinking.type field / "effort"=reasoning_effort field
|
|
90
|
-
*
|
|
90
|
+
* thinkEnabledValue: when thinkApi is "type", the value used to enable thinking (default "enabled"; MiniMax uses "adaptive")
|
|
91
91
|
* reasoningEcho: reasoning_content cross-turn echo strategy: "required"=must echo (error if missing) / "optional"=echo optional (default: don't echo)
|
|
92
92
|
* reasoningEffortEnum: valid reasoning_effort enum values (if undeclared, no validation — passed through as-is)
|
|
93
93
|
* tempRange: valid temperature range [min, max] (if undeclared, no clamping)
|
|
@@ -96,6 +96,8 @@ const MODEL_SPECS = [
|
|
|
96
96
|
// DeepSeek V4 series
|
|
97
97
|
["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
|
|
98
98
|
["deepseek-v4-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
|
|
99
|
+
// DeepSeek V4 Flash Vision (experimental) — image input on top of the full V4-Flash stack
|
|
100
|
+
["deepseek-v4-flash-vision-exp", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2], multimodal: true }],
|
|
99
101
|
// Kimi series
|
|
100
102
|
["kimi-k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
|
|
101
103
|
// Qwen router prefixes model IDs with provider namespace: kimi/kimi-k3 → kimi-k3 (IK7K4V)
|
|
@@ -103,17 +105,22 @@ const MODEL_SPECS = [
|
|
|
103
105
|
// Kimi For Coding endpoint uses the short model ID "k3" (same specs as kimi-k3) — IK5VGJ
|
|
104
106
|
["k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
|
|
105
107
|
// GLM series
|
|
108
|
+
// GLM-5.3: thinking always-on (no "disabled"); effort converges to low/high/max — NOT the
|
|
109
|
+
// 7-level glm-5.2 enum (verified vs docs.bigmodel.cn GLM-5.3 page, 2026-08)
|
|
110
|
+
["glm-5.3", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 1], noUsageStream: true }],
|
|
106
111
|
["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1], noUsageStream: true }],
|
|
107
112
|
["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1], noUsageStream: true }],
|
|
108
113
|
["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", tempRange: [0, 1], noUsageStream: true }],
|
|
109
114
|
// GPT series
|
|
115
|
+
["gpt-5.6-sol", { context: 1_050_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
|
|
116
|
+
["gpt-5.6", { context: 1_050_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
|
|
110
117
|
["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
|
|
111
118
|
["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
|
|
112
119
|
// Qwen series
|
|
113
|
-
["qwen3.8-max-preview", { context: 1_000_000, maxOutput:
|
|
120
|
+
["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
|
|
114
121
|
// qwen3.7-max rejects image parts outright (DashScope 400 "Unexpected item type in content") — text-only
|
|
115
|
-
["qwen3.7-max", { context: 1_000_000, maxOutput:
|
|
116
|
-
["qwen3.8-max", { context: 1_000_000, maxOutput:
|
|
122
|
+
["qwen3.7-max", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
|
|
123
|
+
["qwen3.8-max", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
|
|
117
124
|
["qwen-max", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
118
125
|
["qwen-plus", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
119
126
|
["qwen", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
@@ -127,6 +134,8 @@ const MODEL_SPECS = [
|
|
|
127
134
|
["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkEnabledValue: "adaptive", tempRange: [0, 2], noUsageStream: true }],
|
|
128
135
|
["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto", noUsageStream: true }],
|
|
129
136
|
// Grok series (xAI — OpenAI-compatible)
|
|
137
|
+
// grok-4.x: 500K context per xAI Grok 4.6 spec (corrected 2026-08; earlier entries said 1M)
|
|
138
|
+
["grok-4.6", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
|
|
130
139
|
["grok-4.5", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
|
|
131
140
|
["grok-4", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
|
|
132
141
|
["grok-4-mini", { context: 128_000, maxOutput: 16_000, thinking: false, tempRange: [0, 2] }],
|
|
@@ -134,10 +143,13 @@ const MODEL_SPECS = [
|
|
|
134
143
|
["mistral-large", { context: 128_000, maxOutput: 32_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
|
|
135
144
|
["codestral", { context: 256_000, maxOutput: 32_000, thinking: false, tempRange: [0, 2] }],
|
|
136
145
|
// Claude series (Anthropic)
|
|
146
|
+
["claude-opus-5", { context: 1_000_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
|
|
147
|
+
["claude-sonnet-5", { context: 1_000_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
|
|
137
148
|
["claude-opus-4", { context: 200_000, maxOutput: 32_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
|
|
138
149
|
["claude-sonnet-4", { context: 200_000, maxOutput: 32_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
|
|
139
150
|
["claude-3.5-haiku", { context: 200_000, maxOutput: 8_192, thinking: false, cacheMode: "none", format: "anthropic" }],
|
|
140
151
|
// Gemini series (Google)
|
|
152
|
+
["gemini-3-pro", { context: 1_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
|
|
141
153
|
["gemini-2.5-pro", { context: 2_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
|
|
142
154
|
["gemini-2.5-flash", { context: 1_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
|
|
143
155
|
]
|
package/src/tools/bash.md
CHANGED
|
@@ -11,6 +11,7 @@ Execute a shell command and return stdout+stderr. Use for running commands, buil
|
|
|
11
11
|
Parameters:
|
|
12
12
|
- command (required): Shell command to execute
|
|
13
13
|
- timeout: Timeout in milliseconds (default 120000, max ~300000)
|
|
14
|
+
- filter: Optional — a regex; only output lines matching it are returned (case-insensitive). Use instead of hand-writing a pipe into `findstr`/`grep`.
|
|
14
15
|
|
|
15
16
|
Output format:
|
|
16
17
|
```
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -1,65 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools/codemode.mjs — CodeMode: JavaScript execution tool
|
|
3
3
|
*
|
|
4
|
-
* Gives the model an `execute` tool
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Gives the model an `execute` tool that runs JS in a child `node
|
|
5
|
+
* --input-type=module --eval` process — NOT the in-process vm sandbox it used
|
|
6
|
+
* to be. The vm route could not support dynamic `import()` (needs the
|
|
7
|
+
* --experimental-vm-modules flag) or await it, which pushed every real JS run
|
|
8
|
+
* back to `bash node -e`. A child node process gives top-level await, dynamic
|
|
9
|
+
* `import()` of the project's own .mjs modules, native `console`/`fetch`, AND a
|
|
10
|
+
* killable timeout (an in-process infinite loop would freeze the CLI; a child
|
|
11
|
+
* process is killed like bash).
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* glob(pattern) — return array of matching paths
|
|
12
|
-
* grep(pattern, file) — return array of matching lines
|
|
13
|
-
* log(...args) — append to output buffer
|
|
14
|
-
* fetch(url) — HTTP GET, return string
|
|
13
|
+
* The child `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
|
|
14
|
+
* log/require (paths confined to the workspace root). Full Node via require()/
|
|
15
|
+
* process/import() is available — same boundary as bash, no fake sandbox.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Limits (engineering guards, not security):
|
|
22
|
-
* timeout: 30s (configurable via timeoutMs param)
|
|
23
|
-
* maxOutput: 50000 bytes
|
|
24
|
-
* maxScriptSize: 50000 bytes
|
|
25
|
-
* file paths confined to cwd (accidental out-of-workspace writes)
|
|
17
|
+
* Parameters:
|
|
18
|
+
* code — JS to run (top-level await and import() supported)
|
|
19
|
+
* workdir — run in this sub-directory (confined to the workspace)
|
|
20
|
+
* filter — return only output lines matching this regex (case-insensitive)
|
|
21
|
+
* timeoutMs — timeout (default 30s, max 60s)
|
|
26
22
|
*/
|
|
23
|
+
import { spawn } from "node:child_process"
|
|
24
|
+
import { dirname, resolve, relative, isAbsolute, sep } from "node:path"
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
26
|
+
import { DESC } from "./shared.mjs"
|
|
27
27
|
|
|
28
|
-
import { Script, createContext } from "node:vm"
|
|
29
|
-
import { createRequire } from "node:module"
|
|
30
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
31
|
-
import { join, dirname, relative, resolve } from "node:path"
|
|
32
|
-
import { DESC, globToRegex, normalizeEOL } from "./shared.mjs"
|
|
33
|
-
|
|
34
|
-
const MAX_OUTPUT = 50_000
|
|
35
28
|
const MAX_SCRIPT = 50_000
|
|
29
|
+
const MAX_OUTPUT = 50_000
|
|
36
30
|
const DEFAULT_TIMEOUT = 30_000
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
|
|
34
|
+
|
|
35
|
+
/** True when `abs` is inside `root` (handles `..` and cross-drive, which
|
|
36
|
+
* relative() returns as an absolute path on Windows). */
|
|
37
|
+
function isInside(root, abs) {
|
|
38
|
+
const rel = relative(root, abs)
|
|
39
|
+
if (isAbsolute(rel)) return false
|
|
40
|
+
return rel !== ".." && !rel.startsWith(".." + sep)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve workdir relative to cwd, asserting it stays within the workspace. */
|
|
44
|
+
function resolveBaseDir(cwd, workdir) {
|
|
45
|
+
if (!workdir || typeof workdir !== "string") return cwd
|
|
46
|
+
const abs = resolve(cwd, workdir)
|
|
47
|
+
if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
|
|
48
|
+
return abs
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
|
51
|
+
/** Keep only output lines matching a regex (execute filter, case-insensitive). */
|
|
52
|
+
function applyFilter(output, filter) {
|
|
54
53
|
try {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
return
|
|
58
|
-
}
|
|
59
|
-
|
|
54
|
+
const re = new RegExp(filter, "i")
|
|
55
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
56
|
+
return lines.length ? lines.join("\n") : `(no output lines matched filter "${filter}")`
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return `Error: filter regex invalid: ${e.message}`
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
/** Spawn node, run code + prelude, capture stdout/stderr, enforce timeout/abort.
|
|
63
|
+
* Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
|
|
64
|
+
function runNodeEval(code, baseDir, root, timeoutMs, signal) {
|
|
65
|
+
return new Promise((resolvePromise) => {
|
|
66
|
+
const src = `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`
|
|
67
|
+
const child = spawn(process.execPath, ["--input-type=module", "--eval", src], {
|
|
68
|
+
cwd: baseDir,
|
|
69
|
+
env: { ...process.env, THINCODER_EXEC_ROOT: root },
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
let outBuf = "", errBuf = "", truncated = false, settled = false, mode = null
|
|
75
|
+
let timer = null, kickTimer = null
|
|
76
|
+
|
|
77
|
+
const settle = (text, ok) => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
settled = true
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
clearTimeout(kickTimer)
|
|
82
|
+
if (signal) signal.removeEventListener("abort", onAbort)
|
|
83
|
+
resolvePromise({ text, ok })
|
|
84
|
+
}
|
|
85
|
+
// SIGKILL (not SIGTERM) so a signal-trapping script can't dodge the watchdog.
|
|
86
|
+
const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
|
|
87
|
+
// After kill, wait for "close" (child fully reaped) before settling — settling
|
|
88
|
+
// early races the caller deleting the cwd dir while the child still holds it.
|
|
89
|
+
const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : `Error: script timed out after ${timeoutMs}ms`, false), 3000) }
|
|
90
|
+
const onAbort = () => { if (mode) return; mode = "abort"; kill(); armKick() }
|
|
91
|
+
|
|
92
|
+
timer = setTimeout(() => { if (!mode) { mode = "timeout"; kill(); armKick() } }, timeoutMs)
|
|
93
|
+
|
|
94
|
+
if (signal) {
|
|
95
|
+
if (signal.aborted) onAbort()
|
|
96
|
+
else signal.addEventListener("abort", onAbort, { once: true })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cap = (buf, d) => {
|
|
100
|
+
if (buf.length < MAX_OUTPUT) return buf + d
|
|
101
|
+
if (!truncated) { truncated = true; return buf + "\n...[output truncated]" }
|
|
102
|
+
return buf
|
|
103
|
+
}
|
|
104
|
+
child.stdout.on("data", (d) => { outBuf = cap(outBuf, d.toString()) })
|
|
105
|
+
child.stderr.on("data", (d) => { errBuf = cap(errBuf, d.toString()) })
|
|
106
|
+
child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
|
|
107
|
+
child.on("close", (code) => {
|
|
108
|
+
if (mode === "abort") return settle("(stopped)", false)
|
|
109
|
+
if (mode === "timeout") return settle(`Error: script timed out after ${timeoutMs}ms`, false)
|
|
110
|
+
const out = outBuf.trimEnd()
|
|
111
|
+
const err = errBuf.trim()
|
|
112
|
+
if (code === 0) {
|
|
113
|
+
settle(out || "(no output)", true)
|
|
114
|
+
} else {
|
|
115
|
+
settle(err ? (out ? `${out}\n\n[stderr]:\n${err}` : err) : `${out}\n(exit code ${code})`.trim(), false)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
63
121
|
export const codeModeTool = {
|
|
64
122
|
name: "execute",
|
|
65
123
|
description: DESC("execute"),
|
|
@@ -68,10 +126,20 @@ export const codeModeTool = {
|
|
|
68
126
|
properties: {
|
|
69
127
|
code: {
|
|
70
128
|
type: "string",
|
|
71
|
-
description: "JavaScript code to execute. Use provided
|
|
129
|
+
description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import.",
|
|
130
|
+
},
|
|
131
|
+
workdir: {
|
|
132
|
+
type: "string",
|
|
133
|
+
description: "Run in this directory (relative to cwd, confined to the workspace; default cwd)",
|
|
134
|
+
},
|
|
135
|
+
filter: {
|
|
136
|
+
type: "string",
|
|
137
|
+
description: "Optional: only return output lines matching this regex (case-insensitive)",
|
|
72
138
|
},
|
|
73
139
|
timeoutMs: {
|
|
74
140
|
type: "integer",
|
|
141
|
+
minimum: 1,
|
|
142
|
+
maximum: 60000,
|
|
75
143
|
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
|
|
76
144
|
},
|
|
77
145
|
},
|
|
@@ -80,97 +148,20 @@ export const codeModeTool = {
|
|
|
80
148
|
readonly: false,
|
|
81
149
|
|
|
82
150
|
async execute(args, ctx) {
|
|
83
|
-
const cwd = ctx.cwd
|
|
84
151
|
const code = args.code ?? ""
|
|
85
|
-
|
|
86
152
|
if (code.length > MAX_SCRIPT) {
|
|
87
153
|
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
88
154
|
}
|
|
155
|
+
let baseDir
|
|
156
|
+
try { baseDir = resolveBaseDir(ctx.cwd, args.workdir) }
|
|
157
|
+
catch (e) { return `Error: ${e.message}` }
|
|
89
158
|
|
|
90
|
-
const
|
|
91
|
-
const timeoutMs = Math.min(
|
|
92
|
-
|
|
93
|
-
// File path guard: ensure paths are within cwd (accidental out-of-workspace writes)
|
|
94
|
-
function safePath(p) {
|
|
95
|
-
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
96
|
-
const abs = resolve(cwd, p)
|
|
97
|
-
const rel = relative(cwd, abs)
|
|
98
|
-
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
99
|
-
throw new Error(`Path traversal denied: ${p}`)
|
|
100
|
-
}
|
|
101
|
-
return abs
|
|
102
|
-
}
|
|
159
|
+
const t = Number(args.timeoutMs)
|
|
160
|
+
const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 60_000) : DEFAULT_TIMEOUT
|
|
103
161
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const st = statSync(abs)
|
|
109
|
-
if (st.size > 5_000_000) throw new Error(`File too large: ${p} (${Math.round(st.size / 1000000)}MB)`)
|
|
110
|
-
return normalizeEOL(readFileSync(abs, "utf8"))
|
|
111
|
-
},
|
|
112
|
-
writeFile: (p, content) => {
|
|
113
|
-
const abs = safePath(p)
|
|
114
|
-
mkdirSync(dirname(abs), { recursive: true })
|
|
115
|
-
writeFileSync(abs, String(content), "utf8")
|
|
116
|
-
},
|
|
117
|
-
glob: (pattern) => {
|
|
118
|
-
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
119
|
-
const regex = globToRegex(pattern)
|
|
120
|
-
const results = []
|
|
121
|
-
function walk(dir, rel) {
|
|
122
|
-
let entries
|
|
123
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
124
|
-
for (const e of entries) {
|
|
125
|
-
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
126
|
-
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
127
|
-
if (e.isDirectory()) { walk(join(dir, e.name), relPath) }
|
|
128
|
-
else if (regex.test(relPath)) results.push(relPath)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
walk(cwd, "")
|
|
132
|
-
return results.slice(0, 200)
|
|
133
|
-
},
|
|
134
|
-
grep: (pattern, file) => {
|
|
135
|
-
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
136
|
-
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
137
|
-
const abs = safePath(file)
|
|
138
|
-
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
139
|
-
const content = normalizeEOL(readFileSync(abs, "utf8"))
|
|
140
|
-
const regex = new RegExp(pattern)
|
|
141
|
-
const lines = content.split("\n")
|
|
142
|
-
const matches = []
|
|
143
|
-
for (let i = 0; i < lines.length; i++) {
|
|
144
|
-
if (regex.test(lines[i])) matches.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
145
|
-
}
|
|
146
|
-
return matches.slice(0, 100)
|
|
147
|
-
},
|
|
148
|
-
log: (...args) => {
|
|
149
|
-
const line = args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")
|
|
150
|
-
output.push(line)
|
|
151
|
-
if (output.join("\n").length > MAX_OUTPUT) {
|
|
152
|
-
output.push("... (output truncated)")
|
|
153
|
-
throw new Error("CodeMode output limit exceeded")
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
fetch: sandboxFetch,
|
|
157
|
-
// Full Node access — no fake sandbox (bash can reach it anyway).
|
|
158
|
-
require: createRequire(join(cwd, "__codemode__.js")),
|
|
159
|
-
process,
|
|
160
|
-
setTimeout,
|
|
161
|
-
clearTimeout,
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
const script = new Script(code, { filename: "codemode.js" })
|
|
166
|
-
// timeout belongs on runInContext — the Script constructor ignores it,
|
|
167
|
-
// so passing it there let runaway scripts (while(true)) hang the process forever.
|
|
168
|
-
script.runInContext(sandbox, { timeout: timeoutMs })
|
|
169
|
-
return output.join("\n") || "(no output)"
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const out = output.join("\n")
|
|
172
|
-
const prefix = out ? `${out}\n\n` : ""
|
|
173
|
-
return `${prefix}Error: ${err.message}`
|
|
174
|
-
}
|
|
162
|
+
const { text, ok } = await runNodeEval(code, baseDir, ctx.cwd, timeoutMs, ctx.signal)
|
|
163
|
+
// Only filter successful output — never swallow an error report behind a filter.
|
|
164
|
+
if (!ok) return text
|
|
165
|
+
return args.filter ? applyFilter(text, args.filter) : text
|
|
175
166
|
},
|
|
176
|
-
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec-prelude.mjs — sandbox API injected into `execute`'s child process.
|
|
3
|
+
*
|
|
4
|
+
* The `execute` tool spawns `node --input-type=module --eval` and `import()`-s
|
|
5
|
+
* this file first, so the user's code gets readFile/writeFile/glob/grep/log/require
|
|
6
|
+
* without hand-writing fs boilerplate. Confines file paths to the workspace root
|
|
7
|
+
* (THINCODER_EXEC_ROOT, default cwd) — an orthopedic guard, NOT a sandbox:
|
|
8
|
+
* require()/process/import()/fetch() are full Node, the same boundary as bash
|
|
9
|
+
* (project philosophy: no fake sandbox; transparency + audit).
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module"
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, readdirSync } from "node:fs"
|
|
13
|
+
import { resolve, relative, dirname, join, isAbsolute, sep } from "node:path"
|
|
14
|
+
|
|
15
|
+
const require = createRequire(join(process.cwd(), "__exec__.js"))
|
|
16
|
+
const root = process.env.THINCODER_EXEC_ROOT || process.cwd()
|
|
17
|
+
|
|
18
|
+
/** Resolve a path against the working dir, asserting it stays within the workspace root. */
|
|
19
|
+
function safe(p) {
|
|
20
|
+
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
21
|
+
const abs = resolve(process.cwd(), p)
|
|
22
|
+
const rel = relative(root, abs)
|
|
23
|
+
// isAbsolute(rel) covers cross-drive (relative() returns an absolute path then)
|
|
24
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
25
|
+
throw new Error(`Path traversal denied: ${p}`)
|
|
26
|
+
}
|
|
27
|
+
return abs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function globToRegex(pattern) {
|
|
31
|
+
const DS = "\u0001", DP = "\u0002"
|
|
32
|
+
const escaped = pattern
|
|
33
|
+
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
34
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
35
|
+
.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
36
|
+
.replace(new RegExp(DS, "g"), "(?:.+/)?").replace(new RegExp(DP, "g"), ".*")
|
|
37
|
+
return new RegExp(`^${escaped}$`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
globalThis.require = require
|
|
41
|
+
globalThis.readFile = (p) => {
|
|
42
|
+
const abs = safe(p)
|
|
43
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
44
|
+
if (statSync(abs).size > 5_000_000) throw new Error(`File too large: ${p}`)
|
|
45
|
+
return readFileSync(abs, "utf8").replace(/\r\n/g, "\n")
|
|
46
|
+
}
|
|
47
|
+
globalThis.writeFile = (p, content) => {
|
|
48
|
+
const abs = safe(p)
|
|
49
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
50
|
+
writeFileSync(abs, String(content), "utf8")
|
|
51
|
+
}
|
|
52
|
+
globalThis.glob = (pattern) => {
|
|
53
|
+
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
54
|
+
const re = globToRegex(pattern)
|
|
55
|
+
const out = []
|
|
56
|
+
function walk(dir, rel) {
|
|
57
|
+
let entries
|
|
58
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
61
|
+
const rp = rel ? `${rel}/${e.name}` : e.name
|
|
62
|
+
if (e.isDirectory()) walk(join(dir, e.name), rp)
|
|
63
|
+
else if (re.test(rp)) out.push(rp)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
walk(process.cwd(), "")
|
|
67
|
+
const capped = out.slice(0, 200)
|
|
68
|
+
if (out.length > 200) capped.push(`... (${out.length - 200} more)`)
|
|
69
|
+
return capped
|
|
70
|
+
}
|
|
71
|
+
globalThis.grep = (pattern, file) => {
|
|
72
|
+
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
73
|
+
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
74
|
+
const abs = safe(file)
|
|
75
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
76
|
+
const re = new RegExp(pattern)
|
|
77
|
+
const lines = readFileSync(abs, "utf8").replace(/\r\n/g, "\n").split("\n")
|
|
78
|
+
const m = []
|
|
79
|
+
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) m.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
80
|
+
const capped = m.slice(0, 100)
|
|
81
|
+
if (m.length > 100) capped.push(`... (${m.length - 100} more)`)
|
|
82
|
+
return capped
|
|
83
|
+
}
|
|
84
|
+
globalThis.log = (...a) => console.log(a.map((x) => (x && typeof x === "object" ? JSON.stringify(x) : String(x))).join(" "))
|
package/src/tools/execute.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
Execute JavaScript code with full Node access. Use this to compose multiple
|
|
1
|
+
Execute JavaScript code with full Node access. Runs in a real `node` process with top-level `await` and dynamic `import()` — so you can load and call the project's own `.mjs` modules directly. Use this to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
|
|
2
|
+
|
|
3
|
+
**Route to execute instead of bash:**
|
|
4
|
+
- `node -e "…"` → execute (top-level await + import() + console all work)
|
|
2
5
|
|
|
3
6
|
Parameters:
|
|
4
|
-
- code (required): JavaScript
|
|
7
|
+
- code (required): JavaScript to run. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import.
|
|
8
|
+
- workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
|
|
9
|
+
- filter: optional — only return output lines matching this regex (case-insensitive)
|
|
5
10
|
- timeoutMs: Timeout in milliseconds (default 30000, max 60000)
|
|
11
|
+
|
|
12
|
+
Notes:
|
|
13
|
+
- `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
|
|
14
|
+
- File paths are confined to the workspace root (`..` traversal is denied) — but `require`/`process`/`import()` are full Node, same boundary as bash.
|
|
15
|
+
- A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
|
|
16
|
+
- Output capped at ~50KB; use `writeFile` to a file if you need more.
|
|
17
|
+
- Use `write`/`edit`/`apply_patch` for source edits and `bash` for subprocess/CLI runs (`npm test`, `node --test`, servers) — execute is for in-process JS, not spawning programs.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Move, copy, or rename a file/directory.
|
|
2
|
+
|
|
3
|
+
**Route to file_ops instead of bash:**
|
|
4
|
+
- `mv a b` → file_ops action=move
|
|
5
|
+
- `cp a b` / `copy` → file_ops action=copy
|
|
6
|
+
- `ren a b` / `rename a b` → file_ops action=rename
|
|
7
|
+
|
|
8
|
+
Parameters:
|
|
9
|
+
- action (required): move | copy | rename
|
|
10
|
+
- source (required): source path, relative to cwd or absolute
|
|
11
|
+
- dest (required): destination path
|
|
12
|
+
|
|
13
|
+
Notes:
|
|
14
|
+
- Paths are confined to the working directory (same safety as write/edit) — bash has NO directory confinement.
|
|
15
|
+
- `dest` is overwritten if it already exists. `copy` is recursive for directories.
|
|
16
|
+
- To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
|
package/src/tools/git.md
CHANGED
|
@@ -2,14 +2,20 @@ Run a git command. Use this to see uncommitted changes, staged changes, diff aga
|
|
|
2
2
|
- action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.
|
|
3
3
|
- action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.
|
|
4
4
|
- action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.
|
|
5
|
+
- action='show': Show a commit's details (--stat). Set ref=<ref> to inspect a specific commit (default HEAD).
|
|
5
6
|
- action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).
|
|
7
|
+
- action='rm': Untrack a file/directory (git rm --cached — keeps the file on disk). path is required.
|
|
8
|
+
- action='commit': Stage all changes and commit. message is required. Confirms with the user (outward action).
|
|
9
|
+
- action='push': Push the current branch to the remote. Confirms with the user (outward action).
|
|
6
10
|
|
|
7
11
|
Parameters:
|
|
8
|
-
- action (required): diff / status / log / checkpoint
|
|
12
|
+
- action (required): diff / status / log / show / checkpoint / rm / commit / push
|
|
9
13
|
- staged: (diff) Show staged changes instead of working tree
|
|
10
|
-
- path: (diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to
|
|
11
|
-
- ref: (
|
|
14
|
+
- path: (diff/log/checkpoint:cat/checkpoint:rewind/rm) File or directory to scope to
|
|
15
|
+
- ref: (show) Commit ref to inspect (default HEAD)
|
|
12
16
|
- count: (log) Number of commits (default 10)
|
|
13
17
|
- oneline: (log) One-line-per-commit format
|
|
18
|
+
- message: (commit) Commit message — required for commit
|
|
19
|
+
- filter: Optional — keep only status/diff/log output lines matching this regex (case-insensitive)
|
|
14
20
|
- checkpointAction: (checkpoint) list snapshots / create one / restore by id / read file from snapshot
|
|
15
|
-
- checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
|
|
21
|
+
- checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
|
package/src/tools/git.mjs
CHANGED
|
@@ -7,19 +7,44 @@ import { escapeXml } from "../agent/helpers.mjs";
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
|
+
/** Keep only output lines matching a regex (git filter, case-insensitive). */
|
|
11
|
+
function filterLines(output, filter) {
|
|
12
|
+
if (!filter) return output
|
|
13
|
+
try {
|
|
14
|
+
const re = new RegExp(filter, "i")
|
|
15
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
16
|
+
return lines.length ? lines.join("\n") : `(no lines matched filter "${filter}")`
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return `Error: filter regex invalid: ${e.message}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Run git and report failure (stderr + exit code) instead of swallowing it.
|
|
23
|
+
* Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
|
|
24
|
+
function runGitStrict(cwd, cmdArgs) {
|
|
25
|
+
try {
|
|
26
|
+
const out = execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
|
|
27
|
+
return { ok: true, out }
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
export const gitTool = {
|
|
11
34
|
name: "git",
|
|
12
35
|
description: DESC("git"),
|
|
13
36
|
parameters: {
|
|
14
37
|
type: "object",
|
|
15
38
|
properties: {
|
|
16
|
-
action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
|
|
39
|
+
action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "rm", "commit", "push"], description: "diff / status / log / show / checkpoint / rm / commit / push" },
|
|
17
40
|
// diff/log params
|
|
18
41
|
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
19
|
-
path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind) File or directory to scope to" },
|
|
20
|
-
ref: { type: "string", description: "(
|
|
42
|
+
path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind/rm) File or directory to scope to" },
|
|
43
|
+
ref: { type: "string", description: "(show) Commit ref to show (default HEAD)" },
|
|
21
44
|
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
22
45
|
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
46
|
+
message: { type: "string", description: "(commit) Commit message — required for commit" },
|
|
47
|
+
filter: { type: "string", description: "Optional: keep only status/diff/log output lines matching this regex (case-insensitive)" },
|
|
23
48
|
// checkpoint params
|
|
24
49
|
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
|
|
25
50
|
checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
|
|
@@ -35,7 +60,7 @@ export const gitTool = {
|
|
|
35
60
|
const flags = args.staged ? ["--staged"] : []
|
|
36
61
|
const paths = args.path ? [args.path] : []
|
|
37
62
|
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
38
|
-
return truncate(out || "(no changes)")
|
|
63
|
+
return truncate(filterLines(out || "(no changes)", args.filter))
|
|
39
64
|
}
|
|
40
65
|
case "status": {
|
|
41
66
|
const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
|
|
@@ -68,17 +93,44 @@ export const gitTool = {
|
|
|
68
93
|
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
69
94
|
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
70
95
|
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
71
|
-
return truncate(parts.join("\n\n"))
|
|
96
|
+
return truncate(filterLines(parts.join("\n\n"), args.filter))
|
|
72
97
|
}
|
|
73
98
|
case "log": {
|
|
74
|
-
const
|
|
99
|
+
const parsed = Number.parseInt(args.count, 10)
|
|
100
|
+
const n = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 10
|
|
75
101
|
const isOneline = args.oneline
|
|
76
102
|
const cmdArgs = isOneline
|
|
77
103
|
? ["log", "-" + n, "--oneline"]
|
|
78
104
|
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
79
105
|
if (args.path) cmdArgs.push("--", args.path)
|
|
80
106
|
const out = runGit(ctx.cwd, cmdArgs)
|
|
81
|
-
return truncate(out || "(no commits)")
|
|
107
|
+
return truncate(filterLines(out || "(no commits)", args.filter))
|
|
108
|
+
}
|
|
109
|
+
case "show": {
|
|
110
|
+
const ref = args.ref ?? "HEAD"
|
|
111
|
+
if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
112
|
+
const out = runGit(ctx.cwd, ["show", "--stat", ref])
|
|
113
|
+
return truncate(out || "(no such commit)")
|
|
114
|
+
}
|
|
115
|
+
case "rm": {
|
|
116
|
+
if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
|
|
117
|
+
const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", args.path])
|
|
118
|
+
return r.ok ? truncate(r.out || `Untracked ${args.path} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
|
|
119
|
+
}
|
|
120
|
+
case "commit": {
|
|
121
|
+
if (!args.message) return "Error: commit requires message"
|
|
122
|
+
const add = runGitStrict(ctx.cwd, ["add", "-A"])
|
|
123
|
+
if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
|
|
124
|
+
const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
125
|
+
const parts = []
|
|
126
|
+
if (add.out) parts.push(add.out)
|
|
127
|
+
if (commit.ok) { if (commit.out) parts.push(commit.out) }
|
|
128
|
+
else parts.push(`git commit failed: ${commit.err || "(no output)"}`)
|
|
129
|
+
return truncate(parts.join("\n") || "(commit produced no output)")
|
|
130
|
+
}
|
|
131
|
+
case "push": {
|
|
132
|
+
const r = runGitStrict(ctx.cwd, ["push"])
|
|
133
|
+
return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
|
|
82
134
|
}
|
|
83
135
|
case "checkpoint": {
|
|
84
136
|
const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
@@ -139,7 +191,7 @@ export const gitTool = {
|
|
|
139
191
|
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
|
|
140
192
|
}
|
|
141
193
|
default:
|
|
142
|
-
return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
|
|
194
|
+
return `Unknown action '${args.action}'. Use: diff | status | log | show | checkpoint | rm | commit | push`
|
|
143
195
|
}
|
|
144
196
|
},
|
|
145
197
|
}
|
package/src/tools/grep.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Search file contents with a regex. Returns matching lines as path:line: content.
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
|
-
- pattern (required): JavaScript regular expression
|
|
4
|
+
- pattern (required): JavaScript regular expression, or a literal string when literal=true
|
|
5
5
|
- path: Directory or file to search (default cwd)
|
|
6
6
|
- glob: Only search files matching this glob (e.g. '*.mjs')
|
|
7
|
+
- ignoreCase: Case-insensitive match (default false)
|
|
8
|
+
- literal: Literal string match — no regex interpretation (default false; use for strings with `. \` etc.)
|
|
7
9
|
- before: Lines of context to show before each match (grep -B). Default 0
|
|
8
10
|
- after: Lines of context to show after each match (grep -A). Default 0
|
|
9
11
|
|
package/src/tools/index.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { checklistTool } from "./checklist.mjs";
|
|
|
10
10
|
import { lintTool } from "./linter.mjs";
|
|
11
11
|
import { lspTool } from "./lsp.mjs";
|
|
12
12
|
import { codeModeTool } from "./codemode.mjs";
|
|
13
|
+
import { fileOpsTool, processTool, getCurrentTimeTool, sleepTool } from "./ops.mjs";
|
|
14
|
+
import { treeTool } from "./tree.mjs";
|
|
13
15
|
|
|
14
16
|
export const builtinTools = [
|
|
15
17
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
@@ -17,6 +19,8 @@ export const builtinTools = [
|
|
|
17
19
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
18
20
|
gitTool, questionTool,
|
|
19
21
|
checklistTool, lintTool, lspTool, codeModeTool,
|
|
22
|
+
fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
|
|
23
|
+
treeTool,
|
|
20
24
|
];
|
|
21
25
|
|
|
22
26
|
export {
|
|
@@ -25,4 +29,6 @@ export {
|
|
|
25
29
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
26
30
|
gitTool, questionTool,
|
|
27
31
|
checklistTool, lintTool, lspTool, codeModeTool,
|
|
28
|
-
|
|
32
|
+
fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
|
|
33
|
+
treeTool,
|
|
34
|
+
};
|
package/src/tools/ls.md
CHANGED
|
@@ -2,6 +2,7 @@ List directory contents with type, size, and modification time. Directories list
|
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
4
|
- path: Directory path (default cwd)
|
|
5
|
+
- filter: Only list entries matching this glob (e.g. '*.mjs', '*test*') — a wildcard filter, not a full listing
|
|
5
6
|
|
|
6
7
|
Notes:
|
|
7
8
|
- Shows first 500 entries
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops.mjs — operational tools: file_ops (move/copy/rename), process (list),
|
|
3
|
+
* get_current_time, sleep. Each exists so the model reaches for a dedicated tool
|
|
4
|
+
* instead of shelling out to `bash` for the same operation (parity with thinworker).
|
|
5
|
+
*/
|
|
6
|
+
import { DESC, resolveInCwd, truncate } from "./shared.mjs"
|
|
7
|
+
import { cp, rename, rm } from "node:fs/promises"
|
|
8
|
+
import { execFileSync } from "node:child_process"
|
|
9
|
+
|
|
10
|
+
// ─── file_ops ──────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const fileOpsTool = {
|
|
13
|
+
name: "file_ops",
|
|
14
|
+
description: DESC("file_ops"),
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
action: { type: "string", enum: ["move", "copy", "rename"], description: "move | copy | rename" },
|
|
19
|
+
source: { type: "string", description: "Source path, relative to cwd or absolute" },
|
|
20
|
+
dest: { type: "string", description: "Destination path" },
|
|
21
|
+
},
|
|
22
|
+
required: ["action", "source", "dest"],
|
|
23
|
+
},
|
|
24
|
+
readonly: false,
|
|
25
|
+
async execute({ action, source, dest }, ctx) {
|
|
26
|
+
if (typeof source !== "string" || !source) return "Error: source is required"
|
|
27
|
+
if (typeof dest !== "string" || !dest) return "Error: dest is required"
|
|
28
|
+
if (!["move", "copy", "rename"].includes(action)) return `Error: action must be move | copy | rename (got "${action}")`
|
|
29
|
+
const src = resolveInCwd(ctx, source)
|
|
30
|
+
const dst = resolveInCwd(ctx, dest)
|
|
31
|
+
if (src === dst) return "Error: source and dest resolve to the same path"
|
|
32
|
+
|
|
33
|
+
if (action === "copy") {
|
|
34
|
+
await cp(src, dst, { recursive: true, force: true })
|
|
35
|
+
return `Copied ${source} → ${dest}`
|
|
36
|
+
}
|
|
37
|
+
// move & rename share the rename syscall; cross-device move falls back to copy+rm.
|
|
38
|
+
try {
|
|
39
|
+
await rename(src, dst)
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e?.code !== "EXDEV") throw e
|
|
42
|
+
await cp(src, dst, { recursive: true, force: true })
|
|
43
|
+
await rm(src, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
return `${action === "rename" ? "Renamed" : "Moved"} ${source} → ${dest}`
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── process ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const processTool = {
|
|
52
|
+
name: "process",
|
|
53
|
+
description: DESC("process"),
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
name: { type: "string", description: "Optional name substring filter (case-insensitive)" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
readonly: true,
|
|
61
|
+
async execute({ name }, ctx) {
|
|
62
|
+
const filter = typeof name === "string" && name.trim() ? name.trim().toLowerCase() : null
|
|
63
|
+
let rows
|
|
64
|
+
try {
|
|
65
|
+
rows = process.platform === "win32" ? listWindows() : listPosix()
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return `process listing failed: ${e?.message ?? String(e)}`
|
|
68
|
+
}
|
|
69
|
+
if (filter) rows = rows.filter((r) => r.name.toLowerCase().includes(filter))
|
|
70
|
+
if (rows.length === 0) return filter ? `No running processes match "${name}"` : "(no processes)"
|
|
71
|
+
return truncate(rows.map((r) => `${r.name}\tPID ${r.pid}${r.mem ? `\t${r.mem}` : ""}`).join("\n"))
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listWindows() {
|
|
76
|
+
// tasklist /FO CSV /NH → lines: "name.exe","1234","Console","1","12,345 K"
|
|
77
|
+
const out = execFileSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 10000 })
|
|
78
|
+
const rows = []
|
|
79
|
+
for (const line of out.split("\n")) {
|
|
80
|
+
const parts = line.split('","')
|
|
81
|
+
if (parts.length < 2) continue
|
|
82
|
+
const name = parts[0].replace(/^"/, "").trim()
|
|
83
|
+
const pid = parts[1].replace(/"/, "").trim()
|
|
84
|
+
const mem = parts[4] ? parts[4].replace(/"/, "").trim() : ""
|
|
85
|
+
if (!name || !pid) continue
|
|
86
|
+
rows.push({ name, pid, mem })
|
|
87
|
+
}
|
|
88
|
+
return rows
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listPosix() {
|
|
92
|
+
const out = execFileSync("ps", ["-eo", "pid=,comm="], { encoding: "utf8", timeout: 10000 })
|
|
93
|
+
const rows = []
|
|
94
|
+
for (const line of out.split("\n")) {
|
|
95
|
+
const m = line.trim().match(/^(\d+)\s+(.+)$/)
|
|
96
|
+
if (m) rows.push({ name: m[2], pid: m[1], mem: "" })
|
|
97
|
+
}
|
|
98
|
+
return rows
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── get_current_time ──────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export const getCurrentTimeTool = {
|
|
104
|
+
name: "get_current_time",
|
|
105
|
+
description: DESC("get_current_time"),
|
|
106
|
+
parameters: { type: "object", properties: {} },
|
|
107
|
+
readonly: true,
|
|
108
|
+
async execute() {
|
|
109
|
+
const now = new Date()
|
|
110
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "unknown"
|
|
111
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
112
|
+
return `Date: ${now.toISOString()} (UTC)\nTimezone: ${tz}\nWeekday: ${days[now.getDay()]}\nLocal: ${now.toLocaleString()}`
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── sleep ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export const sleepTool = {
|
|
119
|
+
name: "sleep",
|
|
120
|
+
description: DESC("sleep"),
|
|
121
|
+
parameters: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
seconds: { type: "number", description: "Seconds to wait (1-300)" },
|
|
125
|
+
reason: { type: "string", description: "Why wait (shown to the user)" },
|
|
126
|
+
},
|
|
127
|
+
required: ["seconds"],
|
|
128
|
+
},
|
|
129
|
+
readonly: true,
|
|
130
|
+
async execute({ seconds, reason }, ctx) {
|
|
131
|
+
const raw = Number(seconds)
|
|
132
|
+
const n = Number.isFinite(raw) ? Math.min(Math.max(Math.round(raw), 1), 300) : 1
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
const t = setTimeout(resolve, n * 1000)
|
|
135
|
+
if (ctx?.signal) {
|
|
136
|
+
if (ctx.signal.aborted) { clearTimeout(t); reject(new Error("aborted")) }
|
|
137
|
+
else ctx.signal.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")) }, { once: true })
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
return `Waited ${n}s${reason ? ` (${reason})` : ""}`
|
|
141
|
+
},
|
|
142
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
List running processes, optionally filtered by name. Returns process name / PID / memory.
|
|
2
|
+
|
|
3
|
+
**Route to process instead of bash:**
|
|
4
|
+
- `tasklist` (Windows) / `ps aux` (POSIX) → process
|
|
5
|
+
|
|
6
|
+
Parameters:
|
|
7
|
+
- name (optional): substring filter (case-insensitive), e.g. "node", "python"
|
|
8
|
+
|
|
9
|
+
Notes:
|
|
10
|
+
- List-only. To kill a process, use `bash taskkill /PID <pid> /F` (Windows) or `bash kill <pid>` — and confirm with the user first.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Wait a number of seconds before continuing. Use to wait for a web page to load, an async task to finish, or to respect a rate limit — cheaper than repeatedly polling.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- seconds (required): how many seconds to wait (1-300)
|
|
5
|
+
- reason (optional): why you are waiting (shown to the user)
|
package/src/tools/system.mjs
CHANGED
|
@@ -17,6 +17,20 @@ import { join } from "node:path";
|
|
|
17
17
|
/** Maximum buffer size per stream (stdout / stderr) before truncation */
|
|
18
18
|
const MAX_STREAM_BUF = 2_000_000
|
|
19
19
|
|
|
20
|
+
/** Escape a string for literal regex matching (grep literal=true). */
|
|
21
|
+
function escapeRegExp(s) {
|
|
22
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep only output lines matching a regex (bash filter, case-insensitive). */
|
|
26
|
+
function applyLineFilter(output, filter) {
|
|
27
|
+
let re
|
|
28
|
+
try { re = new RegExp(filter, "i") } catch (e) { return `Error: filter regex invalid: ${e.message}` }
|
|
29
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
30
|
+
if (lines.length === 0) return `(no output lines matched filter "${filter}")`
|
|
31
|
+
return truncate(lines.join("\n"))
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
// ====================================================================
|
|
21
35
|
// bash — command execution with safety gates
|
|
22
36
|
// ====================================================================
|
|
@@ -223,6 +237,7 @@ export const bashTool = {
|
|
|
223
237
|
properties: {
|
|
224
238
|
command: { type: "string", description: "Shell command to execute" },
|
|
225
239
|
timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
|
|
240
|
+
filter: { type: "string", description: "Optional: only return output lines matching this regex (case-insensitive)" },
|
|
226
241
|
},
|
|
227
242
|
required: ["command"],
|
|
228
243
|
},
|
|
@@ -241,7 +256,8 @@ export const bashTool = {
|
|
|
241
256
|
onOutput: ctx.onOutput,
|
|
242
257
|
shell: ctx.agent?.config?.shell ?? null,
|
|
243
258
|
})
|
|
244
|
-
|
|
259
|
+
const filtered = args.filter ? applyLineFilter(result, args.filter) : result
|
|
260
|
+
return guard ? `${guard.notice}\n\n${filtered}` : filtered
|
|
245
261
|
},
|
|
246
262
|
}
|
|
247
263
|
|
|
@@ -305,9 +321,11 @@ export const grepTool = {
|
|
|
305
321
|
parameters: {
|
|
306
322
|
type: "object",
|
|
307
323
|
properties: {
|
|
308
|
-
pattern: { type: "string", description: "Regular expression" },
|
|
324
|
+
pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
|
|
309
325
|
path: { type: "string", description: "Directory or file to search (default cwd)" },
|
|
310
326
|
glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
|
|
327
|
+
ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
|
|
328
|
+
literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
|
|
311
329
|
before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
|
|
312
330
|
after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
|
|
313
331
|
},
|
|
@@ -318,7 +336,8 @@ export const grepTool = {
|
|
|
318
336
|
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
319
337
|
let regex
|
|
320
338
|
try {
|
|
321
|
-
|
|
339
|
+
const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
|
|
340
|
+
regex = new RegExp(pat, args.ignoreCase ? "i" : "")
|
|
322
341
|
} catch (e) {
|
|
323
342
|
throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
|
|
324
343
|
}
|
|
@@ -409,11 +428,13 @@ export const lsTool = {
|
|
|
409
428
|
type: "object",
|
|
410
429
|
properties: {
|
|
411
430
|
path: { type: "string", description: "Directory path (default cwd)" },
|
|
431
|
+
filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
|
|
412
432
|
},
|
|
413
433
|
},
|
|
414
434
|
readonly: true,
|
|
415
435
|
async execute(args, ctx) {
|
|
416
436
|
const abs = resolveInCwd(ctx, args.path ?? ".")
|
|
437
|
+
const filterRe = args.filter ? globToRegex(args.filter) : null
|
|
417
438
|
let entries
|
|
418
439
|
try {
|
|
419
440
|
entries = await readdir(abs, { withFileTypes: true })
|
|
@@ -422,7 +443,10 @@ export const lsTool = {
|
|
|
422
443
|
throw e
|
|
423
444
|
}
|
|
424
445
|
const rows = await Promise.all(
|
|
425
|
-
entries
|
|
446
|
+
entries
|
|
447
|
+
.filter((e) => !filterRe || filterRe.test(e.name))
|
|
448
|
+
.slice(0, 500)
|
|
449
|
+
.map(async (e) => {
|
|
426
450
|
const s = await stat(join(abs, e.name)).catch(() => null)
|
|
427
451
|
const isDir = e.isDirectory()
|
|
428
452
|
return {
|
|
@@ -439,5 +463,3 @@ export const lsTool = {
|
|
|
439
463
|
return truncate(out.join("\n"))
|
|
440
464
|
},
|
|
441
465
|
}
|
|
442
|
-
|
|
443
|
-
// ---------------------------------------------------------------- fetch
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Generate a directory tree of the codebase (default depth 3). Skips dotfiles, .git/node_modules/dist/build/bin/obj and other build/vendor dirs, and binary files. Use to quickly see which modules exist and where files live.
|
|
2
|
+
|
|
3
|
+
**Route to tree instead of bash:**
|
|
4
|
+
- `tree` / `find .` / `dir /s` → tree
|
|
5
|
+
|
|
6
|
+
Parameters:
|
|
7
|
+
- path: Root directory (default cwd)
|
|
8
|
+
- depth: Tree depth (default 3, max 6). Directories are listed before files, both sorted.
|
|
9
|
+
|
|
10
|
+
Notes:
|
|
11
|
+
- Capped at 200 entries.
|
|
12
|
+
- Directories end with `/`; tree-drawing uses `├──`/`└──`/`│`.
|
|
13
|
+
- Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree.mjs — directory tree tool (parity with thinworker `repomap`).
|
|
3
|
+
* Renders a repo's directory tree (default depth 3), skipping dotfiles,
|
|
4
|
+
* build/vendor dirs and binary files, so the model can see which modules
|
|
5
|
+
* exist without shelling out to `tree`/`find`.
|
|
6
|
+
*/
|
|
7
|
+
import { DESC, truncate, resolveInCwd } from "./shared.mjs"
|
|
8
|
+
import { readdir, stat } from "node:fs/promises"
|
|
9
|
+
import { join, basename, extname } from "node:path"
|
|
10
|
+
|
|
11
|
+
const MAX_ENTRIES = 200
|
|
12
|
+
const DEFAULT_DEPTH = 3
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", "bin", "obj", "dist", "build", "coverage", "turbo", ".git", ".thincoder", ".vs", ".venv", "__pycache__", ".idea"])
|
|
14
|
+
const BINARY_EXTS = new Set([".exe", ".dll", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".docx", ".xlsx", ".pptx", ".zip", ".7z", ".mp3", ".mp4", ".woff", ".woff2", ".ico"])
|
|
15
|
+
|
|
16
|
+
export const treeTool = {
|
|
17
|
+
name: "tree",
|
|
18
|
+
description: DESC("tree"),
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
path: { type: "string", description: "Root directory (default cwd)" },
|
|
23
|
+
depth: { type: "integer", description: `Tree depth (default ${DEFAULT_DEPTH}, max 6)` },
|
|
24
|
+
},
|
|
25
|
+
required: [],
|
|
26
|
+
},
|
|
27
|
+
readonly: true,
|
|
28
|
+
async execute(args, ctx) {
|
|
29
|
+
const root = resolveInCwd(ctx, args.path ?? ".")
|
|
30
|
+
let st
|
|
31
|
+
try { st = await stat(root) } catch { return `Error: directory not found: ${args.path ?? "."}` }
|
|
32
|
+
if (!st.isDirectory()) return `Error: not a directory: ${args.path ?? "."}`
|
|
33
|
+
const d = Number(args.depth)
|
|
34
|
+
const maxDepth = Number.isInteger(d) && d > 0 ? Math.min(d, 6) : DEFAULT_DEPTH
|
|
35
|
+
const lines = [basename(root) + "/"]
|
|
36
|
+
const state = { count: 1, capped: false }
|
|
37
|
+
await walk(root, 0, maxDepth, "", lines, state)
|
|
38
|
+
return truncate(lines.join("\n"))
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function walk(dir, depth, maxDepth, prefix, lines, state) {
|
|
43
|
+
if (state.capped) return
|
|
44
|
+
let entries
|
|
45
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
46
|
+
const items = []
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (e.name.startsWith(".")) continue // dotfiles + dotdirs
|
|
49
|
+
const isDir = e.isDirectory()
|
|
50
|
+
if (isDir) { if (SKIP_DIRS.has(e.name)) continue; items.push({ name: e.name, isDir: true }) }
|
|
51
|
+
else if (!BINARY_EXTS.has(extname(e.name).toLowerCase())) items.push({ name: e.name, isDir: false })
|
|
52
|
+
}
|
|
53
|
+
items.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < items.length; i++) {
|
|
56
|
+
if (state.capped) return
|
|
57
|
+
if (state.count >= MAX_ENTRIES) { state.capped = true; lines.push(prefix + "…(更多项已省略)"); return }
|
|
58
|
+
const { name, isDir } = items[i]
|
|
59
|
+
const isLast = i === items.length - 1
|
|
60
|
+
lines.push(prefix + (isLast ? "└── " : "├── ") + (isDir ? name + "/" : name))
|
|
61
|
+
state.count++
|
|
62
|
+
if (isDir && depth + 1 < maxDepth) {
|
|
63
|
+
await walk(join(dir, name), depth + 1, maxDepth, prefix + (isLast ? " " : "│ "), lines, state)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|