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/CHANGELOG.md +303 -0
- package/package.json +2 -1
- package/src/agent-tools/consult.mjs +48 -9
- package/src/agent-tools/verify.mjs +13 -8
- package/src/agent.mjs +12 -1
- package/src/config.mjs +16 -4
- package/src/context.mjs +148 -2
- package/src/prompts/discipline.md +8 -4
- package/src/prompts/main.md +4 -2
- package/src/tools/bash.md +1 -0
- package/src/tools/codemode.mjs +125 -134
- package/src/tools/exec-prelude.mjs +84 -0
- package/src/tools/execute.md +14 -2
- package/src/tools/file_ops.md +16 -0
- package/src/tools/get_current_time.md +6 -0
- package/src/tools/git.md +10 -4
- package/src/tools/git.mjs +60 -8
- package/src/tools/grep.md +3 -1
- package/src/tools/index.mjs +7 -1
- package/src/tools/ls.md +1 -0
- package/src/tools/ops.mjs +142 -0
- package/src/tools/process.md +10 -0
- package/src/tools/sleep.md +5 -0
- package/src/tools/system.mjs +28 -6
- package/src/tools/tree.md +13 -0
- package/src/tools/tree.mjs +66 -0
package/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
|
-
-
|
|
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
|
-
-
|
|
3
|
-
-
|
|
4
|
-
-
|
|
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`.
|
package/src/prompts/main.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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
|
```
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -1,65 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools/codemode.mjs — CodeMode: JavaScript execution tool
|
|
3
3
|
*
|
|
4
|
-
* Gives the model an `execute` tool
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Gives the model an `execute` tool that runs JS in a child `node
|
|
5
|
+
* --input-type=module --eval` process — NOT the in-process vm sandbox it used
|
|
6
|
+
* to be. The vm route could not support dynamic `import()` (needs the
|
|
7
|
+
* --experimental-vm-modules flag) or await it, which pushed every real JS run
|
|
8
|
+
* back to `bash node -e`. A child node process gives top-level await, dynamic
|
|
9
|
+
* `import()` of the project's own .mjs modules, native `console`/`fetch`, AND a
|
|
10
|
+
* killable timeout (an in-process infinite loop would freeze the CLI; a child
|
|
11
|
+
* process is killed like bash).
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* glob(pattern) — return array of matching paths
|
|
12
|
-
* grep(pattern, file) — return array of matching lines
|
|
13
|
-
* log(...args) — append to output buffer
|
|
14
|
-
* fetch(url) — HTTP GET, return string
|
|
13
|
+
* The child `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
|
|
14
|
+
* log/require (paths confined to the workspace root). Full Node via require()/
|
|
15
|
+
* process/import() is available — same boundary as bash, no fake sandbox.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Limits (engineering guards, not security):
|
|
22
|
-
* timeout: 30s (configurable via timeoutMs param)
|
|
23
|
-
* maxOutput: 50000 bytes
|
|
24
|
-
* maxScriptSize: 50000 bytes
|
|
25
|
-
* file paths confined to cwd (accidental out-of-workspace writes)
|
|
17
|
+
* Parameters:
|
|
18
|
+
* code — JS to run (top-level await and import() supported)
|
|
19
|
+
* workdir — run in this sub-directory (confined to the workspace)
|
|
20
|
+
* filter — return only output lines matching this regex (case-insensitive)
|
|
21
|
+
* timeoutMs — timeout (default 30s, max 60s)
|
|
26
22
|
*/
|
|
23
|
+
import { spawn } from "node:child_process"
|
|
24
|
+
import { dirname, resolve, relative, isAbsolute, sep } from "node:path"
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
26
|
+
import { DESC } from "./shared.mjs"
|
|
27
27
|
|
|
28
|
-
import { Script, createContext } from "node:vm"
|
|
29
|
-
import { createRequire } from "node:module"
|
|
30
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
31
|
-
import { join, dirname, relative, resolve } from "node:path"
|
|
32
|
-
import { DESC, globToRegex, normalizeEOL } from "./shared.mjs"
|
|
33
|
-
|
|
34
|
-
const MAX_OUTPUT = 50_000
|
|
35
28
|
const MAX_SCRIPT = 50_000
|
|
29
|
+
const MAX_OUTPUT = 50_000
|
|
36
30
|
const DEFAULT_TIMEOUT = 30_000
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
|
|
34
|
+
|
|
35
|
+
/** True when `abs` is inside `root` (handles `..` and cross-drive, which
|
|
36
|
+
* relative() returns as an absolute path on Windows). */
|
|
37
|
+
function isInside(root, abs) {
|
|
38
|
+
const rel = relative(root, abs)
|
|
39
|
+
if (isAbsolute(rel)) return false
|
|
40
|
+
return rel !== ".." && !rel.startsWith(".." + sep)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve workdir relative to cwd, asserting it stays within the workspace. */
|
|
44
|
+
function resolveBaseDir(cwd, workdir) {
|
|
45
|
+
if (!workdir || typeof workdir !== "string") return cwd
|
|
46
|
+
const abs = resolve(cwd, workdir)
|
|
47
|
+
if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
|
|
48
|
+
return abs
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
|
51
|
+
/** Keep only output lines matching a regex (execute filter, case-insensitive). */
|
|
52
|
+
function applyFilter(output, filter) {
|
|
54
53
|
try {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
return
|
|
58
|
-
}
|
|
59
|
-
|
|
54
|
+
const re = new RegExp(filter, "i")
|
|
55
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
56
|
+
return lines.length ? lines.join("\n") : `(no output lines matched filter "${filter}")`
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return `Error: filter regex invalid: ${e.message}`
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
/** Spawn node, run code + prelude, capture stdout/stderr, enforce timeout/abort.
|
|
63
|
+
* Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
|
|
64
|
+
function runNodeEval(code, baseDir, root, timeoutMs, signal) {
|
|
65
|
+
return new Promise((resolvePromise) => {
|
|
66
|
+
const src = `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`
|
|
67
|
+
const child = spawn(process.execPath, ["--input-type=module", "--eval", src], {
|
|
68
|
+
cwd: baseDir,
|
|
69
|
+
env: { ...process.env, THINCODER_EXEC_ROOT: root },
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
let outBuf = "", errBuf = "", truncated = false, settled = false, mode = null
|
|
75
|
+
let timer = null, kickTimer = null
|
|
76
|
+
|
|
77
|
+
const settle = (text, ok) => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
settled = true
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
clearTimeout(kickTimer)
|
|
82
|
+
if (signal) signal.removeEventListener("abort", onAbort)
|
|
83
|
+
resolvePromise({ text, ok })
|
|
84
|
+
}
|
|
85
|
+
// SIGKILL (not SIGTERM) so a signal-trapping script can't dodge the watchdog.
|
|
86
|
+
const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
|
|
87
|
+
// After kill, wait for "close" (child fully reaped) before settling — settling
|
|
88
|
+
// early races the caller deleting the cwd dir while the child still holds it.
|
|
89
|
+
const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : `Error: script timed out after ${timeoutMs}ms`, false), 3000) }
|
|
90
|
+
const onAbort = () => { if (mode) return; mode = "abort"; kill(); armKick() }
|
|
91
|
+
|
|
92
|
+
timer = setTimeout(() => { if (!mode) { mode = "timeout"; kill(); armKick() } }, timeoutMs)
|
|
93
|
+
|
|
94
|
+
if (signal) {
|
|
95
|
+
if (signal.aborted) onAbort()
|
|
96
|
+
else signal.addEventListener("abort", onAbort, { once: true })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cap = (buf, d) => {
|
|
100
|
+
if (buf.length < MAX_OUTPUT) return buf + d
|
|
101
|
+
if (!truncated) { truncated = true; return buf + "\n...[output truncated]" }
|
|
102
|
+
return buf
|
|
103
|
+
}
|
|
104
|
+
child.stdout.on("data", (d) => { outBuf = cap(outBuf, d.toString()) })
|
|
105
|
+
child.stderr.on("data", (d) => { errBuf = cap(errBuf, d.toString()) })
|
|
106
|
+
child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
|
|
107
|
+
child.on("close", (code) => {
|
|
108
|
+
if (mode === "abort") return settle("(stopped)", false)
|
|
109
|
+
if (mode === "timeout") return settle(`Error: script timed out after ${timeoutMs}ms`, false)
|
|
110
|
+
const out = outBuf.trimEnd()
|
|
111
|
+
const err = errBuf.trim()
|
|
112
|
+
if (code === 0) {
|
|
113
|
+
settle(out || "(no output)", true)
|
|
114
|
+
} else {
|
|
115
|
+
settle(err ? (out ? `${out}\n\n[stderr]:\n${err}` : err) : `${out}\n(exit code ${code})`.trim(), false)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
63
121
|
export const codeModeTool = {
|
|
64
122
|
name: "execute",
|
|
65
123
|
description: DESC("execute"),
|
|
@@ -68,10 +126,20 @@ export const codeModeTool = {
|
|
|
68
126
|
properties: {
|
|
69
127
|
code: {
|
|
70
128
|
type: "string",
|
|
71
|
-
description: "JavaScript code to execute. Use provided
|
|
129
|
+
description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import.",
|
|
130
|
+
},
|
|
131
|
+
workdir: {
|
|
132
|
+
type: "string",
|
|
133
|
+
description: "Run in this directory (relative to cwd, confined to the workspace; default cwd)",
|
|
134
|
+
},
|
|
135
|
+
filter: {
|
|
136
|
+
type: "string",
|
|
137
|
+
description: "Optional: only return output lines matching this regex (case-insensitive)",
|
|
72
138
|
},
|
|
73
139
|
timeoutMs: {
|
|
74
140
|
type: "integer",
|
|
141
|
+
minimum: 1,
|
|
142
|
+
maximum: 60000,
|
|
75
143
|
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
|
|
76
144
|
},
|
|
77
145
|
},
|
|
@@ -80,97 +148,20 @@ export const codeModeTool = {
|
|
|
80
148
|
readonly: false,
|
|
81
149
|
|
|
82
150
|
async execute(args, ctx) {
|
|
83
|
-
const cwd = ctx.cwd
|
|
84
151
|
const code = args.code ?? ""
|
|
85
|
-
|
|
86
152
|
if (code.length > MAX_SCRIPT) {
|
|
87
153
|
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
88
154
|
}
|
|
155
|
+
let baseDir
|
|
156
|
+
try { baseDir = resolveBaseDir(ctx.cwd, args.workdir) }
|
|
157
|
+
catch (e) { return `Error: ${e.message}` }
|
|
89
158
|
|
|
90
|
-
const
|
|
91
|
-
const timeoutMs = Math.min(
|
|
92
|
-
|
|
93
|
-
// File path guard: ensure paths are within cwd (accidental out-of-workspace writes)
|
|
94
|
-
function safePath(p) {
|
|
95
|
-
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
96
|
-
const abs = resolve(cwd, p)
|
|
97
|
-
const rel = relative(cwd, abs)
|
|
98
|
-
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
99
|
-
throw new Error(`Path traversal denied: ${p}`)
|
|
100
|
-
}
|
|
101
|
-
return abs
|
|
102
|
-
}
|
|
159
|
+
const t = Number(args.timeoutMs)
|
|
160
|
+
const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 60_000) : DEFAULT_TIMEOUT
|
|
103
161
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const st = statSync(abs)
|
|
109
|
-
if (st.size > 5_000_000) throw new Error(`File too large: ${p} (${Math.round(st.size / 1000000)}MB)`)
|
|
110
|
-
return normalizeEOL(readFileSync(abs, "utf8"))
|
|
111
|
-
},
|
|
112
|
-
writeFile: (p, content) => {
|
|
113
|
-
const abs = safePath(p)
|
|
114
|
-
mkdirSync(dirname(abs), { recursive: true })
|
|
115
|
-
writeFileSync(abs, String(content), "utf8")
|
|
116
|
-
},
|
|
117
|
-
glob: (pattern) => {
|
|
118
|
-
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
119
|
-
const regex = globToRegex(pattern)
|
|
120
|
-
const results = []
|
|
121
|
-
function walk(dir, rel) {
|
|
122
|
-
let entries
|
|
123
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
124
|
-
for (const e of entries) {
|
|
125
|
-
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
126
|
-
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
127
|
-
if (e.isDirectory()) { walk(join(dir, e.name), relPath) }
|
|
128
|
-
else if (regex.test(relPath)) results.push(relPath)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
walk(cwd, "")
|
|
132
|
-
return results.slice(0, 200)
|
|
133
|
-
},
|
|
134
|
-
grep: (pattern, file) => {
|
|
135
|
-
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
136
|
-
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
137
|
-
const abs = safePath(file)
|
|
138
|
-
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
139
|
-
const content = normalizeEOL(readFileSync(abs, "utf8"))
|
|
140
|
-
const regex = new RegExp(pattern)
|
|
141
|
-
const lines = content.split("\n")
|
|
142
|
-
const matches = []
|
|
143
|
-
for (let i = 0; i < lines.length; i++) {
|
|
144
|
-
if (regex.test(lines[i])) matches.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
145
|
-
}
|
|
146
|
-
return matches.slice(0, 100)
|
|
147
|
-
},
|
|
148
|
-
log: (...args) => {
|
|
149
|
-
const line = args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")
|
|
150
|
-
output.push(line)
|
|
151
|
-
if (output.join("\n").length > MAX_OUTPUT) {
|
|
152
|
-
output.push("... (output truncated)")
|
|
153
|
-
throw new Error("CodeMode output limit exceeded")
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
fetch: sandboxFetch,
|
|
157
|
-
// Full Node access — no fake sandbox (bash can reach it anyway).
|
|
158
|
-
require: createRequire(join(cwd, "__codemode__.js")),
|
|
159
|
-
process,
|
|
160
|
-
setTimeout,
|
|
161
|
-
clearTimeout,
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
const script = new Script(code, { filename: "codemode.js" })
|
|
166
|
-
// timeout belongs on runInContext — the Script constructor ignores it,
|
|
167
|
-
// so passing it there let runaway scripts (while(true)) hang the process forever.
|
|
168
|
-
script.runInContext(sandbox, { timeout: timeoutMs })
|
|
169
|
-
return output.join("\n") || "(no output)"
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const out = output.join("\n")
|
|
172
|
-
const prefix = out ? `${out}\n\n` : ""
|
|
173
|
-
return `${prefix}Error: ${err.message}`
|
|
174
|
-
}
|
|
162
|
+
const { text, ok } = await runNodeEval(code, baseDir, ctx.cwd, timeoutMs, ctx.signal)
|
|
163
|
+
// Only filter successful output — never swallow an error report behind a filter.
|
|
164
|
+
if (!ok) return text
|
|
165
|
+
return args.filter ? applyFilter(text, args.filter) : text
|
|
175
166
|
},
|
|
176
|
-
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec-prelude.mjs — sandbox API injected into `execute`'s child process.
|
|
3
|
+
*
|
|
4
|
+
* The `execute` tool spawns `node --input-type=module --eval` and `import()`-s
|
|
5
|
+
* this file first, so the user's code gets readFile/writeFile/glob/grep/log/require
|
|
6
|
+
* without hand-writing fs boilerplate. Confines file paths to the workspace root
|
|
7
|
+
* (THINCODER_EXEC_ROOT, default cwd) — an orthopedic guard, NOT a sandbox:
|
|
8
|
+
* require()/process/import()/fetch() are full Node, the same boundary as bash
|
|
9
|
+
* (project philosophy: no fake sandbox; transparency + audit).
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module"
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, readdirSync } from "node:fs"
|
|
13
|
+
import { resolve, relative, dirname, join, isAbsolute, sep } from "node:path"
|
|
14
|
+
|
|
15
|
+
const require = createRequire(join(process.cwd(), "__exec__.js"))
|
|
16
|
+
const root = process.env.THINCODER_EXEC_ROOT || process.cwd()
|
|
17
|
+
|
|
18
|
+
/** Resolve a path against the working dir, asserting it stays within the workspace root. */
|
|
19
|
+
function safe(p) {
|
|
20
|
+
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
21
|
+
const abs = resolve(process.cwd(), p)
|
|
22
|
+
const rel = relative(root, abs)
|
|
23
|
+
// isAbsolute(rel) covers cross-drive (relative() returns an absolute path then)
|
|
24
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
25
|
+
throw new Error(`Path traversal denied: ${p}`)
|
|
26
|
+
}
|
|
27
|
+
return abs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function globToRegex(pattern) {
|
|
31
|
+
const DS = "\u0001", DP = "\u0002"
|
|
32
|
+
const escaped = pattern
|
|
33
|
+
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
34
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
35
|
+
.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
36
|
+
.replace(new RegExp(DS, "g"), "(?:.+/)?").replace(new RegExp(DP, "g"), ".*")
|
|
37
|
+
return new RegExp(`^${escaped}$`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
globalThis.require = require
|
|
41
|
+
globalThis.readFile = (p) => {
|
|
42
|
+
const abs = safe(p)
|
|
43
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
44
|
+
if (statSync(abs).size > 5_000_000) throw new Error(`File too large: ${p}`)
|
|
45
|
+
return readFileSync(abs, "utf8").replace(/\r\n/g, "\n")
|
|
46
|
+
}
|
|
47
|
+
globalThis.writeFile = (p, content) => {
|
|
48
|
+
const abs = safe(p)
|
|
49
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
50
|
+
writeFileSync(abs, String(content), "utf8")
|
|
51
|
+
}
|
|
52
|
+
globalThis.glob = (pattern) => {
|
|
53
|
+
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
54
|
+
const re = globToRegex(pattern)
|
|
55
|
+
const out = []
|
|
56
|
+
function walk(dir, rel) {
|
|
57
|
+
let entries
|
|
58
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
61
|
+
const rp = rel ? `${rel}/${e.name}` : e.name
|
|
62
|
+
if (e.isDirectory()) walk(join(dir, e.name), rp)
|
|
63
|
+
else if (re.test(rp)) out.push(rp)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
walk(process.cwd(), "")
|
|
67
|
+
const capped = out.slice(0, 200)
|
|
68
|
+
if (out.length > 200) capped.push(`... (${out.length - 200} more)`)
|
|
69
|
+
return capped
|
|
70
|
+
}
|
|
71
|
+
globalThis.grep = (pattern, file) => {
|
|
72
|
+
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
73
|
+
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
74
|
+
const abs = safe(file)
|
|
75
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
76
|
+
const re = new RegExp(pattern)
|
|
77
|
+
const lines = readFileSync(abs, "utf8").replace(/\r\n/g, "\n").split("\n")
|
|
78
|
+
const m = []
|
|
79
|
+
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) m.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
80
|
+
const capped = m.slice(0, 100)
|
|
81
|
+
if (m.length > 100) capped.push(`... (${m.length - 100} more)`)
|
|
82
|
+
return capped
|
|
83
|
+
}
|
|
84
|
+
globalThis.log = (...a) => console.log(a.map((x) => (x && typeof x === "object" ? JSON.stringify(x) : String(x))).join(" "))
|
package/src/tools/execute.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
Execute JavaScript code with full Node access. Use this to compose multiple
|
|
1
|
+
Execute JavaScript code with full Node access. Runs in a real `node` process with top-level `await` and dynamic `import()` — so you can load and call the project's own `.mjs` modules directly. Use this to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
|
|
2
|
+
|
|
3
|
+
**Route to execute instead of bash:**
|
|
4
|
+
- `node -e "…"` → execute (top-level await + import() + console all work)
|
|
2
5
|
|
|
3
6
|
Parameters:
|
|
4
|
-
- code (required): JavaScript
|
|
7
|
+
- code (required): JavaScript to run. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import.
|
|
8
|
+
- workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
|
|
9
|
+
- filter: optional — only return output lines matching this regex (case-insensitive)
|
|
5
10
|
- timeoutMs: Timeout in milliseconds (default 30000, max 60000)
|
|
11
|
+
|
|
12
|
+
Notes:
|
|
13
|
+
- `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
|
|
14
|
+
- File paths are confined to the workspace root (`..` traversal is denied) — but `require`/`process`/`import()` are full Node, same boundary as bash.
|
|
15
|
+
- A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
|
|
16
|
+
- Output capped at ~50KB; use `writeFile` to a file if you need more.
|
|
17
|
+
- Use `write`/`edit`/`apply_patch` for source edits and `bash` for subprocess/CLI runs (`npm test`, `node --test`, servers) — execute is for in-process JS, not spawning programs.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Move, copy, or rename a file/directory.
|
|
2
|
+
|
|
3
|
+
**Route to file_ops instead of bash:**
|
|
4
|
+
- `mv a b` → file_ops action=move
|
|
5
|
+
- `cp a b` / `copy` → file_ops action=copy
|
|
6
|
+
- `ren a b` / `rename a b` → file_ops action=rename
|
|
7
|
+
|
|
8
|
+
Parameters:
|
|
9
|
+
- action (required): move | copy | rename
|
|
10
|
+
- source (required): source path, relative to cwd or absolute
|
|
11
|
+
- dest (required): destination path
|
|
12
|
+
|
|
13
|
+
Notes:
|
|
14
|
+
- Paths are confined to the working directory (same safety as write/edit) — bash has NO directory confinement.
|
|
15
|
+
- `dest` is overwritten if it already exists. `copy` is recursive for directories.
|
|
16
|
+
- To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
|