thincoder 0.12.37 → 0.12.39

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/src/context.mjs CHANGED
@@ -48,13 +48,14 @@ function keepTailSize(provider, historyLen) {
48
48
  return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
49
49
  }
50
50
 
51
- const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
51
+ export const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
52
52
  Requirements:
53
53
  - Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
54
54
  - Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
55
55
  - Distinguish COMPLETED vs IN-PROGRESS work: completed tasks get a ONE-LINE recap each (what was done, key outcome); spend the detail budget on unresolved issues, next steps, and the CURRENT task
56
56
  - The user's most recent request defines the current task — anchor on it. Earlier requests are likely already completed and only need the one-line recap; do NOT preserve them at full fidelity
57
- - Keep: files modified and why, unresolved issues, next steps
57
+ - Explicitly list FILES CHANGED: every modified file path plus a one-line "why" so post-compaction work can re-locate what was edited and where
58
+ - Explicitly list UNRESOLVED ISSUES / TODOs: anything still open plus the next steps — so post-compaction recovery knows where to resume
58
59
  - Drop: pleasantries, repetition, fine-grained tool output details
59
60
  - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
60
61
  - Use bullet-point output; aim for information completeness, not a hard word limit (old 500-char cap is deprecated; in a 1M-context era, err on the long side)
@@ -152,6 +153,15 @@ function applyCompression(agent, headEnd, tailStart, note) {
152
153
  { role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
153
154
  ...tail,
154
155
  ]
156
+ // Compaction REBUILDS the machine line (head + note + "Understood" + tail), so the pre-compaction
157
+ // _runStartHistoryLen index is stale — a longer array shrank beneath it, and end-of-run exploration
158
+ // distillation would then silently skip or slice from the wrong offset. Reset the boundary to the
159
+ // verbatim tail start (head.length + 2: the note and the "Understood" placeholder sit between head
160
+ // and tail). Exploration before the tail was already covered by the compaction summary, so only the
161
+ // still-raw tail needs distilling. `head` is empty today (KEEP_HEAD = 0) — the formula stays
162
+ // correct if KEEP_HEAD ever grows. (shrinkOversized only truncates message bodies in place and
163
+ // leaves the array length unchanged, so this boundary stays valid there — no reset needed.)
164
+ agent._runStartHistoryLen = head.length + 2
155
165
  // Measured token baseline is invalidated along with old history (prompt_tokens were for pre-compaction context), fall back to estimation until next response
156
166
  agent._lastPromptTokens = null
157
167
  agent._usageAtLen = null
@@ -281,3 +291,139 @@ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
281
291
  }
282
292
  return shrunk
283
293
  }
294
+
295
+ // ─── End-of-run exploration distillation (AGENT-LOOP §13 + CONTEXT-COMPACTION §5, 2026-08-23) ───
296
+ // The main agent's machine line is flooded by inline step-by-step exploration (read/grep/...).
297
+ // At run end we distill THIS run's exploration tool-results into one semantic summary note that
298
+ // replaces them in the machine line, while agent._fullHistory (the human line) stays untouched.
299
+
300
+ /** Read-only knowledge tools counted as "exploration" (execute writes files → never exploration). */
301
+ export const EXPLORE_TOOLS = new Set([
302
+ "read", "grep", "glob", "ls", "code_search", "doc_search", "repo_outline",
303
+ ])
304
+
305
+ /** Summary prompt for turning a burst of exploration results into a semantic summary. */
306
+ export const EXPLORE_SUMMARY_PROMPT = `You are distilling exploration tool results. Summarize the following read-only codebase exploration into a compact semantic summary for the main agent's own context.
307
+
308
+ Requirements:
309
+ - Capture WHAT was discovered, WHERE (which files / directories / symbols), and the KEY CONCLUSIONS — do not list tool calls mechanically
310
+ - Keep actionable facts the main agent needs to continue: code locations, function names, file paths, structure, and open questions the exploration raised
311
+ - Drop raw tool-output noise, repeated lines, and verbatim file dumps — keep only what must be remembered
312
+ - Be honest: mark anything not actually verified as "unverified"; do not present guesses as facts
313
+ - Use bullet points; aim for information completeness, not a hard word limit
314
+
315
+ Exploration log:
316
+ `
317
+
318
+ /** tool_calls name across both stored shapes ({function:{name}} and flat {name}). */
319
+ function toolCallName(tc) {
320
+ return tc?.function?.name ?? tc?.name ?? ""
321
+ }
322
+
323
+ /** Tool that produced a tool-result message (falls back to its owner assistant's tool_call). */
324
+ function toolResultName(msg, ownerToolCalls) {
325
+ if (typeof msg?.name === "string" && msg.name) return msg.name
326
+ const owner = (ownerToolCalls ?? []).find((tc) => tc.id === msg?.tool_call_id)
327
+ return owner ? toolCallName(owner) : ""
328
+ }
329
+
330
+ /**
331
+ * Find the pure-exploration "assistant(tool_calls)→tool…" pair blocks added since `start`.
332
+ * A block is explorable only when EVERY tool call AND every tool result in it is an exploration
333
+ * tool — mixed blocks (read + edit in one turn) stay untouched, or we'd orphan the edit pairing.
334
+ */
335
+ function findExplorationBlocks(history, start) {
336
+ const blocks = []
337
+ let i = start
338
+ while (i < history.length) {
339
+ const m = history[i]
340
+ if (m?.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
341
+ let j = i + 1
342
+ while (j < history.length && history[j]?.role === "tool") j++
343
+ const toolMsgs = history.slice(i + 1, j)
344
+ const allCallsExplore = m.tool_calls.every((tc) => EXPLORE_TOOLS.has(toolCallName(tc)))
345
+ const allResultsExplore = toolMsgs.length > 0 && toolMsgs.every((t) => EXPLORE_TOOLS.has(toolResultName(t, m.tool_calls)))
346
+ if (allCallsExplore && allResultsExplore) {
347
+ blocks.push({ start: i, end: j, messages: history.slice(i, j), toolCount: toolMsgs.length })
348
+ }
349
+ i = j
350
+ } else {
351
+ i++
352
+ }
353
+ }
354
+ return blocks
355
+ }
356
+
357
+ /** Serialize a batch of exploration messages for the summary LLM (same shape as compaction serialization). */
358
+ function serializeExplorationMessages(messages) {
359
+ const cap = 8000 // exploration results ARE the signal to distill — generous cap (quality-first, N1)
360
+ return messages
361
+ .map((m) => {
362
+ const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map(toolCallName).join(", ")}]` : ""
363
+ let text = ""
364
+ if (typeof m.content === "string") text = m.content
365
+ else if (Array.isArray(m.content)) text = m.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join(" ")
366
+ return `[${m.role}]${toolNote} ${text.slice(0, cap)}`
367
+ })
368
+ .join("\n")
369
+ }
370
+
371
+ /**
372
+ * Core (shared) distillation: replace this run's pure-exploration pair blocks with a single
373
+ * "[Exploration summary]" note placed where the first block was. Returns a NEW history array,
374
+ * or null when there is nothing to shrink (<3 exploration results / LLM failure). Pairing-safe:
375
+ * whole assistant→tool blocks are removed, so no orphan tool_calls/tool can survive.
376
+ */
377
+ async function distillExplorations(history, start, provider, signal) {
378
+ if (!Array.isArray(history) || history.length - start < 2) return null
379
+ const blocks = findExplorationBlocks(history, start)
380
+ const resultCount = blocks.reduce((n, b) => n + b.toolCount, 0)
381
+ if (resultCount < 3) return null
382
+
383
+ const serialized = blocks.map((b) => serializeExplorationMessages(b.messages)).join("\n")
384
+
385
+ let summary
386
+ try {
387
+ // Silent by design (D11): thinking:null and no onToken/onReasoning — this internal
388
+ // distillation must not stream to the frontend. signal propagates user cancellation.
389
+ const resp = await chat({ ...provider, thinking: null, reasoningEffort: null }, {
390
+ messages: [{ role: "user", content: EXPLORE_SUMMARY_PROMPT + serialized }],
391
+ signal,
392
+ })
393
+ summary = resp?.content
394
+ } catch {
395
+ return null // N3: never block the run's return or lose history — original results stay
396
+ }
397
+ if (!summary) return null
398
+
399
+ const drop = new Set()
400
+ for (const b of blocks) for (let k = b.start; k < b.end; k++) drop.add(k)
401
+ const note = { role: "user", content: "[Exploration summary]\n" + summary }
402
+ const next = []
403
+ let inserted = false
404
+ for (let k = 0; k < history.length; k++) {
405
+ if (drop.has(k)) {
406
+ if (!inserted) { next.push(note); inserted = true }
407
+ continue
408
+ }
409
+ next.push(history[k])
410
+ }
411
+ return next
412
+ }
413
+
414
+ /**
415
+ * End-of-run exploration distillation (runAgent's final return). Shrinks the MACHINE line
416
+ * (agent.history) only; agent._fullHistory is never touched. Triggers when this run added ≥3
417
+ * exploration tool results; on LLM failure it silently keeps the original history (N3).
418
+ * `callbacks` is accepted for call-site parity with the other lifecycle hooks — the distillation
419
+ * is silent by design and never streams (D11).
420
+ */
421
+ export async function summarizeRunExplorations(agent, callbacks, signal) {
422
+ const next = await distillExplorations(agent.history, agent._runStartHistoryLen ?? 0, agent.provider, signal)
423
+ if (!next) return
424
+ agent.history = next
425
+ // The machine line changed shape — the measured token baseline was for the pre-shrink context.
426
+ // Invalidate so the next compaction check re-estimates instead of over-counting stale history.
427
+ agent._lastPromptTokens = null
428
+ agent._usageAtLen = null
429
+ }
@@ -1,10 +1,14 @@
1
1
  Workflow — match the process to the task:
2
- - Complex (3+ steps, new features): Requirements Design Development Testing. Write a design doc. Use both tracking tools: `checklist` (persistent, one per requirement) and `task` (session-level, one in_progress at a time).
3
- - Medium (2-3 steps, refactoring): plan briefly, no design doc needed. Use `task` tool.
4
- - Small (typo, one-line fix): confirm understanding, change, verify. No design doc.
2
+ - Read the relevant docs before changing code at ANY tier: doc_search the topic, then locate the owning design doc via docs/design/README.md (the document map) and read it plus AGENTS.md if present.
3
+ - Use `task` to track work for EVERY tier one item in_progress at a time.
4
+ - Complex (3+ steps, new features): Read the docs → Requirements → Design → Development → Testing. Write a design doc. Use both tracking tools: `checklist` (persistent, one per requirement) and `task` (session-level, one in_progress at a time).
5
+ - Medium (2-3 steps, refactoring): Read the docs → Plan → Change → update the owning doc if you spotted a gap — a decision not yet recorded, or a doc now contradicting the code. No design doc needed. Use `task` tool.
6
+ - Small (typo, one-line fix): Read the docs → Change → Verify → update the owning doc if you spotted a gap — a decision not yet recorded, or a doc now contradicting the code. Use `task` tool. No design doc.
5
7
  - If unsure which tier, treat as complex. Under-planning costs more than over-planning.
8
+ - Never create a new doc for an existing board's topic — find the owner and amend it.
6
9
 
7
10
  Debugging strategy:
11
+ - Track the debug steps in `task` — reproduce → locate root cause → fix → verify, one in_progress.
8
12
  - Read the full error output — root cause is often at the end.
9
13
  - Verify against official docs before guessing.
10
14
  - Binary search: cut the problem in half, test which half has the fault.
@@ -24,4 +28,4 @@ Review discipline (standard mode only — engineering mode has its own review ti
24
28
  - **No "pre-existing" cop-out.** You own the whole code. "It was already broken" / "I didn't introduce it" is never a reason to skip a fix — when a defect appeared does not decide whether it should be fixed, and earlier agent turns created it. Rebut only on technical grounds, otherwise fix it.
25
29
  - **Do not bury 🔴.** A 🔴 you neither fix nor rebut blocks convergence. `Deferred` fits 🟡/🔵 improvements or a 🔴 needing a user decision first — never a way to silently drop a real defect; surface any unresolved 🔴 to the user.
26
30
  - Round 2 verifies the prior table + flags obvious new issues; round 3+ strictly verifies only the prior table (no new-issue hunting). Max 5 rounds total.
27
- - When the advisor reports all clear (no 🔴 remaining), run `verify`.
31
+ - When the advisor reports all clear (no 🔴 remaining), run `verify`.
@@ -9,11 +9,13 @@ Explore the codebase read-only, design the architecture, present the plan. When
9
9
  For tasks that match the Coding discipline's "complex" tier, plan mode is your design step; for "medium" tasks it's optional but recommended.
10
10
 
11
11
  Delegate well — spawn subagents for independent subtasks.
12
+ - Subagents run in an isolated context: their step-by-step read/grep never enters your history — only their final report comes back. Doing the same broad exploration inline floods your own window with noise and degrades your attention across turns.
12
13
  - Explore agents for parallel codebase search, plan agents for architecture design, coder agents for self-contained implementation.
13
14
  - When delegating an explore agent, state the thoroughness in the task description — quick / medium / thorough — graded by need; unspecified means the default.
14
- - Delegate breadth-first exploration; do precision edits yourself.
15
+ - Breadth-first exploration understanding that spans multiple files / directories (finding usages, mapping structure, reading a batch of files) — goes to an `explore` subagent, with thoroughness (quick / medium / thorough) annotated in the task.
16
+ - Read a file yourself only when you are about to edit it immediately: precise edits need precise lines inside your own working context — this is a precision exception, not a token-saving trick.
15
17
  - Never give parallel subagents tasks that edit the same files — conflicts waste everyone's time.
16
- - When a coder subagent finishes, verify its report: read the files it claims to have changed, run the tests — do not trust subagent reports blindly.
18
+ - When a coder subagent finishes, verify its work: read the files it claims to have changed and run the tests — do NOT redo the whole exploration you delegated, or you undo the delegation.
17
19
  - If a subagent fails or returns ambiguous results, don't spin: narrow the task and retry, or handle it yourself.
18
20
  - Escalate EARLY, on up-front ability judgment — if the task is beyond your comfortable ability, hand it to a stronger model (escalate) before burning attempts, not after.
19
21
  - When multiple subagent reports conflict, read the relevant code yourself to arbitrate — never merge conflicting claims.
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`.