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