Programmatic tool calling: run_code runs a JS program that calls read/glob/grep/edit/bash in a sandbox and returns only the final value; large results spill to…

在 Claude Code 里实现程序化工具调用(PTC):注册一个工具 mcp__ptc-run__run_code,模型用一段 JS 程序一次完成多步操作,只有 console.log 的输出和返回值会回到上下文。
适合:有依赖关系的多步操作(grep → 按结果读文件 → 批量改)、需要先过滤再看的大输出、批量复制/移动/删除。
tools.read({path,offset?,limit?,numbered?}) / tools.write / tools.edit:edit 是原子写。同一程序里先 read 再 edit 会做 mtime 过期检查(STALE);跨 run_code 调用用 tools.revision({path})(文件内容 sha256 的前 16 位十六进制)配合 edit 的 expectedRevision,内容不一致就整次不写并报 STALE。all:true 时返回改动处数和行号,可传 expect 声明预期处数,不符则整次不写(COUNT_MISMATCH)。tools.glob / grep:调用 ripgrep,遵循 .gitignore,跳过隐藏项、node_modules、target、.venv,单文件上限 2MB;结果分页(glob 默认每页 5000 条,grep 默认每页 500 条)。tools.replace:跨文件正则替换,支持 dryRun、expect 和 expectedRevision({路径: revision} 映射,任一路径不匹配则全部不写);结果带每个文件的处数和行号,maxReplacements 默认 100000。一次改了多处的写操作会在输出开头列出,成功时也会。tools.batch:复制/移动/删除的预检、执行、续跑,带日志,支持跨盘(toRoot);单次最多 512 个操作,计划会记录源文件状态,续跑时重新校验(STALE)。tools.bash:同步执行一条命令,默认超时 120000 ms;background:true 转后台任务(上限 2 小时)。tools.parallel([{command,cwd?,timeoutMs?}], {concurrency}):并发执行多条命令,默认并发 4(最大 16),单次最多 64 条;按输入顺序返回;单项超时不影响其他项,结束时清理子进程。ref.put / page / text / release:输出超过 8000 字符(可用 maxChars 调整,最大 50000)时保留前约 70% 和后 30%,中段落盘成 ref,按需分页读取(ref.page 默认每页 50 条、单条上限 16384 字节)。ref 存在系统临时目录 cc-ptc/ 下,保留 24 小时。run_code 整程序默认超时 300000 ms(最大 1800000 ms);root:"D:/abs/dir" 可把相对路径、glob/grep 和 bash 的基准目录切到该目录。错误带 [CODE] 前缀和 nextAction 提示。
isDeferred: false),会话首轮即可直接调用,不需要先等工具搜索。expectedRevision 的 edit/replace 会在系统临时目录 cc-ptc-locks/ 下加跨进程写锁,保证“校验 revision、写入”在锁内完成;等锁超过约 2 秒报 LOCKED,什么都不写;超过 30 秒的锁视为崩溃遗留,可以接管。多文件 replace 按顺序加锁。注意:多文件写入不是事务,中途崩溃时已写的文件不会回滚。tools.test(测试摘要),以及 read 结果的不可枚举字段 .text / .content;设了 PTC_RUN_DSH_READ_LIMIT 时,不带 limit 的读取会截断,并追加一行 kind:"notice" 提示。这些都是实验功能,默认不写进提示。run_code 里的工具调用不经过 Claude Code 的权限确认,等同于给了模型直接读写文件和执行命令的能力。PTC_DENY_CREDENTIALS=1 后,读取类操作会拒绝 ~/.ssh、.env、*.pem 等凭据文件(bash 不受限)。默认关闭。PTC_RUN_READONLY=1)下写入和命令类工具被拒绝,但 tools.read、tools.revision、tools.glob、tools.grep 等只读工具可用,且只能读取工作目录(及 PTC_RUN_RO_ROOTS 指定的额外目录)内的文件。bin/rg.exe(仓库里不带,可从 ripgrep 官方 Release 下载放进去后运行 node build.cjs),否则从 PATH 找 rg。src/runner.js 和 src/register.tpl.ts 是源码,改完运行 node build.cjs 生成 hooks/register.ts(mod 运行时没有 fs,runner 以字符串内嵌)。
hooks/register.ts 162 lines1import type { Register } from 'claude-code'
2
3const RUNNER = "\"use strict\";\n// ptc-run sandbox runner. CommonJS, node built-ins only. Input (stdin JSON): {cwd, code, rg, scope, maxChars}.\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\"), cp = require(\"child_process\"), crypto = require(\"crypto\");\n// Launched by a tiny bootstrap (the full source exceeds the Windows 32K command-line limit): the bootstrap\n// puts the input JSON in globalThis.__PTC_IN and evals this file. Falls back to stdin when run directly.\nconst input = JSON.parse(typeof globalThis.__PTC_IN === \"string\" ? globalThis.__PTC_IN : fs.readFileSync(0, \"utf8\"));\nlet cwd = input.cwd || process.cwd();\nconst MAX = input.maxChars || 8000;\nconst NL = \"\\n\";\nconst WIN = process.platform === \"win32\";\n// Read-only mode: started by src/broker.js under the Node permission model (no child processes, writes only in the\n// scope dir). rg runs through the broker; write/edit/replace/batch/bash/parallel/job_output are refused.\nconst RO = input.readonly === true;\n// \"bash\" alone would make Windows search System32 before PATH and pick the WSL launcher, which runs the\n// command in Linux and re-parses it ($VAR expanded twice). Prefer a real bash from PATH (Git Bash).\nconst BASH = (() => {\n if (input.readonly === true) return \"bash\"; // read-only mode: bash is refused, and probing PATH would hit the read sandbox\n if (!WIN) return \"bash\";\n const skip = /[\\\\/](windows[\\\\/]system32|windowsapps)[\\\\/]/i;\n for (const d of (process.env.PATH || \"\").split(\";\")) {\n if (!d) continue;\n const f = path.join(d, \"bash.exe\");\n if (!skip.test(f) && fs.existsSync(f)) return f;\n }\n const git = path.join(process.env.ProgramFiles || \"C:\\\\Program Files\", \"Git\", \"bin\", \"bash.exe\");\n return fs.existsSync(git) ? git : \"bash\";\n})();\n\n// ---------- structured errors ----------\nclass PtcError extends Error {\n constructor(code, msg, nextAction, extra) {\n super(\"[\" + code + \"] \" + msg);\n this.name = \"PtcError\"; this.code = code; this.nextAction = nextAction || \"\";\n if (extra) for (const k of Object.keys(extra)) if (k !== \"message\") this[k] = extra[k];\n }\n}\nconst E = (code, msg, next, extra) => new PtcError(code, msg, next, extra);\nconst SYS = {\n ENOENT: [\"NOT_FOUND\", \"Check the path with tools.glob; paths are relative to cwd.\"],\n ENOTDIR: [\"NOT_FOUND\", \"A path component is not a directory; check the path.\"],\n EEXIST: [\"EXISTS\", \"The target already exists; choose another name or remove it first.\"],\n EACCES: [\"POLICY_DENIED\", \"Permission denied by the OS; check file permissions / locks.\"],\n EPERM: [\"POLICY_DENIED\", \"Operation not permitted by the OS; check permissions / read-only attribute / locks.\"],\n EROFS: [\"POLICY_DENIED\", \"Read-only file system.\"],\n EBUSY: [\"BUSY\", \"File is in use by another process; release it, then retry (tools.batch resume for batch jobs).\"],\n EISDIR: [\"INVALID_ARGUMENT\", \"The path is a directory; pass a file path.\"],\n EINVAL: [\"INVALID_ARGUMENT\", \"Invalid argument for this operation.\"],\n ENAMETOOLONG: [\"INVALID_ARGUMENT\", \"Path is too long.\"],\n};\nif (RO) SYS.ERR_ACCESS_DENIED = [\"POLICY_DENIED\", \"Blocked by the read-only sandbox (read-only mode: no writes outside the session scope, no child processes).\"];\nconst sysErr = (e) => {\n if (e instanceof PtcError) return e;\n // BUSY: EBUSY / sharing-violation text. On Windows a lock can also surface as EPERM/EACCES, so those are classified\n // by classifyPerm (read-only attribute, deny ACE, no write permission => POLICY_DENIED; otherwise BUSY).\n const busy = e && (e.code === \"EBUSY\" || /sharing violation|being used by another process/i.test(String(e.message || \"\")));\n let m = busy ? SYS.EBUSY : e && SYS[e.code], note = \"\";\n if (!busy && e && (e.code === \"EPERM\" || e.code === \"EACCES\") && WIN && typeof e.path === \"string\") {\n let c = null; try { c = classifyPerm(e); } catch {}\n if (c) { m = c.code === \"BUSY\" ? SYS.EBUSY : SYS[e.code]; note = c.note; }\n }\n if (!m) return e;\n const x = E(m[0], String(e.message || e.code) + note, m[1], { sys: e.code || \"EBUSY\" });\n if (e.effect) x.effect = e.effect;\n return x;\n};\nconst sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n// short backoff for transient EPERM/EACCES (antivirus / indexer locks): 60+120+180 ms = 360 ms at most\nconst withRetry = (fn) => {\n const waits = [60, 120, 180];\n for (let i = 0; ; i++) {\n try { return fn(); } catch (e) { if (i >= waits.length || !(e && (e.code === \"EPERM\" || e.code === \"EACCES\"))) throw e; sleepSync(waits[i]); }\n }\n};\n// icacls: is there an explicit DENY ACE for the current user / Everyone / Users on p?\nconst hasDenyAce = (p) => {\n if (RO) return false; // read-only mode: no child processes (icacls)\n const r = cp.spawnSync(\"icacls.exe\", [p], { encoding: \"utf8\", timeout: 5000, windowsHide: true });\n if (r.error || r.status !== 0) return false;\n const me = String(os.userInfo().username || \"\").toLowerCase();\n return String(r.stdout).toLowerCase().split(\"\\n\").some((l) => /\\(deny\\)/.test(l) && (l.includes(me + \":(deny)\") || /(everyone|users):\\(deny\\)/.test(l)));\n};\n// Decide whether an EPERM/EACCES on a Windows path is a lock (BUSY) or a real permission problem (POLICY_DENIED).\nconst classifyPerm = (e) => {\n if (RO) return null; // read-only mode: no probe files / icacls; keep the plain EPERM/EACCES mapping\n const p = e.path;\n let st; try { st = fs.lstatSync(p); } catch { return null; }\n const denied = (why) => ({ code: \"POLICY_DENIED\", note: \" (permission denied: \" + why + \")\" });\n if (st.isFile() && (st.mode & 0o200) === 0) return denied(\"read-only attribute is set\");\n const parents = [path.dirname(p)]; if (typeof e.dest === \"string\") parents.push(path.dirname(e.dest));\n for (const q of [p, ...parents]) if (hasDenyAce(q)) return denied(\"explicit deny ACE on \" + q);\n for (const d of parents) {\n const probe = path.join(d, \".ptc-probe-\" + process.pid + \"-\" + crypto.randomBytes(3).toString(\"hex\"));\n try { fs.closeSync(fs.openSync(probe, \"wx\")); } catch (er) { if (er.code === \"EPERM\" || er.code === \"EACCES\") return denied(\"cannot create files in \" + d); continue; }\n try { fs.unlinkSync(probe); } catch { return denied(\"cannot delete in \" + d + \" (probe file \" + probe + \" left behind)\"); }\n }\n if (st.isFile()) {\n try { fs.closeSync(fs.openSync(p, \"r+\")); } catch (er) {\n if (er.code === \"EBUSY\" || /sharing violation|being used by another process/i.test(String(er.message))) return { code: \"BUSY\", note: \" (in use by another process: sharing violation on open; raw \" + e.code + \")\" };\n if (er.code === \"EPERM\" || er.code === \"EACCES\") return denied(\"file cannot be opened for writing\");\n }\n }\n return { code: \"BUSY\", note: \" (inferred in use by another process (\" + e.code + \"): no read-only attribute, deny ACE or missing write permission found)\" };\n};\nconst guard = (fn) => (...a) => { try { return fn(...a); } catch (e) { throw sysErr(e); } };\nconst A = (a, name) => { if (a === null || typeof a !== \"object\" || Array.isArray(a)) throw E(\"INVALID_ARGUMENT\", (name || \"tool\") + \" expects one object argument\", \"Pass an options object, e.g. tools.x({path: 'a.txt'}).\"); return a; };\nconst reqStr = (v, name) => { if (typeof v !== \"string\" || v === \"\") throw E(\"INVALID_ARGUMENT\", name + \" must be a non-empty string\", \"Fix the '\" + name + \"' argument.\"); return v; };\nconst optInt = (v, name, def, min, max) => {\n if (v === undefined || v === null) return def;\n if (!Number.isInteger(v) || v < min || v > max) throw E(\"INVALID_ARGUMENT\", name + \" must be an integer in [\" + min + \", \" + max + \"]\", \"Fix the '\" + name + \"' argument.\");\n return v;\n};\n\n// ---------- optional root: an existing absolute directory that becomes the working directory ----------\nlet rootErr = null;\nif (input.root !== undefined) {\n try {\n if (typeof input.root !== \"string\" || input.root === \"\" || !path.isAbsolute(input.root)) {\n throw E(\"INVALID_ARGUMENT\", \"root must be an absolute path: \" + String(input.root), 'Pass an existing absolute directory, e.g. root: \"D:/proj\".');\n }\n let st;\n try { st = fs.statSync(input.root); } catch (e) {\n if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"root not found: \" + input.root, \"Pass an existing absolute directory.\");\n throw e;\n }\n if (!st.isDirectory()) throw E(\"INVALID_ARGUMENT\", \"root is not a directory: \" + input.root, \"Pass a directory path.\");\n cwd = path.resolve(input.root);\n process.chdir(cwd);\n } catch (e) { rootErr = sysErr(e); }\n}\n\n// ---------- state dirs (per session scope) ----------\nconst scope = (String(input.scope || \"default\").replace(/[^A-Za-z0-9_.-]/g, \"_\").replace(/^\\.+/, \"_\").slice(0, 80)) || \"default\";\nconst dir = RO && typeof input.stateDir === \"string\" ? input.stateDir : path.join(os.tmpdir(), \"cc-ptc\", scope);\nconst jobsDir = path.join(dir, \"jobs\");\nconst bgDir = path.join(dir, \"bg\");\nfs.mkdirSync(jobsDir, { recursive: true });\nfs.mkdirSync(bgDir, { recursive: true });\ntry { // sweep refs older than 24h in this scope (jobs are kept)\n const cut = Date.now() - 24 * 3600 * 1000;\n for (const n of fs.readdirSync(dir)) {\n if (!/\\.(json|tmp|log|ops)$/.test(n)) continue;\n const p = path.join(dir, n);\n try { const st = fs.lstatSync(p); if (st.isFile() && st.mtimeMs < cut) fs.unlinkSync(p); } catch {}\n }\n} catch {}\n\nconst abs = (p) => path.resolve(cwd, p);\nconst rel = (f) => { const r = path.relative(cwd, f).split(path.sep).join(\"/\"); return r.startsWith(\"..\") || path.isAbsolute(r) ? f.split(path.sep).join(\"/\") : r; };\nconst lines = [];\nconst fmt = (v) => typeof v === \"string\" ? v : JSON.stringify(v, null, 1);\n// Streaming log + write-op journal: the hook reads these if the outer timeout kills this process.\nconst logFile = input.logName ? path.join(dir, \"run-\" + String(input.logName).replace(/[^A-Za-z0-9_-]/g, \"_\").slice(0, 64) + \".log\") : null;\nconst opsFile = logFile ? logFile + \".ops\" : null;\nlet logFd = null, opsFd = null;\ntry { if (logFile) logFd = fs.openSync(logFile, \"a\"); } catch {}\ntry { if (opsFile) opsFd = fs.openSync(opsFile, \"a\"); } catch {}\nconst writeOps = new Map();\nconst noteWrite = (op, p, places, lines) => {\n const key = op + \"\\u0000\" + p;\n const e = writeOps.get(key) || { op, path: p, count: 0, places: 0, lines: [] };\n e.count++; e.places += places || 0; if (lines) e.lines.push(...lines); writeOps.set(key, e);\n if (opsFd !== null) { try { fs.writeSync(opsFd, JSON.stringify({ op, path: p }) + NL); } catch {} }\n};\n// Line numbers (1-based, in the text before the change) of every match of a literal needle.\nconst lineNosOf = (text, needle) => {\n const out = []; let i = text.indexOf(needle), ln = 1, last = 0;\n while (i >= 0) { for (let k = last; k < i; k++) if (text.charCodeAt(k) === 10) ln++; last = i; out.push(ln); i = text.indexOf(needle, i + needle.length); }\n return out;\n};\nconst lineNosAt = (text, offsets) => {\n const out = []; let ln = 1, last = 0;\n for (const i of offsets) { for (let k = last; k < i; k++) if (text.charCodeAt(k) === 10) ln++; last = i; out.push(ln); }\n return out;\n};\nconst placesText = (e) => e.places > 1 ? \"(共 \" + e.places + \" 处,行 \" + e.lines.slice(0, 15).join(\",\") + (e.lines.length > 15 ? \",…\" : \"\") + \")\" : \"\";\n// Shown even on success: one call that changed several places is easy to miss (e.g. replacing every \"break\" in a file).\nconst multiText = () => {\n const rows = [...writeOps.values()].filter((e) => e.places > 1).map((e) => \"- \" + e.op + \" \" + e.path + placesText(e));\n return rows.length ? \"注意:以下写操作一次改了多处,确认每处都是有意改的:\\n\" + rows.slice(0, 20).join(\"\\n\") : \"\";\n};\nconst countCheck = (expect, n, what, p) => {\n if (expect === undefined) return;\n if (!Number.isInteger(expect) || expect < 0) throw E(\"INVALID_ARGUMENT\", what + \" must be a non-negative integer\", \"Pass the number of places you intend to change.\");\n if (n !== expect) throw E(\"COUNT_MISMATCH\", \"expected \" + expect + \" match(es) but found \" + n + \" in \" + p + \"; nothing was written\", \"Re-read the matches (tools.grep) and fix the pattern/old text or the expected count.\");\n};\n// Windows files usually have CRLF line endings, while an old string typed in a program has LF. Exact match first; only\n// when that finds nothing, the file contains CRLF and old is LF-only, retry once with CRLF in both old and new. The\n// retried pair is then used for counting, NOT_UNIQUE, expect and the write, so the file keeps its CRLF endings.\nconst crlfMatch = (text, old, nw) => {\n if (text.split(old).length - 1 > 0) return { old, nw };\n if (!text.includes(\"\\r\\n\") || !old.includes(NL) || old.includes(\"\\r\")) return { old, nw };\n const crlf = (s) => s.replace(/\\r?\\n/g, \"\\r\\n\");\n return { old: crlf(old), nw: crlf(nw) };\n};\nconst opsText = () => {\n if (!writeOps.size) return \"本程序已生效的写操作:无\";\n const rows = [...writeOps.values()].map((e) => \"- \" + e.op + \" \" + e.path + (e.count > 1 ? \" ×\" + e.count : \"\") + placesText(e));\n return \"本程序已生效的写操作:\\n\" + rows.slice(0, 50).join(\"\\n\") + (rows.length > 50 ? \"\\n- …等 \" + rows.length + \" 项\" : \"\");\n};\n// The call in flight, so an uncaught throw can name it (tools.* calls run synchronously, so this is the failing one).\nlet curCall = null;\nconst markCall = (k, a) => { curCall = { k, path: a && typeof a === \"object\" ? (a.path || a.file_path) : undefined }; };\n// code/message of the throw, and the call name + path when known (falls back to the code and message alone)\nconst failInfo = (err) => {\n const r = { code: \"\", msg: String((err && err.message) || err) };\n if (err && typeof err.code === \"string\") r.code = err.code;\n else { const m = /^\\[([A-Z_]+)\\]/.exec(r.msg); if (m) r.code = m[1]; }\n r.msg = r.msg.replace(/^\\[[A-Z_]+\\]\\s*/, \"\").split(NL)[0];\n if (err && err.name === \"ToolCallError\") r.call = err.toolName;\n else if (err && err.name === \"PtcError\" && curCall) r.call = curCall.k;\n if (curCall && typeof curCall.path === \"string\" && r.call === curCall.k) r.path = curCall.path;\n if (!r.path) { const m = /\\b(?:in|:)\\s+([^\\s:]+\\.[A-Za-z0-9_]+)/.exec(r.msg); if (m) r.path = m[1]; }\n return r;\n};\n// Error path only, and only when writes already happened: applied / failed / not-run, so the model neither resends\n// applied writes nor forgets the ones after the throw. Kept to 3 short blocks.\nconst errText = (err) => {\n if (!writeOps.size) return \"\";\n const rows = [...writeOps.values()].map((e) => e.op + \" \" + e.path + (e.lines && e.lines.length ? \" 行 \" + e.lines.slice(0, 15).join(\",\") : \"\"));\n const fi = failInfo(err);\n const failed = \"failed:\" + (fi.call ? fi.call + \" \" : \"\") + (fi.path ? fi.path + \" \" : \"\") + (fi.code ? \"[\" + fi.code + \"] \" : \"\") + fi.msg;\n return \"applied(已生效,不要重发):\\n\" + rows.slice(0, 10).join(\"\\n\") + (rows.length > 10 ? \"\\n…另 \" + (rows.length - 10) + \" 条\" : \"\") +\n NL + failed.slice(0, 200) + NL + \"not-run:抛错点之后的代码没有执行;后面若还有写入,需要重新发\";\n};\nconst closeLog = () => {\n try { if (logFd !== null) fs.closeSync(logFd); } catch {}\n try { if (opsFd !== null) fs.closeSync(opsFd); } catch {}\n logFd = opsFd = null;\n try { if (logFile) fs.unlinkSync(logFile); } catch {}\n try { if (opsFile) fs.unlinkSync(opsFile); } catch {}\n};\nconst logf = (...a) => { const s = a.map(fmt).join(\" \"); lines.push(s); if (logFd !== null) { try { fs.writeSync(logFd, s + NL); } catch {} } };\nconst cons = { log: logf, error: logf, warn: logf, info: logf };\nconst readState = {};\n// Content revision: first 16 hex chars of sha256(file bytes). Unlike readState it is stable across run_code processes.\nconst revOf = (f) => crypto.createHash(\"sha256\").update(fs.readFileSync(f)).digest(\"hex\").slice(0, 16);\nconst revKey = (p) => { const r = abs(p); return WIN ? r.toLowerCase() : r; };\nconst checkRevision = (expected, f, label) => {\n if (expected === undefined || expected === null) return;\n if (typeof expected !== \"string\" || expected === \"\") throw E(\"INVALID_ARGUMENT\", \"expectedRevision must be a non-empty string\", \"Pass the value returned by tools.revision.\");\n let cur; try { cur = revOf(f); } catch (e) { if (e.code === \"ENOENT\") throw E(\"STALE\", \"file no longer exists: \" + label + \" (nothing was written)\", \"Re-read the file with tools.read and get the current revision with tools.revision.\"); throw e; }\n if (cur !== expected) throw E(\"STALE\", \"revision mismatch for \" + label + \": expected \" + expected + \", current \" + cur + \" (nothing was written)\", \"Re-read the file with tools.read and get the current revision with tools.revision.\");\n};\n\n// ---------- atomic write ----------\nconst sleepMs = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\nconst renameRetry = (a, b) => {\n for (let i = 0; ; i++) {\n try { fs.renameSync(a, b); return; } catch (e) {\n if (i >= 5 || !(e.code === \"EPERM\" || e.code === \"EACCES\" || e.code === \"EBUSY\")) throw e;\n sleepMs(20 * (i + 1));\n }\n }\n};\nconst tmpName = (f) => path.join(path.dirname(f), \".\" + path.basename(f) + \".\" + process.pid + \".\" + crypto.randomBytes(4).toString(\"hex\") + \".tmp\");\nconst atomicWrite = (f, data) => {\n let target = f, mode;\n let l = null;\n try { l = fs.lstatSync(f); } catch (e) { if (e.code !== \"ENOENT\") throw e; }\n if (l) {\n if (l.isSymbolicLink()) target = fs.realpathSync(f);\n const st = fs.statSync(target);\n if (st.isDirectory()) throw E(\"INVALID_ARGUMENT\", \"path is a directory: \" + rel(f), \"Pass a file path.\");\n mode = st.mode & 0o777;\n }\n const t = tmpName(target);\n try {\n fs.writeFileSync(t, data, { flag: \"wx\" });\n if (mode !== undefined && !WIN) fs.chmodSync(t, mode);\n renameRetry(t, target);\n } catch (e) { try { fs.unlinkSync(t); } catch {} throw e; }\n};\n\n// ---------- cross-process write lock (only for writes that carry expectedRevision) ----------\n// One lock file per canonical target path in the system temp dir. open(..., \"wx\") is atomic on every platform, so\n// exactly one process can hold it. A crashed process leaves its lock behind, so a lock older than 30 s is taken over.\nconst LOCK_DIR = path.join(os.tmpdir(), \"cc-ptc-locks\");\nconst LOCK_WAIT_MS = 2000, LOCK_STALE_MS = 30000;\nconst lockKeyOf = (f) => {\n let r = f;\n try { r = fs.realpathSync.native(f); } catch { try { r = path.join(fs.realpathSync.native(path.dirname(f)), path.basename(f)); } catch {} }\n r = path.resolve(r).split(path.sep).join(\"/\");\n return WIN ? r.toLowerCase() : r; // Windows: same file through any case / slash spelling -> same key\n};\nconst lockPathOf = (f) => path.join(LOCK_DIR, crypto.createHash(\"sha256\").update(lockKeyOf(f)).digest(\"hex\").slice(0, 32) + \".lock\");\nconst releaseLock = (lp) => { try { fs.unlinkSync(lp); } catch {} };\nconst acquireLock = (lp, f) => {\n fs.mkdirSync(LOCK_DIR, { recursive: true });\n const start = Date.now();\n for (;;) {\n let fd = null;\n try { fd = fs.openSync(lp, \"wx\"); }\n catch (e) { if (e.code !== \"EEXIST\") throw sysErr(e); }\n if (fd !== null) {\n try { fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now(), file: rel(f) })); } catch {}\n try { fs.closeSync(fd); } catch {}\n return lp;\n }\n let st = null;\n try { st = fs.statSync(lp); } catch (e) { if (e.code === \"ENOENT\") continue; throw sysErr(e); }\n if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {\n // Rename the stale lock aside first: only one process can win the rename, so only one takes it over.\n const aside = lp + \".\" + process.pid + \".\" + crypto.randomBytes(4).toString(\"hex\") + \".stale\";\n try { fs.renameSync(lp, aside); try { fs.unlinkSync(aside); } catch {} } catch {}\n continue;\n }\n if (Date.now() - start >= LOCK_WAIT_MS) throw E(\"LOCKED\", \"could not acquire the write lock for \" + rel(f) + \" within \" + LOCK_WAIT_MS + \" ms (nothing was written)\", \"Another run_code process is writing the same file; retry after it finishes. A lock left by a crashed process is taken over after 30 s.\");\n sleepMs(10 + Math.floor(Math.random() * 20));\n }\n};\n// Locks are taken in sorted lock-path order so two multi-file callers cannot deadlock; every acquired lock is released.\nconst withFileLocks = (files, fn) => {\n const seen = new Map();\n for (const f of files) { const lp = lockPathOf(f); if (!seen.has(lp)) seen.set(lp, f); }\n const pairs = [...seen].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));\n const held = [];\n try {\n for (const [lp, f] of pairs) held.push(acquireLock(lp, f));\n return fn();\n } finally {\n for (let i = held.length - 1; i >= 0; i--) releaseLock(held[i]);\n }\n};\n// The legacy (no expectedRevision) write paths never lock; only revision-guarded writes do.\nconst withRevisionLock = (f, expected, fn) => (expected === undefined || expected === null ? fn() : withFileLocks([f], fn));\n\n// ---------- credential guard: OFF by default, enabled with env PTC_DENY_CREDENTIALS=1 (tools.bash is never restricted) ----------\nconst CRED_ON = process.env.PTC_DENY_CREDENTIALS === \"1\";\nconst CRED_PRE = /\\.(env|pem|key|ssh|aws|gnupg|docker|npmrc|netrc|git-credentials|config|claude)|id_rsa|id_ed25519/;\nconst CRED_DIRS = [\".ssh\", \".aws\", \".gnupg\"];\nconst CRED_FILES = [\".docker/config.json\", \".npmrc\", \".netrc\", \".git-credentials\", \".config/gh/hosts.yml\", \".claude/.credentials.json\"];\nconst ENV_OK = [\".env.example\", \".env.sample\", \".env.template\"];\nconst normSegs = (a) => String(a).replace(/^\\\\\\\\\\?\\\\/, \"\").split(/[\\\\/]+/).filter(Boolean).map((s, i) => {\n s = s.toLowerCase();\n if (i === 0 && /^[a-z]:$/.test(s)) return s;\n s = s.replace(/:.*$/, \"\"); // NTFS alternate data stream suffix\n if (!/^\\.+$/.test(s)) s = s.replace(/[. ]+$/, \"\"); // Windows ignores trailing dots / spaces\n return s;\n});\nconst HOMES = (() => {\n const out = [];\n for (let h of [os.homedir(), process.env.USERPROFILE, process.env.HOME]) {\n if (!h) continue;\n const m = /^\\/([a-zA-Z])\\/(.*)$/.exec(h); if (m && WIN) h = m[1] + \":/\" + m[2]; // git-bash style /c/Users/x\n const s = normSegs(h); if (s.length && !out.some((o) => o.join(\"/\") === s.join(\"/\"))) out.push(s);\n }\n return out;\n})();\nconst credStr = (a) => {\n if (!CRED_ON) return false;\n const raw = String(a).toLowerCase();\n if (!CRED_PRE.test(raw)) return false;\n const segs = normSegs(a), b = segs[segs.length - 1] || \"\";\n if (b === \".env\" || (b.startsWith(\".env.\") && !ENV_OK.includes(b))) return true;\n if (b.endsWith(\".pem\") || b.endsWith(\".key\") || b.startsWith(\"id_rsa\") || b.startsWith(\"id_ed25519\")) return true;\n for (const h of HOMES) {\n if (segs.length <= h.length || !h.every((x, i) => segs[i] === x)) continue;\n const rest = segs.slice(h.length);\n if (CRED_DIRS.includes(rest[0])) return true;\n if (CRED_FILES.includes(rest.join(\"/\"))) return true;\n }\n return false;\n};\n// fast filter for paths returned by rg (no symlink resolution; rg does not follow links)\nconst credFast = (p) => credStr(path.resolve(cwd, p));\n// full check for a caller-supplied path: lexical (.., slashes, case) and real path (symlinks, 8.3 short names)\nconst credBlocked = (p) => {\n if (!CRED_ON) return null;\n const a = path.resolve(cwd, p);\n if (credStr(a)) return a;\n try { const r = fs.realpathSync.native(a); if (credStr(r)) return r; } catch {\n try { const r = path.join(fs.realpathSync.native(path.dirname(a)), path.basename(a)); if (credStr(r)) return r; } catch {}\n }\n return null;\n};\nconst credGuard = (p) => {\n if (typeof p !== \"string\" || p === \"\") return;\n const hit = credBlocked(p);\n if (hit) throw E(\"POLICY_DENIED\", \"credential path refused: \" + rel(hit), \"Credential files (.ssh, .aws, .env, *.pem, *.key, ...) are not readable through these tools; ask the user for the specific non-secret value instead.\");\n};\n\n// ---------- child environment defaults (UTF-8 for Python; an explicit caller env wins) ----------\nconst childEnv = (extra) => {\n const e = Object.assign({}, process.env, { PYTHONUTF8: \"1\", PYTHONIOENCODING: \"utf-8\" });\n if (extra !== undefined && extra !== null) {\n if (typeof extra !== \"object\" || Array.isArray(extra)) throw E(\"INVALID_ARGUMENT\", \"env must be an object\", \"Pass env as {KEY: 'value'}.\");\n for (const k of Object.keys(extra)) { if (extra[k] === undefined || extra[k] === null) delete e[k]; else e[k] = String(extra[k]); }\n }\n return e;\n};\n// Background jobs: one detached node wrapper per command, state in files, 2h lifetime cap.\nconst BG_MAX_MS = 2 * 60 * 60 * 1000;\n// One write path (write/edit/replace) may not produce more than 32 MiB for a single file. Checked before anything is\n// written, so an over-limit call changes no file at all.\nconst MAX_WRITE_BYTES = 32 * 1024 * 1024;\nconst capWrite = (bytes, what) => {\n if (bytes > MAX_WRITE_BYTES) throw E(\"OUTPUT_LIMIT\", what + \" would be \" + bytes + \" bytes, over the \" + (MAX_WRITE_BYTES / 1048576) + \" MiB write limit (\" + MAX_WRITE_BYTES + \" bytes); nothing was written\", \"Split it: write the first part, then append or edit the rest (tools.edit / tools.bash), or narrow the replacement.\");\n};\nconst BG_WRAP2 = 'const fs=require(\"fs\"),cp=require(\"child_process\");const [id,d,cmd,cwd,bash]=process.argv.slice(1);' +\n 'const stf=d+\"/\"+id+\".st\";const done=(code,sig,timedOut)=>{try{fs.writeFileSync(stf,JSON.stringify({code:code,sig:sig,timedOut:!!timedOut,t:Date.now()}))}catch{};process.exit(0)};' +\n 'let c;try{c=cp.spawn(bash,[\"-c\",cmd],{cwd:cwd,stdio:[\"ignore\",\"pipe\",\"pipe\"],windowsHide:true})}catch(e){try{fs.appendFileSync(d+\"/\"+id+\".err\",String(e))}catch{};done(null,\"error\")}' +\n 'const w=(f,b)=>{try{fs.appendFileSync(d+\"/\"+id+f,b)}catch{}};c.stdout.on(\"data\",b=>w(\".out\",b));c.stderr.on(\"data\",b=>w(\".err\",b));' +\n 'c.on(\"close\",(code,sig)=>done(code,sig,false));c.on(\"error\",e=>{w(\".err\",String(e));done(null,\"error\")});' +\n 'const t=setTimeout(()=>{try{if(process.platform===\"win32\")cp.spawnSync(\"taskkill\",[\"/pid\",String(c.pid),\"/T\",\"/F\"],{windowsHide:true});else c.kill(\"SIGKILL\")}catch{};done(null,\"timeout\",true)},' + BG_MAX_MS + ');if(t.unref)t.unref();';\n\n// ---------- tools: read / write / edit / bash ----------\nconst tools = {};\ntools.read = ({ path: p, offset = 0, limit, numbered = false } = {}) => {\n reqStr(p, \"path\"); credGuard(p);\n const f = abs(p), t = fs.readFileSync(f, \"utf8\");\n readState[f] = fs.statSync(f).mtimeMs;\n if (!offset && limit === undefined && !numbered) return t;\n const ls = t.split(NL);\n if (ls.length > 1 && ls[ls.length - 1] === \"\") ls.pop(); // a trailing newline terminates the last line, it does not start another\n const part = ls.slice(offset, limit === undefined ? undefined : offset + limit);\n // numbered: \"N: text\" with the file's real 1-based line numbers (CR stripped)\n return numbered ? part.map((x, i) => (offset + i + 1) + \": \" + x.replace(/\\r$/, \"\")).join(NL) : part.join(NL);\n};\ntools.revision = (a) => {\n const p = typeof a === \"string\" ? a : (A(a, \"revision\"), a.path);\n reqStr(p, \"path\"); credGuard(p);\n const f = abs(p);\n try { return revOf(f); } catch (e) { if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"file not found: \" + p, \"Check the path with tools.glob.\"); throw e; }\n};\ntools.write = (a) => {\n markCall(\"write\", a); A(a, \"write\"); reqStr(a.path, \"path\");\n if (typeof a.content !== \"string\") throw E(\"INVALID_ARGUMENT\", \"content must be a string\", \"Pass content as a string.\");\n capWrite(Buffer.byteLength(a.content), \"write of \" + a.path);\n const f = abs(a.path);\n fs.mkdirSync(path.dirname(f), { recursive: true });\n atomicWrite(f, a.content);\n try { readState[f] = fs.statSync(f).mtimeMs; } catch {}\n noteWrite(\"write\", rel(f));\n return { bytes: Buffer.byteLength(a.content) };\n};\ntools.edit = (a) => {\n markCall(\"edit\", a); A(a, \"edit\"); reqStr(a.path, \"path\");\n const { path: p, old, new: nw, all = false } = a;\n if (typeof old !== \"string\" || old === \"\") throw E(\"INVALID_ARGUMENT\", \"old must be a non-empty string\", \"Pass the exact text to replace.\");\n if (typeof nw !== \"string\") throw E(\"INVALID_ARGUMENT\", \"new must be a string\", \"Pass the replacement text.\");\n const f = abs(p);\n // With expectedRevision the check and the write are one critical section: a second process that passed the same\n // revision must wait, then re-check and see STALE instead of silently overwriting this write.\n return withRevisionLock(f, a.expectedRevision, () => {\n let st;\n try { st = fs.statSync(f); } catch (e) { if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"file not found: \" + p, \"Check the path with tools.glob.\"); throw e; }\n if (readState[f] !== undefined && st.mtimeMs !== readState[f]) throw E(\"STALE\", \"file changed since it was read: \" + p, \"Re-read the file with tools.read and redo the edit.\");\n checkRevision(a.expectedRevision, f, p);\n const t = fs.readFileSync(f, \"utf8\");\n const m = crlfMatch(t, old, nw), o = m.old, nn = m.nw;\n const n = t.split(o).length - 1;\n if (n === 0) throw E(\"NOT_FOUND\", \"old string not found in \" + p, \"Re-read the file and copy the exact text (whitespace and line endings matter).\");\n countCheck(a.expect, n, \"expect\", p);\n if (n > 1 && !all) throw E(\"NOT_UNIQUE\", \"old string matches \" + n + \" places in \" + p, \"Add surrounding context to make it unique, or pass all:true (with expect:<count>).\");\n const lines = all ? lineNosOf(t, o) : lineNosOf(t, o).slice(0, 1);\n const after = all ? t.split(o).join(nn) : t.replace(o, () => nn);\n capWrite(Buffer.byteLength(after), \"edit of \" + p);\n atomicWrite(f, after);\n readState[f] = fs.statSync(f).mtimeMs;\n noteWrite(\"edit\", rel(f), lines.length, lines);\n return { replaced: lines.length, lines };\n });\n};\n// Foreground bash runs through the same detached wrapper as background:true, so reaching timeoutMs can PROMOTE the\n// command to a job instead of killing it: the job keeps the same 2h cap and state files, and tools.job({id}) reads it\n// like any other job. waitJob polls the state file with Atomics.wait (sleeping, no busy loop).\nconst BG_SYNC_MAX = 256 << 20; // the old spawnSync maxBuffer, kept as the foreground output cap\nconst startBgJob = (command, cd, env) => {\n const id = newId(\"b\");\n const meta = { id, kind: \"bash\", label: String(command).slice(0, 200), startedAt: Date.now(), deadlineAt: Date.now() + BG_MAX_MS };\n fs.writeFileSync(path.join(bgDir, id + \".json\"), JSON.stringify(meta));\n fs.writeFileSync(path.join(bgDir, id + \".out\"), \"\");\n fs.writeFileSync(path.join(bgDir, id + \".err\"), \"\");\n let ch;\n try { ch = cp.spawn(process.execPath, [\"-e\", BG_WRAP2, id, bgDir, command, cd, BASH], { detached: true, stdio: \"ignore\", windowsHide: true, env: childEnv(env) }); }\n catch (e) { try { fs.writeFileSync(path.join(bgDir, id + \".st\"), JSON.stringify({ code: null, sig: \"error\", t: Date.now() })); } catch {} throw sysErr(e); }\n try { meta.pid = ch.pid; fs.writeFileSync(path.join(bgDir, id + \".json\"), JSON.stringify(meta)); } catch {}\n ch.unref();\n return id;\n};\nconst bgFile = (id, ext) => path.join(bgDir, id + ext);\nconst bgText = (id, ext) => { try { return fs.readFileSync(bgFile(id, ext), \"utf8\"); } catch { return \"\"; } };\nconst bgStat = (id) => { try { return JSON.parse(fs.readFileSync(bgFile(id, \".st\"), \"utf8\")); } catch { return null; } };\n// \"done\" | \"timeout\" | \"overflow\"; sleeps between polls, so waiting costs no CPU.\nconst waitJob = (id, timeoutMs, capBytes) => {\n const t0 = Date.now(), end = t0 + (timeoutMs > 0 ? timeoutMs : 0);\n for (;;) {\n if (fs.existsSync(bgFile(id, \".st\"))) return \"done\";\n if (capBytes) {\n let n = 0;\n for (const ext of [\".out\", \".err\"]) { try { n += fs.statSync(bgFile(id, ext)).size; } catch {} }\n if (n > capBytes) return \"overflow\";\n }\n const now = Date.now();\n if (now >= end) return \"timeout\";\n sleepMs(Math.min(40, Math.max(2, Math.round((now - t0) / 16))));\n }\n};\nconst dropJob = (id) => { // a foreground command that finished normally leaves nothing behind (its id was never returned)\n for (const ext of [\".json\", \".out\", \".err\", \".st\", \".mix\"]) { try { fs.unlinkSync(bgFile(id, ext)); } catch {} }\n};\nconst killJobTree = (id) => { // by the recorded wrapper pid only, never by a command-line search\n try {\n const m = JSON.parse(fs.readFileSync(bgFile(id, \".json\"), \"utf8\"));\n if (!m.pid) return;\n if (WIN) cp.spawnSync(\"taskkill\", [\"/pid\", String(m.pid), \"/T\", \"/F\"], { windowsHide: true });\n else { try { process.kill(-m.pid, \"SIGKILL\"); } catch { try { process.kill(m.pid, \"SIGKILL\"); } catch {} } }\n } catch {}\n};\n// The pre-promotion behaviour (promote:false, and the fallback when the wrapper cannot start bash at all).\nconst syncBash = (command, cd, timeoutMs, env) => {\n const o = { cwd: cd, encoding: \"utf8\", timeout: timeoutMs, maxBuffer: BG_SYNC_MAX, env: childEnv(env) };\n let r = cp.spawnSync(BASH, [\"-c\", command], o);\n if (r.error && r.error.code === \"ENOENT\") r = cp.spawnSync(command, Object.assign({ shell: true }, o));\n if (r.error && r.error.code === \"ENOBUFS\") throw E(\"OUTPUT_LIMIT\", \"command output exceeded 256MB\", \"Redirect output to a file or filter it in the command.\");\n if (r.error && r.error.code === \"ETIMEDOUT\") return { code: null, stdout: r.stdout || \"\", stderr: r.stderr || \"\", timedOut: true };\n const res = { code: r.status, stdout: r.stdout, stderr: r.stderr };\n if (r.error) res.error = String(r.error.message || r.error);\n return res;\n};\ntools.bash = (a) => {\n A(a, \"bash\"); reqStr(a.command, \"command\");\n const { command, cwd: c, timeoutMs = 120000, env, background = false } = a;\n if (a.promote !== undefined && typeof a.promote !== \"boolean\") throw E(\"INVALID_ARGUMENT\", \"promote must be a boolean\", \"Pass promote:false to kill the command on timeout (the old behaviour); the default promotes it to a background job.\");\n const promote = a.promote !== false;\n const cd = c ? abs(c) : cwd;\n if (background === true) {\n let st; try { st = fs.statSync(cd); } catch (e) { if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"cwd not found: \" + (c || cd), \"Check the cwd.\"); throw e; }\n if (!st.isDirectory()) throw E(\"INVALID_ARGUMENT\", \"cwd is not a directory: \" + (c || cd), \"Pass a directory.\");\n return { id: startBgJob(command, cd, env) };\n }\n if (!promote) return syncBash(command, cd, timeoutMs, env);\n const id = startBgJob(command, cd, env);\n const how = waitJob(id, timeoutMs, BG_SYNC_MAX);\n if (how === \"overflow\") { killJobTree(id); throw E(\"OUTPUT_LIMIT\", \"command output exceeded 256MB\", \"Redirect output to a file or filter it in the command.\"); }\n const stdout = bgText(id, \".out\"), stderr = bgText(id, \".err\");\n if (how === \"timeout\") return { code: null, stdout, stderr, timedOut: true, promoted: true, jobId: id };\n const st = bgStat(id);\n // sig \"error\" with no code means the wrapper could not spawn bash: the command never started, so the old\n // spawnSync path (which retries through a shell) reproduces the previous result exactly.\n if (!st || (st.sig === \"error\" && st.code === null)) return syncBash(command, cd, timeoutMs, env);\n if (st.timedOut) return { code: null, stdout, stderr, timedOut: true, promoted: true, jobId: id }; // the job's own 2h cap\n dropJob(id);\n return { code: st.code, stdout, stderr };\n};\n\n// tools.job: query a background bash started by tools.bash({background:true}); jobs live in the scope dir and\n// survive across run_code calls.\ntools.job = (a) => {\n A(a, \"job\"); const id = checkId(reqStr(a.id, \"id\"));\n const waitMs = optInt(a.wait, \"wait\", 0, 0, 600000);\n const tailChars = optInt(a.tailChars, \"tailChars\", 4000, 1, 1 << 20);\n const mf = path.join(bgDir, id + \".json\");\n if (!fs.existsSync(mf)) throw E(\"NOT_FOUND\", \"job not found: \" + id, \"Use the id returned by tools.bash({background:true}) or the jobId of a promoted foreground command.\");\n const stf = path.join(bgDir, id + \".st\");\n const end = Date.now() + waitMs;\n while (!fs.existsSync(stf) && Date.now() < end) sleepMs(100);\n let out = \"\", err = \"\";\n try { out = fs.readFileSync(path.join(bgDir, id + \".out\"), \"utf8\"); } catch {}\n try { err = fs.readFileSync(path.join(bgDir, id + \".err\"), \"utf8\"); } catch {}\n const st = fs.existsSync(stf) ? JSON.parse(fs.readFileSync(stf, \"utf8\")) : null;\n const tail = (s) => s.length <= tailChars ? s : \"…[省略 \" + (s.length - tailChars) + \" 字]…\\n\" + s.slice(-tailChars);\n const r = { id, done: !!st, code: st ? st.code : null, stdout: tail(out), stderr: tail(err) };\n if (st && st.timedOut) r.timedOut = true;\n return r;\n};\n\n// ---------- tools.parallel: concurrent bash (async spawn) ----------\nconst WD = \"(\" + (function () { // watchdog: when the runner dies (pipe EOF) kill every still-registered child tree\n const cp = require(\"child_process\"), pids = new Set(); let b = \"\";\n process.stdin.setEncoding(\"utf8\");\n process.stdin.on(\"data\", (d) => { b += d; let i; while ((i = b.indexOf(\"\\n\")) >= 0) { const l = b.slice(0, i); b = b.slice(i + 1); const [c, p] = l.split(\" \"); if (c === \"add\") pids.add(p); else if (c === \"del\") pids.delete(p); } });\n process.stdin.on(\"end\", () => {\n for (const p of pids) { try { if (process.platform === \"win32\") cp.spawnSync(\"taskkill\", [\"/pid\", p, \"/T\", \"/F\"], { windowsHide: true }); else process.kill(-Number(p), \"SIGKILL\"); } catch {} }\n process.exit(0);\n });\n}).toString() + \")()\";\nlet wd = null;\nconst wdSend = (line) => {\n try {\n if (!wd) { wd = cp.spawn(process.execPath, [\"-e\", WD], { stdio: [\"pipe\", \"ignore\", \"ignore\"], windowsHide: true, detached: true }); wd.unref(); wd.stdin.on(\"error\", () => {}); }\n wd.stdin.write(line + \"\\n\");\n } catch {}\n};\nconst killTree = (child) => {\n try { if (WIN) cp.spawnSync(\"taskkill\", [\"/pid\", String(child.pid), \"/T\", \"/F\"], { windowsHide: true }); else process.kill(-child.pid, \"SIGKILL\"); }\n catch { try { child.kill(\"SIGKILL\"); } catch {} }\n};\nconst PAR_MAX_BUF = 256 << 20;\nconst runOne = (s) => new Promise((resolve) => {\n const outB = [], errB = []; let nOut = 0, nErr = 0, timedOut = false, over = false, settled = false, child = null, fallback = false, force = null;\n const finish = (code) => {\n if (settled) return; settled = true; clearTimeout(timer); clearTimeout(force);\n if (child && child.pid) wdSend(\"del \" + child.pid);\n const r = { code: timedOut || over ? null : code, stdout: Buffer.concat(outB).toString(\"utf8\"), stderr: Buffer.concat(errB).toString(\"utf8\") };\n if (timedOut) r.timedOut = true;\n if (over) r.error = \"[OUTPUT_LIMIT] command output exceeded 256MB and was killed\";\n resolve(r);\n };\n const start = (useShell) => {\n child = useShell ? cp.spawn(s.command, { shell: true, cwd: s.cwd, windowsHide: true, stdio: [\"ignore\", \"pipe\", \"pipe\"], env: s.env })\n : cp.spawn(BASH, [\"-c\", s.command], { cwd: s.cwd, windowsHide: true, detached: !WIN, stdio: [\"ignore\", \"pipe\", \"pipe\"], env: s.env });\n if (child.pid) wdSend(\"add \" + child.pid);\n child.stdout.on(\"data\", (d) => { if (over) return; nOut += d.length; if (nOut > PAR_MAX_BUF) { over = true; killTree(child); } else outB.push(d); });\n child.stderr.on(\"data\", (d) => { if (over) return; nErr += d.length; if (nErr > PAR_MAX_BUF) { over = true; killTree(child); } else errB.push(d); });\n child.on(\"error\", (e) => {\n if (e && e.code === \"ENOENT\" && !useShell && !fallback) { fallback = true; start(true); return; }\n errB.push(Buffer.from(String((e && e.message) || e))); finish(null);\n });\n child.on(\"close\", (code) => finish(code));\n };\n const timer = setTimeout(() => { timedOut = true; if (child) { killTree(child); force = setTimeout(() => finish(null), 2000); } else finish(null); }, s.timeoutMs);\n start(false);\n});\ntools.parallel = (calls, opts) => {\n if (!Array.isArray(calls) || !calls.length) throw E(\"INVALID_ARGUMENT\", \"calls must be a non-empty array of {command,cwd?,timeoutMs?}\", \"Pass tools.parallel([{command:'...'}, ...]).\");\n if (calls.length > 64) throw E(\"INVALID_ARGUMENT\", \"at most 64 calls per parallel (got \" + calls.length + \")\", \"Split into several parallel calls.\");\n const o = opts === undefined || opts === null ? {} : A(opts, \"parallel options\");\n const conc = optInt(o.concurrency, \"concurrency\", 4, 1, 16);\n const specs = calls.map((c, i) => {\n A(c, \"parallel call \" + i); reqStr(c.command, \"command\");\n const timeoutMs = optInt(c.timeoutMs, \"timeoutMs\", 120000, 1, 3600000);\n let cd = cwd;\n if (c.cwd !== undefined && c.cwd !== null) {\n cd = abs(reqStr(c.cwd, \"cwd\"));\n let st; try { st = fs.statSync(cd); } catch { throw E(\"NOT_FOUND\", \"cwd not found for call \" + i + \": \" + c.cwd, \"Check the cwd of that call.\"); }\n if (!st.isDirectory()) throw E(\"INVALID_ARGUMENT\", \"cwd of call \" + i + \" is not a directory\", \"Check the cwd of that call.\");\n }\n return { command: c.command, cwd: cd, timeoutMs, env: childEnv(c.env) };\n });\n return new Promise((resolve) => {\n const results = new Array(specs.length); let next = 0, active = 0, done = 0;\n const pump = () => {\n while (active < conc && next < specs.length) {\n const i = next++; active++;\n runOne(specs[i]).then((r) => { results[i] = r; active--; done++; if (done === specs.length) resolve(results); else pump(); });\n }\n };\n pump();\n });\n};\n\n// ---------- ripgrep ----------\nconst SKIP = [\"!node_modules\", \"!target\", \"!.venv\", \"!.git\", \"!.dsh-ops\"];\nlet rgCache;\nconst rgPath = () => {\n if (rgCache) return rgCache;\n if (input.rg && fs.existsSync(input.rg)) return (rgCache = input.rg);\n const names = WIN ? [\"rg.exe\"] : [\"rg\"];\n for (const d of String(process.env.PATH || process.env.Path || \"\").split(path.delimiter)) {\n if (!d) continue;\n for (const n of names) { const p = path.join(d, n); try { if (fs.statSync(p).isFile()) return (rgCache = p); } catch {} }\n }\n throw E(\"RG_UNAVAILABLE\", \"ripgrep (rg) was not found at the bundled path or on PATH\", \"Install ripgrep and put it on PATH, or use tools.bash with another search command.\");\n};\nconst globArgs = (g) => [].concat(g || []).flatMap((x) => [\"-g\", x]).concat(SKIP.flatMap((x) => [\"-g\", x]));\n// read-only mode: hand the argv to the broker (reqDir/<id>.req) and wait for resDir/<id>.res; exit if the broker is gone\nconst brokerGone = () => { try { process.kill(input.brokerPid, 0); return false; } catch (e) { return e.code !== \"EPERM\"; } };\nif (RO) setInterval(() => { if (brokerGone()) process.exit(3); }, 500).unref();\nlet rgSeq = 0;\nconst brokerRg = (args) => {\n const id = \"q\" + process.pid + \"_\" + (++rgSeq) + \"_\" + crypto.randomBytes(3).toString(\"hex\");\n const reqF = path.join(input.reqDir, id + \".req\"), tmp = path.join(input.reqDir, \".\" + id + \".tmp\");\n fs.writeFileSync(tmp, JSON.stringify({ argv: args, cwd })); fs.renameSync(tmp, reqF);\n const resF = path.join(input.resDir, id + \".res\"), outF = path.join(input.resDir, id + \".out\");\n let wait = 1, last = Date.now();\n while (!fs.existsSync(resF)) {\n sleepMs(wait); if (wait < 4) wait *= 2;\n if (Date.now() - last > 250) { last = Date.now(); if (brokerGone()) process.exit(3); }\n }\n let res;\n try { res = JSON.parse(fs.readFileSync(resF, \"utf8\")); } finally { try { fs.unlinkSync(reqF); } catch {} }\n if (res.error) throw E(res.error, String(res.message || \"rg request failed\"), \"Read-only mode: rg runs through the broker with a fixed option whitelist.\");\n return { status: res.status, stdout: fs.existsSync(outF) ? fs.readFileSync(outF, \"utf8\") : \"\", stderr: res.stderr || \"\", error: res.errorCode ? { code: res.errorCode } : undefined };\n};\nconst execRg = (args) => RO ? brokerRg(args) : cp.spawnSync(rgPath(), args, { cwd, encoding: \"utf8\", maxBuffer: 1024 << 20, timeout: 300000 });\nconst runRg = (args, rootAbs, soft) => { // soft: return null instead of throwing when rg rejects the pattern\n if (soft) {\n if (rootAbs !== undefined && !fs.existsSync(rootAbs)) throw E(\"NOT_FOUND\", \"search path not found: \" + rel(rootAbs), \"Check the 'path' argument.\");\n const s = execRg(args);\n if (s.error) { if (s.error.code === \"ENOENT\") throw E(\"RG_UNAVAILABLE\", \"cannot execute rg\", \"Install ripgrep and put it on PATH.\"); throw E(\"OUTPUT_LIMIT\", \"rg failed: \" + s.error.code, \"Narrow 'path' or 'glob'.\"); }\n return s.status === 2 && !s.stdout ? null : (s.stdout || \"\");\n }\n if (rootAbs !== undefined && !fs.existsSync(rootAbs)) throw E(\"NOT_FOUND\", \"search path not found: \" + rel(rootAbs), \"Check the 'path' argument.\");\n const r = execRg(args);\n if (r.error) {\n if (r.error.code === \"ENOENT\") throw E(\"RG_UNAVAILABLE\", \"cannot execute rg\", \"Install ripgrep and put it on PATH.\");\n throw E(\"OUTPUT_LIMIT\", \"rg failed: \" + r.error.code, \"Narrow 'path' or 'glob'.\");\n }\n if (r.status === 2 && /regex|parse error|error parsing|unclosed|unrecognized|unopened/i.test(r.stderr || \"\")) {\n throw E(\"INVALID_ARGUMENT\", \"invalid pattern: \" + String(r.stderr).split(NL).filter(Boolean).slice(0, 3).join(\" | \").slice(0, 400), \"Fix the regex (Rust regex syntax: no lookaround/backreferences).\");\n }\n return r.stdout || \"\";\n};\nconst cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;\nconst paginate = (all, offset, limit) => {\n const items = all.slice(offset, offset + limit);\n const next = offset + items.length;\n const more = next < all.length;\n return { items, total: all.length, complete: !more, nextOffset: more ? next : null };\n};\nconst pathOf = (o) => o.text !== undefined ? o.text : Buffer.from(o.bytes, \"base64\").toString(\"utf8\");\nconst CRLF_END = /\\r?\\n$/;\n\ntools.glob = guard((a = {}) => {\n A(a, \"glob\");\n const { pattern, path: root, sort = \"path\", details = false } = a;\n if (sort !== \"path\" && sort !== \"modified\") throw E(\"INVALID_ARGUMENT\", \"sort must be 'path' or 'modified'\", \"Use sort:'path' or sort:'modified'.\");\n const offset = optInt(a.offset, \"offset\", 0, 0, 1e9), limit = optInt(a.limit, \"limit\", 5000, 1, 1e6);\n credGuard(root || \".\");\n const rootAbs = abs(root || \".\");\n const out = runRg([\"--files\", \"--null\", ...globArgs(pattern), rootAbs], rootAbs);\n let files = out.split(\"\\0\").map((s) => s.replace(/^\\r?\\n/, \"\")).filter(Boolean).filter((f) => !credFast(f)).map((f) => rel(f));\n const st = (f) => { try { return fs.statSync(abs(f)); } catch { return null; } };\n let stats = null;\n if (sort === \"modified\") {\n stats = new Map(files.map((f) => [f, st(f)]));\n files.sort((x, y) => ((stats.get(y) ? stats.get(y).mtimeMs : 0) - (stats.get(x) ? stats.get(x).mtimeMs : 0)) || cmp(x, y));\n } else files.sort(cmp);\n const pg = paginate(files, offset, limit);\n if (details) pg.items = pg.items.map((f) => { const s = stats ? stats.get(f) : st(f); return { path: f, size: s ? s.size : null, mtime: s ? s.mtime.toISOString() : null }; });\n return pg;\n});\n\ntools.grep = guard((a) => {\n A(a, \"grep\");\n const { pattern, path: root, glob, ignoreCase = false, outputMode = \"content\", multiline = false } = a;\n reqStr(pattern, \"pattern\");\n if (![\"content\", \"files_with_matches\", \"count\"].includes(outputMode)) throw E(\"INVALID_ARGUMENT\", \"outputMode must be content|files_with_matches|count\", \"Fix outputMode.\");\n const before = optInt(a.before, \"before\", 0, 0, 100), after = optInt(a.after, \"after\", 0, 0, 100);\n const offset = optInt(a.offset, \"offset\", 0, 0, 1e9), limit = optInt(a.limit !== undefined ? a.limit : a.max, \"limit\", 500, 1, 1e6);\n credGuard(root || \".\");\n const rootAbs = abs(root || \".\");\n const base = [\"--max-filesize\", \"2M\", ...globArgs(glob)];\n if (ignoreCase) base.push(\"-i\");\n if (multiline) base.push(\"-U\");\n if (outputMode === \"files_with_matches\") {\n const out = runRg([\"-l\", \"--null\", ...base, \"-e\", pattern, rootAbs], rootAbs);\n return paginate(out.split(\"\\0\").map((s) => s.replace(/^\\r?\\n/, \"\")).filter(Boolean).filter((f) => !credFast(f)).map(rel).sort(cmp), offset, limit);\n }\n if (outputMode === \"count\") {\n const out = runRg([\"-c\", \"-H\", \"--null\", ...base, \"-e\", pattern, rootAbs], rootAbs);\n const rows = [];\n for (const l of out.split(NL)) { const i = l.lastIndexOf(\"\\0\"); if (i < 0) continue; if (credFast(l.slice(0, i))) continue; rows.push({ file: rel(l.slice(0, i)), count: Number(l.slice(i + 1)) }); }\n rows.sort((x, y) => cmp(x.file, y.file));\n const pg = paginate(rows, offset, limit);\n pg.matchingLines = rows.reduce((s, r) => s + r.count, 0);\n return pg;\n }\n // content: consume everything, group per file, sort by file for stable paging\n const args = [\"--json\", ...base];\n if (before) args.push(\"-B\", String(before));\n if (after) args.push(\"-A\", String(after));\n args.push(\"-e\", pattern, rootAbs);\n const out = runRg(args, rootAbs);\n const blocks = []; let cur = null, total = 0;\n for (const l of out.split(NL)) {\n if (l.startsWith('{\"type\":\"match\"')) { if (cur) { cur.rows.push(l); cur.m++; total++; } }\n else if (l.startsWith('{\"type\":\"context\"')) { if (cur) cur.rows.push(l); }\n else if (l.startsWith('{\"type\":\"begin\"')) { const pf = pathOf(JSON.parse(l).data.path); if (credFast(pf)) cur = null; else { cur = { file: rel(pf), rows: [], m: 0 }; blocks.push(cur); } }\n }\n blocks.sort((x, y) => cmp(x.file, y.file));\n const items = [];\n let idx = 0;\n const end = offset + limit;\n for (const b of blocks) {\n const lo = idx, hi = idx + b.m;\n idx = hi;\n if (hi <= offset || lo >= end) continue;\n const ent = []; const picked = []; let mi = lo;\n for (const raw of b.rows) {\n const d = JSON.parse(raw).data;\n const isMatch = raw.startsWith('{\"type\":\"match\"');\n const e = { file: b.file, line: d.line_number, text: pathOf(d.lines).replace(CRLF_END, \"\").slice(0, 300) };\n if (isMatch) {\n const inWin = mi >= offset && mi < end; mi++;\n if (inWin) { e.end = e.line + (pathOf(d.lines).replace(CRLF_END, \"\").split(NL).length - 1); picked.push(e); ent.push(e); }\n } else { e.context = true; ent.push(e); }\n }\n for (const e of ent) {\n if (e.context) {\n if (!picked.some((m) => (e.line >= m.line - before && e.line < m.line) || (e.line > m.end && e.line <= m.end + after))) continue;\n }\n items.push(e);\n }\n for (const m of picked) delete m.end;\n }\n const more = offset + limit < total;\n return { items, total, complete: !more, nextOffset: more ? offset + limit : null };\n});\n\n// loosen a JS pattern into a superset-safe pattern for the file finder\nconst looseForFinder = (pattern) => {\n let pat = \"\", inClass = false, needsPcre = false, needsScan = false;\n for (let i = 0; i < pattern.length; i++) {\n const c = pattern[i];\n if (c === \"\\\\\") {\n const n = pattern[i + 1]; i++;\n if (n === undefined) { pat += c; break; }\n if (\"WDS\".includes(n)) needsScan = true;\n if (n === \"k\") needsPcre = true;\n if (!inClass && (n === \"b\" || n === \"B\")) continue;\n if (!inClass && n >= \"1\" && n <= \"9\") needsPcre = true;\n pat += c + n; continue;\n }\n if (inClass) { if (c === \"]\") inClass = false; pat += c; continue; }\n if (c === \"[\") { inClass = true; pat += c; continue; }\n if (c === \"(\" && pattern[i + 1] === \"?\") {\n const t = pattern.slice(i + 2, i + 4);\n if (/^[=!]/.test(t) || /^<[=!]/.test(t)) { needsPcre = true; if (t[0] === \"!\" || t === \"<!\") needsScan = true; }\n }\n if (c === \"^\" || c === \"$\") continue;\n pat += c;\n }\n return { pat, needsPcre, needsScan };\n};\ntools.replace = guard((a) => {\n markCall(\"replace\", a);\n A(a, \"replace\");\n const { pattern, replacement, path: root, glob, flags = \"\", dryRun = false } = a;\n let expRev = null;\n if (a.expectedRevision !== undefined && a.expectedRevision !== null) {\n const m = a.expectedRevision;\n if (typeof m !== \"object\" || Array.isArray(m)) throw E(\"INVALID_ARGUMENT\", \"expectedRevision must be an object mapping path -> revision\", \"Pass {path: revision} using values from tools.revision.\");\n expRev = new Map();\n for (const k of Object.keys(m)) {\n if (typeof m[k] !== \"string\" || m[k] === \"\") throw E(\"INVALID_ARGUMENT\", \"expectedRevision[\" + k + \"] must be a non-empty string\", \"Pass the value returned by tools.revision.\");\n expRev.set(revKey(k), m[k]);\n }\n }\n reqStr(pattern, \"pattern\");\n if (typeof replacement !== \"string\") throw E(\"INVALID_ARGUMENT\", \"replacement must be a string\", \"Use JS replacement syntax ($1, $&).\");\n if (typeof flags !== \"string\" || !/^[dgimsuvy]*$/.test(flags)) throw E(\"INVALID_ARGUMENT\", \"flags must be JS RegExp flags\", \"Use a subset of dgimsuvy.\");\n const maxRep = optInt(a.maxReplacements, \"maxReplacements\", 100000, 1, 1e9);\n let re;\n try { re = new RegExp(pattern, flags.includes(\"g\") ? flags : flags + \"g\"); } catch (e) { throw E(\"INVALID_ARGUMENT\", \"invalid regex: \" + e.message, \"Fix the JS regular expression.\"); }\n // The finder must never miss a file the JS regex would hit (superset), because replacement semantics are JS's.\n // Zero-width assertions (^ $ \\b \\B) are dropped for the finder (a looser pattern only adds candidates); \\W \\D \\S and\n // negative lookaround cannot be loosened safely and go to a full scan; lookaround / backrefs use PCRE2 (rg -P).\n const ls = looseForFinder(pattern);\n const inline = [\"i\", \"m\", \"s\"].filter((f) => flags.includes(f)).join(\"\");\n const rgPattern = (inline ? \"(?\" + inline + \")\" : \"\") + ls.pat;\n credGuard(root || \".\");\n const rootAbs = abs(root || \".\");\n const parse = (o) => o.split(\"\\0\").map((s) => s.replace(/^\\r?\\n/, \"\")).filter(Boolean).sort(cmp);\n let finder = \"scan\", files = null;\n if (!ls.needsScan) {\n const o = runRg([\"-l\", \"--null\", \"-U\", ...(ls.needsPcre ? [\"-P\"] : []), \"--max-filesize\", \"2M\", ...globArgs(glob), \"-e\", rgPattern, rootAbs], rootAbs, true);\n if (o !== null) { files = parse(o); finder = ls.needsPcre ? \"pcre2\" : \"rg\"; }\n }\n if (files === null) files = parse(runRg([\"--files\", \"--null\", \"--max-filesize\", \"2M\", ...globArgs(glob), rootAbs], rootAbs));\n files = files.filter((f) => !credFast(f));\n // phase 1: compute every new content in memory\n const found = []; const skipped = []; let matches = 0, changed = 0;\n for (const f of files) {\n let buf; try { buf = fs.readFileSync(f); } catch (e) { if (finder === \"scan\") continue; throw e; }\n if (finder === \"scan\" && buf.subarray(0, 8000).includes(0)) continue;\n const text = buf.toString(\"utf8\");\n let n = 0; const offs = []; text.replace(re, (...m) => { n++; const o = m.find((v, k) => k > 0 && typeof v === \"number\"); offs.push(o); return m[0]; });\n if (!n) continue;\n if (!Buffer.from(text, \"utf8\").equals(buf)) { skipped.push(rel(f)); continue; }\n matches += n;\n if (matches > maxRep) throw E(\"OUTPUT_LIMIT\", \"more than maxReplacements (\" + maxRep + \") matches; nothing was written\", \"Narrow the pattern/path/glob or raise maxReplacements.\");\n const nt = text.replace(re, replacement), diff = nt !== text;\n if (diff) capWrite(Buffer.byteLength(nt), \"replace of \" + rel(f));\n if (diff) changed++;\n found.push({ f, n, lines: lineNosAt(text, offs), mtime: fs.statSync(f).mtimeMs, nt: diff ? nt : null, buf: diff ? buf : null });\n }\n countCheck(a.expect, matches, \"expect\", \"the searched files\");\n const items = found.map((x) => ({ file: rel(x.f), count: x.n, lines: x.lines.slice(0, 50) }));\n // phase 2: STALE-check every file to be written, then write; on any failure restore the already written files\n if (!dryRun) {\n const todo = found.filter((x) => x.nt !== null);\n const writeAll = () => {\n for (const x of todo) {\n let m; try { m = fs.statSync(x.f).mtimeMs; } catch { m = -1; }\n if (m !== x.mtime) throw E(\"STALE\", \"file changed during replace: \" + rel(x.f) + \" (nothing was written)\", \"Re-run tools.replace.\", { rolledBack: false, written: [] });\n const want = expRev ? expRev.get(revKey(x.f)) : undefined;\n if (want !== undefined && revOf(x.f) !== want) throw E(\"STALE\", \"revision mismatch for \" + rel(x.f) + \": expected \" + want + \", current \" + revOf(x.f) + \" (nothing was written)\", \"Re-read the file with tools.read and get the current revision with tools.revision.\", { rolledBack: false, written: [] });\n }\n const written = []; let cur = null;\n try {\n for (const x of todo) { cur = x; atomicWrite(x.f, x.nt); written.push(x); }\n } catch (e) {\n const pe = sysErr(e), restored = [], restoreFailed = [];\n for (const w of written.slice().reverse()) { try { atomicWrite(w.f, w.buf); restored.push(rel(w.f)); } catch { restoreFailed.push(rel(w.f)); } }\n const rolledBack = restoreFailed.length === 0;\n throw E(rolledBack ? (pe.code || \"RESULT_UNKNOWN\") : \"RESULT_UNKNOWN\",\n \"write failed at \" + rel(cur.f) + \" (\" + String(pe.message).replace(/^\\[\\w+\\]\\s*/, \"\") + \"); \" + (rolledBack ? \"rolledBack=true, restored \" + restored.length + \" file(s)\" : \"rolledBack=false, could not restore: \" + restoreFailed.join(\", \")),\n rolledBack ? \"No files were changed; fix the cause (locked/read-only file?) and re-run.\" : \"Inspect the files listed in restoreFailed; they hold the new content.\",\n { rolledBack, restored, restoreFailed, failedFile: rel(cur.f) });\n }\n for (const x of todo) { try { readState[x.f] = fs.statSync(x.f).mtimeMs; } catch {} }\n for (const x of todo) noteWrite(\"replace\", rel(x.f), x.n, x.lines);\n };\n // With expectedRevision this is one guarded multi-file write: every lock is taken (in sorted order, so two\n // callers cannot deadlock) before the revision checks, and released however the write ends.\n if (expRev) withFileLocks(todo.map((x) => x.f), writeAll); else writeAll();\n }\n const res = { files: found.length, matches, changed, items, dryRun: !!dryRun, finder };\n if (skipped.length) res.skipped = skipped;\n return res;\n});\n\n// ---------- ref store ----------\nconst ID_RE = /^[A-Za-z0-9_-]{1,64}$/;\nconst checkId = (id) => { if (typeof id !== \"string\" || !ID_RE.test(id)) throw E(\"INVALID_ARGUMENT\", \"invalid ref id\", \"Pass the id returned by ref.put / detailsRef.\"); return id; };\nconst refFile = (id) => path.join(dir, id + \".json\");\nconst jobFile = (id) => path.join(jobsDir, id + \".json\");\nconst newId = (p) => p + Date.now().toString(36) + crypto.randomBytes(3).toString(\"hex\");\nconst ENVELOPE = 96;\nconst pageRows = (rows, offset, limit, maxBytes, extra) => {\n const total = rows.length, items = [];\n let used = ENVELOPE;\n const end = Math.min(total, offset + limit);\n for (let i = offset; i < end; i++) {\n const b = Buffer.byteLength(JSON.stringify(rows[i] === undefined ? null : rows[i])) + 1;\n if (used + b > maxBytes) {\n if (!items.length) throw E(\"OUTPUT_LIMIT\", \"row \" + i + \" needs \" + b + \" bytes, over maxBytes \" + maxBytes, \"Raise maxBytes or select fewer fields: ref.page(id,{offset:\" + i + \",limit:1,fields:[...]}).\", { row: i, bytes: b });\n break;\n }\n items.push(rows[i]); used += b;\n }\n const next = offset + items.length;\n const nextOffset = next < total ? next : null;\n return Object.assign({ total, offset, items, nextOffset, complete: nextOffset === null, omitted: Math.max(0, end - offset - items.length) }, extra || {});\n};\nconst ref = {\n put: guard((value) => {\n const s = JSON.stringify(value);\n if (s === undefined) throw E(\"INVALID_ARGUMENT\", \"value is not JSON-serializable\", \"Pass JSON data.\");\n const id = newId(\"r\");\n const f = refFile(id), t = tmpName(f);\n fs.writeFileSync(t, s); renameRetry(t, f);\n return id;\n }),\n page: guard((id, o = {}) => {\n checkId(id); A(o, \"ref.page\");\n const { fields, equals } = o;\n const offset = optInt(o.offset, \"offset\", 0, 0, 1e12), limit = optInt(o.limit, \"limit\", 50, 1, 1e6), maxBytes = optInt(o.maxBytes, \"maxBytes\", 16384, 128, 64 << 20);\n let isJob = false, f = refFile(id);\n if (!fs.existsSync(f)) { f = jobFile(id); isJob = true; }\n let raw;\n try { raw = fs.readFileSync(f, \"utf8\"); } catch (e) { if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"ref not found or expired: \" + id, \"Refs live 24h per session; re-create the data.\"); throw e; }\n let v = JSON.parse(raw);\n if (typeof v === \"string\") {\n let n = Math.min(limit * 100, v.length - offset);\n if (n < 0) n = 0;\n let text = v.slice(offset, offset + n);\n while (text.length > 1 && Buffer.byteLength(JSON.stringify(text)) + ENVELOPE > maxBytes) text = text.slice(0, Math.floor(text.length / 2));\n const next = offset + text.length;\n return { total: v.length, offset, text, nextOffset: next < v.length ? next : null, complete: next >= v.length, omitted: 0 };\n }\n let extra;\n if (isJob || (v && !Array.isArray(v) && Array.isArray(v.items) && v.kind === \"job\")) { extra = { job: { id: v.id, status: v.status, root: v.root } }; v = v.items; }\n if (!Array.isArray(v)) return v;\n if (equals) v = v.filter((r) => Object.keys(equals).every((k) => r && r[k] === equals[k]));\n if (fields) v = v.map((r) => r && typeof r === \"object\" ? Object.fromEntries(fields.map((k) => [k, r[k]])) : r);\n return pageRows(v, offset, limit, maxBytes, extra);\n }),\n text: guard((id, o = {}) => {\n checkId(id); A(o, \"ref.text\");\n const from = optInt(o.from, \"from\", 0, 0, 1e12);\n const to = o.to === undefined || o.to === null ? undefined : optInt(o.to, \"to\", 0, 0, 1e12);\n let f = refFile(id);\n if (!fs.existsSync(f)) f = jobFile(id);\n let raw;\n try { raw = fs.readFileSync(f, \"utf8\"); } catch (e) { if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"ref not found or expired: \" + id, \"Refs live 24h per session; re-create the data.\"); throw e; }\n let s;\n try { const v = JSON.parse(raw); s = typeof v === \"string\" ? v : JSON.stringify(v); } catch { s = raw; }\n return to === undefined ? s.slice(from) : s.slice(from, to);\n }),\n release: guard((id) => {\n checkId(id);\n if (!fs.existsSync(refFile(id)) && fs.existsSync(jobFile(id))) throw E(\"POLICY_DENIED\", \"job logs are read-only\", \"Job logs are kept for recovery; just stop using the id.\");\n try { fs.unlinkSync(refFile(id)); return true; } catch { return false; }\n }),\n};\nref.del = ref.release;\n\n// ---------- batch (copy / move / remove) ----------\nconst MAX_OPS = 512;\nconst WINDEV = /^(con|prn|aux|nul|com[0-9]|lpt[0-9]|conin\\$|conout\\$)(\\..*)?$/i;\nconst normRel = (p, what) => {\n if (typeof p !== \"string\" || p === \"\") throw E(\"INVALID_ARGUMENT\", what + \" must be a non-empty string\", \"Use a root-relative forward-slash path.\");\n if (/[\\u0000-\\u001f]/.test(p)) throw E(\"INVALID_ARGUMENT\", what + \" contains control characters\", \"Remove control characters.\");\n if (p.includes(\"\\\\\")) throw E(\"INVALID_ARGUMENT\", what + \" contains a backslash\", \"Use forward slashes.\");\n if (p.startsWith(\"/\") || /^[A-Za-z]:/.test(p)) throw E(\"POLICY_DENIED\", what + \" must be relative to root, not absolute: \" + p, \"Use a root-relative path.\");\n if (p.includes(\":\")) throw E(\"INVALID_ARGUMENT\", what + \" contains ':' (device/stream names are rejected): \" + p, \"Remove the ':' part.\");\n if (/[<>\"|?*]/.test(p)) throw E(\"INVALID_ARGUMENT\", what + \" contains a reserved character\", \"Remove < > \\\" | ? *.\");\n const segs = p.split(\"/\");\n for (const s of segs) {\n if (s === \"\") throw E(\"INVALID_ARGUMENT\", what + \" has an empty segment or trailing slash: \" + p, \"Use a/b/c without trailing or doubled slashes.\");\n if (s === \".\") throw E(\"INVALID_ARGUMENT\", what + \" contains '.': \" + p, \"Remove '.' segments.\");\n if (s === \"..\") throw E(\"POLICY_DENIED\", what + \" escapes the root with '..': \" + p, \"Stay inside root.\");\n if (/[. ]$/.test(s)) throw E(\"INVALID_ARGUMENT\", what + \" segment ends with '.' or space: \" + p, \"Rename the segment.\");\n if (WINDEV.test(s)) throw E(\"INVALID_ARGUMENT\", what + \" contains a Windows device name: \" + p, \"Avoid CON/PRN/AUX/NUL/COMn/LPTn.\");\n }\n return segs;\n};\nconst keyOf = (segs) => { const k = segs.join(\"/\"); return WIN || process.platform === \"darwin\" ? k.toLowerCase() : k; };\nconst under = (a, b) => a === b || a.startsWith(b + \"/\");\nconst overlap = (a, b) => under(a, b) || under(b, a);\nconst akey = (root, segs) => keyOf([root.split(path.sep).join(\"/\").replace(/[\\/]+$/, \"\"), ...segs]); // absolute comparison key (works across roots)\nconst lst = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.code === \"ENOENT\" || e.code === \"ENOTDIR\") return null; throw e; } };\nconst sha256File = (p) => {\n const h = crypto.createHash(\"sha256\"), fd = fs.openSync(p, \"r\"), buf = Buffer.allocUnsafe(1 << 20);\n try { let n; while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n)); } finally { fs.closeSync(fd); }\n return h.digest(\"hex\");\n};\n// snapshot: metadata digest (content=false) or sha256 content/tree digest (content=true); rejects links and special files\nconst snapshot = (p, content) => {\n if (credFast(p)) throw E(\"POLICY_DENIED\", \"credential path refused: \" + rel(p), \"Credential files are not copied or moved through these tools.\");\n const st = fs.lstatSync(p);\n if (st.isSymbolicLink()) throw E(\"POLICY_DENIED\", \"contains a symbolic link or junction: \" + rel(p), \"Links are not copied or moved; remove them from the tree or operate on real files.\");\n if (st.isFile()) return content ? \"f:\" + sha256File(p) : \"f:\" + st.size + \":\" + Math.floor(st.mtimeMs);\n if (st.isDirectory()) {\n const h = crypto.createHash(\"sha256\");\n for (const n of fs.readdirSync(p).sort()) h.update(n + \"\\0\" + snapshot(path.join(p, n), content) + NL);\n return \"d:\" + h.digest(\"hex\");\n }\n throw E(\"POLICY_DENIED\", \"special file (not a regular file or directory): \" + rel(p), \"Only regular files and directories are supported.\");\n};\nconst copyTree = (src, dst) => {\n const st = fs.lstatSync(src);\n if (st.isSymbolicLink() || !(st.isFile() || st.isDirectory())) throw E(\"POLICY_DENIED\", \"unsupported entry: \" + rel(src), \"Only regular files and directories are supported.\");\n if (st.isDirectory()) { fs.mkdirSync(dst); for (const n of fs.readdirSync(src)) copyTree(path.join(src, n), path.join(dst, n)); }\n else copyFileSynced(src, dst, st);\n};\n// copy one file: exclusive create, data flushed to disk (fsync) before returning; mode and times are carried over\nconst copyFileSynced = (src, dst, st) => {\n const buf = Buffer.allocUnsafe(1 << 20);\n const i = fs.openSync(src, \"r\");\n let o;\n try {\n o = fs.openSync(dst, \"wx\");\n let n; while ((n = fs.readSync(i, buf, 0, buf.length, null)) > 0) { let w = 0; while (w < n) w += fs.writeSync(o, buf, w, n - w); }\n fs.fsyncSync(o);\n } finally { if (o !== undefined) fs.closeSync(o); fs.closeSync(i); }\n try { fs.chmodSync(dst, st.mode & 0o777); } catch {}\n try { fs.utimesSync(dst, st.atime, st.mtime); } catch {}\n};\n// Windows read-only files are never deleted implicitly (libuv would silently clear the attribute and delete them)\nconst unlinkFile = (p, st, force) => { // force: our own staging files only\n if (force && st && (st.mode & 0o200) === 0) { try { fs.chmodSync(p, 0o666); } catch {} }\n else if (WIN && st && (st.mode & 0o200) === 0) throw E(\"POLICY_DENIED\", \"file has the read-only attribute: \" + rel(p), \"Clear it first (attrib -R) if deletion is intended.\");\n withRetry(() => fs.unlinkSync(p));\n};\nconst removeTree = (p, prog, force) => { // prog.n counts removed entries, so callers can tell \"failed, nothing removed\" from \"partially removed\"\n const st = fs.lstatSync(p);\n if (st.isSymbolicLink()) { try { withRetry(() => fs.unlinkSync(p)); } catch (e) { try { fs.rmdirSync(p); } catch { throw e; } } if (prog) prog.n++; return; }\n if (st.isDirectory()) { for (const n of fs.readdirSync(p)) removeTree(path.join(p, n), prog, force); withRetry(() => fs.rmdirSync(p)); }\n else unlinkFile(p, st, force);\n if (prog) prog.n++;\n};\nconst tagged = (e, effect) => { const x = sysErr(e); try { x.effect = effect; } catch {} return x; };\n\nconst resolveRoot = (root) => {\n credGuard(root || \".\");\n const r = path.resolve(cwd, root || \".\");\n let st; try { st = fs.statSync(r); } catch { throw E(\"NOT_FOUND\", \"root not found: \" + r, \"Check root.\"); }\n if (!st.isDirectory()) throw E(\"INVALID_ARGUMENT\", \"root is not a directory\", \"Check root.\");\n return fs.realpathSync(r);\n};\nconst checkParents = (root, segs, what) => {\n for (let i = 1; i < segs.length; i++) {\n const p = path.join(root, ...segs.slice(0, i)), st = lst(p);\n if (!st) throw E(\"NOT_FOUND\", what + \" parent directory does not exist: \" + segs.slice(0, i).join(\"/\"), what === \"destination\" ? \"Destination parents must already exist; create them first (tools.bash mkdir).\" : \"Check the source path.\");\n if (st.isSymbolicLink()) throw E(\"POLICY_DENIED\", what + \" path passes through a symbolic link or junction: \" + segs.slice(0, i).join(\"/\"), \"Linked ancestors are never traversed.\");\n if (!st.isDirectory()) throw E(\"INVALID_ARGUMENT\", what + \" ancestor is not a directory: \" + segs.slice(0, i).join(\"/\"), \"Fix the path.\");\n }\n};\n// validates one operation against the filesystem; returns {sk, dk, observed}\nconst preflightOne = (it, roots, protect) => { // roots = {s: source root, d: destination root}; protect = absolute keys\n if (it === null || typeof it !== \"object\") throw E(\"INVALID_ARGUMENT\", \"operation must be an object\", \"Use {op,path,to?}.\");\n const op = it.op;\n if (![\"copy\", \"move\", \"remove\"].includes(op)) throw E(\"INVALID_ARGUMENT\", \"op must be copy|move|remove\", \"Fix op.\");\n const s = normRel(it.path, \"path\");\n let d = null;\n if (op === \"remove\") { if (it.to !== undefined && it.to !== null) throw E(\"INVALID_ARGUMENT\", \"remove does not take 'to'\", \"Drop 'to'.\"); }\n else { if (it.to === undefined || it.to === null) throw E(\"INVALID_ARGUMENT\", op + \" requires 'to'\", \"Add 'to'.\"); d = normRel(it.to, \"to\"); }\n const sk = akey(roots.s, s), dk = d ? akey(roots.d, d) : null;\n for (const pk of protect) if (overlap(sk, pk) || (dk && overlap(dk, pk))) throw E(\"POLICY_DENIED\", \"touches protected path '\" + pk + \"'\", \"Remove the operation or change protect.\");\n checkParents(roots.s, s, \"source\");\n credGuard(path.join(roots.s, ...s));\n if (d) credGuard(path.join(roots.d, ...d));\n const sp = path.join(roots.s, ...s), sst = lst(sp);\n if (!sst) throw E(\"NOT_FOUND\", \"source does not exist: \" + it.path, \"Check the path.\");\n let observed;\n if (sst.isSymbolicLink()) {\n if (op !== \"remove\") throw E(\"POLICY_DENIED\", \"source is a symbolic link or junction: \" + it.path, \"Links are not copied or moved; remove can unlink the link itself.\");\n observed = \"link\";\n } else observed = op === \"remove\" ? (sst.isDirectory() ? \"d\" : \"f\") : snapshot(sp, false);\n if (d) {\n checkParents(roots.d, d, \"destination\");\n if (dk !== sk && under(dk, sk)) throw E(\"INVALID_ARGUMENT\", \"destination is inside the source (cyclic): \" + it.to, \"Choose a destination outside the source.\");\n if (lst(path.join(roots.d, ...d))) throw E(\"EXISTS\", \"destination already exists: \" + it.to, \"Batch never overwrites; pick another destination or remove it first.\");\n }\n return { sk, dk, op, observed };\n};\nconst crossCheck = (list) => { // list: [{i, op, sk, dk}] ; returns Map index -> message\n const bad = new Map();\n for (let x = 0; x < list.length; x++) for (let y = x + 1; y < list.length; y++) {\n const a = list[x], b = list[y];\n let why = null;\n if (a.dk && b.dk && overlap(a.dk, b.dk)) why = \"destination overlaps destination of operation \" + a.i;\n else if (a.dk && overlap(a.dk, b.sk)) why = \"source overlaps destination of operation \" + a.i;\n else if (b.dk && overlap(b.dk, a.sk)) why = \"destination overlaps source of operation \" + a.i;\n else if (overlap(a.sk, b.sk) && (a.op !== \"copy\" || b.op !== \"copy\")) why = \"source overlaps source of operation \" + a.i;\n if (why && !bad.has(b.i)) bad.set(b.i, why);\n }\n return bad;\n};\n\nconst saveJob = (job) => {\n job.updatedAt = new Date().toISOString();\n const c = { done: 0, failed: 0, unknown: 0, pending: 0 };\n for (const it of job.items) c[it.state === \"completed\" ? \"done\" : it.state]++;\n job.status = c.done === job.items.length ? \"completed\" : c.done === 0 && !c.failed && !c.unknown ? \"planned\" : c.done > 0 ? \"partial\" : \"failed\";\n atomicWrite(jobFile(job.id), JSON.stringify(job));\n};\nconst loadJob = (id) => {\n checkId(id);\n let raw; try { raw = fs.readFileSync(jobFile(id), \"utf8\"); } catch (e) { if (e.code === \"ENOENT\") throw E(\"NOT_FOUND\", \"job not found: \" + id, \"Use tools.batch({action:'list'}) to see jobs in this session.\"); throw e; }\n return JSON.parse(raw);\n};\nconst summary = (job) => {\n const c = { done: 0, failed: 0, unknown: 0, pending: 0 };\n for (const it of job.items) c[it.state === \"completed\" ? \"done\" : it.state]++;\n const r = { id: job.id, status: job.status, done: c.done, failed: c.failed, unknown: c.unknown, pending: c.pending, total: job.items.length, detailsRef: job.id };\n const bad = job.items.filter((it) => it.state === \"failed\" || it.state === \"unknown\").slice(0, 5);\n if (bad.length) r.errors = bad.map((it) => ({ index: it.index, op: it.op, path: it.path, state: it.state, error: it.error }));\n return r;\n};\nconst lockFile = (id) => path.join(jobsDir, id + \".lock\");\nconst acquire = (id) => {\n const f = lockFile(id);\n for (let k = 0; k < 2; k++) {\n try { fs.writeFileSync(f, String(process.pid), { flag: \"wx\" }); return; } catch (e) {\n if (e.code !== \"EEXIST\") throw e;\n let pid = 0; try { pid = Number(fs.readFileSync(f, \"utf8\")); } catch {}\n let alive = false; if (pid && pid !== process.pid) { try { process.kill(pid, 0); alive = true; } catch (er) { alive = er.code === \"EPERM\"; } }\n if (alive) throw E(\"POLICY_DENIED\", \"job \" + id + \" is being executed by another process (pid \" + pid + \")\", \"Wait for it to finish, then resume.\");\n try { fs.unlinkSync(f); } catch {}\n }\n }\n throw E(\"POLICY_DENIED\", \"cannot lock job \" + id, \"Retry.\");\n};\nconst release = (id) => { try { fs.unlinkSync(lockFile(id)); } catch {} };\n\nconst segsOf = (it) => [normRel(it.path, \"path\"), it.to ? normRel(it.to, \"to\") : null];\nconst postcondition = (roots, it) => {\n try {\n const [s, d] = segsOf(it), sp = path.join(roots.s, ...s), dp = d && path.join(roots.d, ...d);\n if (it.op === \"remove\") return !lst(sp);\n if (it.op === \"copy\") return !!lst(sp) && !!lst(dp) && snapshot(sp, true) === snapshot(dp, true);\n const dst = lst(dp);\n if (lst(sp) || !dst) return false;\n return it.observed ? (it.observed[0] === (dst.isDirectory() ? \"d\" : \"f\")) : true;\n } catch { return false; }\n};\nconst tmpRelOf = (job, it) => { const segs = normRel(it.to, \"to\"); return segs.slice(0, -1).concat(\".ptc-tmp-\" + job.id + \"-\" + it.index).join(\"/\"); };\nconst tmpPathOf = (roots, it) => path.join(roots.d, ...normRel(it.tmp, \"tmp\"));\n// Copies and cross-device moves never write the final name directly: data goes to a journaled staging name next to the\n// destination (fsync + sha256/tree verification), then a same-volume rename publishes it, then the source is removed.\nconst execItem = (roots, it, job) => {\n const [s, d] = segsOf(it), sp = path.join(roots.s, ...s), dp = d && path.join(roots.d, ...d);\n if (it.op === \"remove\") { const prog = { n: 0 }; try { removeTree(sp, prog); } catch (e) { const pe = sysErr(e); if (prog.n) pe.message += \" (partially removed: \" + prog.n + \" entries already deleted; remove is idempotent, resume continues it)\"; throw tagged(pe, \"none\"); } if (lst(sp)) throw tagged(E(\"RESULT_UNKNOWN\", \"source still present after remove\", \"Inspect the path.\"), \"maybe\"); return; }\n if (lst(dp)) throw tagged(E(\"EXISTS\", \"destination already exists: \" + it.to, \"Pick another destination.\"), \"none\");\n const tp = tmpPathOf(roots, it);\n const dropTmp = () => { try { if (lst(tp)) removeTree(tp, undefined, true); } catch {} return !lst(tp); };\n const stagedCopy = () => {\n if (!dropTmp()) throw tagged(E(\"RESULT_UNKNOWN\", \"cannot clear leftover staging path \" + it.tmp, \"Remove it manually, then resume.\"), \"none\");\n it.stage = \"copying\"; if (job) saveJob(job);\n try { copyTree(sp, tp); } catch (e) { throw tagged(e, dropTmp() ? \"none\" : \"maybe\"); }\n let ok = false; try { ok = snapshot(sp, true) === snapshot(tp, true); } catch {}\n if (!ok) throw tagged(E(\"STALE\", \"copy verification failed (source changed or copy differs)\", \"Retry with a new batch.\"), dropTmp() ? \"none\" : \"maybe\");\n if (lst(dp)) { dropTmp(); throw tagged(E(\"EXISTS\", \"destination appeared during copy: \" + it.to, \"Pick another destination.\"), \"none\"); }\n try { withRetry(() => fs.renameSync(tp, dp)); } catch (e) { throw tagged(e, dropTmp() ? \"none\" : \"maybe\"); }\n it.stage = \"placed\"; if (job) saveJob(job);\n };\n if (it.op === \"copy\") return stagedCopy();\n try { withRetry(() => fs.renameSync(sp, dp)); } catch (e) {\n if (e.code !== \"EXDEV\") throw tagged(e, \"none\");\n stagedCopy();\n try { removeTree(sp, undefined, true); } catch (er) { throw tagged(er, \"maybe\"); }\n return;\n }\n if (lst(sp) || !lst(dp)) throw tagged(E(\"RESULT_UNKNOWN\", \"move postcondition not met\", \"Inspect source and destination.\"), \"maybe\");\n};\n// resume of an \"unknown\" item: verify post-conditions; only a journaled staging copy that never reached its final name is redone\nconst resumeUnknown = (roots, it) => {\n const dropTmp = () => { try { const tp = tmpPathOf(roots, it); if (lst(tp)) removeTree(tp, undefined, true); } catch {} };\n const fail = (m) => { it.code = \"RESULT_UNKNOWN\"; it.error = \"[RESULT_UNKNOWN] \" + m; return \"unknown\"; };\n if (postcondition(roots, it)) { if (it.tmp) dropTmp(); return \"completed\"; }\n if (it.op !== \"remove\" && it.tmp) {\n const [s, d] = segsOf(it), sp = path.join(roots.s, ...s), dp = path.join(roots.d, ...d), tp = tmpPathOf(roots, it);\n const hasSp = !!lst(sp), hasDp = !!lst(dp), hasTp = !!lst(tp);\n if (it.op === \"move\" && hasSp && hasDp && !hasTp) { // copy was published, source not yet removed: finish without copying again\n let same = false; try { same = snapshot(sp, true) === snapshot(dp, true); } catch {}\n if (!same) return fail(\"destination and source differ after an interrupted move; not retried. Inspect both paths.\");\n try { removeTree(sp, undefined, true); } catch (e) { return fail(\"verified copy exists at the destination but the source could not be removed: \" + String(sysErr(e).message)); }\n return lst(sp) ? fail(\"source still present after remove\") : \"completed\";\n }\n if (hasSp && !hasDp && (hasTp || it.stage === \"copying\")) { // interrupted before publishing: nothing visible happened, redo it\n try { if (hasTp) removeTree(tp, undefined, true); } catch (e) { return fail(\"cannot clear leftover staging path \" + it.tmp + \": \" + String(sysErr(e).message)); }\n return lst(tp) ? fail(\"cannot clear leftover staging path \" + it.tmp) : \"pending\";\n }\n }\n return fail(\"postcondition not met; not retried. Inspect the paths and plan a new batch if needed.\");\n};\nconst runItems = (job, roots, idxs) => {\n for (const i of idxs) {\n const it = job.items[i];\n it.state = \"unknown\"; delete it.error; delete it.code; delete it.stage; it.startedAt = new Date().toISOString();\n if (it.op !== \"remove\") it.tmp = tmpRelOf(job, it); // journaled before anything is written\n saveJob(job);\n try { execItem(roots, it, job); it.state = \"completed\"; delete it.startedAt; delete it.stage; delete it.tmp; saveJob(job);\n try { const segs = segsOf(it); if (it.op === \"remove\") noteWrite(\"batch\", rel(path.join(roots.s, ...segs[0]))); else { noteWrite(\"batch\", rel(path.join(roots.d, ...segs[1]))); if (it.op === \"move\") noteWrite(\"batch\", rel(path.join(roots.s, ...segs[0]))); } } catch {}\n }\n catch (e) {\n const pe = sysErr(e);\n it.state = pe.effect === \"none\" ? \"failed\" : \"unknown\";\n it.code = pe.effect === \"none\" ? (pe.code || \"RESULT_UNKNOWN\") : \"RESULT_UNKNOWN\";\n it.error = pe.effect === \"none\" ? String(pe.message) : \"[RESULT_UNKNOWN] \" + String(pe.message) + \" (partial effects possible; inspect before retrying)\";\n if (pe.effect === \"none\") delete it.stage;\n saveJob(job);\n return;\n }\n }\n};\nconst preflightJob = (job, roots, idxs) => { // returns true if all ok; marks failed items\n const protect = []; // protect paths are root-relative; applied under both roots\n for (const p of job.protect) for (const r of new Set([roots.s, roots.d])) protect.push(akey(r, normRel(p, \"protect\")));\n const ok = []; let all = true;\n for (const i of idxs) {\n const it = job.items[i];\n try {\n const r = preflightOne(it, roots, protect);\n if (it.observed && it.observed !== r.observed && it.op !== \"remove\") throw E(\"STALE\", \"source changed since it was planned: \" + it.path, \"Create a new plan.\");\n it.observed = r.observed; ok.push({ i, op: r.op, sk: r.sk, dk: r.dk });\n if (it.state === \"failed\") { it.state = \"pending\"; delete it.error; delete it.code; }\n } catch (e) { const pe = sysErr(e); all = false; it.state = \"failed\"; it.code = pe.code || \"INVALID_ARGUMENT\"; it.error = String(pe.message); }\n }\n for (const [i, why] of crossCheck(ok)) { all = false; const it = job.items[i]; it.state = \"failed\"; it.code = \"INVALID_ARGUMENT\"; it.error = \"[INVALID_ARGUMENT] \" + why; }\n return all;\n};\n\ntools.batch = guard((a) => {\n markCall(\"batch\", a);\n A(a, \"batch\");\n const { action, operations, protect = [], root, toRoot, id } = a;\n if (action === \"list\") {\n const rows = fs.readdirSync(jobsDir).filter((n) => n.endsWith(\".json\")).map((n) => { const p = path.join(jobsDir, n); try { const j = JSON.parse(fs.readFileSync(p, \"utf8\")); return { id: j.id, status: j.status, total: j.items.length, updatedAt: j.updatedAt }; } catch { return null; } }).filter(Boolean).sort((x, y) => cmp(y.updatedAt, x.updatedAt));\n const offset = optInt(a.offset, \"offset\", 0, 0, 1e9), limit = optInt(a.limit, \"limit\", 20, 1, 1000);\n const pg = paginate(rows, offset, limit);\n return { items: pg.items, total: pg.total, complete: pg.complete, nextOffset: pg.nextOffset, jobs: pg.items };\n }\n if (![\"plan\", \"run\", \"resume\"].includes(action)) throw E(\"INVALID_ARGUMENT\", \"action must be plan|run|resume\", \"Fix action.\");\n if (action === \"resume\") {\n if (operations !== undefined || a.protect !== undefined || root !== undefined || toRoot !== undefined) throw E(\"INVALID_ARGUMENT\", \"resume accepts only id\", \"Use tools.batch({action:'resume', id}); root/toRoot/protect/operations come from the stored plan.\");\n reqStr(id, \"id\");\n const job = loadJob(id);\n acquire(id);\n try {\n const roots = { s: resolveRoot(job.root), d: job.toRoot ? resolveRoot(job.toRoot) : null }; roots.d = roots.d || roots.s;\n for (const it of job.items) if (it.state === \"unknown\") {\n const r = resumeUnknown(roots, it);\n if (r === \"completed\") { it.state = \"completed\"; delete it.error; delete it.code; delete it.startedAt; delete it.stage; delete it.tmp; }\n else if (r === \"pending\") { it.state = \"pending\"; delete it.error; delete it.code; delete it.startedAt; delete it.stage; }\n }\n const todo = job.items.map((it, i) => i).filter((i) => job.items[i].state === \"pending\" || job.items[i].state === \"failed\");\n if (todo.length && preflightJob(job, roots, todo)) runItems(job, roots, todo);\n saveJob(job);\n } finally { release(id); }\n return summary(job);\n }\n if (id !== undefined) throw E(\"INVALID_ARGUMENT\", action + \" does not take id\", action === \"run\" ? \"run needs operations; use resume with an id to execute a plan.\" : \"plan needs operations.\");\n if (!Array.isArray(operations) || !operations.length) throw E(\"INVALID_ARGUMENT\", \"operations must be a non-empty array\", \"Pass operations:[{op,path,to?}].\");\n if (operations.length > MAX_OPS) throw E(\"INVALID_ARGUMENT\", \"at most \" + MAX_OPS + \" operations per batch (got \" + operations.length + \")\", \"Split into several batches.\");\n if (!Array.isArray(protect) || protect.some((p) => typeof p !== \"string\")) throw E(\"INVALID_ARGUMENT\", \"protect must be a string array\", \"Pass root-relative paths.\");\n if (toRoot !== undefined && toRoot !== null && (typeof toRoot !== \"string\" || toRoot === \"\")) throw E(\"INVALID_ARGUMENT\", \"toRoot must be a non-empty string\", \"Pass a directory path (absolute or relative to cwd).\");\n const rootAbs = resolveRoot(root);\n const hasTo = typeof toRoot === \"string\";\n const roots = { s: rootAbs, d: hasTo ? resolveRoot(toRoot) : rootAbs };\n protect.forEach((p) => normRel(p, \"protect\"));\n const job = { version: 1, kind: \"job\", id: newId(\"j\"), scope, root: rootAbs, toRoot: hasTo ? roots.d : undefined, protect: protect.slice(), createdAt: new Date().toISOString(), items: operations.map((o, i) => ({ index: i, op: o && o.op, path: o && o.path, to: o && o.to != null ? o.to : undefined, state: \"pending\" })) };\n const all = job.items.map((x, i) => i);\n const okAll = preflightJob(job, roots, all);\n saveJob(job);\n if (okAll && action === \"run\") { acquire(job.id); try { runItems(job, roots, all); } finally { release(job.id); } }\n return summary(job);\n});\n\n// ---------- DSH-compatible bindings (read/write/edit/glob/grep/bash/job_output, plus test) ----------\n// Same names, parameters and return shapes as DeepSeek's DSH tools. They live in tools.dsh always; when the\n// caller sets input.dsh (delegate ptc-only: PTC_RUN_SDK_IN_PROMPT=1) they also take over the top-level\n// names read/write/edit/glob/grep/bash, because the system prompt there declares those names with DSH shapes.\n// The legacy shapes keep their names otherwise (main session).\nclass ToolCallError extends Error {\n constructor(toolName, message) { super(message); this.name = \"ToolCallError\"; this.toolName = toolName; }\n}\nconst DSH_READ_LIMIT = 2000, DSH_LINE_CAP = 2000, DSH_GLOB_MAX = 1000, DSH_GREP_MAX = 200, DSH_OUT_TAIL = 30000;\n// PTC_RUN_DSH_READ_LIMIT (passed as input.dshReadLimit): a positive integer that becomes the default read limit for\n// calls that pass no limit. Unset => exactly the old behaviour (2000). An explicit limit keeps its own 2000-line cap.\nconst DSH_READ_DEFAULT = Number.isInteger(input.dshReadLimit) && input.dshReadLimit > 0 ? input.dshReadLimit : DSH_READ_LIMIT;\nconst DSH_READ_LIMITED = Number.isInteger(input.dshReadLimit) && input.dshReadLimit > 0;\nconst dshArgs = (name, a) => { if (a === null || typeof a !== \"object\" || Array.isArray(a)) throw E(\"INVALID_ARGUMENT\", name + \" expects one object argument\", \"Pass an object, e.g. tools.\" + name + \"({...}).\"); return a; };\nconst dshStr = (v, n) => { if (typeof v !== \"string\" || v === \"\") throw E(\"INVALID_ARGUMENT\", n + \" must be a non-empty string\", \"Fix '\" + n + \"'.\"); return v; };\nconst dshOptInt = (v, n, min) => { if (v === undefined || v === null) return undefined; if (typeof v !== \"number\" || !Number.isFinite(v) || v < min) throw E(\"INVALID_ARGUMENT\", n + \" must be a number >= \" + min, \"Fix '\" + n + \"'.\"); return Math.floor(v); };\nconst dshWrap = (name, fn) => async (a) => {\n try { return fn(dshArgs(name, a === undefined ? {} : a)); }\n catch (e) {\n const pe = sysErr(e);\n throw new ToolCallError(name, String((pe && pe.message) || pe) + (pe && pe.nextAction ? \" (\" + pe.nextAction + \")\" : \"\"));\n }\n};\nconst dsh = {};\ndsh.read = dshWrap(\"read\", (a) => {\n const p = dshStr(a.file_path, \"file_path\"); credGuard(p);\n const explicit = dshOptInt(a.limit, \"limit\", 1);\n const off = dshOptInt(a.offset, \"offset\", 1) || 1, lim = explicit === undefined ? DSH_READ_DEFAULT : explicit;\n const f = abs(p), st = fs.statSync(f);\n if (st.isDirectory()) throw E(\"INVALID_ARGUMENT\", \"path is a directory: \" + p, \"Use glob to list files.\");\n const t = fs.readFileSync(f, \"utf8\");\n readState[f] = st.mtimeMs;\n const all = t.split(/\\r?\\n/);\n if (all.length > 1 && all[all.length - 1] === \"\") all.pop();\n const lines = all.slice(off - 1, off - 1 + lim).map((s, i) => ({ number: off + i, text: s.length > DSH_LINE_CAP ? s.slice(0, DSH_LINE_CAP) + \"...[line truncated]\" : s }));\n const r = { path: rel(f), offset: off, lines, totalLines: all.length };\n const end = off - 1 + lines.length;\n if (explicit === undefined && DSH_READ_LIMITED && end < all.length) {\n r.more = \"lines \" + off + \"-\" + end + \" of \" + all.length + \" shown; read with offset=\" + (end + 1) + \" for more\";\n // Also as a last pseudo-line, so code that only prints lines still shows the cut. kind:\"notice\" and number:null\n // mark it as not being part of the file; real lines never carry kind.\n lines.push({ number: null, kind: \"notice\", text: \"... [\" + (all.length - end) + \" more lines not shown; file has \" + all.length + \" lines; read with offset=\" + (end + 1) + \"]\" });\n }\n // text: the same lines as \"N: text\" (a notice line has no number). Non-enumerable so JSON.stringify / console.log\n // output is unchanged (no doubled payload).\n Object.defineProperty(r, \"text\", { get() { return lines.map((l) => (l.kind ? l.text : l.number + \": \" + l.text)).join(NL); }, enumerable: false });\n // content: only the file's real lines, joined with \\n (no notice, no line numbers), so it can be parsed or edited.\n Object.defineProperty(r, \"content\", { get() { return lines.filter((l) => !l.kind).map((l) => l.text).join(NL); }, enumerable: false });\n return r;\n});\ndsh.write = dshWrap(\"write\", (a) => {\n markCall(\"write\", a); const p = dshStr(a.file_path, \"file_path\");\n if (typeof a.content !== \"string\") throw E(\"INVALID_ARGUMENT\", \"content must be a string\", \"Pass content as a string.\");\n capWrite(Buffer.byteLength(a.content), \"write of \" + p);\n credGuard(p);\n const f = abs(p); let before = null;\n try { before = fs.readFileSync(f, \"utf8\"); } catch (e) { if (e.code !== \"ENOENT\") throw e; }\n fs.mkdirSync(path.dirname(f), { recursive: true });\n atomicWrite(f, a.content);\n try { readState[f] = fs.statSync(f).mtimeMs; } catch {}\n noteWrite(\"write\", rel(f));\n return { path: rel(f), operation: before === null ? \"create\" : \"update\", before, after: a.content };\n});\ndsh.edit = dshWrap(\"edit\", (a) => {\n markCall(\"edit\", a); const p = dshStr(a.file_path, \"file_path\"); credGuard(p);\n if (typeof a.old_string !== \"string\" || a.old_string === \"\") throw E(\"INVALID_ARGUMENT\", \"old_string must be a non-empty string\", \"Pass the exact text to replace.\");\n if (typeof a.new_string !== \"string\") throw E(\"INVALID_ARGUMENT\", \"new_string must be a string\", \"Pass the replacement text (empty string deletes).\");\n const f = abs(p), before = fs.readFileSync(f, \"utf8\");\n const m = crlfMatch(before, a.old_string, a.new_string), o = m.old, nn = m.nw;\n const n = before.split(o).length - 1;\n if (n === 0) throw E(\"NOT_FOUND\", \"old_string not found in \" + p, \"Re-read the file and copy the exact text.\");\n countCheck(a.expected_count, n, \"expected_count\", p);\n if (n > 1 && a.replace_all !== true) throw E(\"NOT_UNIQUE\", \"old_string matches \" + n + \" places in \" + p, \"Add context to make it unique, or set replace_all to true (with expected_count).\");\n const lines = a.replace_all === true ? lineNosOf(before, o) : lineNosOf(before, o).slice(0, 1);\n const after = a.replace_all === true ? before.split(o).join(nn) : before.replace(o, () => nn);\n capWrite(Buffer.byteLength(after), \"edit of \" + p);\n atomicWrite(f, after);\n try { readState[f] = fs.statSync(f).mtimeMs; } catch {}\n noteWrite(\"edit\", rel(f), lines.length, lines);\n return { path: rel(f), replaced: lines.length, lines, before, after };\n});\nconst VCS = [\"!.git\", \"!.hg\", \"!.svn\"].flatMap((x) => [\"-g\", x]);\ndsh.glob = dshWrap(\"glob\", (a) => {\n const pattern = dshStr(a.pattern, \"pattern\"), root = a.path === undefined ? \".\" : dshStr(a.path, \"path\");\n credGuard(root);\n const rootAbs = abs(root);\n const out = runRg([\"--files\", \"--null\", \"--hidden\", \"--no-ignore\", \"-g\", pattern, ...VCS, rootAbs], rootAbs);\n const files = out.split(\"\\0\").map((s) => s.replace(/^\\r?\\n/, \"\")).filter(Boolean).filter((f) => !credFast(f));\n const withT = files.map((f) => { let m = 0; try { m = fs.statSync(f).mtimeMs; } catch {} return [f, m]; });\n withT.sort((x, y) => (y[1] - x[1]) || cmp(x[0], y[0]));\n const r = { root: rel(rootAbs) === \"\" ? \".\" : rel(rootAbs), paths: withT.slice(0, DSH_GLOB_MAX).map((x) => rel(x[0])) };\n if (withT.length > DSH_GLOB_MAX) { r.truncated = true; r.total = withT.length; }\n return r;\n});\ndsh.grep = dshWrap(\"grep\", (a) => {\n const pattern = dshStr(a.pattern, \"pattern\"), root = a.path === undefined ? \".\" : dshStr(a.path, \"path\");\n credGuard(root);\n const rootAbs = abs(root);\n const args = [\"--json\", \"--max-filesize\", \"2M\", ...VCS];\n if (a.include !== undefined) args.push(\"-g\", dshStr(a.include, \"include\"));\n args.push(\"-e\", pattern, rootAbs);\n const out = runRg(args, rootAbs);\n const matches = []; let total = 0;\n for (const l of out.split(NL)) {\n if (!l.startsWith('{\"type\":\"match\"')) continue;\n const d = JSON.parse(l).data, pf = pathOf(d.path);\n if (credFast(pf)) continue;\n total++;\n matches.push({ path: rel(pf), lineNumber: d.line_number, line: pathOf(d.lines).replace(CRLF_END, \"\").slice(0, 500) });\n }\n matches.sort((x, y) => cmp(x.path, y.path) || x.lineNumber - y.lineNumber);\n const r = { matches: matches.slice(0, DSH_GREP_MAX) };\n if (total > DSH_GREP_MAX) { r.truncated = true; r.total = total; }\n return r;\n});\n// background jobs: one detached node wrapper per command; state lives in files so later run_code calls can read it\nconst BG_WRAP = 'const fs=require(\"fs\"),cp=require(\"child_process\");const [id,d,cmd,cwd,bash]=process.argv.slice(1);' +\n 'const f=d+\"/\"+id+\".out\",stf=d+\"/\"+id+\".st\";const done=(code,sig,timedOut)=>{try{if(!fs.existsSync(stf))fs.writeFileSync(stf,JSON.stringify({code:code,sig:sig,timedOut:!!timedOut,t:Date.now()}))}catch{};process.exit(0)};' +\n 'const c=cp.spawn(bash,[\"-c\",cmd],{cwd:cwd,stdio:[\"ignore\",\"pipe\",\"pipe\"],windowsHide:true});const w=b=>fs.appendFileSync(f,b);c.stdout.on(\"data\",w);c.stderr.on(\"data\",w);' +\n 'c.on(\"close\",(code,sig)=>done(code,sig,false));c.on(\"error\",e=>{fs.appendFileSync(f,String(e));done(null,\"error\")});' +\n 'const t=setTimeout(()=>{try{if(process.platform===\"win32\")cp.spawnSync(\"taskkill\",[\"/pid\",String(c.pid),\"/T\",\"/F\"],{windowsHide:true});else c.kill(\"SIGKILL\")}catch{};done(null,\"timeout\",true)},' + BG_MAX_MS + ');if(t.unref)t.unref();';\nconst spillTail = (s) => {\n if (s.length <= DSH_OUT_TAIL) return { text: s, truncated: false };\n const f = path.join(os.tmpdir(), \"ptc-bash-\" + crypto.randomBytes(4).toString(\"hex\") + \".txt\");\n fs.writeFileSync(f, s);\n return { text: s.slice(-DSH_OUT_TAIL), truncated: true, spillPath: f };\n};\n// Foreground wrapper for the dsh dialect: .out/.err stay separate (the foreground result needs both) and .mix holds\n// stdout+stderr interleaved, which is what job_output reads for a promoted job (the same merged view as BG_WRAP).\nconst BG_WRAP_D = 'const fs=require(\"fs\"),cp=require(\"child_process\");const [id,d,cmd,cwd,bash]=process.argv.slice(1);' +\n 'const stf=d+\"/\"+id+\".st\";const done=(code,sig,timedOut)=>{try{if(!fs.existsSync(stf))fs.writeFileSync(stf,JSON.stringify({code:code,sig:sig,timedOut:!!timedOut,t:Date.now()}))}catch{};process.exit(0)};' +\n 'const c=cp.spawn(bash,[\"-c\",cmd],{cwd:cwd,stdio:[\"ignore\",\"pipe\",\"pipe\"],windowsHide:true});const w=(e,b)=>{try{fs.appendFileSync(d+\"/\"+id+e,b)}catch{}};' +\n 'c.stdout.on(\"data\",b=>{w(\".out\",b);w(\".mix\",b)});c.stderr.on(\"data\",b=>{w(\".err\",b);w(\".mix\",b)});' +\n 'c.on(\"close\",(code,sig)=>done(code,sig,false));c.on(\"error\",e=>{w(\".err\",String(e));w(\".mix\",String(e));done(null,\"error\")});' +\n 'const t=setTimeout(()=>{try{if(process.platform===\"win32\")cp.spawnSync(\"taskkill\",[\"/pid\",String(c.pid),\"/T\",\"/F\"],{windowsHide:true});else c.kill(\"SIGKILL\")}catch{};done(null,\"timeout\",true)},' + BG_MAX_MS + ');if(t.unref)t.unref();';\nconst startDshJob = (a, c, wrap, split) => {\n fs.mkdirSync(bgDir, { recursive: true });\n const id = newId(\"d\");\n const meta = { id, kind: \"bash\", label: a.description, startedAt: Date.now(), deadlineAt: Date.now() + BG_MAX_MS, read: 0 };\n if (split) meta.mixed = true;\n fs.writeFileSync(path.join(bgDir, id + \".json\"), JSON.stringify(meta));\n fs.writeFileSync(path.join(bgDir, id + \".out\"), \"\");\n if (split) { fs.writeFileSync(path.join(bgDir, id + \".err\"), \"\"); fs.writeFileSync(path.join(bgDir, id + \".mix\"), \"\"); }\n const ch = cp.spawn(process.execPath, [\"-e\", wrap, id, bgDir, a.command, c, BASH], { detached: true, stdio: \"ignore\", windowsHide: true, env: childEnv(a.env) });\n ch.unref();\n return id;\n};\ndsh.bash = dshWrap(\"bash\", (a) => {\n dshStr(a.command, \"command\"); dshStr(a.description, \"description\");\n if (a.promote !== undefined && typeof a.promote !== \"boolean\") throw E(\"INVALID_ARGUMENT\", \"promote must be a boolean\", \"Pass promote:false to kill the command on timeout; the default promotes it to a background task.\");\n const c = a.workdir === undefined ? cwd : abs(dshStr(a.workdir, \"workdir\"));\n if (a.run_in_background === true) return { kind: \"background\", taskId: startDshJob(a, c, BG_WRAP, false) };\n const timeoutMs = dshOptInt(a.timeoutMs, \"timeoutMs\", 1) || 120000;\n if (a.promote === false) {\n const r = legacyBash({ command: a.command, cwd: c, timeoutMs, env: a.env, promote: false });\n return { kind: \"foreground\", exitCode: r.code === undefined ? null : r.code, signal: null, timedOut: !!r.timedOut, aborted: false, timeoutMs, stdout: spillTail(String(r.stdout || \"\")), stderr: spillTail(String(r.stderr || \"\")) };\n }\n const id = startDshJob(a, c, BG_WRAP_D, true);\n const how = waitJob(id, timeoutMs, BG_SYNC_MAX);\n if (how === \"overflow\") { killJobTree(id); throw E(\"OUTPUT_LIMIT\", \"command output exceeded 256MB\", \"Redirect output to a file or filter it in the command.\"); }\n const st = how === \"done\" ? bgStat(id) : null;\n const res = { kind: \"foreground\", exitCode: st ? st.code : null, signal: null, timedOut: !st || !!st.timedOut, aborted: false, timeoutMs, stdout: spillTail(bgText(id, \".out\")), stderr: spillTail(bgText(id, \".err\")) };\n if (!st || st.timedOut) { res.promoted = true; res.taskId = id; } // still running (or hit the 2h cap): read it with job_output\n else dropJob(id);\n return res;\n});\n// tools.test: run a test command through the bash path and return a compact summary instead of the raw stream.\n// Parsing is conservative: status comes from the exit code first, counts only from explicit summary lines, and an\n// unrecognised framework stays \"unknown\" with a tail (never an error).\nconst TEST_TAIL_LINES = 40, TEST_DETAIL_LINES = 15, TEST_DEFAULT_MS = 300000;\nconst lastLines = (s, n) => { const a = String(s).replace(/\\r/g, \"\").split(NL); while (a.length && a[a.length - 1] === \"\") a.pop(); return a.slice(-n).join(NL); };\nconst detailAfter = (ls, i) => {\n const out = [];\n for (let j = i + 1; j < ls.length && out.length < TEST_DETAIL_LINES; j++) {\n const l = ls[j];\n if (/^\\s*(not ok \\d|ok \\d|# |FAIL\\s|× |● |PASS\\s|✖ |ℹ )/.test(l)) break;\n if (!l.trim()) { if (out.length) break; continue; }\n if (/^\\s*(---|\\.\\.\\.)\\s*$/.test(l) || /^\\s*(duration_ms|type|location|failureType|code|stack|\\.\\.\\.)\\s*:/.test(l)) continue;\n out.push(l.trim());\n }\n return out.join(NL);\n};\nconst dedupeFailures = (fs) => {\n const m = new Map();\n for (const f of fs) { const e = m.get(f.name); if (!e) m.set(f.name, f); else if (!e.detail && f.detail) e.detail = f.detail; }\n return [...m.values()];\n};\nconst countsFrom = (s) => {\n const c = {}, re = /(\\d+)\\s+(passed|failed|errors?|skipped|todo)\\b/g; let m;\n while ((m = re.exec(s))) { if (m[2] === \"todo\") continue; const k = m[2].startsWith(\"error\") ? \"errors\" : m[2]; c[k] = (c[k] || 0) + Number(m[1]); }\n return c;\n};\nconst parsePytest = (out) => {\n const ls = out.split(/\\r?\\n/); let counts = null;\n for (const l of ls) {\n if (/\\d+ (?:passed|failed|errors?|skipped)\\b/.test(l) && (/\\bin \\d+(?:\\.\\d+)?s/.test(l) || /^\\s*=+.*=+\\s*$/.test(l))) {\n const c = countsFrom(l); if (Object.keys(c).length) counts = c;\n }\n }\n const sec = new Map(); let cur = null;\n for (const l of ls) {\n const h = l.match(/^_+\\s+(.+?)\\s+_+$/);\n if (h) { cur = h[1].trim(); sec.set(cur, []); continue; }\n if (!cur) continue;\n const e = l.match(/^E\\s+(.*)$/);\n if (e) { if (sec.get(cur).length < TEST_DETAIL_LINES) sec.get(cur).push(e[1]); }\n else if (/^=+\\s.*=+\\s*$/.test(l) || /^FAILED\\s/.test(l)) cur = null;\n }\n const failures = [];\n for (const l of ls) {\n const m = l.match(/^FAILED\\s+(\\S+)(?:\\s+-\\s+(.*))?$/);\n if (!m) continue;\n const d = sec.get(m[1].split(\"::\").pop());\n failures.push({ name: m[1], detail: d && d.length ? d.join(NL) : (m[2] || \"\").trim() });\n }\n if (!failures.length) for (const [n, d] of sec) if (d.length) failures.push({ name: n, detail: d.join(NL) });\n return counts || failures.length ? { counts: counts || undefined, failures } : null;\n};\nconst parseNodeTest = (out) => {\n const ls = out.split(/\\r?\\n/), counts = {}; let found = false;\n for (const l of ls) {\n // TAP (\"# pass 1\") and the spec reporter (\"ℹ pass 1\", the default when stdout is a pipe on recent Node).\n const m = l.match(/^[#ℹ]\\s*(pass|fail|skipped)\\s+(\\d+)\\s*$/);\n if (m) { found = true; counts[m[1] === \"pass\" ? \"passed\" : m[1] === \"fail\" ? \"failed\" : \"skipped\"] = Number(m[2]); }\n }\n const failures = [];\n for (let i = 0; i < ls.length; i++) {\n const m = ls[i].match(/^\\s*not ok \\d+ - (.+)$/);\n if (m) failures.push({ name: m[1].trim(), detail: detailAfter(ls, i) });\n }\n let spec = false;\n for (let i = 0; i < ls.length; i++) {\n if (/failing tests:/.test(ls[i])) { spec = true; continue; }\n if (!spec) continue;\n const m = ls[i].match(/^\\s*✖\\s+(.+?)(?:\\s+\\([\\d.]+m?s\\))?\\s*$/);\n if (m) failures.push({ name: m[1].trim(), detail: detailAfter(ls, i) });\n }\n return found || failures.length ? { counts: Object.keys(counts).length ? counts : undefined, failures } : null;\n};\nconst parseJsTest = (out) => {\n const ls = out.split(/\\r?\\n/); let counts = null;\n for (const l of ls) {\n const m = l.match(/^\\s*Tests:\\s+(.+)$/) || l.match(/^\\s*Tests\\s{2,}(.+)$/);\n if (!m) continue;\n const c = countsFrom(m[1]); if (Object.keys(c).length) counts = c;\n }\n const fail = [], cases = [];\n for (let i = 0; i < ls.length; i++) {\n let m = ls[i].match(/^\\s*FAIL\\s+(.+)$/);\n if (m) { fail.push({ name: m[1].trim(), detail: detailAfter(ls, i) }); continue; }\n m = ls[i].match(/^\\s*[×●]\\s+(.+)$/);\n if (m) cases.push({ name: m[1].trim().replace(/\\s+\\d+(?:\\.\\d+)?m?s$/, \"\"), detail: detailAfter(ls, i) });\n }\n const failures = fail.length ? fail : cases; // vitest repeats each failure as \"× name\"; prefer the \"FAIL path > name\" line\n return counts || failures.length ? { counts: counts || undefined, failures } : null;\n};\n// msys-style workdirs (\"/c/x\", \"/tmp/...\"): convert before spawning. /tmp is resolved with cygpath when available;\n// otherwise the caller gets an explicit error instead of a confusing ENOENT.\nconst msysWorkdir = (w) => {\n if (!WIN || typeof w !== \"string\" || w.charAt(0) !== \"/\") return w;\n const m = /^\\/([A-Za-z])(?:\\/(.*))?$/.exec(w);\n if (m) return m[1].toUpperCase() + \":/\" + (m[2] || \"\");\n if (/^\\/tmp(?:\\/|$)/.test(w)) {\n let out = \"\";\n try { const r = cp.spawnSync(BASH, [\"-c\", 'cygpath -w -- \"$1\"', \"x\", w], { encoding: \"utf8\", timeout: 5000, windowsHide: true }); if (!r.error && r.status === 0) out = String(r.stdout || \"\").trim(); } catch {}\n if (out) return out;\n throw E(\"INVALID_ARGUMENT\", \"workdir is an MSYS path that cannot be converted: \" + w, \"Pass a Windows path (e.g. C:/...) or a path relative to the working directory.\");\n }\n return w;\n};\ndsh.test = dshWrap(\"test\", (a) => {\n const command = dshStr(a.command, \"command\");\n const timeoutMs = dshOptInt(a.timeoutMs, \"timeoutMs\", 1) || TEST_DEFAULT_MS;\n const maxFailures = a.maxFailures === undefined ? 10 : dshOptInt(a.maxFailures, \"maxFailures\", 0);\n const outputPath = path.join(os.tmpdir(), \"ptc-test-\" + crypto.randomBytes(4).toString(\"hex\") + \".txt\");\n const finish = (res) => {\n res.outputPath = outputPath;\n // Compatibility alias: readable as fullOutput, but non-enumerable so JSON.stringify only shows outputPath.\n Object.defineProperty(res, \"fullOutput\", { get() { return res.outputPath; }, enumerable: false, configurable: true });\n return res;\n };\n const startError = (msg) => { try { fs.writeFileSync(outputPath, \"\"); } catch {} return finish({ status: \"error\", exitCode: null, failures: [], error: msg, tail: \"(no output)\" }); };\n let c = cwd;\n try {\n if (a.workdir !== undefined) c = abs(msysWorkdir(dshStr(a.workdir, \"workdir\")));\n if (!fs.statSync(c).isDirectory()) return startError(\"workdir is not a directory: \" + c);\n } catch (e) {\n return startError(String((e && e.message) || e));\n }\n const r = legacyBash({ command, cwd: c, timeoutMs, promote: false });\n const out = String(r.stdout || \"\") + (r.stderr ? NL + String(r.stderr) : \"\");\n try { fs.writeFileSync(outputPath, out); } catch {}\n const p = parsePytest(out) || parseNodeTest(out) || parseJsTest(out) || { counts: undefined, failures: [] };\n const counts = p.counts, failures = dedupeFailures(p.failures);\n const exitCode = r.code === undefined ? null : r.code;\n let status, error;\n if (r.timedOut) status = \"timeout\";\n else if (r.error) { status = \"error\"; error = \"command failed to start: \" + r.error; }\n else if (exitCode === 0) status = \"passed\";\n else if (failures.length || (counts && (counts.failed > 0 || counts.errors > 0))) status = counts && counts.errors > 0 && !counts.failed && !failures.length ? \"error\" : \"failed\";\n else status = \"unknown\";\n // No output means the command never produced a parseable result: report an error, never a bare unknown.\n if (status === \"unknown\" && !out.trim()) { status = \"error\"; error = \"command produced no output (exit code \" + exitCode + \")\"; }\n if (status === \"error\" && !error) error = \"test command reported errors\";\n const res = { status, exitCode };\n if (error) res.error = error;\n if (counts && Object.keys(counts).length) res.counts = counts;\n res.failures = failures.slice(0, maxFailures);\n if (status === \"unknown\" || status === \"error\" || (status === \"failed\" && !res.failures.length)) res.tail = lastLines(out, TEST_TAIL_LINES) || \"(no output)\";\n return finish(res);\n});\ndsh.job_output = dshWrap(\"job_output\", (a) => {\n const id = checkId(dshStr(a.job_id, \"job_id\"));\n const mf = path.join(bgDir, id + \".json\");\n if (!fs.existsSync(mf)) throw E(\"NOT_FOUND\", \"job not found: \" + id, \"Use the job id returned by bash with run_in_background.\");\n const meta = JSON.parse(fs.readFileSync(mf, \"utf8\"));\n const stf = path.join(bgDir, id + \".st\");\n const t = dshOptInt(a.timeout_ms, \"timeout_ms\", 0);\n const waitMs = Math.min(t === undefined ? 30000 : t, 100000);\n if (a.wait === true) { const end = Date.now() + waitMs; while (!fs.existsSync(stf) && Date.now() < end) sleepMs(100); }\n const buf = fs.readFileSync(path.join(bgDir, id + (meta.mixed ? \".mix\" : \".out\")));\n const text = buf.subarray(meta.read).toString(\"utf8\");\n meta.read = buf.length; fs.writeFileSync(mf, JSON.stringify(meta));\n const job = { id, kind: meta.kind, label: meta.label, status: \"running\", startedAt: meta.startedAt };\n if (fs.existsSync(stf)) {\n const s = JSON.parse(fs.readFileSync(stf, \"utf8\"));\n job.status = s.code === 0 ? \"completed\" : s.sig ? \"killed\" : \"failed\";\n job.detail = \"exit code \" + s.code; job.finishedAt = s.t;\n }\n return { text, job };\n});\n// tools.fetch: async (the only async legacy tool) web fetch for batch reading of pages and raw source files.\n// Works in read-only mode too: the Node permission model restricts the filesystem, not the network.\n// {url} -> one result object; {urls:[...]} -> results in order, fetched concurrently. Network errors never throw:\n// they come back as {ok:false, error}. HTML is reduced to text unless raw:true; github.com blob links are rewritten\n// to raw.githubusercontent.com. Text over maxChars is cut and the full text is saved in ref (field ref).\nconst FETCH_MAX_BYTES = 20 << 20;\nconst htmlText = (h) => h\n .replace(/<(script|style|noscript|svg|head)\\b[\\s\\S]*?<\\/\\1>/gi, \"\")\n .replace(/<!--[\\s\\S]*?-->/g, \"\")\n .replace(/<(br|\\/p|\\/div|\\/li|\\/tr|\\/h[1-6]|\\/pre|\\/section|\\/article)\\b[^>]*>/gi, NL)\n .replace(/<li\\b[^>]*>/gi, \"- \")\n .replace(/<[^>]+>/g, \"\")\n .replace(/ /g, \" \").replace(/</g, \"<\").replace(/>/g, \">\").replace(/"/g, '\"').replace(/'|'/g, \"'\")\n .replace(/&#(\\d+);/g, (m, d) => String.fromCodePoint(+d)).replace(/&#x([0-9a-f]+);/gi, (m, x) => String.fromCodePoint(parseInt(x, 16)))\n .replace(/&/g, \"&\")\n .replace(/[ \\t]+\\n/g, NL).replace(/\\n{3,}/g, NL + NL).trim();\nconst rawGithub = (u) => {\n const m = /^https:\\/\\/github\\.com\\/([^/]+)\\/([^/]+)\\/blob\\/(.+)$/.exec(u);\n return m ? \"https://raw.githubusercontent.com/\" + m[1] + \"/\" + m[2] + \"/\" + m[3] : u;\n};\ntools.fetch = async (a) => {\n A(a, \"fetch\");\n const many = a.urls !== undefined;\n if (many && a.url !== undefined) throw E(\"INVALID_ARGUMENT\", \"pass url or urls, not both\", \"Use urls:[...] for a batch.\");\n const list = many ? a.urls : [a.url];\n if (!Array.isArray(list) || list.length === 0 || list.length > 50) throw E(\"INVALID_ARGUMENT\", \"urls must be a non-empty array of at most 50 URLs\", \"Split larger batches.\");\n for (const u of list) if (typeof u !== \"string\" || !/^https?:\\/\\//i.test(u)) throw E(\"INVALID_ARGUMENT\", \"not an http(s) URL: \" + String(u), \"Pass absolute http:// or https:// URLs.\");\n const maxChars = optInt(a.maxChars, \"maxChars\", 20000, 100, 200000);\n const timeoutMs = optInt(a.timeoutMs, \"timeoutMs\", 30000, 1000, 120000);\n const conc = optInt(a.concurrency, \"concurrency\", 6, 1, 16);\n const raw = a.raw === true;\n const one = async (u0) => {\n const u = rawGithub(u0);\n const ac = new AbortController(), t = setTimeout(() => ac.abort(), timeoutMs);\n try {\n const r = await fetch(u, { signal: ac.signal, redirect: \"follow\", headers: { \"user-agent\": \"ptc-run-fetch/1\", accept: \"text/*,application/json,*/*;q=0.5\" } });\n const type = r.headers.get(\"content-type\") || \"\";\n const buf = Buffer.from(await r.arrayBuffer());\n if (buf.length > FETCH_MAX_BYTES) return { url: u0, ok: false, status: r.status, error: \"body over 20MB\" };\n if (/^(image|audio|video)\\/|application\\/(octet-stream|zip|pdf|gzip)/i.test(type)) return { url: u0, ok: false, status: r.status, contentType: type, error: \"binary content (\" + type + \")\" };\n let text = buf.toString(\"utf8\");\n if (!raw && /html/i.test(type)) text = htmlText(text);\n const res = { url: u0, ok: r.ok, status: r.status, contentType: type, chars: text.length };\n if (u !== u0) res.fetched = u;\n if (r.url && r.url !== u) res.finalUrl = r.url;\n if (text.length > maxChars) { res.ref = ref.put(text); res.truncated = true; text = text.slice(0, maxChars); }\n res.text = text;\n return res;\n } catch (e) {\n return { url: u0, ok: false, error: e && e.name === \"AbortError\" ? \"timeout after \" + timeoutMs + \"ms\" : String((e && e.cause && e.cause.message) || (e && e.message) || e) };\n } finally { clearTimeout(t); }\n };\n const out = new Array(list.length);\n let next = 0;\n await Promise.all(Array.from({ length: Math.min(conc, list.length) }, async () => { while (next < list.length) { const i = next++; out[i] = await one(list[i]); } }));\n return many ? out : out[0];\n};\nconst legacyBash = tools.bash;\ntools.dsh = dsh;\ntools.test = dsh.test; // normal mode gets the same summary shape (same fields, same parsing)\nif (RO) { // read-only mode: everything that writes or runs a command is refused (legacy and DSH shapes alike)\n const roMsg = (k) => \"[POLICY_DENIED] tools.\" + k + \" is disabled in read-only mode\";\n const roNext = \"This run_code is read-only: use read, glob, grep and ref.*; report the change or command instead of making it.\";\n for (const k of [\"write\", \"edit\", \"replace\", \"batch\", \"bash\", \"parallel\", \"job\", \"job_output\", \"test\"]) tools[k] = () => { throw new PtcError(\"POLICY_DENIED\", \"tools.\" + k + \" is disabled in read-only mode\", roNext); };\n for (const k of [\"write\", \"edit\", \"bash\", \"job_output\", \"test\"]) dsh[k] = async () => { const e = new ToolCallError(k, roMsg(k) + \" (\" + roNext + \")\"); e.code = \"POLICY_DENIED\"; throw e; };\n}\nif (input.dsh) for (const k of [\"read\", \"write\", \"edit\", \"glob\", \"grep\", \"bash\", \"job_output\", \"test\"]) tools[k] = dsh[k];\n\n// ---------- run user code ----------\nfor (const k of Object.keys(tools)) if (k !== \"dsh\" && !(input.dsh && k in dsh)) tools[k] = guard(tools[k]);\ntools.root = cwd; // the base directory for relative paths, grep/glob and bash; set via the run_code root parameter\nconst AF = Object.getPrototypeOf(async function () {}).constructor;\n(async () => {\n let out, err;\n if (rootErr) err = rootErr;\n else {\n try { out = await new AF(\"tools\", \"ref\", \"console\", \"ToolCallError\", input.code)(tools, ref, cons, ToolCallError); } catch (e) { err = e; }\n }\n let body = lines.join(NL);\n if (out !== undefined) body += (body ? NL : \"\") + fmt(out);\n let text = body;\n if (!err) { const w = multiText(); if (w) text = w + (body ? NL + NL + body : \"\"); }\n if (err) {\n let m;\n if (err && err.name === \"ToolCallError\") m = \"ToolCallError (\" + err.toolName + \"): \" + err.message;\n else if (err && err.name === \"PtcError\") m = \"Error: \" + err.message + (err.nextAction ? NL + \"nextAction: \" + err.nextAction : \"\");\n else {\n m = \"Error: \" + (err && err.stack ? String(err.stack).split(NL).slice(0, 4).join(NL) : String(err));\n if (err && typeof err.code === \"string\") m += NL + \"[code: \" + err.code + \"]\";\n }\n if (/\\.(catch|then) is not a function/.test(String(err && err.message))) m += NL + \"hint: tools.* 是同步的,用 try/catch,不要链 .then/.catch\";\n const et = errText(err);\n text = et ? et + NL + NL + m + (body ? NL + NL + body : \"\") : opsText() + NL + NL + m + (body ? NL + NL + body : \"\");\n }\n if (text.length > MAX) {\n const id = ref.put(text);\n // Cut on line boundaries and say which output lines / chars were dropped, so the model re-reads only that part.\n const tailLen = Math.max(1, Math.floor(MAX * 0.3));\n let headEnd = text.lastIndexOf(NL, Math.max(0, MAX - tailLen));\n if (headEnd <= 0) headEnd = Math.max(0, MAX - tailLen);\n let tailStart = text.indexOf(NL, text.length - tailLen);\n if (tailStart < 0 || tailStart <= headEnd) tailStart = text.length - tailLen;\n const lineAt = (i) => text.slice(0, i).split(NL).length;\n const totalLines = text.split(NL).length;\n const fromLine = lineAt(headEnd) + 1, toLine = lineAt(tailStart);\n text = text.slice(0, headEnd) + NL + \"…[省略输出第 \" + fromLine + \"–\" + toLine + \" 行(共 \" + totalLines + \" 行,\" + (tailStart - headEnd) + \" 字),完整内容 ref \" + id + \"]…\" + text.slice(tailStart) + NL +\n \"输出被截断(上限 \" + MAX + \" 字):只补读缺的部分,ref.text(\\\"\" + id + \"\\\", {from: \" + headEnd + \", to: \" + tailStart + \"}) 取回被省略的中段;或缩小打印范围;确需一次看全再传 maxChars:N(最大 50000)\";\n }\n process.stdout.write(text || \"(run_code completed with no output)\", () => { closeLog(); process.exit(err ? 2 : 0); });\n})();\n"
4const RG = "C:/Users/yadaz/.claude/skills/ptc-run/bin/rg.exe"
5// Read-only mode (PTC_RUN_READONLY=1): the hook starts this broker instead of the runner; the broker runs the runner
6// under the Node permission model and executes rg for it (see src/broker.js). The runner travels inside its input JSON.
7const BROKER = "\"use strict\";\n// ptc-run read-only broker. Started by the hook instead of the runner when PTC_RUN_READONLY=1.\n// It launches the runner under the Node permission model (reads only in the session cwd, the scope dir and its own\n// response dir; writes only in the session scope dir;\n// no child processes / workers / addons) and runs ripgrep on its behalf. The restricted runner asks for an rg run by\n// writing <reqDir>/<id>.req; the broker validates the argv against a strict whitelist, runs the bundled rg and writes\n// <resDir>/<id>.out + <id>.res. reqDir lives in the writable scope dir (user code can forge requests there, so the\n// whitelist is the hard boundary); resDir is outside it, so the restricted process can only read the answers.\n// Input (line 1 of stdin, via the bootstrap): {cwd, code, rg, scope, dsh, maxChars, timeoutMs, root, roRoots, logName, boot, runner}.\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\"), cp = require(\"child_process\"), crypto = require(\"crypto\");\nconst input = JSON.parse(typeof globalThis.__PTC_IN === \"string\" ? globalThis.__PTC_IN : fs.readFileSync(0, \"utf8\"));\nconst runnerSrc = input.runner, boot = input.boot;\ndelete input.runner; delete input.boot;\nlet cwd = input.cwd || process.cwd();\nconst NL = \"\\n\";\nconst started = Date.now();\nconst timeoutMs = Number.isFinite(input.timeoutMs) ? Math.min(Math.max(input.timeoutMs, 1000), 1800000) : 300000;\nconst deadline = started + Math.max(500, timeoutMs - 1500); // a little earlier than the hook's own timeout\nconst rand = () => crypto.randomBytes(6).toString(\"hex\");\nconst alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === \"EPERM\"; } };\nconst fail = (msg, code) => { process.stdout.write(\"run_code (read-only) failed: \" + msg, () => process.exit(code || 1)); };\n\n(function main() {\n\nif (typeof runnerSrc !== \"string\" || typeof boot !== \"string\") { fail(\"broker input is missing the runner\"); return; }\n// Optional root: an existing absolute directory. It becomes the session cwd for the runner, rg validation and reads.\nif (input.root !== undefined) {\n if (typeof input.root !== \"string\" || input.root === \"\" || !path.isAbsolute(input.root)) { fail(\"root must be an absolute path: \" + String(input.root)); return; }\n let st; try { st = fs.statSync(input.root); } catch (e) { fail(e.code === \"ENOENT\" ? \"root not found: \" + input.root : \"cannot read root: \" + String(e.message || e)); return; }\n if (!st.isDirectory()) { fail(\"root is not a directory: \" + input.root); return; }\n // Read-only scope: root may only narrow the session cwd, never widen it (checked lexically and after resolving links).\n const within = (p, base) => { const r = path.relative(base.toLowerCase(), p.toLowerCase()); return r === \"\" || (!r.startsWith(\"..\") && !path.isAbsolute(r)); };\n const real = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };\n const want = path.resolve(input.root);\n // Besides the session cwd, the hook may name extra trusted roots (PTC_RUN_RO_ROOTS); never from user code.\n const bases = [cwd, ...(Array.isArray(input.roRoots) ? input.roRoots.filter((b) => typeof b === \"string\" && path.isAbsolute(b)) : [])];\n if (!bases.some((b) => within(want, path.resolve(b)) && within(real(want), real(b)))) { fail(\"[POLICY_DENIED] read-only root must be inside the session cwd: \" + input.root); return; }\n cwd = want;\n}\n// rg comes from the broker's own input, never from a request\nconst RG = typeof input.rg === \"string\" && input.rg && fs.existsSync(input.rg) ? input.rg : null;\n\n// ---------- state dirs ----------\nconst scope = (String(input.scope || \"default\").replace(/[^A-Za-z0-9_.-]/g, \"_\").replace(/^\\.+/, \"_\").slice(0, 80)) || \"default\";\nconst stateDir = path.join(os.tmpdir(), \"cc-ptc\", scope);\nconst resRoot = path.join(os.tmpdir(), \"cc-ptc-ro\");\nfs.mkdirSync(path.join(stateDir, \"jobs\"), { recursive: true });\nfs.mkdirSync(resRoot, { recursive: true });\nconst tag = process.pid + \"-\" + rand();\nconst reqDir = path.join(stateDir, \"ro-req-\" + tag);\nconst resDir = path.join(resRoot, tag);\nfs.mkdirSync(reqDir);\nfs.mkdirSync(resDir);\n\n// Remove a directory this broker created, without following links: one level of plain files / links only.\nconst cleanDir = (d) => {\n let st; try { st = fs.lstatSync(d); } catch { return; }\n if (!st.isDirectory()) { try { fs.unlinkSync(d); } catch {} return; } // replaced by a link / junction: drop the link itself\n let names = []; try { names = fs.readdirSync(d); } catch {}\n for (const n of names) {\n const p = path.join(d, n);\n try { const s = fs.lstatSync(p); if (s.isDirectory()) fs.rmdirSync(p); else fs.unlinkSync(p); } catch {}\n }\n try { fs.rmdirSync(d); } catch {}\n};\n// sweep leftovers of brokers that were killed before they could clean up (their pid is gone)\nconst sweep = (root, re) => {\n let names = []; try { names = fs.readdirSync(root); } catch { return; }\n for (const n of names) { const m = re.exec(n); if (m && Number(m[1]) !== process.pid && !alive(Number(m[1]))) cleanDir(path.join(root, n)); }\n};\nsweep(stateDir, /^ro-req-(\\d+)-[0-9a-f]+$/);\nsweep(resRoot, /^(\\d+)-[0-9a-f]+$/);\nlet cleaned = false;\nconst cleanup = () => { if (cleaned) return; cleaned = true; cleanDir(reqDir); cleanDir(resDir); };\nprocess.on(\"exit\", cleanup);\n\n// ---------- rg argv whitelist ----------\n// Exactly the tokens the runner generates. No combined short flags, no --flag=value forms, no \"--\".\nconst FLAG0 = new Set([\"--files\", \"--null\", \"-l\", \"-c\", \"-H\", \"--json\", \"-U\", \"-P\", \"-i\", \"--hidden\", \"--no-ignore\"]);\nconst ANY = () => true, NUM = (v) => /^\\d{1,6}$/.test(v);\nconst FLAG1 = new Map([[\"-g\", ANY], [\"-e\", ANY], [\"--max-filesize\", (v) => v === \"2M\"], [\"-B\", NUM], [\"-A\", NUM]]);\nconst DENY = new Set([\"--pre\", \"--pre-glob\", \"-z\", \"--search-zip\", \"--hostname-bin\"]);\nconst isAbsPath = (p) => (/^[A-Za-z]:[\\\\/]/.test(p) || /^\\\\\\\\[^\\\\/?.]/.test(p) || (process.platform !== \"win32\" && p.startsWith(\"/\")));\n// Search roots must stay inside the session cwd (lexically and after resolving links); the restricted runner may only\n// read there too, but the broker runs rg unrestricted, so it enforces this itself.\nconst WINP = process.platform === \"win32\";\nconst key = (p) => { const r = path.resolve(p); return WINP ? r.toLowerCase() : r; };\nconst insideOf = (p, root) => { const a = key(p), r = key(root); return a === r || a.startsWith(r.endsWith(path.sep) ? r : r + path.sep); };\nlet cwdReal = cwd; try { cwdReal = fs.realpathSync.native(cwd); } catch {}\nconst inCwd = (p) => {\n if (!insideOf(p, cwd) && !insideOf(p, cwdReal)) return false;\n let real = null; try { real = fs.realpathSync.native(p); } catch { return true; } // missing path: rg finds nothing\n return insideOf(real, cwdReal) || insideOf(real, cwd);\n};\nconst validate = (argv) => {\n if (!Array.isArray(argv) || argv.length === 0 || argv.length > 4096) return \"argv must be a non-empty array (<= 4096 items)\";\n let positional = 0;\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n if (typeof a !== \"string\") return \"argv[\" + i + \"] is not a string\";\n if (a.includes(\"\\0\")) return \"argv[\" + i + \"] contains NUL\";\n if (DENY.has(a) || DENY.has(a.split(\"=\")[0])) return \"option refused: \" + a;\n if (FLAG0.has(a)) continue;\n if (FLAG1.has(a)) {\n if (i + 1 >= argv.length) return \"option \" + a + \" needs a value\";\n const v = argv[i + 1];\n if (typeof v !== \"string\" || v.includes(\"\\0\") || !FLAG1.get(a)(v)) return \"bad value for \" + a;\n i++; continue;\n }\n if (a.startsWith(\"-\")) return \"option not allowed: \" + a;\n if (!isAbsPath(a)) return \"positional argument must be an absolute path: \" + a;\n if (!inCwd(a)) return \"search path is outside the session cwd (read-only mode reads only the working directory): \" + a;\n positional++;\n }\n if (!positional) return \"a search path is required\";\n return null;\n};\nconst rgEnv = (() => { const e = Object.assign({}, process.env); for (const k of Object.keys(e)) if (/^RIPGREP_CONFIG_PATH$/i.test(k)) delete e[k]; return e; })();\n\n// ---------- request handling ----------\nconst writeAtomic = (f, data) => {\n const t = path.join(resDir, \".\" + rand() + \".tmp\");\n fs.writeFileSync(t, data, { flag: \"wx\" });\n fs.renameSync(t, f);\n};\nconst done = new Set();\nconst handle = (name) => {\n const id = name.slice(0, -4);\n done.add(name);\n const reply = (res, out) => {\n if (out !== undefined) writeAtomic(path.join(resDir, id + \".out\"), out);\n writeAtomic(path.join(resDir, id + \".res\"), JSON.stringify(res));\n };\n let req;\n try {\n const f = path.join(reqDir, name), st = fs.lstatSync(f);\n if (!st.isFile() || st.size > (8 << 20)) return reply({ error: \"INVALID_ARGUMENT\", message: \"bad request file\" });\n req = JSON.parse(fs.readFileSync(f, \"utf8\"));\n } catch (e) { return reply({ error: \"INVALID_ARGUMENT\", message: \"unreadable request: \" + String(e.message || e).slice(0, 200) }); }\n if (!req || typeof req !== \"object\") return reply({ error: \"INVALID_ARGUMENT\", message: \"request must be an object\" });\n if (req.cwd !== cwd) return reply({ error: \"POLICY_DENIED\", message: \"request cwd does not match the session cwd\" });\n const why = validate(req.argv);\n if (why) return reply({ error: \"POLICY_DENIED\", message: \"rg request refused by the read-only broker: \" + why });\n if (!RG) return reply({ error: \"RG_UNAVAILABLE\", message: \"bundled rg not found\" });\n const left = deadline - Date.now();\n if (left <= 0) return reply({ error: \"OUTPUT_LIMIT\", message: \"rg not started: run_code deadline reached\" });\n const r = cp.spawnSync(RG, [\"--no-config\", ...req.argv], { cwd, env: rgEnv, maxBuffer: 1024 << 20, timeout: Math.min(300000, left), windowsHide: true });\n const out = r.stdout || Buffer.alloc(0);\n reply({ status: r.status, stderr: String(r.stderr || \"\").slice(0, 64 << 10), errorCode: r.error ? r.error.code || \"ERROR\" : null }, out);\n};\nconst scan = () => {\n let names;\n try {\n const st = fs.lstatSync(reqDir);\n if (!st.isDirectory()) return; // tampered with (replaced by a link): stop serving\n names = fs.readdirSync(reqDir);\n } catch { return; }\n for (const n of names.sort()) {\n if (done.has(n) || !/^[A-Za-z0-9_-]{1,80}\\.req$/.test(n)) continue;\n try { handle(n); } catch (e) { try { writeAtomic(path.join(resDir, n.slice(0, -4) + \".res\"), JSON.stringify({ error: \"RESULT_UNKNOWN\", message: \"broker error: \" + String(e.message || e).slice(0, 200) })); } catch {} }\n }\n};\n\n// ---------- restricted runner ----------\nconst childInput = Object.assign({}, input, { readonly: true, reqDir, resDir, stateDir, brokerPid: process.pid });\n// Reads: the session cwd (as given, its real path and both drive-letter cases, since the permission model compares\n// paths case-sensitively), the scope dir (refs) and this broker's response dir. Writes: the scope dir only.\nconst readRoots = [];\nconst addRead = (p) => { if (p && !readRoots.includes(p)) readRoots.push(p); };\nfor (const p of [cwd, cwdReal]) { addRead(p); if (WINP && /^[A-Za-z]:/.test(p)) { addRead(p[0].toUpperCase() + p.slice(1)); addRead(p[0].toLowerCase() + p.slice(1)); } }\naddRead(stateDir); addRead(resDir);\nconst args = [\"--permission\", ...readRoots.map((p) => \"--allow-fs-read=\" + p), \"--allow-fs-write=\" + stateDir, \"-e\", boot];\nlet child;\ntry { child = cp.spawn(process.execPath, args, { cwd, stdio: [\"pipe\", \"pipe\", \"pipe\"], windowsHide: true }); }\ncatch (e) { fail(\"cannot start the restricted runner: \" + String(e.message || e)); return; }\nconst outB = [], errB = [];\nchild.stdout.on(\"data\", (d) => outB.push(d));\nchild.stderr.on(\"data\", (d) => errB.push(d));\nchild.stdin.on(\"error\", () => {});\nchild.stdin.end(JSON.stringify(childInput) + NL + runnerSrc);\nlet watcher = null;\ntry { watcher = fs.watch(reqDir, () => scan()); watcher.on(\"error\", () => {}); } catch {}\nconst poll = setInterval(scan, 5);\nlet timedOut = false;\nconst killer = setTimeout(() => { timedOut = true; try { child.kill(\"SIGKILL\"); } catch {} }, Math.max(1, deadline - Date.now()));\nchild.on(\"error\", (e) => errB.push(Buffer.from(\"[broker] \" + String(e.message || e))));\nchild.on(\"close\", (code) => {\n clearInterval(poll); clearTimeout(killer); if (watcher) try { watcher.close(); } catch {}\n cleanup();\n let out = Buffer.concat(outB).toString(\"utf8\");\n const err = Buffer.concat(errB).toString(\"utf8\");\n if (timedOut) out += (out ? NL : \"\") + \"[TIMEOUT] run_code (read-only) was killed after \" + (Date.now() - started) + \" ms (timeoutMs \" + timeoutMs + \"). Read-only mode: no project files were changed. Narrow the work or raise timeoutMs.\";\n if (err) process.stderr.write(err);\n process.stdout.write(out, () => process.exit(timedOut ? 124 : code === null ? 1 : code));\n});\n})();\n"
8// The runner source is ~40KB, over the Windows 32K command-line limit, so it travels on stdin:
9// line 1 = input JSON, rest = runner source. BOOT is the only thing passed on the command line.
10const BOOT = 'const s=require("fs").readFileSync(0,"utf8"),k=s.indexOf("\\n");globalThis.__PTC_IN=s.slice(0,k);(0,eval)(s.slice(k+1))'
11const NAME = 'mcp__ptc-run__run_code'
12// On an outer timeout the runner has already been killed, so the hook reads the streaming log and write-op journal it
13// left in the session scope dir. The reader is a separate node process because this hook has no fs access.
14const PARTIAL_READER = 'const fs=require("fs"),path=require("path"),os=require("os");' +
15 'const a=JSON.parse(fs.readFileSync(0,"utf8"));' +
16 'const scope=String(a.scope||"default").replace(/[^A-Za-z0-9_.-]/g,"_").replace(/^\\.+/,"_").slice(0,80)||"default";' +
17 'const d=path.join(os.tmpdir(),"cc-ptc",scope);' +
18 'const base="run-"+String(a.logName).replace(/[^A-Za-z0-9_-]/g,"_").slice(0,64);' +
19 'let out="",ops="";try{out=fs.readFileSync(path.join(d,base+".log"),"utf8")}catch{}' +
20 'try{ops=fs.readFileSync(path.join(d,base+".log.ops"),"utf8")}catch{}' +
21 'const head=a.head,tail=a.tail;' +
22 'const clip=(s)=>s.length<=head+tail?s:s.slice(0,head)+"…[省略 "+(s.length-head-tail)+" 字]…"+s.slice(-tail);' +
23 'const seen=new Map();for(const l of ops.split("\\n")){if(!l)continue;try{const o=JSON.parse(l);const k=o.op+"\\u0000"+o.path;seen.set(k,(seen.get(k)||0)+1)}catch{}}' +
24 'const rows=[...seen].map(([k,n])=>{const i=k.indexOf("\\u0000");return "- "+k.slice(0,i)+" "+k.slice(i+1)+(n>1?" ×"+n:"")});' +
25 'process.stdout.write(JSON.stringify({out:clip(out),ops:rows.length?"本程序已生效的写操作:\\n"+rows.slice(0,50).join("\\n"):"本程序已生效的写操作:无"}))'
26
27const DESC_PARTS = [
28 'Run a JavaScript async-function body in a sandbox that calls tools synchronously; only console.log output and the return value come back. Use for batch work (loops, grep+aggregate, edits). timeoutMs is the whole-program timeout (default 300000 ms, max 1800000). root:"D:/abs/dir" makes relative paths, path-less glob/grep and cwd-less bash use that existing absolute directory (tools.root exposes it).',
29 'Paths: relative to cwd (or root). On big trees give glob/grep a path or specific pattern. Errors carry .code/.nextAction and "[CODE]" prefix: INVALID_ARGUMENT NOT_FOUND NOT_UNIQUE COUNT_MISMATCH STALE LOCKED EXISTS POLICY_DENIED BUSY OUTPUT_LIMIT RG_UNAVAILABLE RESULT_UNKNOWN.',
30 'tools.read({path,offset?,limit?,numbered?}) (numbered:true -> "N: text" lines with real line numbers). tools.revision({path}) -> 16-hex sha256 prefix of the file content; pass it as expectedRevision to tools.edit (or as {path: revision} to tools.replace) to get STALE and write nothing if the content changed since (works across run_code calls). tools.write({path,content}) (write/edit/replace results are capped at 32 MiB per file: over it nothing is written and OUTPUT_LIMIT is thrown); tools.edit({path,old,new,all?,expect?,expectedRevision?}) -> {replaced,lines} (atomic; the mtime STALE check still applies only if this same program read the file first; expectedRevision adds a cross-call content check; revision-guarded writes also take a cross-process lock (one writer at a time; LOCKED after ~2 s, a lock left by a crash is taken over after 30 s); with all:true pass expect:<count you mean to change>, a different count writes nothing and throws COUNT_MISMATCH).',
31 'tools.glob({pattern?:string|string[],path?,sort?:"path"|"modified",details?,offset?,limit?=5000}) and tools.grep({pattern,path?,glob?,ignoreCase?,outputMode?:"content"|"files_with_matches"|"count",before?,after?,multiline?,offset?=0,limit?=500}) -> {items,total,complete,nextOffset} (ripgrep: .gitignore honoured; skips hidden, node_modules, target, .venv, files>2MB). glob items: path, or {path,size,mtime} with details. grep items: {file,line,text<=300} (+uncounted context:true rows), {file,count}, or paths.',
32 'tools.replace({pattern(JS regex; lookaround, \\1, named groups ok),replacement($1 ok),path?,glob?,flags?,dryRun?,maxReplacements?,expect?,expectedRevision?}) -> {files,matches,changed,items:[{file,count,lines}],dryRun} (expect: total matches you mean to change; a different count writes nothing and throws COUNT_MISMATCH). Any write that changed several places is listed at the top of the output with its line numbers: check each one was intended.',
33 'tools.batch({action:"plan"|"run"|"resume"|"list",operations:[{op:"copy"|"move"|"remove",path,to?}],protect?:string[],root?,toRoot?,id?}): root-relative "/" paths (to is relative to toRoot if given: another dir/drive), <=512 ops, no overwrite, destination parent must exist, no links, protect untouched; preflighted first. plan only journals; run needs operations; resume takes only id (unknown items: checked, not redone); list shows jobs. -> {id,status:"completed"|"partial"|"failed"|"planned",done,failed,unknown,total,detailsRef}.',
34 'tools.bash({command,cwd?,timeoutMs?,env?,background?,promote?})->{code,stdout,stderr}; a foreground command that reaches timeoutMs is not killed but promoted to a background job: the call returns {code:null,timedOut:true,promoted:true,jobId,stdout,stderr} (output so far) and tools.job({id:jobId,wait?}) returns the final result; promote:false kills it on timeout instead. background:true returns {id} immediately, then tools.job({id,wait?,tailChars?=4000})->{done,code,stdout,stderr} (jobs survive run_code calls; 2h cap). await tools.parallel([{command,cwd?,timeoutMs?,env?}],{concurrency?=4,<=16}) runs <=64 bash calls -> [{code,stdout,stderr,timedOut?}] in order. Child processes default to PYTHONUTF8=1 and PYTHONIOENCODING=utf-8; an explicit env wins. tools.test({command,workdir?,timeoutMs?=300000,maxFailures?=10}) runs a test command through the same bash path -> {status:"passed"|"failed"|"error"|"timeout"|"unknown",exitCode,counts?,failures,tail?,error?,outputPath} (pytest/vitest/jest/node --test parsed conservatively): print that summary instead of piping test output; when status is "error", read error and tail, and outputPath is the path to the full output (read it with tools.read).',
35 'ref.put(v)->id; ref.page(id,{offset,limit,fields,equals,maxBytes=16384})->{total,offset,items,nextOffset,complete,omitted} (OUTPUT_LIMIT if one row exceeds maxBytes; also reads detailsRef); ref.text(id,{from,to}) returns a slice of the saved text; ref.release(id). Refs: per session, 24h. Output over ~8000 chars (or maxChars, <=50000) keeps about the first 70% and last 30% (cut on line boundaries); the marker names the omitted output lines and the exact ref.text(id,{from,to}) call that returns them',
36]
37const DESC = DESC_PARTS.join(' ')
38
39// Short description for delegate's ptc-only mode: there the SDK declarations are already in the system prompt.
40const DESC_SHORT = 'Run a JavaScript async-function body in a sandbox; the SDK bindings (tools.*) are declared in the system prompt. Only console.log output and the return value come back. timeoutMs defaults to 300000 (max 1800000); root:"D:/abs/dir" changes the base directory for relative paths, glob/grep and bash.'
41
42// Read-only mode descriptions: only the read-side bindings are advertised.
43const DESC_RO = [
44 'Run a JavaScript async-function body in a READ-ONLY sandbox that calls tools synchronously; only console.log output and the return value come back. Use for batch reading, grep+aggregate and summaries. timeoutMs defaults to 300000 (max 1800000); root:"D:/abs/dir" changes the base directory for relative paths and glob/grep.',
45 'Read-only mode: tools.write/edit/replace/batch/bash/parallel/job throw POLICY_DENIED, the program itself cannot write files or start processes (fs writes and child_process are blocked), and only files under the working directory can be read or searched.',
46 DESC_PARTS[1], DESC_PARTS[2].split('. tools.write')[0] + '.', DESC_PARTS[3], DESC_PARTS[7],
47].join(' ')
48const DESC_SHORT_RO = 'Run a JavaScript async-function body in a read-only sandbox (no file writes, no commands, reads only under the working directory); the SDK bindings (tools.*) are declared in the system prompt. Only console.log output and the return value come back.'
49
50export const register: Register = on => {
51 on('session.start', async ($, e, next) => {
52 const sdkInPrompt = (await $.env.get('PTC_RUN_SDK_IN_PROMPT')) === '1'
53 const ro = (await $.env.get('PTC_RUN_READONLY')) === '1'
54 await $.tool.register({
55 name: 'run_code',
56 description: ro ? (sdkInPrompt ? DESC_SHORT_RO : DESC_RO) : sdkInPrompt ? DESC_SHORT : DESC,
57 isDeferred: false,
58 inputSchema: {
59 type: 'object',
60 properties: {
61 code: { type: 'string', description: 'Body of an async function; use return and console.log.' },
62 description: { type: 'string', description: 'One-line summary of what the program does.' },
63 timeoutMs: { type: 'integer', minimum: 1000, maximum: 1800000, description: '整个程序的超时毫秒数,默认 300000,最大 1800000;程序内 bash 的 timeoutMs 不能超过它' },
64 root: { type: 'string', description: '可选:已存在的绝对目录。设置后程序内的相对路径、grep/glob 的默认目录、未指定 cwd 的 bash 都以它为基准,程序里可用 tools.root 读取;不设置则沿用会话 cwd。' },
65 maxChars: {
66 type: 'integer',
67 minimum: 1000,
68 maximum: 50000,
69 description: 'Output cap in chars (default 8000). Over it the first 70% and last 30% are kept, the middle is saved as a ref (ref.text(id,{from,to})), and a truncation hint is printed.',
70 },
71 },
72 required: ['code', 'description'],
73 },
74 })
75 return next(e)
76 })
77
78 on('tool.call', { tool: NAME }, async ($, e) => {
79 const input = e as unknown as { code: string; timeoutMs?: number; maxChars?: number; root?: string }
80 const cwd = await $.session.cwd()
81 const dsh = (await $.env.get('PTC_RUN_SDK_IN_PROMPT')) === '1'
82 let ro = (await $.env.get('PTC_RUN_READONLY')) === '1'
83 // Read-only agent types (reviewer / oracle / researcher by default) always get the read-only broker, whatever the
84 // session's own mode. Fail closed: a subagent call whose type cannot be resolved is treated as read-only.
85 const agentId = (e as unknown as { agentId?: string }).agentId
86 if (!ro && agentId) {
87 const roTypes = ((await $.env.get('PTC_RUN_RO_AGENTS')) || 'reviewer,oracle,researcher').split(',').map(s => s.trim()).filter(Boolean)
88 try {
89 const me = (await $.agent.list()).find(a => a.id === agentId)
90 ro = !me || roTypes.includes(me.type)
91 } catch {
92 ro = true
93 }
94 }
95 // Extra directories a read-only root may point into (besides the session cwd), from the environment only.
96 const roRoots = ro ? ((await $.env.get('PTC_RUN_RO_ROOTS')) || '').split(';').map(s => s.trim()).filter(Boolean) : []
97 let scope = 'default'
98 try {
99 const sid = await $.session.id()
100 if (typeof sid === 'string' && sid) scope = sid
101 } catch {
102 // keep the default scope
103 }
104 const timeoutMs = Math.min(Math.max(Math.trunc(input.timeoutMs ?? 300000), 1000), 1800000)
105 const readLimitRaw = ((await $.env.get('PTC_RUN_DSH_READ_LIMIT')) || '').trim()
106 const dshReadLimit = /^\d+$/.test(readLimitRaw) && Number(readLimitRaw) > 0 ? Number(readLimitRaw) : undefined
107 const logName = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
108 const readPartial = async () => {
109 try {
110 const pr = await $.process.run(['node', '-e', PARTIAL_READER], {
111 cwd,
112 stdin: JSON.stringify({ scope, logName, head: 4000, tail: 4000 }),
113 timeoutMs: 15000,
114 })
115 return pr.stdout || ''
116 } catch {
117 return ''
118 }
119 }
120 const partialText = (body: string) => {
121 let parsed: { out?: string; ops?: string } = {}
122 try { parsed = JSON.parse(body) } catch { /* keep empty */ }
123 const ops = parsed.ops || '本程序已生效的写操作:未知(无法读取写操作日志)'
124 return '[RESULT_UNKNOWN] 程序在 ' + (timeoutMs / 1000) + ' 秒超时被终止,以下是已打印的输出(前 4000 字 + 后 4000 字)' +
125 (ro ? '\n(只读模式:不会修改项目文件)' : '') + '\n' + ops + '\n' + (parsed.out || '(无输出)')
126 }
127 try {
128 const payload = { cwd, code: input.code, rg: RG, scope, dsh, root: input.root, roRoots, logName, dshReadLimit, maxChars: input.maxChars ? Math.min(Math.max(Math.trunc(input.maxChars), 1000), 50000) : dsh ? 50000 : undefined }
129 const r = await $.process.run(['node', '-e', BOOT], {
130 cwd,
131 stdin: ro
132 ? JSON.stringify({ ...payload, timeoutMs, boot: BOOT, runner: RUNNER }) + '\n' + BROKER
133 : JSON.stringify(payload) + '\n' + RUNNER,
134 timeoutMs,
135 })
136 if (r.exitCode === 124 || r.exitCode === 137) {
137 const partial = await readPartial()
138 const extra = r.stdout ? '\n' + r.stdout.slice(0, 2000) : ''
139 return { result: [{ type: 'text', text: partialText(partial) + extra }], isError: true } as never
140 }
141 const text = (r.stdout || '') + (r.stderr ? '\n[stderr] ' + r.stderr.slice(0, 2000) : '')
142 return { result: [{ type: 'text', text }], isError: r.exitCode !== 0 } as never
143 } catch (err) {
144 const msg = String((err as Error)?.message ?? err)
145 // The host reports its own timeout as e.g. "$.process.run(node) aborted: still running after 4000ms".
146 const killed = /time(d)?[ -]?out|timeout|killed|exceed|abort|still running/i.test(msg)
147 if (killed) {
148 const partial = await readPartial()
149 return { result: [{ type: 'text', text: partialText(partial) }], isError: true } as never
150 }
151 const note = ro
152 ? '\n[RESULT_UNKNOWN] Read-only mode never changes project files.'
153 : '\n[RESULT_UNKNOWN] The program may already have produced partial side effects (writes, edits, replace, batch). Inspect the files before retrying. For tools.batch, run tools.batch({action:"list"}) to find the job id (logs: <tmpdir>/cc-ptc/' + scope + '/jobs) and tools.batch({action:"resume", id}) to recover: unknown items are only checked, never redone.'
154 return { result: [{ type: 'text', text: 'run_code failed: ' + msg + note }], isError: true } as never
155 }
156 // Fail closed: if the hook itself throws or overruns, surface an error instead of silently skipping run_code.
157 }).catch(async (_$, _e, next) => {
158 return { result: [{ type: 'text', text: 'run_code hook failed: ' + String((next as { error?: unknown }).error ?? 'unknown error') + '\nThe program may or may not have started; inspect side effects before retrying.' }], isError: true } as never
159 })
160}
161
162