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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.37",
3
+ "version": "0.12.38",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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: { problem: { type: "string", description: "Problem brief (symptom + failure trail + entry files)" } },
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 models = agent.config?.agent?.consultModels ?? []
239
- if (!Array.isArray(models) || models.length === 0)
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 (models.length > 5) return `Error: consultModels supports at most 5 models (got ${models.length})`
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: models.length,
248
- models: models.map(consultLabel),
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 models) {
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(cwd, "package.json")
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(cwd, ctx)
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
- * thinkOnValue: when thinkApi is "type", the value used to enable thinking (default "enabled"; MiniMax uses "adaptive")
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: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
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: 128_000, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
116
- ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
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
  ```
@@ -1,65 +1,123 @@
1
1
  /**
2
2
  * tools/codemode.mjs — CodeMode: JavaScript execution tool
3
3
  *
4
- * Gives the model an `execute` tool backed by Node.js vm.Script.runInNewContext.
5
- * Multiple tool calls can be composed into a single script, reducing API round-trips
6
- * and keeping large intermediate results out of context.
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
- * Sandbox API:
9
- * readFile(path) — read a file relative to cwd, return string
10
- * writeFile(path, c) write content to a file (auto-creates parent dirs)
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
- * Full Node access via require()/process — no fake sandbox. The bash tool can
17
- * already reach any Node API, so blocking require here only misled the model
18
- * about its real capability boundary (project philosophy: no command-level
19
- * sandbox; transparency + trust + audit).
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
- /** fetch: only http/https (protocol guard kept; no private-host rejection — the
39
- * bash tool can reach anything anyway, so the SSRF check was a fake boundary).
40
- * Validation throws SYNCHRONOUSLY so the vm sandbox's try/catch can catch it —
41
- * an async throw here would become an unhandled rejection and crash the host process,
42
- * and the rejection message would never reach the model. */
43
- function sandboxFetch(url) {
44
- const parsed = new URL(url)
45
- if (!["http:", "https:"].includes(parsed.protocol)) {
46
- throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
47
- }
48
- return doFetch(url)
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
- async function doFetch(url) {
52
- const ctrl = new AbortController()
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 res = await fetch(url, { signal: ctrl.signal })
56
- const text = await res.text()
57
- return text.slice(0, 100_000)
58
- } finally {
59
- clearTimeout(timer)
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 functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args). require()/process/Node modules are available.",
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 output = []
91
- const timeoutMs = Math.min(args.timeoutMs ?? DEFAULT_TIMEOUT, 60_000)
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 sandbox = createContext({
105
- readFile: (p) => {
106
- const abs = safePath(p)
107
- if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
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(" "))
@@ -1,5 +1,17 @@
1
- Execute JavaScript code with full Node access. Use this to compose multiple file operations into one call — read, write, glob, grep, log, or require() any module. Max 30s timeout, 50KB output.
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 code to execute. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args). require()/process/Node modules are available.
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`.
@@ -0,0 +1,6 @@
1
+ Get the current date, time, weekday, and timezone.
2
+
3
+ **Route to get_current_time instead of bash:**
4
+ - `date` / `time` → get_current_time
5
+
6
+ Use it whenever a task depends on the current time or date (deadlines, freshness, timestamps) rather than shelling out.
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: (diff) Compare against this ref (default HEAD)
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: "(diff) Compare against this ref (default HEAD)" },
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 n = Math.min(Math.max(1, args.count ?? 10), 200)
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
 
@@ -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)
@@ -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
- return guard ? `${guard.notice}\n\n${result}` : result
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
- regex = new RegExp(args.pattern)
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.slice(0, 500).map(async (e) => {
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
+ }