@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,1343 +1,1701 @@
1
- import path from "node:path"
2
- import { readdir, readFile } from "node:fs/promises"
3
- import { access, stat, unlink } from "node:fs/promises"
4
- import { exec as execCb, spawn } from "node:child_process"
5
- import { promisify } from "node:util"
6
- import { pathToFileURL } from "node:url"
7
- import { atomicWriteFile, replaceInFileTransactional, replaceAllInFileTransactional, diffLineCount } from "./edit-transaction.mjs"
8
- import { withFileLock } from "./file-lock-manager.mjs"
9
- import { BackgroundManager } from "../orchestration/background-manager.mjs"
10
- import { createTaskTool } from "./task-tool.mjs"
11
- import { McpRegistry } from "../mcp/registry.mjs"
12
- import { SkillRegistry } from "../skill/registry.mjs"
13
- import { askQuestionInteractive } from "./question-prompt.mjs"
14
- import { checkBashAllowed } from "../permission/exec-policy.mjs"
15
- import { gitAutoTools } from "./git-auto.mjs"
16
- import { gitFullAutoTools } from "./git-full-auto.mjs"
17
-
18
- const exec = promisify(execCb)
19
-
20
- const state = {
21
- initialized: false,
22
- tools: [],
23
- loadedAt: 0,
24
- lastSignature: "",
25
- lastCwd: "",
26
- lastConfig: null,
27
- refreshing: false
28
- }
29
-
30
- function schema(type, description) {
31
- return { type, description }
32
- }
33
-
34
- function safeStringify(value) {
35
- if (typeof value === "string") return value
36
- return JSON.stringify(value, null, 2)
37
- }
38
-
39
- function signatureFor(config = {}, cwd = process.cwd()) {
40
- const payload = {
41
- cwd,
42
- tool: config.tool || {},
43
- mcp: config.mcp || {},
44
- runtime: config.runtime || {}
45
- }
46
- return JSON.stringify(payload)
47
- }
48
-
49
- async function exists(target) {
50
- try {
51
- await access(target)
52
- return true
53
- } catch {
54
- return false
55
- }
56
- }
57
-
58
- async function listDir(dir) {
59
- const items = await readdir(dir, { withFileTypes: true })
60
- return items.map((item) => `${item.isDirectory() ? "d" : "f"} ${item.name}`).join("\n")
61
- }
62
-
63
- // Track which files have been read in this session (for edit safety)
64
- const fileReadTracker = new Map() // path -> { readAt: timestamp }
65
-
66
- function markFileRead(filePath) {
67
- fileReadTracker.set(filePath, { readAt: Date.now() })
68
- }
69
-
70
- function wasFileRead(filePath) {
71
- return fileReadTracker.has(filePath)
72
- }
73
-
74
- function runRg(args, cwd, timeoutMs = 30000) {
75
- return new Promise((resolve) => {
76
- let stdout = "", stderr = "", done = false
77
- const child = spawn("rg", ["--no-config", ...args], {
78
- cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"]
79
- })
80
- const timer = setTimeout(() => {
81
- if (done) return
82
- done = true
83
- child.kill("SIGTERM")
84
- setTimeout(() => { try { child.kill("SIGKILL") } catch {} }, 2000).unref()
85
- resolve({ ok: false, stdout, stderr: "search timed out" })
86
- }, timeoutMs)
87
- child.stdout.on("data", (b) => { stdout += b })
88
- child.stderr.on("data", (b) => { stderr += b })
89
- child.on("error", (e) => {
90
- if (done) return; done = true; clearTimeout(timer)
91
- resolve({ ok: false, stdout, stderr: e.message })
92
- })
93
- child.on("close", (code) => {
94
- if (done) return; done = true; clearTimeout(timer)
95
- resolve({ ok: code === 0 || code === 1, stdout: stdout.trim(), stderr: stderr.trim() })
96
- })
97
- })
98
- }
99
-
100
- async function runGlob(pattern, cwd, searchPath) {
101
- if (!pattern) return "pattern is required"
102
- const target = searchPath ? path.resolve(cwd, searchPath) : "."
103
- const { stdout } = await runRg(["--files", "--glob", pattern, target], cwd, 15000)
104
- const text = stdout.trim()
105
- if (!text) return "no files matched"
106
- const lines = text.split("\n").filter(Boolean)
107
- if (lines.length > 200) {
108
- return lines.slice(0, 200).join("\n") + `\n... (+${lines.length - 200} more files)`
109
- }
110
- return `${lines.length} file(s):\n${text}`
111
- }
112
-
113
- async function runGrep(pattern, cwd, options = {}) {
114
- if (!pattern) return "pattern is required"
115
- const args = []
116
- if (options.multiline) args.push("-U", "--multiline-dotall")
117
- if (options.outputMode === "count") args.push("-c")
118
- else if (options.outputMode === "files") args.push("-l")
119
- else args.push("-n")
120
- if (options.beforeContext) args.push("-B", String(options.beforeContext))
121
- if (options.afterContext) args.push("-A", String(options.afterContext))
122
- if (options.context) args.push("-C", String(options.context))
123
- if (options.type) args.push("--type", options.type)
124
- if (options.glob) args.push("--glob", options.glob)
125
- if (options.maxCount) args.push("-m", String(options.maxCount))
126
- if (options.ignoreCase) args.push("-i")
127
- args.push(pattern)
128
- args.push(options.path ? path.resolve(cwd, options.path) : ".")
129
- const { stdout, stderr } = await runRg(args, cwd)
130
- let text = stdout.trim()
131
- if (!text && stderr) text = `[search error] ${stderr}`
132
- if (text && (options.offset || options.headLimit)) {
133
- const lines = text.split("\n")
134
- const start = options.offset || 0
135
- const limit = options.headLimit || lines.length
136
- text = lines.slice(start, start + limit).join("\n")
137
- }
138
- return text || "no matches"
139
- }
140
-
141
- const LONG_RUNNING_PATTERNS = [
142
- /\bnpm\s+run\s+dev\b/i,
143
- /\bnpm\s+run\s+start\b/i,
144
- /\bnpm\s+start\b/i,
145
- /\byarn\s+dev\b/i,
146
- /\byarn\s+start\b/i,
147
- /\bpnpm\s+dev\b/i,
148
- /\bpnpm\s+start\b/i,
149
- /\bnpx\s+vite\b/i,
150
- /\bnpx\s+next\s+dev\b/i,
151
- /\bnpx\s+serve\b/i,
152
- /\bnode\s+.*server/i,
153
- /\bwebpack\s+serve\b/i,
154
- /\bwebpack\s+--watch\b/i,
155
- /\bjest\s+--watch\b/i,
156
- /\bvitest(?!\s+--run)\b.*(?!--run)/i,
157
- /\bnodemon\b/i,
158
- /\btsc\s+--watch\b/i,
159
- /\btailwindcss\s+--watch\b/i,
160
- /\bnpm\s+run\s+serve\b/i,
161
- /\bnpm\s+run\s+watch\b/i
162
- ]
163
-
164
- const BASH_TIMEOUT_MS = 120_000
165
- const IS_WIN = process.platform === "win32"
166
- function wrapCmd(cmd) { return IS_WIN ? `chcp 65001 >nul & ${cmd}` : cmd }
167
-
168
- function isLongRunningCommand(command) {
169
- const cmd = String(command || "").trim()
170
- return LONG_RUNNING_PATTERNS.some((re) => re.test(cmd))
171
- }
172
-
173
- async function runBash(command, cwd, timeoutMs = BASH_TIMEOUT_MS) {
174
- if (isLongRunningCommand(command)) {
175
- return `[blocked] "${command}" looks like a long-running/dev-server command that would block execution. Please tell the user to run it manually in their terminal, or use run_in_background: true.`
176
- }
177
- const out = await exec(wrapCmd(command), { cwd, timeout: timeoutMs, encoding: "utf8" }).catch((error) => {
178
- if (error.killed || error.signal === "SIGTERM") {
179
- return {
180
- stdout: error.stdout ?? "",
181
- stderr: `${error.stderr || ""}\n[timeout] command killed after ${timeoutMs / 1000}s`
182
- }
183
- }
184
- return {
185
- stdout: error.stdout ?? "",
186
- stderr: error.stderr ?? error.message
187
- }
188
- })
189
- const raw = `${out.stdout || ""}${out.stderr || ""}`.trim() || "(empty output)"
190
- if (raw.length > 30000) return raw.slice(0, 30000) + `\n\n[truncated] output exceeded 30000 chars (total: ${raw.length})`
191
- return raw
192
- }
193
-
194
- function lockOptions(ctx = {}) {
195
- const mode = String(ctx?.config?.tool?.write_lock?.mode || "file_lock")
196
- const waitTimeoutMs = Math.max(0, Number(ctx?.config?.tool?.write_lock?.wait_timeout_ms || 120000))
197
- const owner = String(ctx?.taskId || ctx?.sessionId || ctx?.turnId || "kkcode")
198
- return { mode, waitTimeoutMs, owner }
199
- }
200
-
201
- async function loadDynamicTools(dirs) {
202
- const loaded = []
203
- for (const dir of dirs) {
204
- const absolute = path.resolve(dir)
205
- if (!(await exists(absolute))) continue
206
- const entries = await readdir(absolute, { withFileTypes: true })
207
- for (const entry of entries) {
208
- if (!entry.isFile()) continue
209
- if (![".mjs", ".js"].includes(path.extname(entry.name).toLowerCase())) continue
210
- const file = path.join(absolute, entry.name)
211
- try {
212
- const mod = await import(pathToFileURL(file).href)
213
- const def = mod.default || mod.tool || mod
214
- if (!def || typeof def !== "object" || typeof def.name !== "string" || typeof def.execute !== "function") {
215
- continue
216
- }
217
- loaded.push({
218
- name: def.name,
219
- description: def.description || `dynamic tool from ${file}`,
220
- inputSchema: def.inputSchema || { type: "object", properties: {}, required: [] },
221
- execute: def.execute
222
- })
223
- } catch {
224
- // ignore invalid tool module
225
- }
226
- }
227
- }
228
- return loaded
229
- }
230
-
231
- function builtinTools(config) {
232
- const listTool = {
233
- name: "list",
234
- description: "List files and subdirectories in a directory. Returns entry names with type prefix (d=directory, f=file). Use this for quick directory overview; use `glob` for recursive pattern matching.",
235
- inputSchema: {
236
- type: "object",
237
- properties: { path: schema("string", "directory path") },
238
- required: []
239
- },
240
- async execute(args, ctx) {
241
- const target = path.resolve(ctx.cwd, args.path || ".")
242
- return listDir(target)
243
- }
244
- }
245
-
246
- const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"])
247
- const IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon" }
248
-
249
- function readNotebook(raw) {
250
- const notebook = JSON.parse(raw)
251
- if (!notebook.cells || !Array.isArray(notebook.cells)) return "Not a valid .ipynb file (missing cells array)"
252
- const lines = []
253
- notebook.cells.forEach((cell, i) => {
254
- const type = cell.cell_type || "unknown"
255
- lines.push(`--- Cell ${i} [${type}] ---`)
256
- const source = Array.isArray(cell.source) ? cell.source.join("") : String(cell.source || "")
257
- lines.push(source)
258
- if (cell.outputs && cell.outputs.length > 0) {
259
- lines.push("[Output]:")
260
- for (const out of cell.outputs) {
261
- if (out.text) lines.push(Array.isArray(out.text) ? out.text.join("") : String(out.text))
262
- else if (out.data?.["text/plain"]) {
263
- const plain = out.data["text/plain"]
264
- lines.push(Array.isArray(plain) ? plain.join("") : String(plain))
265
- }
266
- }
267
- }
268
- lines.push("")
269
- })
270
- return lines.join("\n")
271
- }
272
-
273
- function extractPdfText(buffer) {
274
- // Basic PDF text extraction: find text between BT/ET operators and parenthesized strings
275
- const str = buffer.toString("latin1")
276
- const texts = []
277
- const tjRegex = /\(([^)]*)\)/g
278
- // Extract strings from content streams
279
- let match
280
- while ((match = tjRegex.exec(str)) !== null) {
281
- const decoded = match[1]
282
- .replace(/\\n/g, "\n").replace(/\\r/g, "\r")
283
- .replace(/\\t/g, "\t").replace(/\\\\/g, "\\")
284
- .replace(/\\([()])/g, "$1")
285
- if (decoded.trim()) texts.push(decoded)
286
- }
287
- if (texts.length === 0) return "(PDF contains no extractable text — may be image-based or encrypted)"
288
- return texts.join(" ").replace(/\s+/g, " ").trim()
289
- }
290
-
291
- const readTool = {
292
- name: "read",
293
- description: "Read file content with line numbers. Supports text files, images (PNG/JPG/GIF/SVG/WebP/BMP/ICO as base64), PDF (text extraction), and Jupyter notebooks (.ipynb cell parsing). Use `offset` and `limit` to read specific line ranges. ALWAYS use this instead of `bash` with cat/head/tail. You MUST read a file before editing it with `edit`.",
294
- inputSchema: {
295
- type: "object",
296
- properties: {
297
- path: schema("string", "file path"),
298
- offset: schema("number", "start line number (1-based, optional)"),
299
- limit: schema("number", "max lines to return (optional)"),
300
- encoding: schema("string", "file encoding (default: utf8)"),
301
- pages: schema("string", "page range for PDF files, e.g. '1-5' (optional)")
302
- },
303
- required: ["path"]
304
- },
305
- async execute(args, ctx) {
306
- const target = path.resolve(ctx.cwd, args.path)
307
- const ext = path.extname(target).toLowerCase()
308
- markFileRead(target)
309
-
310
- // Image files: return base64 data URI
311
- if (IMAGE_EXTENSIONS.has(ext)) {
312
- const buffer = await readFile(target)
313
- const base64 = buffer.toString("base64")
314
- const mime = IMAGE_MIME[ext] || "application/octet-stream"
315
- return {
316
- type: "image",
317
- output: `Image file: ${args.path} (${buffer.length} bytes, ${mime})`,
318
- data: `data:${mime};base64,${base64}`
319
- }
320
- }
321
-
322
- // PDF files: extract text
323
- if (ext === ".pdf") {
324
- const buffer = await readFile(target)
325
- return extractPdfText(buffer)
326
- }
327
-
328
- // Jupyter notebooks: parse cells
329
- if (ext === ".ipynb") {
330
- const raw = await readFile(target, "utf8")
331
- return readNotebook(raw)
332
- }
333
-
334
- // Default: text file with line numbers
335
- const encoding = args.encoding || "utf8"
336
- const content = await readFile(target, encoding)
337
- const allLines = content.split("\n")
338
- const start = Math.max(0, (Number(args.offset) || 1) - 1)
339
- const count = Number(args.limit) || Math.min(allLines.length, 2000)
340
- const slice = allLines.slice(start, start + count)
341
- const numbered = slice.map((line, i) => {
342
- const num = String(start + i + 1).padStart(6)
343
- const truncated = line.length > 2000 ? line.slice(0, 2000) + "... (truncated)" : line
344
- return `${num}→${truncated}`
345
- })
346
- return numbered.join("\n")
347
- }
348
- }
349
-
350
- const writeTool = {
351
- name: "write",
352
- description: "Create or overwrite a file atomically. Auto-creates parent directories. Supports three modes: 'overwrite' (default, full replacement), 'append' (add to end of file), 'insert' (insert at a specific line). For large files (200+ lines), use mode='append' to build incrementally across multiple calls to avoid output truncation. Use `edit` instead when only a small part of an existing file needs to change.",
353
- inputSchema: {
354
- type: "object",
355
- properties: {
356
- path: schema("string", "file path"),
357
- content: schema("string", "file content to write"),
358
- mode: schema("string", "write mode: 'overwrite' (default), 'append' (add to end), 'insert' (insert at line number)"),
359
- insert_at_line: schema("number", "1-based line number for insert mode. Content is inserted BEFORE this line.")
360
- },
361
- required: ["path", "content"]
362
- },
363
- async execute(args, ctx) {
364
- const target = path.resolve(ctx.cwd, args.path)
365
- const content = String(args.content ?? "")
366
- const mode = String(args.mode || "overwrite")
367
-
368
- // Guard: detect empty/parse-error writes that would destroy existing content
369
- if (args.__parse_error) {
370
- return {
371
- output: `error: tool call arguments were corrupted (JSON parse failed). The write was NOT executed. This usually means the response was truncated — try using write with mode="append" to build the file incrementally.`,
372
- metadata: { blocked: true, reason: "parse_error" }
373
- }
374
- }
375
- if (!content && !args.content && mode === "overwrite") {
376
- return {
377
- output: `error: content is empty or missing. The write was NOT executed. If you intended to create an empty file, pass content as an empty string explicitly.`,
378
- metadata: { blocked: true, reason: "empty_content" }
379
- }
380
- }
381
-
382
- let previous = ""
383
- const options = lockOptions(ctx)
384
-
385
- const runWrite = async () => {
386
- try {
387
- previous = await readFile(target, "utf8")
388
- } catch {
389
- previous = ""
390
- }
391
-
392
- if (mode === "append") {
393
- const separator = previous && !previous.endsWith("\n") ? "\n" : ""
394
- await atomicWriteFile(target, previous + separator + content)
395
- } else if (mode === "insert") {
396
- const lineNum = Math.max(1, Number(args.insert_at_line) || 1)
397
- const lines = previous ? previous.split("\n") : []
398
- const insertIdx = Math.min(lineNum - 1, lines.length)
399
- const newLines = content.split("\n")
400
- lines.splice(insertIdx, 0, ...newLines)
401
- await atomicWriteFile(target, lines.join("\n"))
402
- } else {
403
- // overwrite (default)
404
- await atomicWriteFile(target, content)
405
- }
406
- }
407
-
408
- if (options.mode === "file_lock") {
409
- await withFileLock({
410
- targetPath: target,
411
- owner: options.owner,
412
- waitTimeoutMs: options.waitTimeoutMs,
413
- run: runWrite
414
- })
415
- } else {
416
- await runWrite()
417
- }
418
-
419
- let finalContent
420
- try { finalContent = await readFile(target, "utf8") } catch { finalContent = content }
421
- const diff = diffLineCount(previous, finalContent)
422
- const modeLabel = mode === "append" ? "appended" : mode === "insert" ? "inserted" : "written"
423
- return {
424
- output: `${modeLabel}: ${target}`,
425
- metadata: {
426
- fileChanges: [
427
- {
428
- path: String(args.path || target),
429
- tool: "write",
430
- addedLines: diff.added,
431
- removedLines: diff.removed,
432
- stageId: ctx.stageId || null,
433
- taskId: ctx.logicalTaskId || ctx.taskId || null
434
- }
435
- ]
436
- }
437
- }
438
- }
439
- }
440
-
441
- const editTool = {
442
- name: "edit",
443
- description: "Replace a specific text snippet in an existing file. Transactional with automatic rollback on failure. You MUST `read` the file first — edits on unread files are rejected. Provide enough surrounding context in `before` to ensure a unique match. Set `replace_all: true` to replace ALL occurrences.",
444
- inputSchema: {
445
- type: "object",
446
- properties: {
447
- path: schema("string", "file path"),
448
- before: schema("string", "target snippet"),
449
- after: schema("string", "replacement snippet"),
450
- replace_all: schema("boolean", "replace all occurrences instead of requiring unique match (default: false)")
451
- },
452
- required: ["path", "before", "after"]
453
- },
454
- async execute(args, ctx) {
455
- const target = path.resolve(ctx.cwd, args.path)
456
- // Safety: warn if file was not read first
457
- if (!wasFileRead(target)) {
458
- const fileExists = await exists(target)
459
- if (fileExists) {
460
- return {
461
- output: `warning: you should read "${args.path}" before editing it. Use the read tool first to understand the file content, then retry the edit.`,
462
- metadata: { fileChanges: [] }
463
- }
464
- }
465
- }
466
- // Safety: check if file was modified externally since last read
467
- const readInfo = fileReadTracker.get(target)
468
- if (readInfo) {
469
- try {
470
- const fileStat = await stat(target)
471
- if (fileStat.mtimeMs > readInfo.readAt + 500) {
472
- return {
473
- output: `warning: "${args.path}" was modified since you last read it. Read it again to see the latest content before editing.`,
474
- metadata: { fileChanges: [] }
475
- }
476
- }
477
- } catch { /* file may not exist yet */ }
478
- }
479
- const options = lockOptions(ctx)
480
- const runEdit = async () =>
481
- args.replace_all
482
- ? replaceAllInFileTransactional(target, String(args.before), String(args.after))
483
- : replaceInFileTransactional(target, String(args.before), String(args.after))
484
- const result = options.mode === "file_lock"
485
- ? await withFileLock({
486
- targetPath: target,
487
- owner: options.owner,
488
- waitTimeoutMs: options.waitTimeoutMs,
489
- run: runEdit
490
- })
491
- : await runEdit()
492
- // Update read tracker after successful edit
493
- markFileRead(target)
494
- return {
495
- output: result.output,
496
- metadata: {
497
- fileChanges: [
498
- {
499
- path: String(args.path || target),
500
- tool: "edit",
501
- addedLines: Number(result.addedLines || 0),
502
- removedLines: Number(result.removedLines || 0),
503
- stageId: ctx.stageId || null,
504
- taskId: ctx.logicalTaskId || ctx.taskId || null
505
- }
506
- ]
507
- }
508
- }
509
- }
510
- }
511
-
512
- const globTool = {
513
- name: "glob",
514
- description: "Find files by glob pattern recursively. Use this instead of `bash` with find/ls. Optionally specify a `path` to search within a specific directory. Returns up to 200 matching file paths.",
515
- inputSchema: {
516
- type: "object",
517
- properties: {
518
- pattern: schema("string", "glob pattern, e.g. **/*.mjs, src/**/*.ts"),
519
- path: schema("string", "directory to search in (default: cwd)")
520
- },
521
- required: ["pattern"]
522
- },
523
- async execute(args, ctx) {
524
- return runGlob(String(args.pattern || ""), ctx.cwd, args.path || null)
525
- }
526
- }
527
-
528
- const grepTool = {
529
- name: "grep",
530
- description: "Search file contents by regex pattern. Use this instead of `bash` with grep/rg. Supports searching within a specific file or directory via `path`, output modes (content/files/count), multiline matching, context lines, and pagination.",
531
- inputSchema: {
532
- type: "object",
533
- properties: {
534
- pattern: schema("string", "regex or string pattern"),
535
- path: schema("string", "file or directory to search in (default: cwd). Use this to search within a specific file."),
536
- output_mode: schema("string", "output mode: 'content' (lines with numbers), 'files' (file paths only, default), 'count' (match counts per file)"),
537
- type: schema("string", "file type filter, e.g. js, ts, py (optional)"),
538
- glob: schema("string", "glob filter, e.g. *.mjs, src/**/*.ts (optional)"),
539
- maxCount: schema("number", "max matches per file (optional)"),
540
- context: schema("number", "lines of context around match, -C (optional)"),
541
- before_context: schema("number", "lines before each match, -B (optional)"),
542
- after_context: schema("number", "lines after each match, -A (optional)"),
543
- ignoreCase: schema("boolean", "case insensitive search (optional)"),
544
- multiline: schema("boolean", "enable cross-line matching (optional)"),
545
- head_limit: schema("number", "limit output to first N lines/entries (optional)"),
546
- offset: schema("number", "skip first N lines/entries before head_limit (optional)")
547
- },
548
- required: ["pattern"]
549
- },
550
- async execute(args, ctx) {
551
- return runGrep(String(args.pattern || ""), ctx.cwd, {
552
- path: args.path || null,
553
- outputMode: args.output_mode || "files",
554
- type: args.type || null,
555
- glob: args.glob || null,
556
- maxCount: args.maxCount || null,
557
- context: args.context || null,
558
- beforeContext: args.before_context || null,
559
- afterContext: args.after_context || null,
560
- ignoreCase: !!args.ignoreCase,
561
- multiline: !!args.multiline,
562
- headLimit: args.head_limit || null,
563
- offset: args.offset || null
564
- })
565
- }
566
- }
567
-
568
- const bashTool = {
569
- name: "bash",
570
- description: "Run a shell command in cwd. ONLY use for commands that have no dedicated tool (e.g. git, npm, pip, docker). Do NOT use for: reading files (use `read`), searching files (use `grep`/`glob`), writing files (use `write`/`edit`). Long-running commands are blocked unless run_in_background is true.",
571
- inputSchema: {
572
- type: "object",
573
- properties: {
574
- command: schema("string", "shell command"),
575
- timeout: schema("number", "timeout in ms (default 120000, max 600000)"),
576
- description: schema("string", "human-readable description of what this command does (optional)"),
577
- run_in_background: schema("boolean", "run as background task, returns task_id immediately (optional)")
578
- },
579
- required: ["command"]
580
- },
581
- async execute(args, ctx) {
582
- const command = String(args.command || "")
583
- const configBashTimeout = Number(ctx.config?.tool?.bash_timeout_ms || BASH_TIMEOUT_MS)
584
- const timeoutMs = Math.min(Math.max(Number(args.timeout) || configBashTimeout, 1000), 600_000)
585
-
586
- // 执行策略检查
587
- const policyCheck = checkBashAllowed(command, ctx.config)
588
- if (!policyCheck.allowed) {
589
- return {
590
- ok: false,
591
- blocked: true,
592
- error: "execution_policy_violation",
593
- message: policyCheck.reason,
594
- suggestion: "Use git_snapshot to create temporary snapshots, then manually commit when satisfied."
595
- }
596
- }
597
-
598
- if (args.run_in_background) {
599
- // Launch as background task
600
- const task = await BackgroundManager.launch({
601
- description: args.description || command,
602
- payload: { command, cwd: ctx.cwd },
603
- run: async () => {
604
- const out = await exec(wrapCmd(command), { cwd: ctx.cwd, timeout: 600_000, encoding: "utf8" })
605
- .catch(e => ({ stdout: e.stdout ?? "", stderr: e.stderr ?? e.message }))
606
- return `${out.stdout || ""}${out.stderr || ""}`.trim() || "(empty output)"
607
- },
608
- config: ctx.config
609
- })
610
- return `background task launched: ${task.id}\nUse background_output to check results.`
611
- }
612
-
613
- return runBash(command, ctx.cwd, timeoutMs)
614
- }
615
- }
616
-
617
- const outputTool = {
618
- name: "background_output",
619
- description: "Retrieve status, logs, and result of a background task launched via `task` with `run_in_background: true`. Returns the task object including status and output.",
620
- inputSchema: {
621
- type: "object",
622
- properties: {
623
- task_id: schema("string", "background task id")
624
- },
625
- required: ["task_id"]
626
- },
627
- async execute(args) {
628
- const task = await BackgroundManager.get(String(args.task_id || ""))
629
- if (!task) return "background task not found"
630
- return task
631
- }
632
- }
633
-
634
- const cancelTool = {
635
- name: "background_cancel",
636
- description: "Cancel a running background task by its task_id. Only works on tasks launched via `task` with `run_in_background: true`.",
637
- inputSchema: {
638
- type: "object",
639
- properties: {
640
- task_id: schema("string", "background task id")
641
- },
642
- required: ["task_id"]
643
- },
644
- async execute(args) {
645
- const ok = await BackgroundManager.cancel(String(args.task_id || ""))
646
- return ok ? "cancel requested" : "background task not found"
647
- }
648
- }
649
-
650
- const todowriteTool = {
651
- name: "todowrite",
652
- description: "Create or update a structured task list for tracking multi-step work. ALWAYS create a todo list before starting any task with 2+ steps. Mark items in_progress/completed as you work. Only ONE item should be in_progress at a time.",
653
- inputSchema: {
654
- type: "object",
655
- properties: {
656
- todos: {
657
- type: "array",
658
- description: "The updated todo list",
659
- items: {
660
- type: "object",
661
- properties: {
662
- content: schema("string", "task description in imperative form (e.g. 'Run tests')"),
663
- activeForm: schema("string", "present continuous form shown during execution (e.g. 'Running tests')"),
664
- status: { type: "string", enum: ["pending", "in_progress", "completed"], description: "task status" }
665
- },
666
- required: ["content", "status"]
667
- }
668
- }
669
- },
670
- required: ["todos"]
671
- },
672
- async execute(args, ctx) {
673
- const todos = args.todos || []
674
- ctx._todoState = todos
675
- const summary = todos.map((t) => {
676
- const active = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : ""
677
- return `[${t.status}] ${t.content}${active}`
678
- }).join("\n")
679
- return `Todo list updated (${todos.length} items):\n${summary}`
680
- }
681
- }
682
-
683
- const questionTool = {
684
- name: "question",
685
- description: "Ask the user one or more structured questions and wait for their answers. Use when you need user input to proceed — e.g. ambiguous requirements, implementation choices, or missing information. Supports predefined options, multi-select, and custom text input. Returns actual user answers.",
686
- inputSchema: {
687
- type: "object",
688
- properties: {
689
- questions: {
690
- type: "array",
691
- description: "questions to ask the user",
692
- items: {
693
- type: "object",
694
- properties: {
695
- id: schema("string", "unique question identifier"),
696
- text: schema("string", "question text"),
697
- header: schema("string", "short label for tab chip (max 12 chars)"),
698
- description: schema("string", "supplementary description (optional)"),
699
- options: {
700
- type: "array",
701
- items: {
702
- type: "object",
703
- properties: {
704
- label: schema("string", "option display text"),
705
- value: schema("string", "option value (defaults to label)"),
706
- description: schema("string", "option description (optional)")
707
- },
708
- required: ["label"]
709
- },
710
- description: "predefined choices (optional)"
711
- },
712
- multi: schema("boolean", "allow multiple selections (default false)"),
713
- allowCustom: schema("boolean", "allow custom text input (default true)")
714
- },
715
- required: ["id", "text"]
716
- }
717
- }
718
- },
719
- required: ["questions"]
720
- },
721
- async execute(args) {
722
- if (args && args._allowQuestion === false) {
723
- return "question tool disabled in this phase"
724
- }
725
- const questions = Array.isArray(args.questions) ? args.questions : []
726
- if (questions.length === 0) {
727
- return "error: at least one question is required"
728
- }
729
- // Normalize questions
730
- const normalized = questions.map((q, i) => ({
731
- id: String(q.id || `q${i}`),
732
- text: String(q.text || ""),
733
- description: q.description ? String(q.description) : "",
734
- options: Array.isArray(q.options) ? q.options.map((o) => ({
735
- label: String(o.label || ""),
736
- value: String(o.value || o.label || ""),
737
- description: o.description ? String(o.description) : ""
738
- })) : [],
739
- multi: !!q.multi,
740
- allowCustom: q.allowCustom !== false
741
- }))
742
- const answers = await askQuestionInteractive({ questions: normalized })
743
- // Format response
744
- const lines = normalized.map((q) => {
745
- const answer = answers[q.id] ?? "(skipped)"
746
- return `[${q.id}] ${q.text} → ${answer}`
747
- })
748
- return lines.join("\n")
749
- }
750
- }
751
-
752
- const webfetchTool = {
753
- name: "webfetch",
754
- description: "Fetch content from a public URL and return it as text. HTML is converted to markdown. Content over 50KB is truncated. Only use for public, unauthenticated URLs. Do NOT use for local file reading — use `read` instead.",
755
- inputSchema: {
756
- type: "object",
757
- properties: {
758
- url: schema("string", "URL to fetch"),
759
- prompt: schema("string", "optional processing instruction")
760
- },
761
- required: ["url"]
762
- },
763
- async execute(args) {
764
- const url = String(args.url || "")
765
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
766
- return "error: URL must start with http:// or https://"
767
- }
768
- try {
769
- const response = await fetch(url, {
770
- headers: { "user-agent": "kkcode/0.1" },
771
- signal: AbortSignal.timeout(30000)
772
- })
773
- if (!response.ok) return `error: HTTP ${response.status}`
774
- const text = await response.text()
775
- const truncated = text.length > 50000 ? text.slice(0, 50000) + "\n...(truncated)" : text
776
- return truncated
777
- } catch (error) {
778
- return `error: ${error.message}`
779
- }
780
- }
781
- }
782
-
783
- const skillTool = {
784
- name: "skill",
785
- description: "Invoke a registered skill by name. Skills are pre-built prompt templates or programmable modules that provide specialized capabilities. Use this when a task matches an available skill listed in the system prompt, or when the user mentions a slash command like '/commit'.",
786
- inputSchema: {
787
- type: "object",
788
- properties: {
789
- skill: schema("string", "skill name without '/' prefix (e.g. 'commit', 'init', 'frontend')"),
790
- args: schema("string", "optional arguments to pass to the skill (e.g. 'vue' for /init vue)")
791
- },
792
- required: ["skill"]
793
- },
794
- async execute(args, ctx) {
795
- const name = String(args.skill || "").trim()
796
- if (!name) return "error: skill name is required"
797
- if (!SkillRegistry.isReady()) return "error: skill registry not initialized"
798
- const skill = SkillRegistry.get(name)
799
- if (!skill) {
800
- const available = SkillRegistry.list().map(s => s.name).join(", ")
801
- return `error: skill "${name}" not found. Available: ${available}`
802
- }
803
- const result = await SkillRegistry.execute(name, String(args.args || ""), {
804
- cwd: ctx.cwd,
805
- mode: ctx.mode || "agent",
806
- model: ctx.model || "",
807
- provider: ctx.provider || ""
808
- })
809
- if (!result) return `skill /${name} returned no output`
810
- return result
811
- }
812
- }
813
-
814
- const EXA_MCP_URL = "https://mcp.exa.ai/mcp"
815
- const EXA_TIMEOUT_MS = 25000
816
-
817
- async function callExaMcp(toolName, args, signal) {
818
- const body = JSON.stringify({
819
- jsonrpc: "2.0",
820
- id: 1,
821
- method: "tools/call",
822
- params: { name: toolName, arguments: args }
823
- })
824
- const response = await fetch(EXA_MCP_URL, {
825
- method: "POST",
826
- headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
827
- body,
828
- signal: signal || AbortSignal.timeout(EXA_TIMEOUT_MS)
829
- })
830
- if (!response.ok) {
831
- const err = await response.text().catch(() => "")
832
- throw new Error(`Exa search error (${response.status}): ${err}`)
833
- }
834
- const text = await response.text()
835
- for (const line of text.split("\n")) {
836
- if (line.startsWith("data: ")) {
837
- const data = JSON.parse(line.slice(6))
838
- if (data.result?.content?.[0]?.text) return data.result.content[0].text
839
- }
840
- }
841
- return null
842
- }
843
-
844
- const websearchTool = {
845
- name: "websearch",
846
- description: "Search the web for up-to-date information. Use this PROACTIVELY when you are unsure about facts, APIs, library versions, error messages, or anything beyond your training data. Reduces hallucination by grounding answers in real search results. Returns relevant web page content.",
847
- inputSchema: {
848
- type: "object",
849
- properties: {
850
- query: schema("string", "search query"),
851
- numResults: schema("number", "number of results to return (default: 5)"),
852
- type: schema("string", "search type: 'auto' (default), 'fast' (quick), 'deep' (comprehensive)")
853
- },
854
- required: ["query"]
855
- },
856
- async execute(args, ctx) {
857
- const query = String(args.query || "").trim()
858
- if (!query) return "error: query is required"
859
- try {
860
- const result = await callExaMcp("web_search_exa", {
861
- query,
862
- numResults: Number(args.numResults) || 5,
863
- type: args.type || "auto",
864
- livecrawl: "fallback"
865
- }, ctx.signal)
866
- return result || "No results found. Try a different query."
867
- } catch (error) {
868
- if (error.name === "AbortError" || error.name === "TimeoutError") return "error: search request timed out"
869
- return `error: ${error.message}`
870
- }
871
- }
872
- }
873
-
874
- const codesearchTool = {
875
- name: "codesearch",
876
- description: "Search for code examples, API documentation, and SDK usage. Use this PROACTIVELY when working with unfamiliar libraries, frameworks, or APIs. Returns relevant code snippets and documentation from the web. Especially useful for: correct API signatures, configuration examples, migration guides, and best practices.",
877
- inputSchema: {
878
- type: "object",
879
- properties: {
880
- query: schema("string", "search query for APIs, libraries, SDKs (e.g. 'Express.js middleware', 'React useState hook')"),
881
- tokensNum: schema("number", "amount of context to return, 1000-50000 (default: 5000)")
882
- },
883
- required: ["query"]
884
- },
885
- async execute(args, ctx) {
886
- const query = String(args.query || "").trim()
887
- if (!query) return "error: query is required"
888
- try {
889
- const result = await callExaMcp("get_code_context_exa", {
890
- query,
891
- tokensNum: Math.min(Math.max(Number(args.tokensNum) || 5000, 1000), 50000)
892
- }, ctx.signal)
893
- return result || "No code context found. Try a more specific query."
894
- } catch (error) {
895
- if (error.name === "AbortError" || error.name === "TimeoutError") return "error: code search request timed out"
896
- return `error: ${error.message}`
897
- }
898
- }
899
- }
900
-
901
- const multieditTool = {
902
- name: "multiedit",
903
- description: "Apply multiple file edits atomically in a single operation. All changes succeed together or are rolled back entirely. Use this instead of multiple sequential `edit` calls when modifying related code across files (e.g. renaming an export and updating all imports). Each file must have been `read` first.",
904
- inputSchema: {
905
- type: "object",
906
- properties: {
907
- changes: {
908
- type: "array",
909
- description: "list of file changes to apply atomically",
910
- items: {
911
- type: "object",
912
- properties: {
913
- path: schema("string", "file path"),
914
- before: schema("string", "text to find (required for edits, omit for new file creation)"),
915
- after: schema("string", "replacement text (for edits) or full content (for new files)"),
916
- replace_all: schema("boolean", "replace all occurrences of before (default: false)")
917
- },
918
- required: ["path", "after"]
919
- }
920
- }
921
- },
922
- required: ["changes"]
923
- },
924
- async execute(args, ctx) {
925
- const changes = Array.isArray(args.changes) ? args.changes : []
926
- if (!changes.length) return "error: at least one change is required"
927
-
928
- // Phase 1: validate all changes and collect original content for rollback
929
- const snapshots = [] // { path, original, isNew }
930
- const resolved = []
931
- for (const change of changes) {
932
- const target = path.resolve(ctx.cwd, change.path)
933
- const isCreate = !change.before && change.before !== ""
934
- if (!isCreate && !wasFileRead(target)) {
935
- const fileExists = await exists(target)
936
- if (fileExists) {
937
- return `error: you must read "${change.path}" before editing it. Use the read tool first.`
938
- }
939
- }
940
- let original = null
941
- try {
942
- original = await readFile(target, "utf8")
943
- } catch { /* new file */ }
944
-
945
- if (!isCreate) {
946
- const matches = (original || "").split(change.before).length - 1
947
- if (matches === 0) return `error: no match for "before" in ${change.path}. Re-read the file and check your snippet.`
948
- if (matches > 1 && !change.replace_all) return `error: ${matches} matches in ${change.path} — set replace_all: true or provide more context.`
949
- }
950
-
951
- snapshots.push({ path: target, original, isNew: original === null })
952
- resolved.push({ target, ...change, isCreate })
953
- }
954
-
955
- // Phase 2: apply all changes
956
- const applied = []
957
- try {
958
- for (const change of resolved) {
959
- if (change.isCreate) {
960
- await atomicWriteFile(change.target, String(change.after))
961
- } else {
962
- const content = await readFile(change.target, "utf8")
963
- const next = change.replace_all
964
- ? content.replaceAll(change.before, change.after)
965
- : content.replace(change.before, change.after)
966
- await atomicWriteFile(change.target, next)
967
- }
968
- markFileRead(change.target)
969
- applied.push(change.target)
970
- }
971
- } catch (error) {
972
- // Rollback all applied changes
973
- for (let i = applied.length - 1; i >= 0; i--) {
974
- const snap = snapshots.find(s => s.path === applied[i])
975
- if (!snap) continue
976
- try {
977
- if (snap.isNew) {
978
- await unlink(applied[i]).catch(() => {})
979
- } else if (snap.original !== null) {
980
- await atomicWriteFile(applied[i], snap.original)
981
- }
982
- } catch { /* best effort rollback */ }
983
- }
984
- return `error: failed at ${applied.length + 1}/${resolved.length} all changes rolled back. Cause: ${error.message}`
985
- }
986
-
987
- // Phase 3: summarize
988
- const summary = resolved.map(c => ` ${c.isCreate ? "+" : "~"} ${c.path}`).join("\n")
989
- return {
990
- output: `${resolved.length} file(s) updated atomically:\n${summary}`,
991
- metadata: {
992
- fileChanges: resolved.map(c => ({
993
- path: String(c.path || c.target),
994
- tool: "multiedit",
995
- stageId: ctx.stageId || null,
996
- taskId: ctx.logicalTaskId || ctx.taskId || null
997
- }))
998
- }
999
- }
1000
- }
1001
- }
1002
-
1003
- const enterPlanTool = {
1004
- name: "enter_plan",
1005
- description: "Enter planning mode. Use this PROACTIVELY when the task is non-trivial and requires architectural decisions, multi-file changes, or when multiple valid approaches exist. After calling this, outline your plan, then call `exit_plan` to present it to the user for approval.",
1006
- inputSchema: {
1007
- type: "object",
1008
- properties: {
1009
- reason: schema("string", "why planning is needed (shown to user)")
1010
- },
1011
- required: []
1012
- },
1013
- async execute(args, ctx) {
1014
- ctx._planMode = true
1015
- return `Planning mode entered. Outline your plan now, then call exit_plan to present it for user approval.${args.reason ? ` Reason: ${args.reason}` : ""}`
1016
- }
1017
- }
1018
-
1019
- const exitPlanTool = {
1020
- name: "exit_plan",
1021
- description: "Present your plan to the user for approval. The user will see the plan and can approve, reject, or request changes. Only call this after enter_plan and after you have outlined a complete plan in your response.",
1022
- inputSchema: {
1023
- type: "object",
1024
- properties: {
1025
- plan: schema("string", "the complete plan text to present to the user"),
1026
- files: {
1027
- type: "array", items: { type: "string" },
1028
- description: "list of files that will be created or modified"
1029
- }
1030
- },
1031
- required: ["plan"]
1032
- },
1033
- async execute(args, ctx) {
1034
- ctx._planMode = false
1035
- return {
1036
- output: "Plan submitted for user approval.",
1037
- metadata: {
1038
- planApproval: true,
1039
- plan: String(args.plan || ""),
1040
- files: Array.isArray(args.files) ? args.files : []
1041
- }
1042
- }
1043
- }
1044
- }
1045
-
1046
- const notebookeditTool = {
1047
- name: "notebookedit",
1048
- description: "Edit a Jupyter notebook (.ipynb) cell. Supports replace, insert, and delete operations on individual cells. Use this instead of `write` when modifying notebooks — it preserves cell metadata and outputs.",
1049
- inputSchema: {
1050
- type: "object",
1051
- properties: {
1052
- path: schema("string", "notebook file path (.ipynb)"),
1053
- cell_number: schema("number", "0-indexed cell number to operate on (default: 0)"),
1054
- new_source: schema("string", "new cell source content"),
1055
- cell_type: { type: "string", enum: ["code", "markdown"], description: "cell type (required for insert)" },
1056
- edit_mode: { type: "string", enum: ["replace", "insert", "delete"], description: "operation type (default: replace)" }
1057
- },
1058
- required: ["path", "new_source"]
1059
- },
1060
- async execute(args, ctx) {
1061
- const target = path.resolve(ctx.cwd, args.path)
1062
- const raw = await readFile(target, "utf8")
1063
- const notebook = JSON.parse(raw)
1064
- if (!notebook.cells || !Array.isArray(notebook.cells)) {
1065
- return "error: not a valid .ipynb file (missing cells array)"
1066
- }
1067
- const mode = args.edit_mode || "replace"
1068
- const cellNum = Number(args.cell_number ?? 0)
1069
- const source = String(args.new_source ?? "")
1070
- const sourceLines = source.split("\n").map((line, i, arr) => i < arr.length - 1 ? line + "\n" : line)
1071
-
1072
- if (mode === "insert") {
1073
- const cellType = args.cell_type
1074
- if (!cellType || !["code", "markdown"].includes(cellType)) {
1075
- return "error: cell_type is required for insert mode (must be 'code' or 'markdown')"
1076
- }
1077
- const newCell = {
1078
- cell_type: cellType,
1079
- metadata: {},
1080
- source: sourceLines
1081
- }
1082
- if (cellType === "code") {
1083
- newCell.execution_count = null
1084
- newCell.outputs = []
1085
- }
1086
- const insertAt = cellNum < 0 ? 0 : Math.min(cellNum + 1, notebook.cells.length)
1087
- notebook.cells.splice(insertAt, 0, newCell)
1088
- } else if (mode === "delete") {
1089
- if (cellNum < 0 || cellNum >= notebook.cells.length) {
1090
- return `error: cell_number ${cellNum} out of range (0-${notebook.cells.length - 1})`
1091
- }
1092
- notebook.cells.splice(cellNum, 1)
1093
- } else {
1094
- // replace
1095
- if (cellNum < 0 || cellNum >= notebook.cells.length) {
1096
- return `error: cell_number ${cellNum} out of range (0-${notebook.cells.length - 1})`
1097
- }
1098
- const cell = notebook.cells[cellNum]
1099
- cell.source = sourceLines
1100
- if (args.cell_type && args.cell_type !== cell.cell_type) {
1101
- cell.cell_type = args.cell_type
1102
- if (args.cell_type === "markdown") {
1103
- delete cell.execution_count
1104
- delete cell.outputs
1105
- } else if (args.cell_type === "code") {
1106
- cell.execution_count = null
1107
- cell.outputs = []
1108
- }
1109
- }
1110
- }
1111
-
1112
- await atomicWriteFile(target, JSON.stringify(notebook, null, 1) + "\n")
1113
- markFileRead(target)
1114
- const actionLabel = mode === "insert" ? "inserted" : mode === "delete" ? "deleted" : "replaced"
1115
- return {
1116
- output: `${actionLabel} cell ${cellNum} in ${args.path} (${notebook.cells.length} cells total)`,
1117
- metadata: {
1118
- fileChanges: [{
1119
- path: String(args.path || target),
1120
- tool: "notebookedit",
1121
- stageId: ctx.stageId || null,
1122
- taskId: ctx.logicalTaskId || ctx.taskId || null
1123
- }]
1124
- }
1125
- }
1126
- }
1127
- }
1128
-
1129
- const patchTool = {
1130
- name: "patch",
1131
- description: "Replace a range of lines in a file by line numbers. Read the file first with `read` (use offset/limit for large files) to see line numbers, then specify the line range to replace. Lines are 1-based and inclusive. Ideal for modifying specific sections of large files without needing to match exact text.",
1132
- inputSchema: {
1133
- type: "object",
1134
- properties: {
1135
- path: schema("string", "file path"),
1136
- start_line: schema("number", "first line to replace (1-based, inclusive)"),
1137
- end_line: schema("number", "last line to replace (1-based, inclusive)"),
1138
- content: schema("string", "replacement content (replaces the line range). Empty string deletes lines.")
1139
- },
1140
- required: ["path", "start_line", "end_line", "content"]
1141
- },
1142
- async execute(args, ctx) {
1143
- const target = path.resolve(ctx.cwd, args.path)
1144
-
1145
- if (!wasFileRead(target)) {
1146
- try {
1147
- await access(target)
1148
- return {
1149
- output: `warning: you should read "${args.path}" before patching it. Use the read tool first.`,
1150
- metadata: { fileChanges: [] }
1151
- }
1152
- } catch { /* new file — allow */ }
1153
- }
1154
-
1155
- const readInfo = fileReadTracker.get(target)
1156
- if (readInfo) {
1157
- try {
1158
- const fileStat = await stat(target)
1159
- if (fileStat.mtimeMs > readInfo.readAt + 500) {
1160
- return {
1161
- output: `warning: "${args.path}" was modified since you last read it. Read it again before patching.`,
1162
- metadata: { fileChanges: [] }
1163
- }
1164
- }
1165
- } catch { /* ok */ }
1166
- }
1167
-
1168
- const startLine = Math.max(1, Number(args.start_line) || 1)
1169
- const endLine = Math.max(startLine, Number(args.end_line) || startLine)
1170
- const content = String(args.content ?? "")
1171
-
1172
- const options = lockOptions(ctx)
1173
- let result
1174
- const runPatch = async () => {
1175
- const existing = await readFile(target, "utf8")
1176
- const lines = existing.split("\n")
1177
- if (startLine > lines.length) {
1178
- throw new Error(`start_line ${startLine} exceeds file length (${lines.length} lines)`)
1179
- }
1180
- const startIdx = startLine - 1
1181
- const endIdx = Math.min(endLine, lines.length)
1182
- const newLines = content === "" ? [] : content.split("\n")
1183
- lines.splice(startIdx, endIdx - startIdx, ...newLines)
1184
- const final = lines.join("\n")
1185
- await atomicWriteFile(target, final)
1186
- return { removedCount: endIdx - startIdx, insertedCount: newLines.length, previous: existing, final }
1187
- }
1188
-
1189
- if (options.mode === "file_lock") {
1190
- result = await withFileLock({ targetPath: target, owner: options.owner, waitTimeoutMs: options.waitTimeoutMs, run: runPatch })
1191
- } else {
1192
- result = await runPatch()
1193
- }
1194
-
1195
- markFileRead(target)
1196
- return {
1197
- output: `patched ${args.path}: replaced lines ${startLine}-${endLine} (removed ${result.removedCount}, inserted ${result.insertedCount})`,
1198
- metadata: {
1199
- fileChanges: [{
1200
- path: String(args.path || target),
1201
- tool: "patch",
1202
- addedLines: result.insertedCount,
1203
- removedLines: result.removedCount,
1204
- stageId: ctx.stageId || null,
1205
- taskId: ctx.logicalTaskId || ctx.taskId || null
1206
- }]
1207
- }
1208
- }
1209
- }
1210
- }
1211
-
1212
- const gitTools = config?.git_auto?.enabled !== false ? gitAutoTools : []
1213
- const gitFullAutoToolsList = config?.git_auto?.full_auto === true ? gitFullAutoTools : []
1214
-
1215
- return [listTool, readTool, writeTool, editTool, patchTool, multieditTool, globTool, grepTool, bashTool, createTaskTool(), outputTool, cancelTool, todowriteTool, questionTool, skillTool, webfetchTool, websearchTool, codesearchTool, notebookeditTool, enterPlanTool, exitPlanTool, ...gitTools, ...gitFullAutoToolsList]
1216
- }
1217
-
1218
- function mcpTools() {
1219
- return McpRegistry.listTools().map((tool) => ({
1220
- name: tool.id,
1221
- description: `[mcp:${tool.server}] ${tool.description}`,
1222
- inputSchema: tool.inputSchema,
1223
- async execute(args, ctx) {
1224
- try {
1225
- const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1226
- return result.output
1227
- } catch (error) {
1228
- const reason = error.reason || "unknown"
1229
- const server = error.server || tool.server
1230
- return `[MCP Error: ${server} ${reason}] ${error.message}`
1231
- }
1232
- }
1233
- }))
1234
- }
1235
-
1236
- function toolAllowedByMode(toolName, mode) {
1237
- if (mode === "ask" || mode === "plan") {
1238
- return !["write", "edit", "patch", "bash", "task", "git_snapshot", "git_restore", "git_apply_patch", "git_delete_snapshot"].includes(toolName)
1239
- }
1240
- return true
1241
- }
1242
-
1243
- export const ToolRegistry = {
1244
- async initialize({ config = {}, cwd = process.cwd(), force = false } = {}) {
1245
- const ttlMs = Math.max(0, Number(config.runtime?.tool_registry_cache_ttl_ms || 30000))
1246
- const sig = signatureFor(config, cwd)
1247
- const cacheValid =
1248
- state.initialized &&
1249
- !force &&
1250
- state.lastSignature === sig &&
1251
- state.lastCwd === cwd &&
1252
- Date.now() - state.loadedAt <= ttlMs
1253
- if (cacheValid) return
1254
-
1255
- const tools = []
1256
-
1257
- if (config.tool?.sources?.builtin !== false) {
1258
- tools.push(...builtinTools(config))
1259
- }
1260
-
1261
- if (config.tool?.sources?.local !== false) {
1262
- const localDirs = (config.tool?.local_dirs || []).map((dir) => path.resolve(cwd, dir))
1263
- tools.push(...(await loadDynamicTools(localDirs)))
1264
- }
1265
-
1266
- if (config.tool?.sources?.plugin !== false) {
1267
- const pluginDirs = (config.tool?.plugin_dirs || []).map((dir) => path.resolve(cwd, dir))
1268
- tools.push(...(await loadDynamicTools(pluginDirs)))
1269
- }
1270
-
1271
- if (config.tool?.sources?.mcp !== false) {
1272
- await McpRegistry.initialize(config, { cwd })
1273
- tools.push(...mcpTools())
1274
- }
1275
-
1276
- state.tools = tools
1277
- state.initialized = true
1278
- state.loadedAt = Date.now()
1279
- state.lastSignature = sig
1280
- state.lastCwd = cwd
1281
- state.lastConfig = config
1282
- },
1283
-
1284
- isReady() {
1285
- return state.initialized
1286
- },
1287
-
1288
- async list({ mode, cwd = process.cwd(), config = undefined } = {}) {
1289
- const resolvedConfig = config === undefined ? state.lastConfig || {} : config
1290
- if (!state.initialized) {
1291
- await this.initialize({ config: resolvedConfig, cwd })
1292
- } else {
1293
- await this.initialize({ config: resolvedConfig, cwd, force: false })
1294
- }
1295
- return state.tools
1296
- .filter((tool) => toolAllowedByMode(tool.name, mode))
1297
- .map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }))
1298
- },
1299
-
1300
- async get(toolName) {
1301
- return state.tools.find((tool) => tool.name === toolName) || null
1302
- },
1303
-
1304
- async call(toolName, args, ctx) {
1305
- const tool = await this.get(toolName)
1306
- if (!tool) {
1307
- return {
1308
- name: toolName,
1309
- status: "error",
1310
- output: `unknown tool: ${toolName}`,
1311
- error: `unknown tool: ${toolName}`
1312
- }
1313
- }
1314
- try {
1315
- const output = await tool.execute(args || {}, ctx)
1316
- return {
1317
- name: toolName,
1318
- status: "completed",
1319
- output: safeStringify(output)
1320
- }
1321
- } catch (error) {
1322
- return {
1323
- name: toolName,
1324
- status: "error",
1325
- output: error.message,
1326
- error: error.message
1327
- }
1328
- }
1329
- },
1330
-
1331
- refreshMcpTools() {
1332
- if (!state.initialized || state.refreshing) return
1333
- state.refreshing = true
1334
- try {
1335
- // Atomic replacement: build new list, then assign once
1336
- const nonMcp = state.tools.filter((t) => !t.name.startsWith("mcp_"))
1337
- const newMcpTools = mcpTools()
1338
- state.tools = [...nonMcp, ...newMcpTools]
1339
- } finally {
1340
- state.refreshing = false
1341
- }
1342
- }
1343
- }
1
+ import path from "node:path"
2
+ import os from "node:os"
3
+ import { readdir, readFile } from "node:fs/promises"
4
+ import { access, stat, statfs, unlink } from "node:fs/promises"
5
+ import { exec as execCb, spawn } from "node:child_process"
6
+ import { promisify } from "node:util"
7
+ import { pathToFileURL } from "node:url"
8
+ import { atomicWriteFile, replaceInFileTransactional, replaceAllInFileTransactional, diffLineCount, buildStructuredPatch } from "./edit-transaction.mjs"
9
+ import { withFileLock } from "./file-lock-manager.mjs"
10
+ import { BackgroundManager } from "../orchestration/background-manager.mjs"
11
+ import { createTaskTool } from "./task-tool.mjs"
12
+ import { McpRegistry } from "../mcp/registry.mjs"
13
+ import { SkillRegistry } from "../skill/registry.mjs"
14
+ import { askQuestionInteractive } from "./question-prompt.mjs"
15
+ import { checkBashAllowed } from "../permission/exec-policy.mjs"
16
+ import { gitAutoTools } from "./git-auto.mjs"
17
+ import { gitFullAutoTools } from "./git-full-auto.mjs"
18
+ import { markFileRead, refreshFileReadStateFromDisk } from "./file-read-state.mjs"
19
+ import { validateExistingFileMutation } from "./mutation-guard.mjs"
20
+ import { buildMutationObservability } from "../observability/edit-diagnostics.mjs"
21
+
22
+ const exec = promisify(execCb)
23
+
24
+ const state = {
25
+ initialized: false,
26
+ tools: [],
27
+ loadedAt: 0,
28
+ lastSignature: "",
29
+ lastCwd: "",
30
+ lastConfig: null,
31
+ refreshing: false
32
+ }
33
+
34
+ function schema(type, description) {
35
+ return { type, description }
36
+ }
37
+
38
+ function safeStringify(value) {
39
+ if (typeof value === "string") return value
40
+ return JSON.stringify(value, null, 2)
41
+ }
42
+
43
+ function signatureFor(config = {}, cwd = process.cwd()) {
44
+ const payload = {
45
+ cwd,
46
+ tool: config.tool || {},
47
+ mcp: config.mcp || {},
48
+ runtime: config.runtime || {}
49
+ }
50
+ return JSON.stringify(payload)
51
+ }
52
+
53
+ async function exists(target) {
54
+ try {
55
+ await access(target)
56
+ return true
57
+ } catch {
58
+ return false
59
+ }
60
+ }
61
+
62
+ async function listDir(dir) {
63
+ const items = await readdir(dir, { withFileTypes: true })
64
+ return items.map((item) => `${item.isDirectory() ? "d" : "f"} ${item.name}`).join("\n")
65
+ }
66
+
67
+ function formatBytes(bytes) {
68
+ const value = Number(bytes || 0)
69
+ if (!Number.isFinite(value) || value <= 0) return "0 B"
70
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"]
71
+ let size = value
72
+ let unitIndex = 0
73
+ while (size >= 1024 && unitIndex < units.length - 1) {
74
+ size /= 1024
75
+ unitIndex += 1
76
+ }
77
+ const decimals = size >= 10 || unitIndex === 0 ? 0 : 1
78
+ return `${size.toFixed(decimals)} ${units[unitIndex]}`
79
+ }
80
+
81
+ function detectShellInfo() {
82
+ if (process.platform === "win32") {
83
+ return process.env.ComSpec || process.env.SHELL || "powershell/cmd"
84
+ }
85
+ return process.env.SHELL || "/bin/sh"
86
+ }
87
+
88
+ async function detectGitRepo(cwd) {
89
+ try {
90
+ await exec("git rev-parse --is-inside-work-tree", { cwd, timeout: 3000 })
91
+ return true
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ async function detectPackageManagers(cwd) {
98
+ const candidates = [
99
+ ["npm", "package-lock.json"],
100
+ ["pnpm", "pnpm-lock.yaml"],
101
+ ["yarn", "yarn.lock"],
102
+ ["bun", "bun.lockb"]
103
+ ]
104
+ const present = []
105
+ for (const [name, file] of candidates) {
106
+ if (await exists(path.join(cwd, file))) present.push(name)
107
+ }
108
+ return present
109
+ }
110
+
111
+ function assertWithinCwd(resolved, cwd) {
112
+ const normalCwd = path.resolve(cwd)
113
+ if (!resolved.startsWith(normalCwd + path.sep) && resolved !== normalCwd) {
114
+ throw new Error(`path traversal blocked: ${resolved} is outside working directory`)
115
+ }
116
+ }
117
+
118
+ function runRg(args, cwd, timeoutMs = 30000) {
119
+ return new Promise((resolve) => {
120
+ let stdout = "", stderr = "", done = false
121
+ const child = spawn("rg", ["--no-config", ...args], {
122
+ cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"]
123
+ })
124
+ const timer = setTimeout(() => {
125
+ if (done) return
126
+ done = true
127
+ child.kill("SIGTERM")
128
+ setTimeout(() => { try { child.kill("SIGKILL") } catch {} }, 2000).unref()
129
+ resolve({ ok: false, stdout, stderr: "search timed out" })
130
+ }, timeoutMs)
131
+ child.stdout.on("data", (b) => { stdout += b })
132
+ child.stderr.on("data", (b) => { stderr += b })
133
+ child.on("error", (e) => {
134
+ if (done) return; done = true; clearTimeout(timer)
135
+ resolve({ ok: false, stdout, stderr: e.message })
136
+ })
137
+ child.on("close", (code) => {
138
+ if (done) return; done = true; clearTimeout(timer)
139
+ resolve({ ok: code === 0 || code === 1, stdout: stdout.trim(), stderr: stderr.trim() })
140
+ })
141
+ })
142
+ }
143
+
144
+ async function runGlob(pattern, cwd, searchPath) {
145
+ if (!pattern) return "pattern is required"
146
+ const target = searchPath ? path.resolve(cwd, searchPath) : "."
147
+ const { stdout } = await runRg(["--files", "--glob", pattern, target], cwd, 15000)
148
+ const text = stdout.trim()
149
+ if (!text) return "no files matched"
150
+ const lines = text.split("\n").filter(Boolean)
151
+ if (lines.length > 200) {
152
+ return lines.slice(0, 200).join("\n") + `\n... (+${lines.length - 200} more files)`
153
+ }
154
+ return `${lines.length} file(s):\n${text}`
155
+ }
156
+
157
+ async function runGrep(pattern, cwd, options = {}) {
158
+ if (!pattern) return "pattern is required"
159
+ const args = []
160
+ if (options.multiline) args.push("-U", "--multiline-dotall")
161
+ if (options.outputMode === "count") args.push("-c")
162
+ else if (options.outputMode === "files") args.push("-l")
163
+ else args.push("-n")
164
+ if (options.beforeContext) args.push("-B", String(options.beforeContext))
165
+ if (options.afterContext) args.push("-A", String(options.afterContext))
166
+ if (options.context) args.push("-C", String(options.context))
167
+ if (options.type) args.push("--type", options.type)
168
+ if (options.glob) args.push("--glob", options.glob)
169
+ if (options.maxCount) args.push("-m", String(options.maxCount))
170
+ if (options.ignoreCase) args.push("-i")
171
+ args.push(pattern)
172
+ args.push(options.path ? path.resolve(cwd, options.path) : ".")
173
+ const { stdout, stderr } = await runRg(args, cwd)
174
+ let text = stdout.trim()
175
+ if (!text && stderr) text = `[search error] ${stderr}`
176
+ if (text && (options.offset || options.headLimit)) {
177
+ const lines = text.split("\n")
178
+ const start = options.offset || 0
179
+ const limit = options.headLimit || lines.length
180
+ text = lines.slice(start, start + limit).join("\n")
181
+ }
182
+ return text || "no matches"
183
+ }
184
+
185
+ const LONG_RUNNING_PATTERNS = [
186
+ /\bnpm\s+run\s+dev\b/i,
187
+ /\bnpm\s+run\s+start\b/i,
188
+ /\bnpm\s+start\b/i,
189
+ /\byarn\s+dev\b/i,
190
+ /\byarn\s+start\b/i,
191
+ /\bpnpm\s+dev\b/i,
192
+ /\bpnpm\s+start\b/i,
193
+ /\bnpx\s+vite\b/i,
194
+ /\bnpx\s+next\s+dev\b/i,
195
+ /\bnpx\s+serve\b/i,
196
+ /\bnode\s+.*server/i,
197
+ /\bwebpack\s+serve\b/i,
198
+ /\bwebpack\s+--watch\b/i,
199
+ /\bjest\s+--watch\b/i,
200
+ /\bvitest(?!\s+--run)\b.*(?!--run)/i,
201
+ /\bnodemon\b/i,
202
+ /\btsc\s+--watch\b/i,
203
+ /\btailwindcss\s+--watch\b/i,
204
+ /\bnpm\s+run\s+serve\b/i,
205
+ /\bnpm\s+run\s+watch\b/i
206
+ ]
207
+
208
+ const BASH_TIMEOUT_MS = 120_000
209
+ const IS_WIN = process.platform === "win32"
210
+ function wrapCmd(cmd) { return IS_WIN ? `chcp 65001 >nul & ${cmd}` : cmd }
211
+
212
+ function isLongRunningCommand(command) {
213
+ const cmd = String(command || "").trim()
214
+ return LONG_RUNNING_PATTERNS.some((re) => re.test(cmd))
215
+ }
216
+
217
+ async function runBash(command, cwd, timeoutMs = BASH_TIMEOUT_MS) {
218
+ if (isLongRunningCommand(command)) {
219
+ return `[blocked] "${command}" looks like a long-running/dev-server command that would block execution. Please tell the user to run it manually in their terminal, or use run_in_background: true.`
220
+ }
221
+ const out = await exec(wrapCmd(command), { cwd, timeout: timeoutMs, encoding: "utf8" }).catch((error) => {
222
+ if (error.killed || error.signal === "SIGTERM") {
223
+ return {
224
+ stdout: error.stdout ?? "",
225
+ stderr: `${error.stderr || ""}\n[timeout] command killed after ${timeoutMs / 1000}s`
226
+ }
227
+ }
228
+ return {
229
+ stdout: error.stdout ?? "",
230
+ stderr: error.stderr ?? error.message
231
+ }
232
+ })
233
+ const raw = `${out.stdout || ""}${out.stderr || ""}`.trim() || "(empty output)"
234
+ if (raw.length > 30000) return raw.slice(0, 30000) + `\n\n[truncated] output exceeded 30000 chars (total: ${raw.length})`
235
+ return raw
236
+ }
237
+
238
+ function lockOptions(ctx = {}) {
239
+ const mode = String(ctx?.config?.tool?.write_lock?.mode || "file_lock")
240
+ const waitTimeoutMs = Math.max(0, Number(ctx?.config?.tool?.write_lock?.wait_timeout_ms || 120000))
241
+ const owner = String(ctx?.taskId || ctx?.sessionId || ctx?.turnId || "kkcode")
242
+ return { mode, waitTimeoutMs, owner }
243
+ }
244
+
245
+ function mutationMetadata({
246
+ operation,
247
+ filePath,
248
+ originalContent = null,
249
+ updatedContent = null,
250
+ structuredPatch = [],
251
+ addedLines = 0,
252
+ removedLines = 0,
253
+ stageId = null,
254
+ taskId = null
255
+ }) {
256
+ return {
257
+ fileChanges: [{
258
+ path: filePath,
259
+ tool: operation,
260
+ addedLines,
261
+ removedLines,
262
+ stageId,
263
+ taskId
264
+ }],
265
+ mutation: {
266
+ operation,
267
+ filePath,
268
+ originalContent,
269
+ updatedContent,
270
+ structuredPatch,
271
+ addedLines,
272
+ removedLines
273
+ },
274
+ observability: buildMutationObservability({
275
+ fileChanges: [{
276
+ path: filePath,
277
+ tool: operation,
278
+ addedLines,
279
+ removedLines,
280
+ stageId,
281
+ taskId
282
+ }],
283
+ mutation: {
284
+ operation,
285
+ filePath,
286
+ originalContent,
287
+ updatedContent,
288
+ structuredPatch,
289
+ addedLines,
290
+ removedLines
291
+ }
292
+ })
293
+ }
294
+ }
295
+
296
+ async function loadDynamicTools(dirs) {
297
+ const loaded = []
298
+ for (const dir of dirs) {
299
+ const absolute = path.resolve(dir)
300
+ if (!(await exists(absolute))) continue
301
+ const entries = await readdir(absolute, { withFileTypes: true })
302
+ for (const entry of entries) {
303
+ if (!entry.isFile()) continue
304
+ if (![".mjs", ".js"].includes(path.extname(entry.name).toLowerCase())) continue
305
+ const file = path.join(absolute, entry.name)
306
+ try {
307
+ const mod = await import(pathToFileURL(file).href)
308
+ const def = mod.default || mod.tool || mod
309
+ if (!def || typeof def !== "object" || typeof def.name !== "string" || typeof def.execute !== "function") {
310
+ continue
311
+ }
312
+ loaded.push({
313
+ name: def.name,
314
+ description: def.description || `dynamic tool from ${file}`,
315
+ inputSchema: def.inputSchema || { type: "object", properties: {}, required: [] },
316
+ execute: def.execute
317
+ })
318
+ } catch {
319
+ // ignore invalid tool module
320
+ }
321
+ }
322
+ }
323
+ return loaded
324
+ }
325
+
326
+ function builtinTools(config) {
327
+ const listTool = {
328
+ name: "list",
329
+ description: "List files and subdirectories in a directory. Returns entry names with type prefix (d=directory, f=file). Use this for quick directory overview; use `glob` for recursive pattern matching.",
330
+ inputSchema: {
331
+ type: "object",
332
+ properties: { path: schema("string", "directory path") },
333
+ required: []
334
+ },
335
+ async execute(args, ctx) {
336
+ const target = path.resolve(ctx.cwd, args.path || ".")
337
+ return listDir(target)
338
+ }
339
+ }
340
+
341
+ const sysinfoTool = {
342
+ name: "sysinfo",
343
+ description: "Return structured, read-only system and runtime information for the current machine/workspace. Good for OS/runtime/workspace/cpu/memory/disk summaries without relying on raw shell output.",
344
+ inputSchema: {
345
+ type: "object",
346
+ properties: {
347
+ sections: {
348
+ type: "array",
349
+ description: "optional sections to return: os, runtime, workspace, cpu, memory, disk",
350
+ items: { type: "string" }
351
+ },
352
+ path: schema("string", "optional workspace path for disk/workspace inspection (default: cwd)")
353
+ },
354
+ required: []
355
+ },
356
+ async execute(args, ctx) {
357
+ const targetPath = path.resolve(ctx.cwd, String(args.path || "."))
358
+ assertWithinCwd(targetPath, ctx.cwd)
359
+ const requestedSections = Array.isArray(args.sections) && args.sections.length
360
+ ? args.sections.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean)
361
+ : ["os", "runtime", "workspace", "cpu", "memory", "disk"]
362
+ const sectionSet = new Set(requestedSections)
363
+
364
+ const result = {
365
+ generatedAt: new Date().toISOString(),
366
+ path: targetPath,
367
+ sections: {}
368
+ }
369
+
370
+ if (sectionSet.has("os")) {
371
+ result.sections.os = {
372
+ platform: process.platform,
373
+ arch: process.arch,
374
+ hostname: os.hostname(),
375
+ release: os.release(),
376
+ version: typeof os.version === "function" ? os.version() : null
377
+ }
378
+ }
379
+
380
+ if (sectionSet.has("runtime")) {
381
+ result.sections.runtime = {
382
+ nodeVersion: process.version,
383
+ shell: detectShellInfo(),
384
+ pid: process.pid,
385
+ uptimeSeconds: Math.round(process.uptime()),
386
+ uptimeHuman: `${Math.round(process.uptime())}s`
387
+ }
388
+ }
389
+
390
+ if (sectionSet.has("workspace")) {
391
+ const packageManagers = await detectPackageManagers(targetPath)
392
+ result.sections.workspace = {
393
+ cwd: targetPath,
394
+ isGitRepo: await detectGitRepo(targetPath),
395
+ packageManagers,
396
+ hasPackageJson: await exists(path.join(targetPath, "package.json")),
397
+ hasNodeModules: await exists(path.join(targetPath, "node_modules"))
398
+ }
399
+ }
400
+
401
+ if (sectionSet.has("cpu")) {
402
+ const cpus = os.cpus() || []
403
+ result.sections.cpu = {
404
+ cores: cpus.length,
405
+ model: cpus[0]?.model || null,
406
+ loadAverage: typeof os.loadavg === "function" ? os.loadavg() : []
407
+ }
408
+ }
409
+
410
+ if (sectionSet.has("memory")) {
411
+ const total = os.totalmem()
412
+ const free = os.freemem()
413
+ result.sections.memory = {
414
+ totalBytes: total,
415
+ freeBytes: free,
416
+ usedBytes: Math.max(0, total - free),
417
+ total: formatBytes(total),
418
+ free: formatBytes(free),
419
+ used: formatBytes(Math.max(0, total - free))
420
+ }
421
+ }
422
+
423
+ if (sectionSet.has("disk")) {
424
+ try {
425
+ const disk = await statfs(targetPath)
426
+ const blockSize = Number(disk.bsize || disk.frsize || 0)
427
+ const totalBytes = Number(disk.blocks || 0) * blockSize
428
+ const freeBytes = Number(disk.bavail || disk.bfree || 0) * blockSize
429
+ result.sections.disk = {
430
+ path: targetPath,
431
+ totalBytes,
432
+ freeBytes,
433
+ usedBytes: Math.max(0, totalBytes - freeBytes),
434
+ total: formatBytes(totalBytes),
435
+ free: formatBytes(freeBytes),
436
+ used: formatBytes(Math.max(0, totalBytes - freeBytes))
437
+ }
438
+ } catch (error) {
439
+ result.sections.disk = {
440
+ path: targetPath,
441
+ error: error.message
442
+ }
443
+ }
444
+ }
445
+
446
+ const summaryParts = []
447
+ if (result.sections.os) summaryParts.push(`${result.sections.os.platform}/${result.sections.os.arch}`)
448
+ if (result.sections.runtime) summaryParts.push(`node ${result.sections.runtime.nodeVersion}`)
449
+ if (result.sections.workspace) summaryParts.push(result.sections.workspace.isGitRepo ? "git repo" : "non-git cwd")
450
+ if (result.sections.memory) summaryParts.push(`mem ${result.sections.memory.used}/${result.sections.memory.total}`)
451
+ if (result.sections.disk?.total) summaryParts.push(`disk ${result.sections.disk.used}/${result.sections.disk.total}`)
452
+ result.summary = summaryParts.join(" · ")
453
+
454
+ return result
455
+ }
456
+ }
457
+
458
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"])
459
+ const IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon" }
460
+
461
+ function readNotebook(raw) {
462
+ const notebook = JSON.parse(raw)
463
+ if (!notebook.cells || !Array.isArray(notebook.cells)) return "Not a valid .ipynb file (missing cells array)"
464
+ const lines = []
465
+ notebook.cells.forEach((cell, i) => {
466
+ const type = cell.cell_type || "unknown"
467
+ lines.push(`--- Cell ${i} [${type}] ---`)
468
+ const source = Array.isArray(cell.source) ? cell.source.join("") : String(cell.source || "")
469
+ lines.push(source)
470
+ if (cell.outputs && cell.outputs.length > 0) {
471
+ lines.push("[Output]:")
472
+ for (const out of cell.outputs) {
473
+ if (out.text) lines.push(Array.isArray(out.text) ? out.text.join("") : String(out.text))
474
+ else if (out.data?.["text/plain"]) {
475
+ const plain = out.data["text/plain"]
476
+ lines.push(Array.isArray(plain) ? plain.join("") : String(plain))
477
+ }
478
+ }
479
+ }
480
+ lines.push("")
481
+ })
482
+ return lines.join("\n")
483
+ }
484
+
485
+ function extractPdfText(buffer) {
486
+ // Basic PDF text extraction: find text between BT/ET operators and parenthesized strings
487
+ const str = buffer.toString("latin1")
488
+ const texts = []
489
+ const tjRegex = /\(([^)]*)\)/g
490
+ // Extract strings from content streams
491
+ let match
492
+ while ((match = tjRegex.exec(str)) !== null) {
493
+ const decoded = match[1]
494
+ .replace(/\\n/g, "\n").replace(/\\r/g, "\r")
495
+ .replace(/\\t/g, "\t").replace(/\\\\/g, "\\")
496
+ .replace(/\\([()])/g, "$1")
497
+ if (decoded.trim()) texts.push(decoded)
498
+ }
499
+ if (texts.length === 0) return "(PDF contains no extractable text — may be image-based or encrypted)"
500
+ return texts.join(" ").replace(/\s+/g, " ").trim()
501
+ }
502
+
503
+ const readTool = {
504
+ name: "read",
505
+ description: "Read file content with line numbers. Supports text files, images (PNG/JPG/GIF/SVG/WebP/BMP/ICO as base64), PDF (text extraction), and Jupyter notebooks (.ipynb cell parsing). Use `offset` and `limit` to read specific line ranges. ALWAYS use this instead of `bash` with cat/head/tail. Existing-file write/edit/patch/notebookedit flows require a recent read first.",
506
+ inputSchema: {
507
+ type: "object",
508
+ properties: {
509
+ path: schema("string", "file path"),
510
+ offset: schema("number", "start line number (1-based, optional)"),
511
+ limit: schema("number", "max lines to return (optional)"),
512
+ encoding: schema("string", "file encoding (default: utf8)"),
513
+ pages: schema("string", "page range for PDF files, e.g. '1-5' (optional)")
514
+ },
515
+ required: ["path"]
516
+ },
517
+ async execute(args, ctx) {
518
+ const target = path.resolve(ctx.cwd, args.path)
519
+ assertWithinCwd(target, ctx.cwd)
520
+ const ext = path.extname(target).toLowerCase()
521
+
522
+ // Image files: return base64 data URI
523
+ if (IMAGE_EXTENSIONS.has(ext)) {
524
+ const buffer = await readFile(target)
525
+ const base64 = buffer.toString("base64")
526
+ const mime = IMAGE_MIME[ext] || "application/octet-stream"
527
+ return {
528
+ type: "image",
529
+ output: `Image file: ${args.path} (${buffer.length} bytes, ${mime})`,
530
+ data: `data:${mime};base64,${base64}`
531
+ }
532
+ }
533
+
534
+ // PDF files: extract text
535
+ if (ext === ".pdf") {
536
+ const buffer = await readFile(target)
537
+ return extractPdfText(buffer)
538
+ }
539
+
540
+ // Jupyter notebooks: parse cells
541
+ if (ext === ".ipynb") {
542
+ const raw = await readFile(target, "utf8")
543
+ const fileStat = await stat(target)
544
+ markFileRead(target, {
545
+ content: raw,
546
+ timestamp: fileStat.mtimeMs,
547
+ isPartialView: false
548
+ })
549
+ return readNotebook(raw)
550
+ }
551
+
552
+ // Default: text file with line numbers
553
+ const encoding = args.encoding || "utf8"
554
+ const content = await readFile(target, encoding)
555
+ const fileStat = await stat(target)
556
+ const allLines = content.split("\n")
557
+ const start = Math.max(0, (Number(args.offset) || 1) - 1)
558
+ const count = Number(args.limit) || Math.min(allLines.length, 2000)
559
+ const slice = allLines.slice(start, start + count)
560
+ const isPartialView = start > 0 || start + count < allLines.length
561
+ markFileRead(target, {
562
+ content: isPartialView ? slice.join("\n") : content,
563
+ timestamp: fileStat.mtimeMs,
564
+ offset: isPartialView ? start + 1 : undefined,
565
+ limit: isPartialView ? count : undefined,
566
+ isPartialView
567
+ })
568
+ const numbered = slice.map((line, i) => {
569
+ const num = String(start + i + 1).padStart(6)
570
+ const truncated = line.length > 2000 ? line.slice(0, 2000) + "... (truncated)" : line
571
+ return `${num}→${truncated}`
572
+ })
573
+ return numbered.join("\n")
574
+ }
575
+ }
576
+
577
+ const writeTool = {
578
+ name: "write",
579
+ description: "Create or overwrite a file atomically. Auto-creates parent directories. Supports three modes: 'overwrite' (default, full replacement), 'append' (add to end of file), 'insert' (insert at a specific line). Existing-file writes require a recent full read first. For large files (200+ lines), use mode='append' to build incrementally across multiple calls to avoid output truncation. Use `edit` instead when only a small part of an existing file needs to change.",
580
+ inputSchema: {
581
+ type: "object",
582
+ properties: {
583
+ path: schema("string", "file path"),
584
+ content: schema("string", "file content to write"),
585
+ mode: schema("string", "write mode: 'overwrite' (default), 'append' (add to end), 'insert' (insert at line number)"),
586
+ insert_at_line: schema("number", "1-based line number for insert mode. Content is inserted BEFORE this line.")
587
+ },
588
+ required: ["path", "content"]
589
+ },
590
+ async execute(args, ctx) {
591
+ const target = path.resolve(ctx.cwd, args.path)
592
+ assertWithinCwd(target, ctx.cwd)
593
+ const content = String(args.content ?? "")
594
+ const mode = String(args.mode || "overwrite")
595
+
596
+ // Guard: detect empty/parse-error writes that would destroy existing content
597
+ if (args.__parse_error) {
598
+ return {
599
+ output: `error: tool call arguments were corrupted (JSON parse failed). The write was NOT executed. This usually means the response was truncated — try using write with mode="append" to build the file incrementally.`,
600
+ metadata: { blocked: true, reason: "parse_error" }
601
+ }
602
+ }
603
+ if (!content && !args.content && mode === "overwrite") {
604
+ return {
605
+ output: `error: content is empty or missing. The write was NOT executed. If you intended to create an empty file, pass content as an empty string explicitly.`,
606
+ metadata: { blocked: true, reason: "empty_content" }
607
+ }
608
+ }
609
+
610
+ if (await exists(target)) {
611
+ const validation = await validateExistingFileMutation({
612
+ targetPath: target,
613
+ displayPath: String(args.path || target),
614
+ operation: "writing to it",
615
+ requireFullRead: true
616
+ })
617
+ if (!validation.ok) {
618
+ return {
619
+ output: validation.message,
620
+ metadata: { blocked: true, reason: validation.reason, fileChanges: [] }
621
+ }
622
+ }
623
+ }
624
+
625
+ let previous = ""
626
+ const options = lockOptions(ctx)
627
+
628
+ const runWrite = async () => {
629
+ try {
630
+ previous = await readFile(target, "utf8")
631
+ } catch {
632
+ previous = ""
633
+ }
634
+
635
+ if (mode === "append") {
636
+ const separator = previous && !previous.endsWith("\n") ? "\n" : ""
637
+ await atomicWriteFile(target, previous + separator + content)
638
+ } else if (mode === "insert") {
639
+ const lineNum = Math.max(1, Number(args.insert_at_line) || 1)
640
+ const lines = previous ? previous.split("\n") : []
641
+ const insertIdx = Math.min(lineNum - 1, lines.length)
642
+ const newLines = content.split("\n")
643
+ lines.splice(insertIdx, 0, ...newLines)
644
+ await atomicWriteFile(target, lines.join("\n"))
645
+ } else {
646
+ // overwrite (default)
647
+ await atomicWriteFile(target, content)
648
+ }
649
+ }
650
+
651
+ if (options.mode === "file_lock") {
652
+ await withFileLock({
653
+ targetPath: target,
654
+ owner: options.owner,
655
+ waitTimeoutMs: options.waitTimeoutMs,
656
+ run: runWrite
657
+ })
658
+ } else {
659
+ await runWrite()
660
+ }
661
+
662
+ let finalContent
663
+ try { finalContent = await readFile(target, "utf8") } catch { finalContent = content }
664
+ await refreshFileReadStateFromDisk(target, { content: finalContent }).catch(() => {})
665
+ const diff = diffLineCount(previous, finalContent)
666
+ const modeLabel = mode === "append" ? "appended" : mode === "insert" ? "inserted" : "written"
667
+ return {
668
+ output: `${modeLabel}: ${target}`,
669
+ metadata: mutationMetadata({
670
+ operation: "write",
671
+ filePath: String(args.path || target),
672
+ originalContent: previous,
673
+ updatedContent: finalContent,
674
+ structuredPatch: buildStructuredPatch(previous, finalContent),
675
+ addedLines: diff.added,
676
+ removedLines: diff.removed,
677
+ stageId: ctx.stageId || null,
678
+ taskId: ctx.logicalTaskId || ctx.taskId || null
679
+ })
680
+ }
681
+ }
682
+ }
683
+
684
+ const editTool = {
685
+ name: "edit",
686
+ description: "Replace a specific text snippet in an existing file. Transactional with automatic rollback on failure. You MUST `read` the file first — edits on unread or stale files are rejected. Provide enough surrounding context in `before` to ensure a unique match. Set `replace_all: true` to replace ALL occurrences.",
687
+ inputSchema: {
688
+ type: "object",
689
+ properties: {
690
+ path: schema("string", "file path"),
691
+ before: schema("string", "target snippet"),
692
+ after: schema("string", "replacement snippet"),
693
+ replace_all: schema("boolean", "replace all occurrences instead of requiring unique match (default: false)")
694
+ },
695
+ required: ["path", "before", "after"]
696
+ },
697
+ async execute(args, ctx) {
698
+ const target = path.resolve(ctx.cwd, args.path)
699
+ assertWithinCwd(target, ctx.cwd)
700
+ if (await exists(target)) {
701
+ const validation = await validateExistingFileMutation({
702
+ targetPath: target,
703
+ displayPath: String(args.path || target),
704
+ operation: "editing it"
705
+ })
706
+ if (!validation.ok) {
707
+ return {
708
+ output: validation.message,
709
+ metadata: { blocked: true, reason: validation.reason, fileChanges: [] }
710
+ }
711
+ }
712
+ }
713
+ const options = lockOptions(ctx)
714
+ const runEdit = async () =>
715
+ args.replace_all
716
+ ? replaceAllInFileTransactional(target, String(args.before), String(args.after))
717
+ : replaceInFileTransactional(target, String(args.before), String(args.after))
718
+ const result = options.mode === "file_lock"
719
+ ? await withFileLock({
720
+ targetPath: target,
721
+ owner: options.owner,
722
+ waitTimeoutMs: options.waitTimeoutMs,
723
+ run: runEdit
724
+ })
725
+ : await runEdit()
726
+ const updatedContent = await readFile(target, "utf8").catch(() => null)
727
+ await refreshFileReadStateFromDisk(target, { content: updatedContent ?? undefined }).catch(() => {})
728
+ return {
729
+ output: result.output,
730
+ metadata: mutationMetadata({
731
+ operation: "edit",
732
+ filePath: String(args.path || target),
733
+ originalContent: String(args.before),
734
+ updatedContent: String(args.after),
735
+ structuredPatch: buildStructuredPatch(String(args.before), String(args.after)),
736
+ addedLines: Number(result.addedLines || 0),
737
+ removedLines: Number(result.removedLines || 0),
738
+ stageId: ctx.stageId || null,
739
+ taskId: ctx.logicalTaskId || ctx.taskId || null
740
+ })
741
+ }
742
+ }
743
+ }
744
+
745
+ const globTool = {
746
+ name: "glob",
747
+ description: "Find files by glob pattern recursively. Use this instead of `bash` with find/ls. Optionally specify a `path` to search within a specific directory. Returns up to 200 matching file paths.",
748
+ inputSchema: {
749
+ type: "object",
750
+ properties: {
751
+ pattern: schema("string", "glob pattern, e.g. **/*.mjs, src/**/*.ts"),
752
+ path: schema("string", "directory to search in (default: cwd)")
753
+ },
754
+ required: ["pattern"]
755
+ },
756
+ async execute(args, ctx) {
757
+ return runGlob(String(args.pattern || ""), ctx.cwd, args.path || null)
758
+ }
759
+ }
760
+
761
+ const grepTool = {
762
+ name: "grep",
763
+ description: "Search file contents by regex pattern. Use this instead of `bash` with grep/rg. Supports searching within a specific file or directory via `path`, output modes (content/files/count), multiline matching, context lines, and pagination.",
764
+ inputSchema: {
765
+ type: "object",
766
+ properties: {
767
+ pattern: schema("string", "regex or string pattern"),
768
+ path: schema("string", "file or directory to search in (default: cwd). Use this to search within a specific file."),
769
+ output_mode: schema("string", "output mode: 'content' (lines with numbers), 'files' (file paths only, default), 'count' (match counts per file)"),
770
+ type: schema("string", "file type filter, e.g. js, ts, py (optional)"),
771
+ glob: schema("string", "glob filter, e.g. *.mjs, src/**/*.ts (optional)"),
772
+ maxCount: schema("number", "max matches per file (optional)"),
773
+ context: schema("number", "lines of context around match, -C (optional)"),
774
+ before_context: schema("number", "lines before each match, -B (optional)"),
775
+ after_context: schema("number", "lines after each match, -A (optional)"),
776
+ ignoreCase: schema("boolean", "case insensitive search (optional)"),
777
+ multiline: schema("boolean", "enable cross-line matching (optional)"),
778
+ head_limit: schema("number", "limit output to first N lines/entries (optional)"),
779
+ offset: schema("number", "skip first N lines/entries before head_limit (optional)")
780
+ },
781
+ required: ["pattern"]
782
+ },
783
+ async execute(args, ctx) {
784
+ return runGrep(String(args.pattern || ""), ctx.cwd, {
785
+ path: args.path || null,
786
+ outputMode: args.output_mode || "files",
787
+ type: args.type || null,
788
+ glob: args.glob || null,
789
+ maxCount: args.maxCount || null,
790
+ context: args.context || null,
791
+ beforeContext: args.before_context || null,
792
+ afterContext: args.after_context || null,
793
+ ignoreCase: !!args.ignoreCase,
794
+ multiline: !!args.multiline,
795
+ headLimit: args.head_limit || null,
796
+ offset: args.offset || null
797
+ })
798
+ }
799
+ }
800
+
801
+ const bashTool = {
802
+ name: "bash",
803
+ description: "Run a shell command in cwd. ONLY use for commands that have no dedicated tool (e.g. git, npm, pip, docker). Do NOT use for: reading files (use `read`), searching files (use `grep`/`glob`), writing files (use `write`/`edit`). Long-running commands are blocked unless run_in_background is true.",
804
+ inputSchema: {
805
+ type: "object",
806
+ properties: {
807
+ command: schema("string", "shell command"),
808
+ timeout: schema("number", "timeout in ms (default 120000, max 600000)"),
809
+ description: schema("string", "human-readable description of what this command does (optional)"),
810
+ run_in_background: schema("boolean", "run as background task, returns task_id immediately (optional)")
811
+ },
812
+ required: ["command"]
813
+ },
814
+ async execute(args, ctx) {
815
+ const command = String(args.command || "")
816
+ const configBashTimeout = Number(ctx.config?.tool?.bash_timeout_ms || BASH_TIMEOUT_MS)
817
+ const timeoutMs = Math.min(Math.max(Number(args.timeout) || configBashTimeout, 1000), 600_000)
818
+
819
+ // 执行策略检查
820
+ const policyCheck = checkBashAllowed(command, ctx.config)
821
+ if (!policyCheck.allowed) {
822
+ return {
823
+ ok: false,
824
+ blocked: true,
825
+ error: "execution_policy_violation",
826
+ message: policyCheck.reason,
827
+ suggestion: "Use git_snapshot to create temporary snapshots, then manually commit when satisfied."
828
+ }
829
+ }
830
+
831
+ if (args.run_in_background) {
832
+ if (isLongRunningCommand(command)) {
833
+ return `[blocked] "${command}" appears to be a long-running command. Run it manually in your terminal.`
834
+ }
835
+ // Launch as background task
836
+ const task = await BackgroundManager.launch({
837
+ description: args.description || command,
838
+ payload: { command, cwd: ctx.cwd },
839
+ run: async () => {
840
+ const out = await exec(wrapCmd(command), { cwd: ctx.cwd, timeout: 600_000, encoding: "utf8" })
841
+ .catch(e => ({ stdout: e.stdout ?? "", stderr: e.stderr ?? e.message }))
842
+ return `${out.stdout || ""}${out.stderr || ""}`.trim() || "(empty output)"
843
+ },
844
+ config: ctx.config
845
+ })
846
+ return `background task launched: ${task.id}\nUse background_output to check results.`
847
+ }
848
+
849
+ return runBash(command, ctx.cwd, timeoutMs)
850
+ }
851
+ }
852
+
853
+ const outputTool = {
854
+ name: "background_output",
855
+ description: "Retrieve status, logs, and result of a background task launched via `task` with `run_in_background: true`. Returns the task object including status and output.",
856
+ inputSchema: {
857
+ type: "object",
858
+ properties: {
859
+ task_id: schema("string", "background task id")
860
+ },
861
+ required: ["task_id"]
862
+ },
863
+ async execute(args) {
864
+ const task = await BackgroundManager.get(String(args.task_id || ""))
865
+ if (!task) return "background task not found"
866
+ return {
867
+ ...BackgroundManager.summarize(task),
868
+ result: task.result,
869
+ error: task.error || null
870
+ }
871
+ }
872
+ }
873
+
874
+ const taskListTool = {
875
+ name: "task_list",
876
+ description: "List delegated background tasks with concise lifecycle summaries.",
877
+ inputSchema: { type: "object", properties: {}, required: [] },
878
+ async execute() {
879
+ const tasks = await BackgroundManager.list()
880
+ return tasks.map((task) => BackgroundManager.summarize(task))
881
+ }
882
+ }
883
+
884
+ const taskGetTool = {
885
+ name: "task_get",
886
+ description: "Retrieve one delegated background task summary and result payload by task_id.",
887
+ inputSchema: {
888
+ type: "object",
889
+ properties: {
890
+ task_id: schema("string", "background task id")
891
+ },
892
+ required: ["task_id"]
893
+ },
894
+ async execute(args) {
895
+ const task = await BackgroundManager.get(String(args.task_id || ""))
896
+ if (!task) return "background task not found"
897
+ return {
898
+ ...BackgroundManager.summarize(task),
899
+ result: task.result,
900
+ error: task.error || null
901
+ }
902
+ }
903
+ }
904
+
905
+ const taskStopTool = {
906
+ name: "task_stop",
907
+ description: "Cancel a delegated background task by task_id.",
908
+ inputSchema: {
909
+ type: "object",
910
+ properties: {
911
+ task_id: schema("string", "background task id")
912
+ },
913
+ required: ["task_id"]
914
+ },
915
+ async execute(args) {
916
+ const ok = await BackgroundManager.cancel(String(args.task_id || ""))
917
+ return ok ? "cancel requested" : "background task not found"
918
+ }
919
+ }
920
+
921
+ const taskOutputTool = {
922
+ name: "task_output",
923
+ description: "Retrieve delegated background task output with summary, result payload, and next-action guidance.",
924
+ inputSchema: {
925
+ type: "object",
926
+ properties: {
927
+ task_id: schema("string", "background task id")
928
+ },
929
+ required: ["task_id"]
930
+ },
931
+ async execute(args) {
932
+ const task = await BackgroundManager.get(String(args.task_id || ""))
933
+ if (!task) return "background task not found"
934
+ return {
935
+ ...BackgroundManager.summarize(task),
936
+ result: task.result,
937
+ error: task.error || null
938
+ }
939
+ }
940
+ }
941
+
942
+ const cancelTool = {
943
+ name: "background_cancel",
944
+ description: "Cancel a running background task by its task_id. Only works on tasks launched via `task` with `run_in_background: true`.",
945
+ inputSchema: {
946
+ type: "object",
947
+ properties: {
948
+ task_id: schema("string", "background task id")
949
+ },
950
+ required: ["task_id"]
951
+ },
952
+ async execute(args) {
953
+ const ok = await BackgroundManager.cancel(String(args.task_id || ""))
954
+ return ok ? "cancel requested" : "background task not found"
955
+ }
956
+ }
957
+
958
+ const todowriteTool = {
959
+ name: "todowrite",
960
+ description: "Create or update a structured task list for tracking multi-step work. ALWAYS create a todo list before starting any task with 2+ steps. Mark items in_progress/completed as you work. Only ONE item should be in_progress at a time.",
961
+ inputSchema: {
962
+ type: "object",
963
+ properties: {
964
+ todos: {
965
+ type: "array",
966
+ description: "The updated todo list",
967
+ items: {
968
+ type: "object",
969
+ properties: {
970
+ content: schema("string", "task description in imperative form (e.g. 'Run tests')"),
971
+ activeForm: schema("string", "present continuous form shown during execution (e.g. 'Running tests')"),
972
+ status: { type: "string", enum: ["pending", "in_progress", "completed"], description: "task status" }
973
+ },
974
+ required: ["content", "status"]
975
+ }
976
+ }
977
+ },
978
+ required: ["todos"]
979
+ },
980
+ async execute(args, ctx) {
981
+ const todos = args.todos || []
982
+ ctx._todoState = todos
983
+ const summary = todos.map((t) => {
984
+ const active = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : ""
985
+ return `[${t.status}] ${t.content}${active}`
986
+ }).join("\n")
987
+ return `Todo list updated (${todos.length} items):\n${summary}`
988
+ }
989
+ }
990
+
991
+ const questionTool = {
992
+ name: "question",
993
+ description: "Ask the user one or more structured questions and wait for their answers. Use when you need user input to proceed — e.g. ambiguous requirements, implementation choices, or missing information. Supports predefined options, multi-select, and custom text input. Returns actual user answers.",
994
+ inputSchema: {
995
+ type: "object",
996
+ properties: {
997
+ questions: {
998
+ type: "array",
999
+ description: "questions to ask the user",
1000
+ items: {
1001
+ type: "object",
1002
+ properties: {
1003
+ id: schema("string", "unique question identifier"),
1004
+ text: schema("string", "question text"),
1005
+ header: schema("string", "short label for tab chip (max 12 chars)"),
1006
+ description: schema("string", "supplementary description (optional)"),
1007
+ options: {
1008
+ type: "array",
1009
+ items: {
1010
+ type: "object",
1011
+ properties: {
1012
+ label: schema("string", "option display text"),
1013
+ value: schema("string", "option value (defaults to label)"),
1014
+ description: schema("string", "option description (optional)")
1015
+ },
1016
+ required: ["label"]
1017
+ },
1018
+ description: "predefined choices (optional)"
1019
+ },
1020
+ multi: schema("boolean", "allow multiple selections (default false)"),
1021
+ allowCustom: schema("boolean", "allow custom text input (default true)")
1022
+ },
1023
+ required: ["id", "text"]
1024
+ }
1025
+ }
1026
+ },
1027
+ required: ["questions"]
1028
+ },
1029
+ async execute(args) {
1030
+ if (args && args._allowQuestion === false) {
1031
+ return "question tool disabled in this phase"
1032
+ }
1033
+ const questions = Array.isArray(args.questions) ? args.questions : []
1034
+ if (questions.length === 0) {
1035
+ return "error: at least one question is required"
1036
+ }
1037
+ // Normalize questions
1038
+ const normalized = questions.map((q, i) => ({
1039
+ id: String(q.id || `q${i}`),
1040
+ text: String(q.text || ""),
1041
+ description: q.description ? String(q.description) : "",
1042
+ options: Array.isArray(q.options) ? q.options.map((o) => ({
1043
+ label: String(o.label || ""),
1044
+ value: String(o.value || o.label || ""),
1045
+ description: o.description ? String(o.description) : ""
1046
+ })) : [],
1047
+ multi: !!q.multi,
1048
+ allowCustom: q.allowCustom !== false
1049
+ }))
1050
+ const answers = await askQuestionInteractive({ questions: normalized })
1051
+ // Format response
1052
+ const lines = normalized.map((q) => {
1053
+ const answer = answers[q.id] ?? "(skipped)"
1054
+ return `[${q.id}] ${q.text} ${answer}`
1055
+ })
1056
+ return lines.join("\n")
1057
+ }
1058
+ }
1059
+
1060
+ const webfetchTool = {
1061
+ name: "webfetch",
1062
+ description: "Fetch content from a public URL and return it as text. HTML is converted to markdown. Content over 50KB is truncated. Only use for public, unauthenticated URLs. Do NOT use for local file reading — use `read` instead.",
1063
+ inputSchema: {
1064
+ type: "object",
1065
+ properties: {
1066
+ url: schema("string", "URL to fetch"),
1067
+ prompt: schema("string", "optional processing instruction")
1068
+ },
1069
+ required: ["url"]
1070
+ },
1071
+ async execute(args) {
1072
+ const url = String(args.url || "")
1073
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
1074
+ return "error: URL must start with http:// or https://"
1075
+ }
1076
+ try {
1077
+ const response = await fetch(url, {
1078
+ headers: { "user-agent": "kkcode/0.1" },
1079
+ signal: AbortSignal.timeout(30000)
1080
+ })
1081
+ if (!response.ok) return `error: HTTP ${response.status}`
1082
+ const text = await response.text()
1083
+ const truncated = text.length > 50000 ? text.slice(0, 50000) + "\n...(truncated)" : text
1084
+ return truncated
1085
+ } catch (error) {
1086
+ return `error: ${error.message}`
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ const skillTool = {
1092
+ name: "skill",
1093
+ description: "Invoke a registered skill by name. Skills are pre-built prompt templates or programmable modules that provide specialized capabilities. Use this when a task matches an available skill listed in the system prompt, or when the user mentions a slash command like '/commit'.",
1094
+ inputSchema: {
1095
+ type: "object",
1096
+ properties: {
1097
+ skill: schema("string", "skill name without '/' prefix (e.g. 'commit', 'init', 'frontend')"),
1098
+ args: schema("string", "optional arguments to pass to the skill (e.g. 'vue' for /init vue)")
1099
+ },
1100
+ required: ["skill"]
1101
+ },
1102
+ async execute(args, ctx) {
1103
+ const name = String(args.skill || "").trim()
1104
+ if (!name) return "error: skill name is required"
1105
+ if (!SkillRegistry.isReady()) return "error: skill registry not initialized"
1106
+ const skill = SkillRegistry.get(name)
1107
+ if (!skill) {
1108
+ const available = SkillRegistry.list().map(s => s.name).join(", ")
1109
+ return `error: skill "${name}" not found. Available: ${available}`
1110
+ }
1111
+ const result = await SkillRegistry.execute(name, String(args.args || ""), {
1112
+ cwd: ctx.cwd,
1113
+ mode: ctx.mode || "agent",
1114
+ model: ctx.model || "",
1115
+ provider: ctx.provider || "",
1116
+ config: ctx.config || null
1117
+ })
1118
+ if (!result) return `skill /${name} returned no output`
1119
+ // contextFork skills return { prompt, contextFork, model }
1120
+ if (typeof result === "object" && result.contextFork) {
1121
+ return result.prompt || ""
1122
+ }
1123
+ return result
1124
+ }
1125
+ }
1126
+
1127
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp"
1128
+ const EXA_TIMEOUT_MS = 25000
1129
+
1130
+ async function callExaMcp(toolName, args, signal) {
1131
+ const body = JSON.stringify({
1132
+ jsonrpc: "2.0",
1133
+ id: 1,
1134
+ method: "tools/call",
1135
+ params: { name: toolName, arguments: args }
1136
+ })
1137
+ const response = await fetch(EXA_MCP_URL, {
1138
+ method: "POST",
1139
+ headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
1140
+ body,
1141
+ signal: signal || AbortSignal.timeout(EXA_TIMEOUT_MS)
1142
+ })
1143
+ if (!response.ok) {
1144
+ const err = await response.text().catch(() => "")
1145
+ throw new Error(`Exa search error (${response.status}): ${err}`)
1146
+ }
1147
+ const text = await response.text()
1148
+ for (const line of text.split("\n")) {
1149
+ if (line.startsWith("data: ")) {
1150
+ const data = JSON.parse(line.slice(6))
1151
+ if (data.result?.content?.[0]?.text) return data.result.content[0].text
1152
+ }
1153
+ }
1154
+ return null
1155
+ }
1156
+
1157
+ const websearchTool = {
1158
+ name: "websearch",
1159
+ description: "Search the web for up-to-date information. Use this PROACTIVELY when you are unsure about facts, APIs, library versions, error messages, or anything beyond your training data. Reduces hallucination by grounding answers in real search results. Returns relevant web page content.",
1160
+ inputSchema: {
1161
+ type: "object",
1162
+ properties: {
1163
+ query: schema("string", "search query"),
1164
+ numResults: schema("number", "number of results to return (default: 5)"),
1165
+ type: schema("string", "search type: 'auto' (default), 'fast' (quick), 'deep' (comprehensive)")
1166
+ },
1167
+ required: ["query"]
1168
+ },
1169
+ async execute(args, ctx) {
1170
+ const query = String(args.query || "").trim()
1171
+ if (!query) return "error: query is required"
1172
+ try {
1173
+ const result = await callExaMcp("web_search_exa", {
1174
+ query,
1175
+ numResults: Number(args.numResults) || 5,
1176
+ type: args.type || "auto",
1177
+ livecrawl: "fallback"
1178
+ }, ctx.signal)
1179
+ return result || "No results found. Try a different query."
1180
+ } catch (error) {
1181
+ if (error.name === "AbortError" || error.name === "TimeoutError") return "error: search request timed out"
1182
+ return `error: ${error.message}`
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ const codesearchTool = {
1188
+ name: "codesearch",
1189
+ description: "Search for code examples, API documentation, and SDK usage. Use this PROACTIVELY when working with unfamiliar libraries, frameworks, or APIs. Returns relevant code snippets and documentation from the web. Especially useful for: correct API signatures, configuration examples, migration guides, and best practices.",
1190
+ inputSchema: {
1191
+ type: "object",
1192
+ properties: {
1193
+ query: schema("string", "search query for APIs, libraries, SDKs (e.g. 'Express.js middleware', 'React useState hook')"),
1194
+ tokensNum: schema("number", "amount of context to return, 1000-50000 (default: 5000)")
1195
+ },
1196
+ required: ["query"]
1197
+ },
1198
+ async execute(args, ctx) {
1199
+ const query = String(args.query || "").trim()
1200
+ if (!query) return "error: query is required"
1201
+ try {
1202
+ const result = await callExaMcp("get_code_context_exa", {
1203
+ query,
1204
+ tokensNum: Math.min(Math.max(Number(args.tokensNum) || 5000, 1000), 50000)
1205
+ }, ctx.signal)
1206
+ return result || "No code context found. Try a more specific query."
1207
+ } catch (error) {
1208
+ if (error.name === "AbortError" || error.name === "TimeoutError") return "error: code search request timed out"
1209
+ return `error: ${error.message}`
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ const multieditTool = {
1215
+ name: "multiedit",
1216
+ description: "Apply multiple file edits atomically in a single operation. All changes succeed together or are rolled back entirely. Use this instead of multiple sequential `edit` calls when modifying related code across files (e.g. renaming an export and updating all imports). Each file must have been `read` first.",
1217
+ inputSchema: {
1218
+ type: "object",
1219
+ properties: {
1220
+ changes: {
1221
+ type: "array",
1222
+ description: "list of file changes to apply atomically",
1223
+ items: {
1224
+ type: "object",
1225
+ properties: {
1226
+ path: schema("string", "file path"),
1227
+ before: schema("string", "text to find (required for edits, omit for new file creation)"),
1228
+ after: schema("string", "replacement text (for edits) or full content (for new files)"),
1229
+ replace_all: schema("boolean", "replace all occurrences of before (default: false)")
1230
+ },
1231
+ required: ["path", "after"]
1232
+ }
1233
+ }
1234
+ },
1235
+ required: ["changes"]
1236
+ },
1237
+ async execute(args, ctx) {
1238
+ const changes = Array.isArray(args.changes) ? args.changes : []
1239
+ if (!changes.length) return "error: at least one change is required"
1240
+
1241
+ // Phase 1: validate all changes and collect original content for rollback
1242
+ const snapshots = [] // { path, original, isNew }
1243
+ const resolved = []
1244
+ for (const change of changes) {
1245
+ const target = path.resolve(ctx.cwd, change.path)
1246
+ const originalExists = await exists(target)
1247
+ const hasBefore = Object.prototype.hasOwnProperty.call(change, "before")
1248
+ const isCreate = !originalExists && !hasBefore
1249
+ if (originalExists && !hasBefore) {
1250
+ return `error: "${change.path}" already exists. Provide a "before" snippet for existing-file multiedit changes.`
1251
+ }
1252
+ let original = null
1253
+ try {
1254
+ original = await readFile(target, "utf8")
1255
+ } catch { /* new file */ }
1256
+
1257
+ if (!isCreate && original === null) {
1258
+ return `error: "${change.path}" does not exist. Omit "before" only for new-file creation.`
1259
+ }
1260
+
1261
+ if (!isCreate && original !== null) {
1262
+ const validation = await validateExistingFileMutation({
1263
+ targetPath: target,
1264
+ displayPath: String(change.path || target),
1265
+ operation: "applying this multiedit change"
1266
+ })
1267
+ if (!validation.ok) return validation.message
1268
+ const matches = (original || "").split(change.before).length - 1
1269
+ if (matches === 0) return `error: no match for "before" in ${change.path}. Re-read the file and check your snippet.`
1270
+ if (matches > 1 && !change.replace_all) return `error: ${matches} matches in ${change.path} — set replace_all: true or provide more context.`
1271
+ }
1272
+
1273
+ snapshots.push({ path: target, original, isNew: original === null })
1274
+ resolved.push({ target, ...change, isCreate })
1275
+ }
1276
+
1277
+ // Phase 2: apply all changes
1278
+ const applied = []
1279
+ try {
1280
+ for (const change of resolved) {
1281
+ if (change.isCreate) {
1282
+ await atomicWriteFile(change.target, String(change.after))
1283
+ } else {
1284
+ const snap = snapshots.find(s => s.path === change.target)
1285
+ const content = snap?.original ?? await readFile(change.target, "utf8")
1286
+ const next = change.replace_all
1287
+ ? content.replaceAll(change.before, change.after)
1288
+ : content.replace(change.before, change.after)
1289
+ await atomicWriteFile(change.target, next)
1290
+ }
1291
+ await refreshFileReadStateFromDisk(change.target).catch(() => {})
1292
+ applied.push(change.target)
1293
+ }
1294
+ } catch (error) {
1295
+ // Rollback all applied changes
1296
+ for (let i = applied.length - 1; i >= 0; i--) {
1297
+ const snap = snapshots.find(s => s.path === applied[i])
1298
+ if (!snap) continue
1299
+ try {
1300
+ if (snap.isNew) {
1301
+ await unlink(applied[i]).catch(() => {})
1302
+ } else if (snap.original !== null) {
1303
+ await atomicWriteFile(applied[i], snap.original)
1304
+ }
1305
+ } catch { /* best effort rollback */ }
1306
+ }
1307
+ return `error: failed at ${applied.length + 1}/${resolved.length} — all changes rolled back. Cause: ${error.message}`
1308
+ }
1309
+
1310
+ // Phase 3: summarize
1311
+ const summary = resolved.map(c => ` ${c.isCreate ? "+" : "~"} ${c.path}`).join("\n")
1312
+ return {
1313
+ output: `${resolved.length} file(s) updated atomically:\n${summary}`,
1314
+ metadata: {
1315
+ fileChanges: resolved.map(c => ({
1316
+ path: String(c.path || c.target),
1317
+ tool: "multiedit",
1318
+ stageId: ctx.stageId || null,
1319
+ taskId: ctx.logicalTaskId || ctx.taskId || null
1320
+ })),
1321
+ mutations: resolved.map((c) => {
1322
+ const snap = snapshots.find((s) => s.path === c.target)
1323
+ const originalContent = snap?.original ?? null
1324
+ const updatedContent = c.isCreate
1325
+ ? String(c.after)
1326
+ : c.replace_all
1327
+ ? String(originalContent ?? "").replaceAll(String(c.before), String(c.after))
1328
+ : String(originalContent ?? "").replace(String(c.before), String(c.after))
1329
+ const diff = diffLineCount(originalContent ?? "", updatedContent)
1330
+ return {
1331
+ operation: "multiedit",
1332
+ filePath: String(c.path || c.target),
1333
+ originalContent,
1334
+ updatedContent,
1335
+ structuredPatch: buildStructuredPatch(originalContent ?? "", updatedContent),
1336
+ addedLines: diff.added,
1337
+ removedLines: diff.removed
1338
+ }
1339
+ })
1340
+ }
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ const enterPlanTool = {
1346
+ name: "enter_plan",
1347
+ description: "Enter planning mode. Use this PROACTIVELY when the task is non-trivial and requires architectural decisions, multi-file changes, or when multiple valid approaches exist. After calling this, outline your plan, then call `exit_plan` to present it to the user for approval.",
1348
+ inputSchema: {
1349
+ type: "object",
1350
+ properties: {
1351
+ reason: schema("string", "why planning is needed (shown to user)")
1352
+ },
1353
+ required: []
1354
+ },
1355
+ async execute(args, ctx) {
1356
+ ctx._planMode = true
1357
+ return `Planning mode entered. Outline your plan now, then call exit_plan to present it for user approval.${args.reason ? ` Reason: ${args.reason}` : ""}`
1358
+ }
1359
+ }
1360
+
1361
+ const exitPlanTool = {
1362
+ name: "exit_plan",
1363
+ description: "Present your plan to the user for approval. The user will see the plan and can approve, reject, or request changes. Only call this after enter_plan and after you have outlined a complete plan in your response.",
1364
+ inputSchema: {
1365
+ type: "object",
1366
+ properties: {
1367
+ plan: schema("string", "the complete plan text to present to the user"),
1368
+ files: {
1369
+ type: "array", items: { type: "string" },
1370
+ description: "list of files that will be created or modified"
1371
+ }
1372
+ },
1373
+ required: ["plan"]
1374
+ },
1375
+ async execute(args, ctx) {
1376
+ if (!ctx._planMode) {
1377
+ return {
1378
+ output: "Cannot exit plan mode — you are not currently in plan mode. Call enter_plan first.",
1379
+ metadata: {}
1380
+ }
1381
+ }
1382
+ ctx._planMode = false
1383
+ return {
1384
+ output: "Plan submitted for user approval.",
1385
+ metadata: {
1386
+ planApproval: true,
1387
+ plan: String(args.plan || ""),
1388
+ files: Array.isArray(args.files) ? args.files : []
1389
+ }
1390
+ }
1391
+ }
1392
+ }
1393
+
1394
+ const notebookeditTool = {
1395
+ name: "notebookedit",
1396
+ description: "Edit a Jupyter notebook (.ipynb) cell. Supports replace, insert, and delete operations on individual cells. Use this instead of `write` when modifying notebooks — it preserves cell metadata and outputs. Notebooks must be read first and stale notebooks are rejected.",
1397
+ inputSchema: {
1398
+ type: "object",
1399
+ properties: {
1400
+ path: schema("string", "notebook file path (.ipynb)"),
1401
+ cell_number: schema("number", "0-indexed cell number to operate on (default: 0)"),
1402
+ new_source: schema("string", "new cell source content"),
1403
+ cell_type: { type: "string", enum: ["code", "markdown"], description: "cell type (required for insert)" },
1404
+ edit_mode: { type: "string", enum: ["replace", "insert", "delete"], description: "operation type (default: replace)" }
1405
+ },
1406
+ required: ["path", "new_source"]
1407
+ },
1408
+ async execute(args, ctx) {
1409
+ const target = path.resolve(ctx.cwd, args.path)
1410
+ if (await exists(target)) {
1411
+ const validation = await validateExistingFileMutation({
1412
+ targetPath: target,
1413
+ displayPath: String(args.path || target),
1414
+ operation: "editing the notebook",
1415
+ requireFullRead: true
1416
+ })
1417
+ if (!validation.ok) {
1418
+ return {
1419
+ output: validation.message,
1420
+ metadata: { blocked: true, reason: validation.reason, fileChanges: [] }
1421
+ }
1422
+ }
1423
+ }
1424
+ const raw = await readFile(target, "utf8")
1425
+ const notebook = JSON.parse(raw)
1426
+ if (!notebook.cells || !Array.isArray(notebook.cells)) {
1427
+ return "error: not a valid .ipynb file (missing cells array)"
1428
+ }
1429
+ const mode = args.edit_mode || "replace"
1430
+ const cellNum = Number(args.cell_number ?? 0)
1431
+ const source = String(args.new_source ?? "")
1432
+ const sourceLines = source.split("\n").map((line, i, arr) => i < arr.length - 1 ? line + "\n" : line)
1433
+
1434
+ if (mode === "insert") {
1435
+ const cellType = args.cell_type
1436
+ if (!cellType || !["code", "markdown"].includes(cellType)) {
1437
+ return "error: cell_type is required for insert mode (must be 'code' or 'markdown')"
1438
+ }
1439
+ const newCell = {
1440
+ cell_type: cellType,
1441
+ metadata: {},
1442
+ source: sourceLines
1443
+ }
1444
+ if (cellType === "code") {
1445
+ newCell.execution_count = null
1446
+ newCell.outputs = []
1447
+ }
1448
+ const insertAt = cellNum < 0 ? 0 : Math.min(cellNum + 1, notebook.cells.length)
1449
+ notebook.cells.splice(insertAt, 0, newCell)
1450
+ } else if (mode === "delete") {
1451
+ if (cellNum < 0 || cellNum >= notebook.cells.length) {
1452
+ return `error: cell_number ${cellNum} out of range (0-${notebook.cells.length - 1})`
1453
+ }
1454
+ notebook.cells.splice(cellNum, 1)
1455
+ } else {
1456
+ // replace
1457
+ if (cellNum < 0 || cellNum >= notebook.cells.length) {
1458
+ return `error: cell_number ${cellNum} out of range (0-${notebook.cells.length - 1})`
1459
+ }
1460
+ const cell = notebook.cells[cellNum]
1461
+ cell.source = sourceLines
1462
+ if (args.cell_type && args.cell_type !== cell.cell_type) {
1463
+ cell.cell_type = args.cell_type
1464
+ if (args.cell_type === "markdown") {
1465
+ delete cell.execution_count
1466
+ delete cell.outputs
1467
+ } else if (args.cell_type === "code") {
1468
+ cell.execution_count = null
1469
+ cell.outputs = []
1470
+ }
1471
+ }
1472
+ }
1473
+
1474
+ const finalNotebook = JSON.stringify(notebook, null, 1) + "\n"
1475
+ await atomicWriteFile(target, finalNotebook)
1476
+ await refreshFileReadStateFromDisk(target, { content: finalNotebook }).catch(() => {})
1477
+ const actionLabel = mode === "insert" ? "inserted" : mode === "delete" ? "deleted" : "replaced"
1478
+ return {
1479
+ output: `${actionLabel} cell ${cellNum} in ${args.path} (${notebook.cells.length} cells total)`,
1480
+ metadata: mutationMetadata({
1481
+ operation: "notebookedit",
1482
+ filePath: String(args.path || target),
1483
+ originalContent: raw,
1484
+ updatedContent: finalNotebook,
1485
+ structuredPatch: buildStructuredPatch(raw, finalNotebook),
1486
+ addedLines: 0,
1487
+ removedLines: 0,
1488
+ stageId: ctx.stageId || null,
1489
+ taskId: ctx.logicalTaskId || ctx.taskId || null
1490
+ })
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ const patchTool = {
1496
+ name: "patch",
1497
+ description: "Replace a range of lines in a file by line numbers. Read the file first with `read` (use offset/limit for large files) to see line numbers, then specify the line range to replace. Lines are 1-based and inclusive. Ideal for modifying specific sections of large files without needing to match exact text.",
1498
+ inputSchema: {
1499
+ type: "object",
1500
+ properties: {
1501
+ path: schema("string", "file path"),
1502
+ start_line: schema("number", "first line to replace (1-based, inclusive)"),
1503
+ end_line: schema("number", "last line to replace (1-based, inclusive)"),
1504
+ content: schema("string", "replacement content (replaces the line range). Empty string deletes lines.")
1505
+ },
1506
+ required: ["path", "start_line", "end_line", "content"]
1507
+ },
1508
+ async execute(args, ctx) {
1509
+ const target = path.resolve(ctx.cwd, args.path)
1510
+
1511
+ if (await exists(target)) {
1512
+ const validation = await validateExistingFileMutation({
1513
+ targetPath: target,
1514
+ displayPath: String(args.path || target),
1515
+ operation: "patching it"
1516
+ })
1517
+ if (!validation.ok) {
1518
+ return {
1519
+ output: validation.message,
1520
+ metadata: { blocked: true, reason: validation.reason, fileChanges: [] }
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ const startLine = Math.max(1, Number(args.start_line) || 1)
1526
+ const endLine = Math.max(startLine, Number(args.end_line) || startLine)
1527
+ const content = String(args.content ?? "")
1528
+
1529
+ const options = lockOptions(ctx)
1530
+ let result
1531
+ const runPatch = async () => {
1532
+ const existing = await readFile(target, "utf8")
1533
+ const lines = existing.split("\n")
1534
+ if (startLine > lines.length) {
1535
+ throw new Error(`start_line ${startLine} exceeds file length (${lines.length} lines)`)
1536
+ }
1537
+ const startIdx = startLine - 1
1538
+ const endIdx = Math.min(endLine, lines.length)
1539
+ const newLines = content === "" ? [] : content.split("\n")
1540
+ lines.splice(startIdx, endIdx - startIdx, ...newLines)
1541
+ const final = lines.join("\n")
1542
+ await atomicWriteFile(target, final)
1543
+ return { removedCount: endIdx - startIdx, insertedCount: newLines.length, previous: existing, final }
1544
+ }
1545
+
1546
+ if (options.mode === "file_lock") {
1547
+ result = await withFileLock({ targetPath: target, owner: options.owner, waitTimeoutMs: options.waitTimeoutMs, run: runPatch })
1548
+ } else {
1549
+ result = await runPatch()
1550
+ }
1551
+
1552
+ await refreshFileReadStateFromDisk(target, { content: result.final }).catch(() => {})
1553
+ return {
1554
+ output: `patched ${args.path}: replaced lines ${startLine}-${endLine} (removed ${result.removedCount}, inserted ${result.insertedCount})`,
1555
+ metadata: mutationMetadata({
1556
+ operation: "patch",
1557
+ filePath: String(args.path || target),
1558
+ originalContent: result.previous,
1559
+ updatedContent: result.final,
1560
+ structuredPatch: buildStructuredPatch(result.previous, result.final, { oldStart: startLine, newStart: startLine }),
1561
+ addedLines: result.insertedCount,
1562
+ removedLines: result.removedCount,
1563
+ stageId: ctx.stageId || null,
1564
+ taskId: ctx.logicalTaskId || ctx.taskId || null
1565
+ })
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ const gitTools = config?.git_auto?.enabled !== false ? gitAutoTools : []
1571
+ const gitFullAutoToolsList = config?.git_auto?.full_auto === true ? gitFullAutoTools : []
1572
+
1573
+ return [listTool, sysinfoTool, readTool, writeTool, editTool, patchTool, multieditTool, globTool, grepTool, bashTool, createTaskTool(), outputTool, cancelTool, taskListTool, taskGetTool, taskStopTool, taskOutputTool, todowriteTool, questionTool, skillTool, webfetchTool, websearchTool, codesearchTool, notebookeditTool, enterPlanTool, exitPlanTool, ...gitTools, ...gitFullAutoToolsList]
1574
+ }
1575
+
1576
+ function mcpTools() {
1577
+ return McpRegistry.listTools().map((tool) => ({
1578
+ name: tool.id,
1579
+ description: `[mcp:${tool.server}] ${tool.description}`,
1580
+ inputSchema: tool.inputSchema,
1581
+ async execute(args, ctx) {
1582
+ try {
1583
+ const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1584
+ return result.output
1585
+ } catch (error) {
1586
+ const reason = error.reason || "unknown"
1587
+ const server = error.server || tool.server
1588
+ return `[MCP Error: ${server} ${reason}] ${error.message}`
1589
+ }
1590
+ }
1591
+ }))
1592
+ }
1593
+
1594
+ function toolAllowedByMode(toolName, mode) {
1595
+ if (mode === "plan") {
1596
+ return !["write", "edit", "patch", "multiedit", "notebookedit", "bash", "task", "git_snapshot", "git_restore", "git_apply_patch", "git_delete_snapshot"].includes(toolName)
1597
+ }
1598
+ return true
1599
+ }
1600
+
1601
+ export const ToolRegistry = {
1602
+ async initialize({ config = {}, cwd = process.cwd(), force = false } = {}) {
1603
+ const ttlMs = Math.max(0, Number(config.runtime?.tool_registry_cache_ttl_ms || 30000))
1604
+ const sig = signatureFor(config, cwd)
1605
+ const cacheValid =
1606
+ state.initialized &&
1607
+ !force &&
1608
+ state.lastSignature === sig &&
1609
+ state.lastCwd === cwd &&
1610
+ Date.now() - state.loadedAt <= ttlMs
1611
+ if (cacheValid) return
1612
+
1613
+ const tools = []
1614
+
1615
+ if (config.tool?.sources?.builtin !== false) {
1616
+ tools.push(...builtinTools(config))
1617
+ }
1618
+
1619
+ if (config.tool?.sources?.local !== false) {
1620
+ const localDirs = (config.tool?.local_dirs || []).map((dir) => path.resolve(cwd, dir))
1621
+ tools.push(...(await loadDynamicTools(localDirs)))
1622
+ }
1623
+
1624
+ if (config.tool?.sources?.plugin !== false) {
1625
+ const pluginDirs = (config.tool?.plugin_dirs || []).map((dir) => path.resolve(cwd, dir))
1626
+ tools.push(...(await loadDynamicTools(pluginDirs)))
1627
+ }
1628
+
1629
+ if (config.tool && config.tool?.sources?.mcp !== false) {
1630
+ await McpRegistry.initialize(config, { cwd })
1631
+ tools.push(...mcpTools())
1632
+ }
1633
+
1634
+ state.tools = tools
1635
+ state.initialized = true
1636
+ state.loadedAt = Date.now()
1637
+ state.lastSignature = sig
1638
+ state.lastCwd = cwd
1639
+ state.lastConfig = config
1640
+ },
1641
+
1642
+ isReady() {
1643
+ return state.initialized
1644
+ },
1645
+
1646
+ async list({ mode, cwd = process.cwd(), config = undefined } = {}) {
1647
+ const resolvedConfig = config === undefined ? state.lastConfig || {} : config
1648
+ if (!state.initialized) {
1649
+ await this.initialize({ config: resolvedConfig, cwd })
1650
+ } else {
1651
+ await this.initialize({ config: resolvedConfig, cwd, force: false })
1652
+ }
1653
+ return state.tools
1654
+ .filter((tool) => toolAllowedByMode(tool.name, mode))
1655
+ .map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }))
1656
+ },
1657
+
1658
+ async get(toolName) {
1659
+ return state.tools.find((tool) => tool.name === toolName) || null
1660
+ },
1661
+
1662
+ async call(toolName, args, ctx) {
1663
+ const tool = await this.get(toolName)
1664
+ if (!tool) {
1665
+ return {
1666
+ name: toolName,
1667
+ status: "error",
1668
+ output: `unknown tool: ${toolName}`,
1669
+ error: `unknown tool: ${toolName}`
1670
+ }
1671
+ }
1672
+ try {
1673
+ const output = await tool.execute(args || {}, ctx)
1674
+ return {
1675
+ name: toolName,
1676
+ status: "completed",
1677
+ output: safeStringify(output)
1678
+ }
1679
+ } catch (error) {
1680
+ return {
1681
+ name: toolName,
1682
+ status: "error",
1683
+ output: error.message,
1684
+ error: error.message
1685
+ }
1686
+ }
1687
+ },
1688
+
1689
+ refreshMcpTools() {
1690
+ if (!state.initialized || state.refreshing) return
1691
+ state.refreshing = true
1692
+ try {
1693
+ // Atomic replacement: build new list, then assign once
1694
+ const nonMcp = state.tools.filter((t) => !t.name.startsWith("mcp_"))
1695
+ const newMcpTools = mcpTools()
1696
+ state.tools = [...nonMcp, ...newMcpTools]
1697
+ } finally {
1698
+ state.refreshing = false
1699
+ }
1700
+ }
1701
+ }