thincoder 0.12.36 → 0.12.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/advisor.mjs +4 -22
- package/src/agent-tools/consult.mjs +48 -9
- package/src/agent-tools/verify.mjs +13 -8
- package/src/config.mjs +17 -5
- package/src/context.mjs +1 -1
- package/src/escape.mjs +49 -0
- package/src/generate-title.mjs +12 -2
- package/src/prompts/discipline.md +8 -1
- package/src/prompts/engineering.md +13 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/core.mjs +5 -0
- package/src/provider/sse.mjs +56 -14
- package/src/session-migrate.mjs +42 -0
- package/src/session.mjs +1 -38
- 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/tui/clipboard.mjs +53 -1
- package/src/tui/cmd-advisor.mjs +4 -5
- package/src/tui/cmd-config.mjs +14 -2
- package/src/tui/cmd-copy.mjs +29 -0
- package/src/tui/math.mjs +309 -0
- package/src/tui/render-conversation.mjs +11 -3
- package/src/tui/render-frame.mjs +3 -3
- package/src/tui/slash-commands.mjs +4 -1
package/src/session.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsS
|
|
|
13
13
|
import { join, dirname } from "node:path"
|
|
14
14
|
import { execSync } from "node:child_process"
|
|
15
15
|
import { configDir } from "./config.mjs"
|
|
16
|
+
import { migrateHashLength } from "./session-migrate.mjs"
|
|
16
17
|
|
|
17
18
|
let currentSessionId = null
|
|
18
19
|
|
|
@@ -35,40 +36,6 @@ function cwdHash(cwd) {
|
|
|
35
36
|
return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/** One-time migration: rename legacy short-hash session files to the full 40-char hash.
|
|
39
|
-
* Idempotent; runs on first access per cwd.
|
|
40
|
-
* Historical hash algorithms (all sha1, none normalized the drive letter):
|
|
41
|
-
* - CLI: sha1(cwd).slice(0, 12) — cwd comes from process.cwd() (uppercase drive on Windows)
|
|
42
|
-
* - VS Code: sha1(cwd).slice(0, 16) — cwd comes from uri.fsPath (LOWERCASE drive on Windows)
|
|
43
|
-
* Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
|
|
44
|
-
* Every combination is tried — a migration that only checks one candidate misses real
|
|
45
|
-
* legacy files (drive-letter case differs between CLI and VS Code historical paths). */
|
|
46
|
-
function migrateHashLength(cwd, fullHash) {
|
|
47
|
-
const dir = join(configDir, "sessions")
|
|
48
|
-
const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
|
|
49
|
-
const candidates = [
|
|
50
|
-
createHash("sha1").update(cwd).digest("hex").slice(0, 12),
|
|
51
|
-
createHash("sha1").update(cwd).digest("hex").slice(0, 16),
|
|
52
|
-
createHash("sha1").update(lower).digest("hex").slice(0, 12),
|
|
53
|
-
createHash("sha1").update(lower).digest("hex").slice(0, 16),
|
|
54
|
-
fullHash.slice(0, 12),
|
|
55
|
-
]
|
|
56
|
-
const newBase = join(dir, `${fullHash}.json`)
|
|
57
|
-
let migrated = false
|
|
58
|
-
for (const short of new Set(candidates)) {
|
|
59
|
-
const legacyBase = join(dir, `${short}.json`)
|
|
60
|
-
if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) continue
|
|
61
|
-
migrated = true
|
|
62
|
-
try {
|
|
63
|
-
for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
|
|
64
|
-
const from = legacyBase + suffix
|
|
65
|
-
if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
|
|
66
|
-
}
|
|
67
|
-
} catch { /* best-effort; leave files in place on failure */ }
|
|
68
|
-
}
|
|
69
|
-
return migrated
|
|
70
|
-
}
|
|
71
|
-
|
|
72
39
|
/** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
|
|
73
40
|
export function sessionPath(cwd) {
|
|
74
41
|
const hash = cwdHash(cwd)
|
|
@@ -149,10 +116,6 @@ function saveManifest(cwd, m) {
|
|
|
149
116
|
writeSessionFile(manifestPath(cwd), m)
|
|
150
117
|
}
|
|
151
118
|
|
|
152
|
-
/**
|
|
153
|
-
* Ensure an active slot exists in the manifest, migrating legacy data if needed.
|
|
154
|
-
* Called by activeSlot() — idempotent, safe to call repeatedly.
|
|
155
|
-
*/
|
|
156
119
|
/**
|
|
157
120
|
* Claim a slot for this process and set it as active. Idempotent.
|
|
158
121
|
* Preference order:
|
package/src/tools/bash.md
CHANGED
|
@@ -11,6 +11,7 @@ Execute a shell command and return stdout+stderr. Use for running commands, buil
|
|
|
11
11
|
Parameters:
|
|
12
12
|
- command (required): Shell command to execute
|
|
13
13
|
- timeout: Timeout in milliseconds (default 120000, max ~300000)
|
|
14
|
+
- filter: Optional — a regex; only output lines matching it are returned (case-insensitive). Use instead of hand-writing a pipe into `findstr`/`grep`.
|
|
14
15
|
|
|
15
16
|
Output format:
|
|
16
17
|
```
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -1,65 +1,123 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* tools/codemode.mjs — CodeMode: JavaScript execution tool
|
|
3
3
|
*
|
|
4
|
-
* Gives the model an `execute` tool
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Gives the model an `execute` tool that runs JS in a child `node
|
|
5
|
+
* --input-type=module --eval` process — NOT the in-process vm sandbox it used
|
|
6
|
+
* to be. The vm route could not support dynamic `import()` (needs the
|
|
7
|
+
* --experimental-vm-modules flag) or await it, which pushed every real JS run
|
|
8
|
+
* back to `bash node -e`. A child node process gives top-level await, dynamic
|
|
9
|
+
* `import()` of the project's own .mjs modules, native `console`/`fetch`, AND a
|
|
10
|
+
* killable timeout (an in-process infinite loop would freeze the CLI; a child
|
|
11
|
+
* process is killed like bash).
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* glob(pattern) — return array of matching paths
|
|
12
|
-
* grep(pattern, file) — return array of matching lines
|
|
13
|
-
* log(...args) — append to output buffer
|
|
14
|
-
* fetch(url) — HTTP GET, return string
|
|
13
|
+
* The child `import()`-s exec-prelude.mjs first for readFile/writeFile/glob/grep/
|
|
14
|
+
* log/require (paths confined to the workspace root). Full Node via require()/
|
|
15
|
+
* process/import() is available — same boundary as bash, no fake sandbox.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Limits (engineering guards, not security):
|
|
22
|
-
* timeout: 30s (configurable via timeoutMs param)
|
|
23
|
-
* maxOutput: 50000 bytes
|
|
24
|
-
* maxScriptSize: 50000 bytes
|
|
25
|
-
* file paths confined to cwd (accidental out-of-workspace writes)
|
|
17
|
+
* Parameters:
|
|
18
|
+
* code — JS to run (top-level await and import() supported)
|
|
19
|
+
* workdir — run in this sub-directory (confined to the workspace)
|
|
20
|
+
* filter — return only output lines matching this regex (case-insensitive)
|
|
21
|
+
* timeoutMs — timeout (default 30s, max 60s)
|
|
26
22
|
*/
|
|
23
|
+
import { spawn } from "node:child_process"
|
|
24
|
+
import { dirname, resolve, relative, isAbsolute, sep } from "node:path"
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
26
|
+
import { DESC } from "./shared.mjs"
|
|
27
27
|
|
|
28
|
-
import { Script, createContext } from "node:vm"
|
|
29
|
-
import { createRequire } from "node:module"
|
|
30
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
31
|
-
import { join, dirname, relative, resolve } from "node:path"
|
|
32
|
-
import { DESC, globToRegex, normalizeEOL } from "./shared.mjs"
|
|
33
|
-
|
|
34
|
-
const MAX_OUTPUT = 50_000
|
|
35
28
|
const MAX_SCRIPT = 50_000
|
|
29
|
+
const MAX_OUTPUT = 50_000
|
|
36
30
|
const DEFAULT_TIMEOUT = 30_000
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const PRELUDE_URL = pathToFileURL(resolve(__dirname, "exec-prelude.mjs")).href
|
|
34
|
+
|
|
35
|
+
/** True when `abs` is inside `root` (handles `..` and cross-drive, which
|
|
36
|
+
* relative() returns as an absolute path on Windows). */
|
|
37
|
+
function isInside(root, abs) {
|
|
38
|
+
const rel = relative(root, abs)
|
|
39
|
+
if (isAbsolute(rel)) return false
|
|
40
|
+
return rel !== ".." && !rel.startsWith(".." + sep)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve workdir relative to cwd, asserting it stays within the workspace. */
|
|
44
|
+
function resolveBaseDir(cwd, workdir) {
|
|
45
|
+
if (!workdir || typeof workdir !== "string") return cwd
|
|
46
|
+
const abs = resolve(cwd, workdir)
|
|
47
|
+
if (!isInside(cwd, abs)) throw new Error(`workdir escapes the workspace: ${workdir}`)
|
|
48
|
+
return abs
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
|
51
|
+
/** Keep only output lines matching a regex (execute filter, case-insensitive). */
|
|
52
|
+
function applyFilter(output, filter) {
|
|
54
53
|
try {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
return
|
|
58
|
-
}
|
|
59
|
-
|
|
54
|
+
const re = new RegExp(filter, "i")
|
|
55
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
56
|
+
return lines.length ? lines.join("\n") : `(no output lines matched filter "${filter}")`
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return `Error: filter regex invalid: ${e.message}`
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
/** Spawn node, run code + prelude, capture stdout/stderr, enforce timeout/abort.
|
|
63
|
+
* Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
|
|
64
|
+
function runNodeEval(code, baseDir, root, timeoutMs, signal) {
|
|
65
|
+
return new Promise((resolvePromise) => {
|
|
66
|
+
const src = `await import(${JSON.stringify(PRELUDE_URL)});\n${code}`
|
|
67
|
+
const child = spawn(process.execPath, ["--input-type=module", "--eval", src], {
|
|
68
|
+
cwd: baseDir,
|
|
69
|
+
env: { ...process.env, THINCODER_EXEC_ROOT: root },
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
let outBuf = "", errBuf = "", truncated = false, settled = false, mode = null
|
|
75
|
+
let timer = null, kickTimer = null
|
|
76
|
+
|
|
77
|
+
const settle = (text, ok) => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
settled = true
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
clearTimeout(kickTimer)
|
|
82
|
+
if (signal) signal.removeEventListener("abort", onAbort)
|
|
83
|
+
resolvePromise({ text, ok })
|
|
84
|
+
}
|
|
85
|
+
// SIGKILL (not SIGTERM) so a signal-trapping script can't dodge the watchdog.
|
|
86
|
+
const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
|
|
87
|
+
// After kill, wait for "close" (child fully reaped) before settling — settling
|
|
88
|
+
// early races the caller deleting the cwd dir while the child still holds it.
|
|
89
|
+
const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : `Error: script timed out after ${timeoutMs}ms`, false), 3000) }
|
|
90
|
+
const onAbort = () => { if (mode) return; mode = "abort"; kill(); armKick() }
|
|
91
|
+
|
|
92
|
+
timer = setTimeout(() => { if (!mode) { mode = "timeout"; kill(); armKick() } }, timeoutMs)
|
|
93
|
+
|
|
94
|
+
if (signal) {
|
|
95
|
+
if (signal.aborted) onAbort()
|
|
96
|
+
else signal.addEventListener("abort", onAbort, { once: true })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cap = (buf, d) => {
|
|
100
|
+
if (buf.length < MAX_OUTPUT) return buf + d
|
|
101
|
+
if (!truncated) { truncated = true; return buf + "\n...[output truncated]" }
|
|
102
|
+
return buf
|
|
103
|
+
}
|
|
104
|
+
child.stdout.on("data", (d) => { outBuf = cap(outBuf, d.toString()) })
|
|
105
|
+
child.stderr.on("data", (d) => { errBuf = cap(errBuf, d.toString()) })
|
|
106
|
+
child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
|
|
107
|
+
child.on("close", (code) => {
|
|
108
|
+
if (mode === "abort") return settle("(stopped)", false)
|
|
109
|
+
if (mode === "timeout") return settle(`Error: script timed out after ${timeoutMs}ms`, false)
|
|
110
|
+
const out = outBuf.trimEnd()
|
|
111
|
+
const err = errBuf.trim()
|
|
112
|
+
if (code === 0) {
|
|
113
|
+
settle(out || "(no output)", true)
|
|
114
|
+
} else {
|
|
115
|
+
settle(err ? (out ? `${out}\n\n[stderr]:\n${err}` : err) : `${out}\n(exit code ${code})`.trim(), false)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
63
121
|
export const codeModeTool = {
|
|
64
122
|
name: "execute",
|
|
65
123
|
description: DESC("execute"),
|
|
@@ -68,10 +126,20 @@ export const codeModeTool = {
|
|
|
68
126
|
properties: {
|
|
69
127
|
code: {
|
|
70
128
|
type: "string",
|
|
71
|
-
description: "JavaScript code to execute. Use provided
|
|
129
|
+
description: "JavaScript code to execute (top-level await and dynamic import() supported). Use provided globals: readFile/writeFile/glob/grep/log, plus native require/process/console/fetch/import.",
|
|
130
|
+
},
|
|
131
|
+
workdir: {
|
|
132
|
+
type: "string",
|
|
133
|
+
description: "Run in this directory (relative to cwd, confined to the workspace; default cwd)",
|
|
134
|
+
},
|
|
135
|
+
filter: {
|
|
136
|
+
type: "string",
|
|
137
|
+
description: "Optional: only return output lines matching this regex (case-insensitive)",
|
|
72
138
|
},
|
|
73
139
|
timeoutMs: {
|
|
74
140
|
type: "integer",
|
|
141
|
+
minimum: 1,
|
|
142
|
+
maximum: 60000,
|
|
75
143
|
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
|
|
76
144
|
},
|
|
77
145
|
},
|
|
@@ -80,97 +148,20 @@ export const codeModeTool = {
|
|
|
80
148
|
readonly: false,
|
|
81
149
|
|
|
82
150
|
async execute(args, ctx) {
|
|
83
|
-
const cwd = ctx.cwd
|
|
84
151
|
const code = args.code ?? ""
|
|
85
|
-
|
|
86
152
|
if (code.length > MAX_SCRIPT) {
|
|
87
153
|
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
88
154
|
}
|
|
155
|
+
let baseDir
|
|
156
|
+
try { baseDir = resolveBaseDir(ctx.cwd, args.workdir) }
|
|
157
|
+
catch (e) { return `Error: ${e.message}` }
|
|
89
158
|
|
|
90
|
-
const
|
|
91
|
-
const timeoutMs = Math.min(
|
|
92
|
-
|
|
93
|
-
// File path guard: ensure paths are within cwd (accidental out-of-workspace writes)
|
|
94
|
-
function safePath(p) {
|
|
95
|
-
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
96
|
-
const abs = resolve(cwd, p)
|
|
97
|
-
const rel = relative(cwd, abs)
|
|
98
|
-
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
99
|
-
throw new Error(`Path traversal denied: ${p}`)
|
|
100
|
-
}
|
|
101
|
-
return abs
|
|
102
|
-
}
|
|
159
|
+
const t = Number(args.timeoutMs)
|
|
160
|
+
const timeoutMs = Number.isFinite(t) && t > 0 ? Math.min(t, 60_000) : DEFAULT_TIMEOUT
|
|
103
161
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const st = statSync(abs)
|
|
109
|
-
if (st.size > 5_000_000) throw new Error(`File too large: ${p} (${Math.round(st.size / 1000000)}MB)`)
|
|
110
|
-
return normalizeEOL(readFileSync(abs, "utf8"))
|
|
111
|
-
},
|
|
112
|
-
writeFile: (p, content) => {
|
|
113
|
-
const abs = safePath(p)
|
|
114
|
-
mkdirSync(dirname(abs), { recursive: true })
|
|
115
|
-
writeFileSync(abs, String(content), "utf8")
|
|
116
|
-
},
|
|
117
|
-
glob: (pattern) => {
|
|
118
|
-
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
119
|
-
const regex = globToRegex(pattern)
|
|
120
|
-
const results = []
|
|
121
|
-
function walk(dir, rel) {
|
|
122
|
-
let entries
|
|
123
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
124
|
-
for (const e of entries) {
|
|
125
|
-
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
126
|
-
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
127
|
-
if (e.isDirectory()) { walk(join(dir, e.name), relPath) }
|
|
128
|
-
else if (regex.test(relPath)) results.push(relPath)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
walk(cwd, "")
|
|
132
|
-
return results.slice(0, 200)
|
|
133
|
-
},
|
|
134
|
-
grep: (pattern, file) => {
|
|
135
|
-
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
136
|
-
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
137
|
-
const abs = safePath(file)
|
|
138
|
-
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
139
|
-
const content = normalizeEOL(readFileSync(abs, "utf8"))
|
|
140
|
-
const regex = new RegExp(pattern)
|
|
141
|
-
const lines = content.split("\n")
|
|
142
|
-
const matches = []
|
|
143
|
-
for (let i = 0; i < lines.length; i++) {
|
|
144
|
-
if (regex.test(lines[i])) matches.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
145
|
-
}
|
|
146
|
-
return matches.slice(0, 100)
|
|
147
|
-
},
|
|
148
|
-
log: (...args) => {
|
|
149
|
-
const line = args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")
|
|
150
|
-
output.push(line)
|
|
151
|
-
if (output.join("\n").length > MAX_OUTPUT) {
|
|
152
|
-
output.push("... (output truncated)")
|
|
153
|
-
throw new Error("CodeMode output limit exceeded")
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
fetch: sandboxFetch,
|
|
157
|
-
// Full Node access — no fake sandbox (bash can reach it anyway).
|
|
158
|
-
require: createRequire(join(cwd, "__codemode__.js")),
|
|
159
|
-
process,
|
|
160
|
-
setTimeout,
|
|
161
|
-
clearTimeout,
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
const script = new Script(code, { filename: "codemode.js" })
|
|
166
|
-
// timeout belongs on runInContext — the Script constructor ignores it,
|
|
167
|
-
// so passing it there let runaway scripts (while(true)) hang the process forever.
|
|
168
|
-
script.runInContext(sandbox, { timeout: timeoutMs })
|
|
169
|
-
return output.join("\n") || "(no output)"
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const out = output.join("\n")
|
|
172
|
-
const prefix = out ? `${out}\n\n` : ""
|
|
173
|
-
return `${prefix}Error: ${err.message}`
|
|
174
|
-
}
|
|
162
|
+
const { text, ok } = await runNodeEval(code, baseDir, ctx.cwd, timeoutMs, ctx.signal)
|
|
163
|
+
// Only filter successful output — never swallow an error report behind a filter.
|
|
164
|
+
if (!ok) return text
|
|
165
|
+
return args.filter ? applyFilter(text, args.filter) : text
|
|
175
166
|
},
|
|
176
|
-
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec-prelude.mjs — sandbox API injected into `execute`'s child process.
|
|
3
|
+
*
|
|
4
|
+
* The `execute` tool spawns `node --input-type=module --eval` and `import()`-s
|
|
5
|
+
* this file first, so the user's code gets readFile/writeFile/glob/grep/log/require
|
|
6
|
+
* without hand-writing fs boilerplate. Confines file paths to the workspace root
|
|
7
|
+
* (THINCODER_EXEC_ROOT, default cwd) — an orthopedic guard, NOT a sandbox:
|
|
8
|
+
* require()/process/import()/fetch() are full Node, the same boundary as bash
|
|
9
|
+
* (project philosophy: no fake sandbox; transparency + audit).
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module"
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, readdirSync } from "node:fs"
|
|
13
|
+
import { resolve, relative, dirname, join, isAbsolute, sep } from "node:path"
|
|
14
|
+
|
|
15
|
+
const require = createRequire(join(process.cwd(), "__exec__.js"))
|
|
16
|
+
const root = process.env.THINCODER_EXEC_ROOT || process.cwd()
|
|
17
|
+
|
|
18
|
+
/** Resolve a path against the working dir, asserting it stays within the workspace root. */
|
|
19
|
+
function safe(p) {
|
|
20
|
+
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
21
|
+
const abs = resolve(process.cwd(), p)
|
|
22
|
+
const rel = relative(root, abs)
|
|
23
|
+
// isAbsolute(rel) covers cross-drive (relative() returns an absolute path then)
|
|
24
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
25
|
+
throw new Error(`Path traversal denied: ${p}`)
|
|
26
|
+
}
|
|
27
|
+
return abs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function globToRegex(pattern) {
|
|
31
|
+
const DS = "\u0001", DP = "\u0002"
|
|
32
|
+
const escaped = pattern
|
|
33
|
+
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
34
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
35
|
+
.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")
|
|
36
|
+
.replace(new RegExp(DS, "g"), "(?:.+/)?").replace(new RegExp(DP, "g"), ".*")
|
|
37
|
+
return new RegExp(`^${escaped}$`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
globalThis.require = require
|
|
41
|
+
globalThis.readFile = (p) => {
|
|
42
|
+
const abs = safe(p)
|
|
43
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
44
|
+
if (statSync(abs).size > 5_000_000) throw new Error(`File too large: ${p}`)
|
|
45
|
+
return readFileSync(abs, "utf8").replace(/\r\n/g, "\n")
|
|
46
|
+
}
|
|
47
|
+
globalThis.writeFile = (p, content) => {
|
|
48
|
+
const abs = safe(p)
|
|
49
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
50
|
+
writeFileSync(abs, String(content), "utf8")
|
|
51
|
+
}
|
|
52
|
+
globalThis.glob = (pattern) => {
|
|
53
|
+
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
54
|
+
const re = globToRegex(pattern)
|
|
55
|
+
const out = []
|
|
56
|
+
function walk(dir, rel) {
|
|
57
|
+
let entries
|
|
58
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
61
|
+
const rp = rel ? `${rel}/${e.name}` : e.name
|
|
62
|
+
if (e.isDirectory()) walk(join(dir, e.name), rp)
|
|
63
|
+
else if (re.test(rp)) out.push(rp)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
walk(process.cwd(), "")
|
|
67
|
+
const capped = out.slice(0, 200)
|
|
68
|
+
if (out.length > 200) capped.push(`... (${out.length - 200} more)`)
|
|
69
|
+
return capped
|
|
70
|
+
}
|
|
71
|
+
globalThis.grep = (pattern, file) => {
|
|
72
|
+
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
73
|
+
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
74
|
+
const abs = safe(file)
|
|
75
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
76
|
+
const re = new RegExp(pattern)
|
|
77
|
+
const lines = readFileSync(abs, "utf8").replace(/\r\n/g, "\n").split("\n")
|
|
78
|
+
const m = []
|
|
79
|
+
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) m.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
80
|
+
const capped = m.slice(0, 100)
|
|
81
|
+
if (m.length > 100) capped.push(`... (${m.length - 100} more)`)
|
|
82
|
+
return capped
|
|
83
|
+
}
|
|
84
|
+
globalThis.log = (...a) => console.log(a.map((x) => (x && typeof x === "object" ? JSON.stringify(x) : String(x))).join(" "))
|
package/src/tools/execute.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
Execute JavaScript code with full Node access. Use this to compose multiple
|
|
1
|
+
Execute JavaScript code with full Node access. Runs in a real `node` process with top-level `await` and dynamic `import()` — so you can load and call the project's own `.mjs` modules directly. Use this to compose multiple operations into one call — read, write, glob, grep, log, import, or require() — without shelling out to `bash node -e`.
|
|
2
|
+
|
|
3
|
+
**Route to execute instead of bash:**
|
|
4
|
+
- `node -e "…"` → execute (top-level await + import() + console all work)
|
|
2
5
|
|
|
3
6
|
Parameters:
|
|
4
|
-
- code (required): JavaScript
|
|
7
|
+
- code (required): JavaScript to run. Top-level `await` and `import('./x.mjs')` are supported. Globals: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args) — plus native require/process/console/fetch/import.
|
|
8
|
+
- workdir: run in this directory (relative to cwd, confined to the workspace; default cwd)
|
|
9
|
+
- filter: optional — only return output lines matching this regex (case-insensitive)
|
|
5
10
|
- timeoutMs: Timeout in milliseconds (default 30000, max 60000)
|
|
11
|
+
|
|
12
|
+
Notes:
|
|
13
|
+
- `console.log(...)` and `log(...)` both print to the result; objects are JSON-stringified by `log`.
|
|
14
|
+
- File paths are confined to the workspace root (`..` traversal is denied) — but `require`/`process`/`import()` are full Node, same boundary as bash.
|
|
15
|
+
- A non-zero exit / thrown exception returns the stderr (error + stack) as the result.
|
|
16
|
+
- Output capped at ~50KB; use `writeFile` to a file if you need more.
|
|
17
|
+
- Use `write`/`edit`/`apply_patch` for source edits and `bash` for subprocess/CLI runs (`npm test`, `node --test`, servers) — execute is for in-process JS, not spawning programs.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Move, copy, or rename a file/directory.
|
|
2
|
+
|
|
3
|
+
**Route to file_ops instead of bash:**
|
|
4
|
+
- `mv a b` → file_ops action=move
|
|
5
|
+
- `cp a b` / `copy` → file_ops action=copy
|
|
6
|
+
- `ren a b` / `rename a b` → file_ops action=rename
|
|
7
|
+
|
|
8
|
+
Parameters:
|
|
9
|
+
- action (required): move | copy | rename
|
|
10
|
+
- source (required): source path, relative to cwd or absolute
|
|
11
|
+
- dest (required): destination path
|
|
12
|
+
|
|
13
|
+
Notes:
|
|
14
|
+
- Paths are confined to the working directory (same safety as write/edit) — bash has NO directory confinement.
|
|
15
|
+
- `dest` is overwritten if it already exists. `copy` is recursive for directories.
|
|
16
|
+
- To create a directory, use `write` (creates parent dirs) or `bash mkdir`.
|
package/src/tools/git.md
CHANGED
|
@@ -2,14 +2,20 @@ Run a git command. Use this to see uncommitted changes, staged changes, diff aga
|
|
|
2
2
|
- action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.
|
|
3
3
|
- action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.
|
|
4
4
|
- action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.
|
|
5
|
+
- action='show': Show a commit's details (--stat). Set ref=<ref> to inspect a specific commit (default HEAD).
|
|
5
6
|
- action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).
|
|
7
|
+
- action='rm': Untrack a file/directory (git rm --cached — keeps the file on disk). path is required.
|
|
8
|
+
- action='commit': Stage all changes and commit. message is required. Confirms with the user (outward action).
|
|
9
|
+
- action='push': Push the current branch to the remote. Confirms with the user (outward action).
|
|
6
10
|
|
|
7
11
|
Parameters:
|
|
8
|
-
- action (required): diff / status / log / checkpoint
|
|
12
|
+
- action (required): diff / status / log / show / checkpoint / rm / commit / push
|
|
9
13
|
- staged: (diff) Show staged changes instead of working tree
|
|
10
|
-
- path: (diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to
|
|
11
|
-
- ref: (
|
|
14
|
+
- path: (diff/log/checkpoint:cat/checkpoint:rewind/rm) File or directory to scope to
|
|
15
|
+
- ref: (show) Commit ref to inspect (default HEAD)
|
|
12
16
|
- count: (log) Number of commits (default 10)
|
|
13
17
|
- oneline: (log) One-line-per-commit format
|
|
18
|
+
- message: (commit) Commit message — required for commit
|
|
19
|
+
- filter: Optional — keep only status/diff/log output lines matching this regex (case-insensitive)
|
|
14
20
|
- checkpointAction: (checkpoint) list snapshots / create one / restore by id / read file from snapshot
|
|
15
|
-
- checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
|
|
21
|
+
- checkpointId: (checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)
|
package/src/tools/git.mjs
CHANGED
|
@@ -7,19 +7,44 @@ import { escapeXml } from "../agent/helpers.mjs";
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
|
+
/** Keep only output lines matching a regex (git filter, case-insensitive). */
|
|
11
|
+
function filterLines(output, filter) {
|
|
12
|
+
if (!filter) return output
|
|
13
|
+
try {
|
|
14
|
+
const re = new RegExp(filter, "i")
|
|
15
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
16
|
+
return lines.length ? lines.join("\n") : `(no lines matched filter "${filter}")`
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return `Error: filter regex invalid: ${e.message}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Run git and report failure (stderr + exit code) instead of swallowing it.
|
|
23
|
+
* Used by write ops (commit/push/rm) where a silent "" would masquerade as success. */
|
|
24
|
+
function runGitStrict(cwd, cmdArgs) {
|
|
25
|
+
try {
|
|
26
|
+
const out = execFileSync("git", cmdArgs, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().replace(/\r/g, "")
|
|
27
|
+
return { ok: true, out }
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { ok: false, out: String(e.stdout || "").trim(), err: String(e.stderr || e.message || "").trim() }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
export const gitTool = {
|
|
11
34
|
name: "git",
|
|
12
35
|
description: DESC("git"),
|
|
13
36
|
parameters: {
|
|
14
37
|
type: "object",
|
|
15
38
|
properties: {
|
|
16
|
-
action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
|
|
39
|
+
action: { type: "string", enum: ["diff", "status", "log", "show", "checkpoint", "rm", "commit", "push"], description: "diff / status / log / show / checkpoint / rm / commit / push" },
|
|
17
40
|
// diff/log params
|
|
18
41
|
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
19
|
-
path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind) File or directory to scope to" },
|
|
20
|
-
ref: { type: "string", description: "(
|
|
42
|
+
path: { type: "string", description: "(diff/log/checkpoint:cat/versions/rewind/rm) File or directory to scope to" },
|
|
43
|
+
ref: { type: "string", description: "(show) Commit ref to show (default HEAD)" },
|
|
21
44
|
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
22
45
|
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
46
|
+
message: { type: "string", description: "(commit) Commit message — required for commit" },
|
|
47
|
+
filter: { type: "string", description: "Optional: keep only status/diff/log output lines matching this regex (case-insensitive)" },
|
|
23
48
|
// checkpoint params
|
|
24
49
|
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat", "versions"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot / list a file's historical versions" },
|
|
25
50
|
checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
|
|
@@ -35,7 +60,7 @@ export const gitTool = {
|
|
|
35
60
|
const flags = args.staged ? ["--staged"] : []
|
|
36
61
|
const paths = args.path ? [args.path] : []
|
|
37
62
|
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
38
|
-
return truncate(out || "(no changes)")
|
|
63
|
+
return truncate(filterLines(out || "(no changes)", args.filter))
|
|
39
64
|
}
|
|
40
65
|
case "status": {
|
|
41
66
|
const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
|
|
@@ -68,17 +93,44 @@ export const gitTool = {
|
|
|
68
93
|
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
69
94
|
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
70
95
|
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
71
|
-
return truncate(parts.join("\n\n"))
|
|
96
|
+
return truncate(filterLines(parts.join("\n\n"), args.filter))
|
|
72
97
|
}
|
|
73
98
|
case "log": {
|
|
74
|
-
const
|
|
99
|
+
const parsed = Number.parseInt(args.count, 10)
|
|
100
|
+
const n = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 10
|
|
75
101
|
const isOneline = args.oneline
|
|
76
102
|
const cmdArgs = isOneline
|
|
77
103
|
? ["log", "-" + n, "--oneline"]
|
|
78
104
|
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
79
105
|
if (args.path) cmdArgs.push("--", args.path)
|
|
80
106
|
const out = runGit(ctx.cwd, cmdArgs)
|
|
81
|
-
return truncate(out || "(no commits)")
|
|
107
|
+
return truncate(filterLines(out || "(no commits)", args.filter))
|
|
108
|
+
}
|
|
109
|
+
case "show": {
|
|
110
|
+
const ref = args.ref ?? "HEAD"
|
|
111
|
+
if (!/^[A-Za-z0-9._\/~^@][A-Za-z0-9._\/~^@{}\-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
112
|
+
const out = runGit(ctx.cwd, ["show", "--stat", ref])
|
|
113
|
+
return truncate(out || "(no such commit)")
|
|
114
|
+
}
|
|
115
|
+
case "rm": {
|
|
116
|
+
if (!args.path) return "Error: rm requires path (the file/directory to untrack, relative to repo root)"
|
|
117
|
+
const r = runGitStrict(ctx.cwd, ["rm", "--cached", "-r", "--", args.path])
|
|
118
|
+
return r.ok ? truncate(r.out || `Untracked ${args.path} (kept on disk)`) : truncate(`git rm failed: ${r.err || r.out}`)
|
|
119
|
+
}
|
|
120
|
+
case "commit": {
|
|
121
|
+
if (!args.message) return "Error: commit requires message"
|
|
122
|
+
const add = runGitStrict(ctx.cwd, ["add", "-A"])
|
|
123
|
+
if (!add.ok) return truncate(`git add failed: ${add.err || add.out || "(no output)"}`)
|
|
124
|
+
const commit = runGitStrict(ctx.cwd, ["commit", "-m", args.message])
|
|
125
|
+
const parts = []
|
|
126
|
+
if (add.out) parts.push(add.out)
|
|
127
|
+
if (commit.ok) { if (commit.out) parts.push(commit.out) }
|
|
128
|
+
else parts.push(`git commit failed: ${commit.err || "(no output)"}`)
|
|
129
|
+
return truncate(parts.join("\n") || "(commit produced no output)")
|
|
130
|
+
}
|
|
131
|
+
case "push": {
|
|
132
|
+
const r = runGitStrict(ctx.cwd, ["push"])
|
|
133
|
+
return r.ok ? truncate(r.out || "(push complete — no output)") : truncate(`git push failed: ${r.err || r.out || "(no output)"}`)
|
|
82
134
|
}
|
|
83
135
|
case "checkpoint": {
|
|
84
136
|
const { createCheckpoint, listCheckpoints, rewind, listFileVersions, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
@@ -139,7 +191,7 @@ export const gitTool = {
|
|
|
139
191
|
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat | versions`)
|
|
140
192
|
}
|
|
141
193
|
default:
|
|
142
|
-
return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
|
|
194
|
+
return `Unknown action '${args.action}'. Use: diff | status | log | show | checkpoint | rm | commit | push`
|
|
143
195
|
}
|
|
144
196
|
},
|
|
145
197
|
}
|