@shawnstack/quickforge 1.3.17 → 1.3.19

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 (133) hide show
  1. package/README.md +10 -10
  2. package/bin/quickforge.mjs +258 -49
  3. package/dist/assets/anthropic-Bj3HAZgj.js +39 -0
  4. package/dist/assets/azure-openai-responses-IdZZrSrI.js +1 -0
  5. package/dist/assets/github-copilot-headers-CMb2BbzT.js +1 -0
  6. package/dist/assets/google-Brt_lS1J.js +1 -0
  7. package/dist/assets/{google-shared-XhYUKiGZ.js → google-shared-CLc4ziON.js} +3 -3
  8. package/dist/assets/google-vertex-B6HsoZ34.js +1 -0
  9. package/dist/assets/{index-Dm7aEWvT.js → index-D0CVLdX_.js} +525 -489
  10. package/dist/assets/index-D0W9hAl_.css +3 -0
  11. package/dist/assets/{mistral-DxhS4Wkn.js → mistral-CenXqwPz.js} +3 -3
  12. package/dist/assets/openai-codex-responses-D9ffGwbj.js +7 -0
  13. package/dist/assets/openai-completions-eWdeSGBG.js +5 -0
  14. package/dist/assets/openai-responses-Cavpmjeu.js +1 -0
  15. package/dist/assets/{openai-responses-shared-f_P3e1nz.js → openai-responses-shared-DF3ZGaUx.js} +5 -3
  16. package/dist/assets/transform-messages-CmnxG9RB.js +1 -0
  17. package/dist/index.html +2 -2
  18. package/node_modules/@anthropic-ai/sdk/CHANGELOG.md +34 -0
  19. package/node_modules/@anthropic-ai/sdk/bin/migration-config.json +185 -0
  20. package/node_modules/@anthropic-ai/sdk/package.json +1 -1
  21. package/node_modules/@anthropic-ai/sdk/resources/beta/beta.js +4 -0
  22. package/node_modules/@anthropic-ai/sdk/resources/beta/beta.mjs +4 -0
  23. package/node_modules/@anthropic-ai/sdk/resources/beta/files.js +5 -5
  24. package/node_modules/@anthropic-ai/sdk/resources/beta/files.mjs +5 -5
  25. package/node_modules/@anthropic-ai/sdk/resources/beta/index.js +11 -9
  26. package/node_modules/@anthropic-ai/sdk/resources/beta/index.mjs +1 -0
  27. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/index.js +11 -0
  28. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/index.mjs +5 -0
  29. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/memories.js +130 -0
  30. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/memories.mjs +126 -0
  31. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/memory-stores.js +145 -0
  32. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/memory-stores.mjs +140 -0
  33. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/memory-versions.js +81 -0
  34. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores/memory-versions.mjs +77 -0
  35. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores.js +6 -0
  36. package/node_modules/@anthropic-ai/sdk/resources/beta/memory-stores.mjs +3 -0
  37. package/node_modules/@anthropic-ai/sdk/tools/memory/node.js +12 -5
  38. package/node_modules/@anthropic-ai/sdk/tools/memory/node.mjs +12 -5
  39. package/node_modules/@anthropic-ai/sdk/version.js +1 -1
  40. package/node_modules/@anthropic-ai/sdk/version.mjs +1 -1
  41. package/node_modules/@aws-sdk/client-bedrock-runtime/package.json +5 -5
  42. package/node_modules/@aws-sdk/core/package.json +2 -2
  43. package/node_modules/@aws-sdk/credential-provider-env/package.json +2 -2
  44. package/node_modules/@aws-sdk/credential-provider-http/dist-cjs/fromHttp/fromHttp.js +12 -6
  45. package/node_modules/@aws-sdk/credential-provider-http/dist-es/fromHttp/fromHttp.js +12 -6
  46. package/node_modules/@aws-sdk/credential-provider-http/package.json +3 -2
  47. package/node_modules/@aws-sdk/credential-provider-ini/package.json +9 -9
  48. package/node_modules/@aws-sdk/credential-provider-login/package.json +3 -3
  49. package/node_modules/@aws-sdk/credential-provider-node/package.json +7 -7
  50. package/node_modules/@aws-sdk/credential-provider-process/package.json +2 -2
  51. package/node_modules/@aws-sdk/credential-provider-sso/package.json +4 -4
  52. package/node_modules/@aws-sdk/credential-provider-web-identity/package.json +3 -3
  53. package/node_modules/@aws-sdk/middleware-websocket/package.json +2 -2
  54. package/node_modules/@aws-sdk/nested-clients/dist-cjs/submodules/cognito-identity/index.js +1 -1
  55. package/node_modules/@aws-sdk/nested-clients/dist-cjs/submodules/signin/index.js +1 -1
  56. package/node_modules/@aws-sdk/nested-clients/dist-cjs/submodules/sso/index.js +1 -1
  57. package/node_modules/@aws-sdk/nested-clients/dist-cjs/submodules/sso-oidc/index.js +1 -1
  58. package/node_modules/@aws-sdk/nested-clients/dist-cjs/submodules/sts/index.js +1 -1
  59. package/node_modules/@aws-sdk/nested-clients/package.json +3 -3
  60. package/node_modules/@aws-sdk/signature-v4-multi-region/package.json +1 -2
  61. package/node_modules/@aws-sdk/token-providers/package.json +3 -3
  62. package/node_modules/@aws-sdk/xml-builder/package.json +2 -2
  63. package/node_modules/@mariozechner/pi-agent-core/README.md +14 -0
  64. package/node_modules/@mariozechner/pi-agent-core/dist/agent-loop.js +9 -0
  65. package/node_modules/@mariozechner/pi-agent-core/dist/agent.js +1 -1
  66. package/node_modules/@mariozechner/pi-agent-core/package.json +2 -2
  67. package/node_modules/@mariozechner/pi-ai/README.md +20 -31
  68. package/node_modules/@mariozechner/pi-ai/dist/env-api-keys.js +7 -0
  69. package/node_modules/@mariozechner/pi-ai/dist/index.js +2 -0
  70. package/node_modules/@mariozechner/pi-ai/dist/models.generated.js +2420 -1213
  71. package/node_modules/@mariozechner/pi-ai/dist/models.js +28 -20
  72. package/node_modules/@mariozechner/pi-ai/dist/providers/amazon-bedrock.js +11 -11
  73. package/node_modules/@mariozechner/pi-ai/dist/providers/anthropic.js +43 -26
  74. package/node_modules/@mariozechner/pi-ai/dist/providers/azure-openai-responses.js +12 -6
  75. package/node_modules/@mariozechner/pi-ai/dist/providers/cloudflare.js +10 -3
  76. package/node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js +4 -13
  77. package/node_modules/@mariozechner/pi-ai/dist/providers/google-vertex.js +4 -3
  78. package/node_modules/@mariozechner/pi-ai/dist/providers/google.js +4 -3
  79. package/node_modules/@mariozechner/pi-ai/dist/providers/mistral.js +8 -7
  80. package/node_modules/@mariozechner/pi-ai/dist/providers/openai-codex-responses.js +296 -41
  81. package/node_modules/@mariozechner/pi-ai/dist/providers/openai-completions.js +169 -153
  82. package/node_modules/@mariozechner/pi-ai/dist/providers/openai-responses-shared.js +14 -1
  83. package/node_modules/@mariozechner/pi-ai/dist/providers/openai-responses.js +22 -8
  84. package/node_modules/@mariozechner/pi-ai/dist/providers/register-builtins.js +0 -18
  85. package/node_modules/@mariozechner/pi-ai/dist/providers/simple-options.js +1 -0
  86. package/node_modules/@mariozechner/pi-ai/dist/session-resources.js +22 -0
  87. package/node_modules/@mariozechner/pi-ai/dist/utils/diagnostics.js +25 -0
  88. package/node_modules/@mariozechner/pi-ai/dist/utils/oauth/index.js +0 -10
  89. package/node_modules/@mariozechner/pi-ai/dist/utils/oauth/openai-codex.js +25 -14
  90. package/node_modules/@mariozechner/pi-ai/dist/utils/overflow.js +14 -0
  91. package/node_modules/@mariozechner/pi-ai/package.json +2 -6
  92. package/package.json +3 -3
  93. package/server/agent-manager.mjs +279 -12
  94. package/server/auto-compaction.mjs +1 -2
  95. package/server/conversation-compaction.mjs +0 -5
  96. package/server/index.mjs +1 -0
  97. package/server/routes/static.mjs +1 -0
  98. package/server/routes/tools.mjs +3 -1
  99. package/server/session-utils.mjs +6 -1
  100. package/server/share-store.mjs +27 -4
  101. package/server/subagents.mjs +101 -0
  102. package/server/system-prompt.mjs +30 -1
  103. package/server/tools/definitions.mjs +20 -0
  104. package/server/tools/index.mjs +956 -726
  105. package/dist/assets/anthropic-Ck2DxOfr.js +0 -39
  106. package/dist/assets/azure-openai-responses-DIoz5q4Z.js +0 -1
  107. package/dist/assets/github-copilot-headers-CrI0CIJ7.js +0 -1
  108. package/dist/assets/google-Dau-4ve_.js +0 -1
  109. package/dist/assets/google-gemini-cli-DttMmbGb.js +0 -2
  110. package/dist/assets/google-vertex-BeukMl44.js +0 -1
  111. package/dist/assets/index-DgJVElbv.css +0 -3
  112. package/dist/assets/openai-codex-responses-X3sTzNAa.js +0 -7
  113. package/dist/assets/openai-completions-CRB9Vm0w.js +0 -5
  114. package/dist/assets/openai-responses-DXluu3oi.js +0 -1
  115. package/dist/assets/transform-messages-CV4kCtBB.js +0 -1
  116. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/LICENSE +0 -201
  117. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/README.md +0 -62
  118. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-cjs/index.js +0 -156
  119. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/constants.js +0 -2
  120. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/fromEnvSigningName.js +0 -16
  121. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/fromSso.js +0 -80
  122. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/fromStatic.js +0 -8
  123. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/getNewSsoOidcToken.js +0 -11
  124. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/getSsoOidcClient.js +0 -10
  125. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/index.js +0 -4
  126. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/nodeProvider.js +0 -5
  127. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/validateTokenExpiry.js +0 -7
  128. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/validateTokenKey.js +0 -7
  129. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/dist-es/writeSSOTokenToFile.js +0 -8
  130. package/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers/package.json +0 -69
  131. package/node_modules/@mariozechner/pi-ai/dist/providers/google-gemini-cli.js +0 -779
  132. package/node_modules/@mariozechner/pi-ai/dist/utils/oauth/google-antigravity.js +0 -377
  133. package/node_modules/@mariozechner/pi-ai/dist/utils/oauth/google-gemini-cli.js +0 -482
@@ -1,481 +1,513 @@
1
- import { promises as fs } from 'node:fs'
2
- import path from 'node:path'
3
- import { spawn } from 'node:child_process'
4
- import { createRequire } from 'node:module'
5
- import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, walkFiles } from '../utils/workspace.mjs'
6
- import { createTextDiff } from '../utils/text-diff.mjs'
7
- import {
8
- formatSkillActivation,
9
- loadSelectedGlobalSkills,
10
- loadSelectedProjectSkills,
11
- mergeSkills,
12
- readSkillResource,
13
- } from '../skills.mjs'
14
- import { getToolWorkspaceRoot } from '../utils/workspace.mjs'
15
-
16
- const require = createRequire(import.meta.url)
17
-
18
- // --- read_file ---
19
- export async function toolReadFile(params, context) {
20
- const file = resolveWorkspacePath(params?.path, context)
21
- await assertSafeWorkspacePath(file, context)
22
-
23
- const text = await fs.readFile(file, 'utf8')
24
- const lines = splitLines(text)
25
- const offset = Math.max(1, Number(params?.offset || 1))
26
- const limit = Math.min(2000, Math.max(1, Number(params?.limit || 200)))
27
- const selected = lines.slice(offset - 1, offset - 1 + limit)
28
- const content = selected.map((line, index) => `${offset + index}: ${line}`).join('\n')
29
- const suffix = offset - 1 + limit < lines.length ? `\n\n[showing ${selected.length} of ${lines.length} lines]` : ''
30
-
31
- return {
32
- content: truncateText(`${content}${suffix}`),
33
- details: { path: toWorkspaceRelative(file, context), project: context?.project, totalLines: lines.length, offset, limit },
34
- }
35
- }
36
-
37
- // --- grep_files ---
38
-
39
- const RIPGREP_MAX_FILESIZE = '1M'
40
- const RIPGREP_TIMEOUT_MS = 60 * 1000
41
- const DEFAULT_EXCLUDE_GLOBS = [
42
- '!.git/**',
43
- '!node_modules/**',
44
- '!dist/**',
45
- '!dist-ssr/**',
46
- '!.vite/**',
47
- '!**/*.png',
48
- '!**/*.jpg',
49
- '!**/*.jpeg',
50
- '!**/*.gif',
51
- '!**/*.webp',
52
- '!**/*.ico',
53
- '!**/*.pdf',
54
- '!**/*.zip',
55
- '!**/*.gz',
56
- '!**/*.7z',
57
- '!**/*.exe',
58
- '!**/*.dll',
59
- '!**/*.woff',
60
- '!**/*.woff2',
61
- '!**/*.ttf',
62
- ]
63
- const SENSITIVE_EXCLUDE_GLOBS = [
64
- '!.env',
65
- '!**/.env',
66
- '!.env.*',
67
- '!**/.env.*',
68
- '!**/*.pem',
69
- '!**/*.key',
70
- '!**/*.p12',
71
- '!**/*.pfx',
72
- '!**/*.crt',
73
- '!**/*.cer',
74
- '!**/*.token',
75
- '!credentials.json',
76
- '!**/credentials.json',
77
- '!secrets.json',
78
- '!**/secrets.json',
79
- '!id_rsa',
80
- '!**/id_rsa',
81
- '!id_ed25519',
82
- '!**/id_ed25519',
83
- ]
84
-
85
- let cachedRipgrepExecutable
86
-
87
- /**
88
- * Process items with bounded concurrency. Returns results in input order.
89
- * @template T, R
90
- * @param {T[]} items
91
- * @param {(item: T, index: number) => Promise<R>} fn
92
- * @param {number} concurrency
93
- * @returns {Promise<R[]>}
94
- */
95
- async function poolMap(items, fn, concurrency = 20) {
96
- const results = new Array(items.length)
97
- let cursor = 0
98
-
99
- async function worker() {
100
- while (cursor < items.length) {
101
- const index = cursor++
102
- results[index] = await fn(items[index], index)
103
- }
104
- }
105
-
106
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
107
- await Promise.all(workers)
108
- return results
109
- }
110
-
111
- function clampNumber(value, defaultValue, min, max) {
112
- const number = Number(value)
113
- if (!Number.isFinite(number)) return defaultValue
114
- return Math.min(max, Math.max(min, Math.trunc(number)))
115
- }
116
-
117
- function escapeRegExp(value) {
118
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
119
- }
120
-
121
- function normalizeGlobList(value) {
122
- const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
123
- return values
124
- .map((item) => String(item || '').trim())
125
- .filter(Boolean)
126
- .slice(0, 50)
127
- }
128
-
129
- function normalizeGrepParams(params, context) {
130
- const root = resolveWorkspacePath(params?.path || '.', context)
131
- const query = String(params?.query || '')
132
- if (!query) {
133
- const error = new Error('query is required')
134
- error.statusCode = 400
135
- throw error
136
- }
137
-
138
- const flags = params?.caseSensitive ? 'g' : 'gi'
139
- try {
140
- params?.regex
141
- ? new RegExp(query, flags)
142
- : new RegExp(escapeRegExp(query), flags)
143
- } catch {
144
- const error = new Error('Invalid regular expression')
145
- error.statusCode = 400
146
- throw error
147
- }
148
-
149
- return {
150
- root,
151
- query,
152
- regex: Boolean(params?.regex),
153
- caseSensitive: Boolean(params?.caseSensitive),
154
- limit: clampNumber(params?.limit, 200, 1, 1000),
155
- glob: normalizeGlobList(params?.glob),
156
- context: clampNumber(params?.context, 0, 0, 20),
157
- beforeContext: clampNumber(params?.beforeContext, 0, 0, 20),
158
- afterContext: clampNumber(params?.afterContext, 0, 0, 20),
159
- filesWithMatches: Boolean(params?.filesWithMatches),
160
- respectGitIgnore: Boolean(params?.respectGitIgnore),
161
- }
162
- }
163
-
164
- function isRegexLikelyRipgrepCompatible(query) {
165
- return !(/\(\?[=!<]/.test(query) || /\\[1-9]/.test(query))
166
- }
167
-
168
- function ripgrepCandidatePath() {
169
- try {
170
- return require('@vscode/ripgrep').rgPath || null
171
- } catch {
172
- return null
173
- }
174
- }
175
-
176
- async function verifyRipgrepExecutable(command) {
177
- return new Promise((resolve) => {
178
- const child = spawn(command, ['--version'], { shell: false, windowsHide: true })
179
- child.once('error', () => resolve(false))
180
- child.once('close', (code) => resolve(code === 0))
181
- })
182
- }
183
-
184
- async function resolveRipgrepExecutable() {
185
- if (cachedRipgrepExecutable !== undefined) return cachedRipgrepExecutable
186
-
187
- const bundled = ripgrepCandidatePath()
188
- if (bundled && await verifyRipgrepExecutable(bundled)) {
189
- cachedRipgrepExecutable = { command: bundled, source: 'bundled' }
190
- return cachedRipgrepExecutable
191
- }
192
-
193
- if (await verifyRipgrepExecutable('rg')) {
194
- cachedRipgrepExecutable = { command: 'rg', source: 'system' }
195
- return cachedRipgrepExecutable
196
- }
197
-
198
- cachedRipgrepExecutable = null
199
- return cachedRipgrepExecutable
200
- }
201
-
202
- function buildRipgrepArgs(options, context) {
203
- const args = [
204
- '--line-number',
205
- '--color=never',
206
- '--max-filesize',
207
- RIPGREP_MAX_FILESIZE,
208
- ]
209
-
210
- if (options.filesWithMatches) {
211
- args.push('--files-with-matches')
212
- } else {
213
- args.push('--json')
214
- }
215
- if (!options.regex) args.push('--fixed-strings')
216
- if (!options.caseSensitive) args.push('--ignore-case')
217
- if (!options.respectGitIgnore) args.push('--hidden', '--no-ignore')
218
- if (options.context > 0) args.push('-C', String(options.context))
219
- if (options.beforeContext > 0) args.push('-B', String(options.beforeContext))
220
- if (options.afterContext > 0) args.push('-A', String(options.afterContext))
221
-
222
- for (const pattern of options.glob) args.push('--glob', pattern)
223
- for (const pattern of DEFAULT_EXCLUDE_GLOBS) args.push('--glob', pattern)
224
- for (const pattern of SENSITIVE_EXCLUDE_GLOBS) args.push('--glob', pattern)
225
-
226
- args.push('--', options.query, toWorkspaceRelative(options.root, context) || '.')
227
- return args
228
- }
229
-
230
- function cleanRipgrepLine(value) {
231
- return String(value || '').replace(/[\r\n]+$/, '')
232
- }
233
-
234
- function ripgrepRelativePath(value) {
235
- return String(value || '').replace(/\\/g, '/')
236
- }
237
-
238
- function formatRipgrepJsonEvent(event) {
239
- if (event?.type !== 'match' && event?.type !== 'context') return null
240
- const data = event.data || {}
241
- const file = ripgrepRelativePath(data.path?.text)
242
- const lineNumber = data.line_number
243
- const line = cleanRipgrepLine(data.lines?.text)
244
- if (!file || !lineNumber) return null
245
- const separator = event.type === 'match' ? ':' : '-'
246
- return `${file}:${lineNumber}${separator} ${line}`
247
- }
248
-
249
- function fallbackDetails(extra = {}) {
250
- return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined && value !== null && value !== ''))
251
- }
252
-
253
- async function grepFilesWithRipgrep(executable, options, context, runtime = {}) {
254
- if (options.regex && !isRegexLikelyRipgrepCompatible(options.query)) {
255
- throw new Error('regex uses JavaScript-only features that ripgrep does not support')
256
- }
257
-
258
- const cwd = getToolWorkspaceRoot(context)
259
- const args = buildRipgrepArgs(options, context)
260
- const matches = []
261
- let stderr = ''
262
- let buffer = ''
263
- let killedForLimit = false
264
- let settled = false
265
-
266
- await new Promise((resolve, reject) => {
267
- if (runtime.signal?.aborted) {
268
- reject(new Error('Search aborted'))
269
- return
270
- }
271
-
272
- const child = spawn(executable.command, args, {
273
- cwd,
274
- shell: false,
275
- stdio: ['ignore', 'pipe', 'pipe'],
276
- windowsHide: true,
277
- })
278
-
279
- const cleanup = () => {
280
- clearTimeout(timer)
281
- runtime.signal?.removeEventListener?.('abort', onAbort)
282
- }
283
- const finish = (error) => {
284
- if (settled) return
285
- settled = true
286
- cleanup()
287
- if (error) reject(error)
288
- else resolve()
289
- }
290
- const stopForLimit = () => {
291
- if (killedForLimit) return
292
- killedForLimit = true
293
- killProcessTree(child, 'SIGTERM')
294
- }
295
- const processLine = (line) => {
296
- if (!line || matches.length >= options.limit) return
297
- if (options.filesWithMatches) {
298
- matches.push(ripgrepRelativePath(line))
299
- } else {
300
- try {
301
- const formatted = formatRipgrepJsonEvent(JSON.parse(line))
302
- if (formatted) matches.push(formatted)
303
- } catch {
304
- // Ignore malformed partial output and let process exit handling decide fallback.
305
- }
306
- }
307
- if (matches.length >= options.limit) stopForLimit()
308
- }
309
- const flushLines = (chunk) => {
310
- buffer += chunk.toString()
311
- const lines = buffer.split(/\r?\n/)
312
- buffer = lines.pop() || ''
313
- for (const line of lines) processLine(line)
314
- }
315
- function onAbort() {
316
- killProcessTree(child, 'SIGTERM')
317
- finish(new Error('Search aborted'))
318
- }
319
-
320
- const timer = setTimeout(() => {
321
- killProcessTree(child, 'SIGTERM')
322
- finish(new Error('ripgrep search timed out'))
323
- }, RIPGREP_TIMEOUT_MS)
324
-
325
- runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
326
- child.stdout.on('data', flushLines)
327
- child.stderr.on('data', (chunk) => {
328
- stderr = truncateText(stderr + chunk.toString(), 2000)
329
- })
330
- child.once('error', finish)
331
- child.once('close', (code) => {
332
- if (buffer) processLine(buffer)
333
- if (killedForLimit || code === 0 || code === 1) {
334
- finish()
335
- } else {
336
- finish(new Error(stderr.trim() || `ripgrep exited with code ${code}`))
337
- }
338
- })
339
- })
340
-
341
- return {
342
- content: matches.length ? truncateText(matches.slice(0, options.limit).join('\n')) : 'No matches found.',
343
- details: {
344
- path: toWorkspaceRelative(options.root, context),
345
- project: context?.project,
346
- query: options.query,
347
- count: matches.length,
348
- limit: options.limit,
349
- backend: 'ripgrep',
350
- ripgrepSource: executable.source,
351
- },
352
- }
353
- }
354
-
355
- async function grepFilesWithNode(options, context, extraDetails = {}) {
356
- const flags = options.caseSensitive ? 'g' : 'gi'
357
- const matcher = options.regex
358
- ? new RegExp(options.query, flags)
359
- : new RegExp(escapeRegExp(options.query), flags)
360
-
361
- const files = await walkFiles(options.root, [], context)
362
- const matches = []
363
-
364
- // Stat and filter files in parallel, then grep in parallel batches.
365
- const candidateResults = await poolMap(files, async (file) => {
366
- try {
367
- const stat = await fs.stat(file)
368
- if (stat.size > 1024 * 1024) return { file, skip: true }
369
- return { file, skip: false }
370
- } catch {
371
- return { file, skip: true }
372
- }
373
- })
374
-
375
- const candidates = candidateResults.filter((r) => !r.skip).map((r) => r.file)
376
-
377
- // Grep with bounded concurrency — short-circuit when limit reached.
378
- let matchCount = 0
379
- const filesWithMatches = new Set()
380
- for (let batchStart = 0; batchStart < candidates.length && matchCount < options.limit; batchStart += 20) {
381
- const batch = candidates.slice(batchStart, batchStart + 20)
382
- const batchMatches = await Promise.all(
383
- batch.map(async (file) => {
384
- if (matchCount >= options.limit) return []
385
- try {
386
- const text = await fs.readFile(file, 'utf8')
387
- const lines = splitLines(text)
388
- const fileMatches = []
389
- for (let index = 0; index < lines.length && (matchCount + fileMatches.length) < options.limit; index++) {
390
- matcher.lastIndex = 0
391
- if (matcher.test(lines[index])) {
392
- const relative = toWorkspaceRelative(file, context)
393
- if (options.filesWithMatches) {
394
- if (!filesWithMatches.has(relative)) {
395
- filesWithMatches.add(relative)
396
- fileMatches.push(relative)
397
- }
398
- } else {
399
- fileMatches.push(`${relative}:${index + 1}: ${lines[index]}`)
400
- }
401
- }
402
- }
403
- return fileMatches
404
- } catch {
405
- return []
406
- }
407
- }),
408
- )
409
- for (const fm of batchMatches) {
410
- if (matchCount >= options.limit) break
411
- for (const m of fm) {
412
- if (matchCount >= options.limit) break
413
- matches.push(m)
414
- matchCount++
415
- }
416
- }
417
- }
418
-
419
- return {
420
- content: matches.length ? truncateText(matches.join('\n')) : 'No matches found.',
421
- details: {
422
- path: toWorkspaceRelative(options.root, context),
423
- project: context?.project,
424
- query: options.query,
425
- count: matches.length,
426
- limit: options.limit,
427
- backend: 'node',
428
- ...fallbackDetails(extraDetails),
429
- },
430
- }
431
- }
432
-
433
- export async function toolGrepFiles(params, context, runtime = {}) {
434
- const options = normalizeGrepParams(params, context)
435
- await assertSafeWorkspacePath(options.root, context)
436
-
437
- const executable = await resolveRipgrepExecutable()
438
- if (executable) {
439
- try {
440
- return await grepFilesWithRipgrep(executable, options, context, runtime)
441
- } catch (error) {
442
- if (runtime.signal?.aborted) throw error
443
- return grepFilesWithNode(options, context, {
444
- fallbackFrom: 'ripgrep',
445
- fallbackReason: error?.message || 'ripgrep unavailable',
446
- })
447
- }
448
- }
449
-
450
- return grepFilesWithNode(options, context, { fallbackReason: 'ripgrep unavailable' })
451
- }
452
-
453
- // --- write_file ---
454
- export async function toolWriteFile(params, context) {
455
- const file = resolveWorkspacePath(params?.path, context)
456
- await assertSafeWorkspacePath(file, context, { forWrite: true })
457
-
458
- const content = String(params?.content ?? '')
459
- const relativePath = toWorkspaceRelative(file, context)
460
- let oldText = ''
461
- let existed = true
462
- try {
463
- oldText = await fs.readFile(file, 'utf8')
464
- } catch (error) {
465
- if (error?.code !== 'ENOENT') throw error
466
- existed = false
467
- }
468
- const diff = createTextDiff(oldText, content, relativePath, { oldExists: existed })
469
-
470
- await fs.mkdir(path.dirname(file), { recursive: true })
471
- await fs.writeFile(file, content, 'utf8')
472
-
473
- return {
474
- content: `${existed ? 'Wrote' : 'Created'} ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
475
- details: { path: relativePath, project: context?.project, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, diff },
476
- }
477
- }
478
-
1
+ import { createWriteStream, promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import { createRequire } from 'node:module'
5
+ import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, walkFiles } from '../utils/workspace.mjs'
6
+ import { logsDir } from '../storage.mjs'
7
+ import { createTextDiff } from '../utils/text-diff.mjs'
8
+ import {
9
+ formatSkillActivation,
10
+ loadSelectedGlobalSkills,
11
+ loadSelectedProjectSkills,
12
+ mergeSkills,
13
+ readSkillResource,
14
+ } from '../skills.mjs'
15
+ import { getToolWorkspaceRoot } from '../utils/workspace.mjs'
16
+
17
+ const require = createRequire(import.meta.url)
18
+
19
+ // --- read_file ---
20
+ export async function toolReadFile(params, context) {
21
+ const file = resolveWorkspacePath(params?.path, context)
22
+ await assertSafeWorkspacePath(file, context)
23
+
24
+ const text = await fs.readFile(file, 'utf8')
25
+ const lines = splitLines(text)
26
+ const offset = Math.max(1, Number(params?.offset || 1))
27
+ const limit = Math.min(2000, Math.max(1, Number(params?.limit || 200)))
28
+ const selected = lines.slice(offset - 1, offset - 1 + limit)
29
+ const content = selected.map((line, index) => `${offset + index}: ${line}`).join('\n')
30
+ const suffix = offset - 1 + limit < lines.length ? `\n\n[showing ${selected.length} of ${lines.length} lines]` : ''
31
+
32
+ return {
33
+ content: truncateText(`${content}${suffix}`),
34
+ details: { path: toWorkspaceRelative(file, context), project: context?.project, totalLines: lines.length, offset, limit },
35
+ }
36
+ }
37
+
38
+ // --- grep_files ---
39
+
40
+ const RIPGREP_MAX_FILESIZE = '1M'
41
+ const RIPGREP_TIMEOUT_MS = 60 * 1000
42
+ const DEFAULT_EXCLUDE_GLOBS = [
43
+ '!.git/**',
44
+ '!node_modules/**',
45
+ '!dist/**',
46
+ '!dist-ssr/**',
47
+ '!.vite/**',
48
+ '!**/*.png',
49
+ '!**/*.jpg',
50
+ '!**/*.jpeg',
51
+ '!**/*.gif',
52
+ '!**/*.webp',
53
+ '!**/*.ico',
54
+ '!**/*.pdf',
55
+ '!**/*.zip',
56
+ '!**/*.gz',
57
+ '!**/*.7z',
58
+ '!**/*.exe',
59
+ '!**/*.dll',
60
+ '!**/*.woff',
61
+ '!**/*.woff2',
62
+ '!**/*.ttf',
63
+ ]
64
+ const SENSITIVE_EXCLUDE_GLOBS = [
65
+ '!.env',
66
+ '!**/.env',
67
+ '!.env.*',
68
+ '!**/.env.*',
69
+ '!**/*.pem',
70
+ '!**/*.key',
71
+ '!**/*.p12',
72
+ '!**/*.pfx',
73
+ '!**/*.crt',
74
+ '!**/*.cer',
75
+ '!**/*.token',
76
+ '!credentials.json',
77
+ '!**/credentials.json',
78
+ '!secrets.json',
79
+ '!**/secrets.json',
80
+ '!id_rsa',
81
+ '!**/id_rsa',
82
+ '!id_ed25519',
83
+ '!**/id_ed25519',
84
+ ]
85
+
86
+ let cachedRipgrepExecutable
87
+
88
+ /**
89
+ * Process items with bounded concurrency. Returns results in input order.
90
+ * @template T, R
91
+ * @param {T[]} items
92
+ * @param {(item: T, index: number) => Promise<R>} fn
93
+ * @param {number} concurrency
94
+ * @returns {Promise<R[]>}
95
+ */
96
+ async function poolMap(items, fn, concurrency = 20) {
97
+ const results = new Array(items.length)
98
+ let cursor = 0
99
+
100
+ async function worker() {
101
+ while (cursor < items.length) {
102
+ const index = cursor++
103
+ results[index] = await fn(items[index], index)
104
+ }
105
+ }
106
+
107
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
108
+ await Promise.all(workers)
109
+ return results
110
+ }
111
+
112
+ function clampNumber(value, defaultValue, min, max) {
113
+ const number = Number(value)
114
+ if (!Number.isFinite(number)) return defaultValue
115
+ return Math.min(max, Math.max(min, Math.trunc(number)))
116
+ }
117
+
118
+ function escapeRegExp(value) {
119
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
120
+ }
121
+
122
+ function normalizeGlobList(value) {
123
+ const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
124
+ return values
125
+ .map((item) => String(item || '').trim())
126
+ .filter(Boolean)
127
+ .slice(0, 50)
128
+ }
129
+
130
+ function normalizeGrepParams(params, context) {
131
+ const root = resolveWorkspacePath(params?.path || '.', context)
132
+ const query = String(params?.query || '')
133
+ if (!query) {
134
+ const error = new Error('query is required')
135
+ error.statusCode = 400
136
+ throw error
137
+ }
138
+
139
+ const flags = params?.caseSensitive ? 'g' : 'gi'
140
+ try {
141
+ params?.regex
142
+ ? new RegExp(query, flags)
143
+ : new RegExp(escapeRegExp(query), flags)
144
+ } catch {
145
+ const error = new Error('Invalid regular expression')
146
+ error.statusCode = 400
147
+ throw error
148
+ }
149
+
150
+ return {
151
+ root,
152
+ query,
153
+ regex: Boolean(params?.regex),
154
+ caseSensitive: Boolean(params?.caseSensitive),
155
+ limit: clampNumber(params?.limit, 200, 1, 1000),
156
+ glob: normalizeGlobList(params?.glob),
157
+ context: clampNumber(params?.context, 0, 0, 20),
158
+ beforeContext: clampNumber(params?.beforeContext, 0, 0, 20),
159
+ afterContext: clampNumber(params?.afterContext, 0, 0, 20),
160
+ filesWithMatches: Boolean(params?.filesWithMatches),
161
+ respectGitIgnore: Boolean(params?.respectGitIgnore),
162
+ }
163
+ }
164
+
165
+ function isRegexLikelyRipgrepCompatible(query) {
166
+ return !(/\(\?[=!<]/.test(query) || /\\[1-9]/.test(query))
167
+ }
168
+
169
+ function ripgrepCandidatePath() {
170
+ try {
171
+ return require('@vscode/ripgrep').rgPath || null
172
+ } catch {
173
+ return null
174
+ }
175
+ }
176
+
177
+ async function verifyRipgrepExecutable(command) {
178
+ return new Promise((resolve) => {
179
+ const child = spawn(command, ['--version'], { shell: false, windowsHide: true })
180
+ child.once('error', () => resolve(false))
181
+ child.once('close', (code) => resolve(code === 0))
182
+ })
183
+ }
184
+
185
+ async function resolveRipgrepExecutable() {
186
+ if (cachedRipgrepExecutable !== undefined) return cachedRipgrepExecutable
187
+
188
+ const bundled = ripgrepCandidatePath()
189
+ if (bundled && await verifyRipgrepExecutable(bundled)) {
190
+ cachedRipgrepExecutable = { command: bundled, source: 'bundled' }
191
+ return cachedRipgrepExecutable
192
+ }
193
+
194
+ if (await verifyRipgrepExecutable('rg')) {
195
+ cachedRipgrepExecutable = { command: 'rg', source: 'system' }
196
+ return cachedRipgrepExecutable
197
+ }
198
+
199
+ cachedRipgrepExecutable = null
200
+ return cachedRipgrepExecutable
201
+ }
202
+
203
+ function buildRipgrepArgs(options, context) {
204
+ const args = [
205
+ '--line-number',
206
+ '--color=never',
207
+ '--max-filesize',
208
+ RIPGREP_MAX_FILESIZE,
209
+ ]
210
+
211
+ if (options.filesWithMatches) {
212
+ args.push('--files-with-matches')
213
+ } else {
214
+ args.push('--json')
215
+ }
216
+ if (!options.regex) args.push('--fixed-strings')
217
+ if (!options.caseSensitive) args.push('--ignore-case')
218
+ if (!options.respectGitIgnore) args.push('--hidden', '--no-ignore')
219
+ if (options.context > 0) args.push('-C', String(options.context))
220
+ if (options.beforeContext > 0) args.push('-B', String(options.beforeContext))
221
+ if (options.afterContext > 0) args.push('-A', String(options.afterContext))
222
+
223
+ for (const pattern of options.glob) args.push('--glob', pattern)
224
+ for (const pattern of DEFAULT_EXCLUDE_GLOBS) args.push('--glob', pattern)
225
+ for (const pattern of SENSITIVE_EXCLUDE_GLOBS) args.push('--glob', pattern)
226
+
227
+ args.push('--', options.query, toWorkspaceRelative(options.root, context) || '.')
228
+ return args
229
+ }
230
+
231
+ function cleanRipgrepLine(value) {
232
+ return String(value || '').replace(/[\r\n]+$/, '')
233
+ }
234
+
235
+ function ripgrepRelativePath(value) {
236
+ return String(value || '').replace(/\\/g, '/')
237
+ }
238
+
239
+ function formatCommandArg(value) {
240
+ const text = String(value ?? '')
241
+ return /^[A-Za-z0-9_@%+=:,./\\-]+$/.test(text) ? text : JSON.stringify(text)
242
+ }
243
+
244
+ function formatSpawnCommand(command, args) {
245
+ return [command, ...args].map(formatCommandArg).join(' ')
246
+ }
247
+
248
+ function grepSearchOptionsDetails(options) {
249
+ return {
250
+ regex: options.regex,
251
+ caseSensitive: options.caseSensitive,
252
+ glob: options.glob,
253
+ context: options.context,
254
+ beforeContext: options.beforeContext,
255
+ afterContext: options.afterContext,
256
+ filesWithMatches: options.filesWithMatches,
257
+ respectGitIgnore: options.respectGitIgnore,
258
+ maxFileSize: RIPGREP_MAX_FILESIZE,
259
+ defaultExcludeGlobs: DEFAULT_EXCLUDE_GLOBS,
260
+ sensitiveExcludeGlobs: SENSITIVE_EXCLUDE_GLOBS,
261
+ }
262
+ }
263
+
264
+ function formatRipgrepJsonEvent(event) {
265
+ if (event?.type !== 'match' && event?.type !== 'context') return null
266
+ const data = event.data || {}
267
+ const file = ripgrepRelativePath(data.path?.text)
268
+ const lineNumber = data.line_number
269
+ const line = cleanRipgrepLine(data.lines?.text)
270
+ if (!file || !lineNumber) return null
271
+ const separator = event.type === 'match' ? ':' : '-'
272
+ return `${file}:${lineNumber}${separator} ${line}`
273
+ }
274
+
275
+ function fallbackDetails(extra = {}) {
276
+ return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined && value !== null && value !== ''))
277
+ }
278
+
279
+ async function grepFilesWithRipgrep(executable, options, context, runtime = {}) {
280
+ if (options.regex && !isRegexLikelyRipgrepCompatible(options.query)) {
281
+ throw new Error('regex uses JavaScript-only features that ripgrep does not support')
282
+ }
283
+
284
+ const cwd = getToolWorkspaceRoot(context)
285
+ const args = buildRipgrepArgs(options, context)
286
+ const matches = []
287
+ let stderr = ''
288
+ let buffer = ''
289
+ let killedForLimit = false
290
+ let settled = false
291
+
292
+ await new Promise((resolve, reject) => {
293
+ if (runtime.signal?.aborted) {
294
+ reject(new Error('Search aborted'))
295
+ return
296
+ }
297
+
298
+ const child = spawn(executable.command, args, {
299
+ cwd,
300
+ shell: false,
301
+ stdio: ['ignore', 'pipe', 'pipe'],
302
+ windowsHide: true,
303
+ })
304
+
305
+ const cleanup = () => {
306
+ clearTimeout(timer)
307
+ runtime.signal?.removeEventListener?.('abort', onAbort)
308
+ }
309
+ const finish = (error) => {
310
+ if (settled) return
311
+ settled = true
312
+ cleanup()
313
+ if (error) reject(error)
314
+ else resolve()
315
+ }
316
+ const stopForLimit = () => {
317
+ if (killedForLimit) return
318
+ killedForLimit = true
319
+ killProcessTree(child, 'SIGTERM')
320
+ }
321
+ const processLine = (line) => {
322
+ if (!line || matches.length >= options.limit) return
323
+ if (options.filesWithMatches) {
324
+ matches.push(ripgrepRelativePath(line))
325
+ } else {
326
+ try {
327
+ const formatted = formatRipgrepJsonEvent(JSON.parse(line))
328
+ if (formatted) matches.push(formatted)
329
+ } catch {
330
+ // Ignore malformed partial output and let process exit handling decide fallback.
331
+ }
332
+ }
333
+ if (matches.length >= options.limit) stopForLimit()
334
+ }
335
+ const flushLines = (chunk) => {
336
+ buffer += chunk.toString()
337
+ const lines = buffer.split(/\r?\n/)
338
+ buffer = lines.pop() || ''
339
+ for (const line of lines) processLine(line)
340
+ }
341
+ function onAbort() {
342
+ killProcessTree(child, 'SIGTERM')
343
+ finish(new Error('Search aborted'))
344
+ }
345
+
346
+ const timer = setTimeout(() => {
347
+ killProcessTree(child, 'SIGTERM')
348
+ finish(new Error('ripgrep search timed out'))
349
+ }, RIPGREP_TIMEOUT_MS)
350
+
351
+ runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
352
+ child.stdout.on('data', flushLines)
353
+ child.stderr.on('data', (chunk) => {
354
+ stderr = truncateText(stderr + chunk.toString(), 2000)
355
+ })
356
+ child.once('error', finish)
357
+ child.once('close', (code) => {
358
+ if (buffer) processLine(buffer)
359
+ if (killedForLimit || code === 0 || code === 1) {
360
+ finish()
361
+ } else {
362
+ finish(new Error(stderr.trim() || `ripgrep exited with code ${code}`))
363
+ }
364
+ })
365
+ })
366
+
367
+ return {
368
+ content: matches.length ? truncateText(matches.slice(0, options.limit).join('\n')) : 'No matches found.',
369
+ details: {
370
+ path: toWorkspaceRelative(options.root, context),
371
+ project: context?.project,
372
+ query: options.query,
373
+ count: matches.length,
374
+ limit: options.limit,
375
+ backend: 'ripgrep',
376
+ ripgrepSource: executable.source,
377
+ cwd,
378
+ command: formatSpawnCommand(executable.command, args),
379
+ executable: executable.command,
380
+ args,
381
+ searchOptions: grepSearchOptionsDetails(options),
382
+ },
383
+ }
384
+ }
385
+
386
+ async function grepFilesWithNode(options, context, extraDetails = {}) {
387
+ const flags = options.caseSensitive ? 'g' : 'gi'
388
+ const matcher = options.regex
389
+ ? new RegExp(options.query, flags)
390
+ : new RegExp(escapeRegExp(options.query), flags)
391
+
392
+ const files = await walkFiles(options.root, [], context)
393
+ const matches = []
394
+
395
+ // Stat and filter files in parallel, then grep in parallel batches.
396
+ const candidateResults = await poolMap(files, async (file) => {
397
+ try {
398
+ const stat = await fs.stat(file)
399
+ if (stat.size > 1024 * 1024) return { file, skip: true }
400
+ return { file, skip: false }
401
+ } catch {
402
+ return { file, skip: true }
403
+ }
404
+ })
405
+
406
+ const candidates = candidateResults.filter((r) => !r.skip).map((r) => r.file)
407
+
408
+ // Grep with bounded concurrency — short-circuit when limit reached.
409
+ let matchCount = 0
410
+ const filesWithMatches = new Set()
411
+ for (let batchStart = 0; batchStart < candidates.length && matchCount < options.limit; batchStart += 20) {
412
+ const batch = candidates.slice(batchStart, batchStart + 20)
413
+ const batchMatches = await Promise.all(
414
+ batch.map(async (file) => {
415
+ if (matchCount >= options.limit) return []
416
+ try {
417
+ const text = await fs.readFile(file, 'utf8')
418
+ const lines = splitLines(text)
419
+ const fileMatches = []
420
+ for (let index = 0; index < lines.length && (matchCount + fileMatches.length) < options.limit; index++) {
421
+ matcher.lastIndex = 0
422
+ if (matcher.test(lines[index])) {
423
+ const relative = toWorkspaceRelative(file, context)
424
+ if (options.filesWithMatches) {
425
+ if (!filesWithMatches.has(relative)) {
426
+ filesWithMatches.add(relative)
427
+ fileMatches.push(relative)
428
+ }
429
+ } else {
430
+ fileMatches.push(`${relative}:${index + 1}: ${lines[index]}`)
431
+ }
432
+ }
433
+ }
434
+ return fileMatches
435
+ } catch {
436
+ return []
437
+ }
438
+ }),
439
+ )
440
+ for (const fm of batchMatches) {
441
+ if (matchCount >= options.limit) break
442
+ for (const m of fm) {
443
+ if (matchCount >= options.limit) break
444
+ matches.push(m)
445
+ matchCount++
446
+ }
447
+ }
448
+ }
449
+
450
+ return {
451
+ content: matches.length ? truncateText(matches.join('\n')) : 'No matches found.',
452
+ details: {
453
+ path: toWorkspaceRelative(options.root, context),
454
+ project: context?.project,
455
+ query: options.query,
456
+ count: matches.length,
457
+ limit: options.limit,
458
+ backend: 'node',
459
+ searchOptions: grepSearchOptionsDetails(options),
460
+ ...fallbackDetails(extraDetails),
461
+ },
462
+ }
463
+ }
464
+
465
+ export async function toolGrepFiles(params, context, runtime = {}) {
466
+ const options = normalizeGrepParams(params, context)
467
+ await assertSafeWorkspacePath(options.root, context)
468
+
469
+ const executable = await resolveRipgrepExecutable()
470
+ if (executable) {
471
+ try {
472
+ return await grepFilesWithRipgrep(executable, options, context, runtime)
473
+ } catch (error) {
474
+ if (runtime.signal?.aborted) throw error
475
+ return grepFilesWithNode(options, context, {
476
+ fallbackFrom: 'ripgrep',
477
+ fallbackReason: error?.message || 'ripgrep unavailable',
478
+ })
479
+ }
480
+ }
481
+
482
+ return grepFilesWithNode(options, context, { fallbackReason: 'ripgrep unavailable' })
483
+ }
484
+
485
+ // --- write_file ---
486
+ export async function toolWriteFile(params, context) {
487
+ const file = resolveWorkspacePath(params?.path, context)
488
+ await assertSafeWorkspacePath(file, context, { forWrite: true })
489
+
490
+ const content = String(params?.content ?? '')
491
+ const relativePath = toWorkspaceRelative(file, context)
492
+ let oldText = ''
493
+ let existed = true
494
+ try {
495
+ oldText = await fs.readFile(file, 'utf8')
496
+ } catch (error) {
497
+ if (error?.code !== 'ENOENT') throw error
498
+ existed = false
499
+ }
500
+ const diff = createTextDiff(oldText, content, relativePath, { oldExists: existed })
501
+
502
+ await fs.mkdir(path.dirname(file), { recursive: true })
503
+ await fs.writeFile(file, content, 'utf8')
504
+
505
+ return {
506
+ content: `${existed ? 'Wrote' : 'Created'} ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
507
+ details: { path: relativePath, project: context?.project, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, diff },
508
+ }
509
+ }
510
+
479
511
  // --- edit_file ---
480
512
  function countOccurrences(text, needle) {
481
513
  if (!needle) return 0
@@ -533,251 +565,449 @@ export async function toolEditFile(params, context) {
533
565
  }
534
566
  }
535
567
 
536
- // --- run_command ---
537
- function activeSkillsForContext(context) {
538
- return mergeSkills(context?.globalSkills, context?.projectSkills)
539
- }
540
-
541
- function activeSkillByName(context, name) {
542
- const skillName = String(name || '')
543
- return activeSkillsForContext(context).find((skill) => skill.name === skillName)
544
- }
545
-
546
- export async function loadSkillToolContext(config = {}) {
547
- const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
548
- const projectSkills = config.workspaceRoot
549
- ? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
550
- : []
551
- return { globalSkills, projectSkills }
552
- }
553
-
554
- // --- activate_skill ---
555
- export async function toolActivateSkill(params, context) {
556
- const skill = activeSkillByName(context, params?.name)
557
- if (!skill) {
558
- const error = new Error(`Unknown or disabled skill: ${params?.name || ''}`)
559
- error.statusCode = 404
560
- throw error
561
- }
562
-
563
- return {
564
- content: truncateText(await formatSkillActivation(skill)),
565
- details: {
566
- skill: skill.name,
567
- source: skill.source,
568
- directory: skill.rootDir,
569
- },
570
- }
571
- }
572
-
573
- // --- read_skill_resource ---
574
- export async function toolReadSkillResource(params, context) {
575
- const skill = activeSkillByName(context, params?.skill)
576
- if (!skill) {
577
- const error = new Error(`Unknown or disabled skill: ${params?.skill || ''}`)
578
- error.statusCode = 404
579
- throw error
580
- }
581
-
582
- const result = await readSkillResource(skill, params?.path, params)
583
- return {
584
- content: truncateText(result.content),
585
- details: result.details,
586
- }
587
- }
588
-
589
- // --- run_command ---
590
- function commandStatus(meta = {}) {
591
- if (meta.running) return 'Status: running'
592
- const flags = [
593
- meta.timedOut ? 'timed out' : null,
594
- meta.aborted ? 'aborted' : null,
595
- ].filter(Boolean)
596
- const suffix = flags.length ? ` (${flags.join(', ')})` : ''
597
- return `Exit code: ${meta.code ?? 'unknown'}${meta.signal ? `, signal: ${meta.signal}` : ''}${suffix}`
598
- }
599
-
600
- function formatCommandOutput(command, stdout, stderr, meta = {}) {
601
- return [
602
- `Command: ${command}`,
603
- commandStatus(meta),
604
- '',
605
- 'STDOUT:',
606
- stdout || '(empty)',
607
- '',
608
- 'STDERR:',
609
- stderr || '(empty)',
610
- ].join('\n')
611
- }
612
-
613
- function killProcessTree(child, signal = 'SIGTERM') {
614
- if (!child?.pid) return
615
-
616
- if (process.platform === 'win32') {
617
- const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
618
- stdio: 'ignore',
619
- windowsHide: true,
620
- })
621
- killer.on('error', () => {
622
- try { child.kill(signal) } catch { /* ignore */ }
623
- })
624
- return
625
- }
626
-
627
- try {
628
- process.kill(-child.pid, signal)
629
- } catch {
630
- try { child.kill(signal) } catch { /* ignore */ }
631
- }
632
- }
633
-
634
- const runningCommands = new Map()
635
-
636
- export function abortRunningCommand(toolCallId) {
637
- if (!toolCallId) return false
638
- const stop = runningCommands.get(toolCallId)
639
- if (!stop) return false
640
- stop('abort')
641
- return true
642
- }
643
-
644
- const RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
645
-
646
- export async function toolRunCommand(params, context, runtime = {}) {
647
- const command = String(params?.command || '')
648
- if (!command.trim()) {
649
- const error = new Error('command is required')
650
- error.statusCode = 400
651
- throw error
652
- }
653
-
654
- const timeoutMs = RUN_COMMAND_TIMEOUT_MS
655
- const cwd = getToolWorkspaceRoot(context)
656
-
657
- if (runtime.signal?.aborted) {
658
- const content = formatCommandOutput(command, '', 'Command aborted before start.', { aborted: true })
659
- return { content: truncateText(content), details: { command, project: context?.project, cwd, aborted: true } }
660
- }
661
-
662
- return new Promise((resolve) => {
663
- const child = spawn(command, {
664
- cwd,
665
- shell: true,
666
- stdio: ['ignore', 'pipe', 'pipe'],
667
- windowsHide: true,
668
- detached: process.platform !== 'win32',
669
- })
670
-
671
- let stdout = ''
672
- let stderr = ''
673
- let timedOut = false
674
- let aborted = false
675
- let settled = false
676
- let updateTimer = null
677
- let updatePending = false
678
- let forceKillTimer = null
679
-
680
- const cleanup = () => {
681
- clearTimeout(timer)
682
- if (forceKillTimer) clearTimeout(forceKillTimer)
683
- if (updateTimer) clearTimeout(updateTimer)
684
- if (runtime.toolCallId) runningCommands.delete(runtime.toolCallId)
685
- runtime.signal?.removeEventListener?.('abort', onAbort)
686
- }
687
-
688
- const finish = ({ code = null, signal = null, error = null } = {}) => {
689
- if (settled) return
690
- flushUpdate()
691
- settled = true
692
- cleanup()
693
- if (error) {
694
- resolve({
695
- isError: true,
696
- content: truncateText(`Error running command: ${error.message}`),
697
- details: { command, project: context?.project, cwd, error: error.message, aborted, timedOut },
698
- })
699
- return
700
- }
701
- const content = formatCommandOutput(command, stdout, stderr, { code, signal, timedOut, aborted })
702
- resolve({ content: truncateText(content), details: { command, project: context?.project, cwd, code, signal, timedOut, aborted } })
703
- }
704
-
705
- const stopChild = (reason) => {
706
- if (reason === 'timeout') timedOut = true
707
- if (reason === 'abort') aborted = true
708
- killProcessTree(child, 'SIGTERM')
709
- forceKillTimer = setTimeout(() => {
710
- killProcessTree(child, 'SIGKILL')
711
- }, 1500)
712
- }
713
-
714
- if (runtime.toolCallId) runningCommands.set(runtime.toolCallId, stopChild)
715
-
716
- function onAbort() {
717
- stopChild('abort')
718
- finish({ signal: 'SIGTERM' })
719
- }
720
-
721
- const runningDetails = () => ({ command, project: context?.project, cwd, running: true, stdout, stderr, toolCallId: runtime.toolCallId })
722
-
723
- const emitUpdate = () => {
724
- updateTimer = null
725
- if (settled || !updatePending) return
726
- updatePending = false
727
- runtime.onUpdate?.({
728
- content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
729
- details: runningDetails(),
730
- })
731
- }
732
- const flushUpdate = () => {
733
- if (updateTimer) {
734
- clearTimeout(updateTimer)
735
- updateTimer = null
736
- }
737
- if (!updatePending) return
738
- updatePending = false
739
- runtime.onUpdate?.({
740
- content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
741
- details: runningDetails(),
742
- })
743
- }
744
- const scheduleUpdate = () => {
745
- if (settled) return
746
- updatePending = true
747
- if (!updateTimer) updateTimer = setTimeout(emitUpdate, 150)
748
- }
749
- const timer = setTimeout(() => {
750
- stopChild('timeout')
751
- finish({ signal: 'SIGTERM' })
752
- }, timeoutMs)
753
-
754
- runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
755
-
756
- child.stdout.on('data', (chunk) => {
757
- if (settled) return
758
- stdout = truncateText(stdout + chunk.toString())
759
- scheduleUpdate()
760
- })
761
- child.stderr.on('data', (chunk) => {
762
- if (settled) return
763
- stderr = truncateText(stderr + chunk.toString())
764
- scheduleUpdate()
765
- })
766
- child.on('close', (code, signal) => {
767
- finish({ code, signal })
768
- })
769
- child.on('error', (err) => {
770
- finish({ error: err })
771
- })
772
- })
773
- }
774
-
775
- export const toolHandlers = {
776
- read_file: toolReadFile,
777
- grep_files: toolGrepFiles,
778
- write_file: toolWriteFile,
779
- edit_file: toolEditFile,
780
- run_command: toolRunCommand,
781
- activate_skill: toolActivateSkill,
782
- read_skill_resource: toolReadSkillResource,
783
- }
568
+ // --- run_command ---
569
+ function activeSkillsForContext(context) {
570
+ return mergeSkills(context?.globalSkills, context?.projectSkills)
571
+ }
572
+
573
+ function activeSkillByName(context, name) {
574
+ const skillName = String(name || '')
575
+ return activeSkillsForContext(context).find((skill) => skill.name === skillName)
576
+ }
577
+
578
+ export async function loadSkillToolContext(config = {}) {
579
+ const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
580
+ const projectSkills = config.workspaceRoot
581
+ ? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
582
+ : []
583
+ return { globalSkills, projectSkills }
584
+ }
585
+
586
+ // --- activate_skill ---
587
+ export async function toolActivateSkill(params, context) {
588
+ const skill = activeSkillByName(context, params?.name)
589
+ if (!skill) {
590
+ const error = new Error(`Unknown or disabled skill: ${params?.name || ''}`)
591
+ error.statusCode = 404
592
+ throw error
593
+ }
594
+
595
+ return {
596
+ content: truncateText(await formatSkillActivation(skill)),
597
+ details: {
598
+ skill: skill.name,
599
+ source: skill.source,
600
+ directory: skill.rootDir,
601
+ },
602
+ }
603
+ }
604
+
605
+ // --- read_skill_resource ---
606
+ export async function toolReadSkillResource(params, context) {
607
+ const skill = activeSkillByName(context, params?.skill)
608
+ if (!skill) {
609
+ const error = new Error(`Unknown or disabled skill: ${params?.skill || ''}`)
610
+ error.statusCode = 404
611
+ throw error
612
+ }
613
+
614
+ const result = await readSkillResource(skill, params?.path, params)
615
+ return {
616
+ content: truncateText(result.content),
617
+ details: result.details,
618
+ }
619
+ }
620
+
621
+ // --- run_command ---
622
+ const DEFAULT_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
623
+ const MIN_RUN_COMMAND_TIMEOUT_MS = 1000
624
+ const MAX_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
625
+ const COMMAND_PREVIEW_LINES = 200
626
+ const COMMAND_PREVIEW_TOTAL_CHARS = 10000
627
+
628
+ function formatDurationMs(durationMs = 0) {
629
+ const totalSeconds = Math.max(0, Math.floor(durationMs / 1000))
630
+ const minutes = Math.floor(totalSeconds / 60)
631
+ const seconds = totalSeconds % 60
632
+ if (minutes > 0) return `${minutes}m ${seconds}s (${durationMs}ms)`
633
+ return `${seconds}s (${durationMs}ms)`
634
+ }
635
+
636
+ function tailText(current, chunk, maxLines = COMMAND_PREVIEW_LINES, maxChars = COMMAND_PREVIEW_TOTAL_CHARS) {
637
+ const lines = (current + chunk).split(/\r?\n/)
638
+ const truncatedByLines = lines.length > maxLines
639
+ const lineLimitedText = truncatedByLines ? lines.slice(lines.length - maxLines).join('\n') : lines.join('\n')
640
+ const charLimitedText = tailChars(lineLimitedText, maxChars)
641
+ return {
642
+ text: charLimitedText.text,
643
+ truncated: truncatedByLines || charLimitedText.truncated,
644
+ }
645
+ }
646
+
647
+ function tailChars(text, maxChars) {
648
+ if (text.length <= maxChars) return { text, truncated: false }
649
+ return { text: text.slice(text.length - maxChars), truncated: true }
650
+ }
651
+
652
+ function balanceCommandPreviews(stdout, stderr, maxChars = COMMAND_PREVIEW_TOTAL_CHARS) {
653
+ if (stdout.length + stderr.length <= maxChars) {
654
+ return { stdout, stderr, stdoutTruncated: false, stderrTruncated: false }
655
+ }
656
+
657
+ const half = Math.floor(maxChars / 2)
658
+ let stdoutBudget = Math.min(stdout.length, half)
659
+ let stderrBudget = Math.min(stderr.length, half)
660
+ const remaining = maxChars - stdoutBudget - stderrBudget
661
+
662
+ if (remaining > 0) {
663
+ const stdoutNeed = Math.max(0, stdout.length - stdoutBudget)
664
+ const stderrNeed = Math.max(0, stderr.length - stderrBudget)
665
+ if (stderrNeed >= stdoutNeed) stderrBudget += Math.min(remaining, stderrNeed)
666
+ else stdoutBudget += Math.min(remaining, stdoutNeed)
667
+ }
668
+
669
+ const nextStdout = tailChars(stdout, stdoutBudget)
670
+ const nextStderr = tailChars(stderr, stderrBudget)
671
+ return {
672
+ stdout: nextStdout.text,
673
+ stderr: nextStderr.text,
674
+ stdoutTruncated: nextStdout.truncated,
675
+ stderrTruncated: nextStderr.truncated,
676
+ }
677
+ }
678
+
679
+ function tailLabel(name, truncated) {
680
+ return truncated ? `${name} preview (last ${COMMAND_PREVIEW_LINES} lines, shared ${COMMAND_PREVIEW_TOTAL_CHARS} chars):` : `${name} preview:`
681
+ }
682
+
683
+ function commandStatus(meta = {}) {
684
+ if (meta.running) return 'Status: running'
685
+ const flags = [
686
+ meta.timedOut ? 'timed out' : null,
687
+ meta.aborted ? 'aborted' : null,
688
+ ].filter(Boolean)
689
+ const suffix = flags.length ? ` (${flags.join(', ')})` : ''
690
+ return `Exit code: ${meta.code ?? 'unknown'}${meta.signal ? `, signal: ${meta.signal}` : ''}${suffix}`
691
+ }
692
+
693
+ function formatCommandOutput(command, stdout, stderr, meta = {}) {
694
+ const lines = [
695
+ `Command: ${command}`,
696
+ ]
697
+ if (meta.description) lines.push(`Description: ${meta.description}`)
698
+ lines.push(commandStatus(meta))
699
+ if (typeof meta.durationMs === 'number') lines.push(`Duration: ${formatDurationMs(meta.durationMs)}`)
700
+ if (typeof meta.timeoutMs === 'number') lines.push(`Timeout: ${formatDurationMs(meta.timeoutMs)}`)
701
+ if (meta.cwd) lines.push(`CWD: ${meta.cwd}`)
702
+ if (meta.outputFile) lines.push(`Full output: ${meta.outputFile}`)
703
+ if (meta.truncated) lines.push(`Output mode: showing stdout/stderr previews; each stream is limited to the last ${COMMAND_PREVIEW_LINES} lines and both streams share ${COMMAND_PREVIEW_TOTAL_CHARS} characters. Full output is saved to the log file.`)
704
+ if (meta.logError) lines.push(`Log warning: ${meta.logError}`)
705
+ lines.push('', tailLabel('STDOUT', meta.stdoutTruncated), stdout || '(empty)', '', tailLabel('STDERR', meta.stderrTruncated), stderr || '(empty)')
706
+ return lines.join('\n')
707
+ }
708
+
709
+ function safeLogFilePart(value, fallback) {
710
+ const text = String(value || '').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80)
711
+ return text || fallback
712
+ }
713
+
714
+ async function createCommandLogStream(command, { cwd, description, toolCallId } = {}) {
715
+ const dir = path.join(logsDir, 'commands')
716
+ await fs.mkdir(dir, { recursive: true })
717
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
718
+ const fileName = `${timestamp}_${safeLogFilePart(toolCallId, 'command')}.log`
719
+ const outputFile = path.join(dir, fileName)
720
+ const stream = createWriteStream(outputFile, { flags: 'wx' })
721
+ stream.write([
722
+ `Command: ${command}`,
723
+ description ? `Description: ${description}` : null,
724
+ `CWD: ${cwd}`,
725
+ `Started at: ${new Date().toISOString()}`,
726
+ '',
727
+ ].filter(Boolean).join('\n'))
728
+ return { stream, outputFile }
729
+ }
730
+
731
+ function writeCommandLog(stream, source, chunk) {
732
+ stream.write(`\n[${source} ${new Date().toISOString()}]\n`)
733
+ stream.write(chunk)
734
+ }
735
+
736
+ function killProcessTree(child, signal = 'SIGTERM') {
737
+ if (!child?.pid) return
738
+
739
+ if (process.platform === 'win32') {
740
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
741
+ stdio: 'ignore',
742
+ windowsHide: true,
743
+ })
744
+ killer.on('error', () => {
745
+ try { child.kill(signal) } catch { /* ignore */ }
746
+ })
747
+ return
748
+ }
749
+
750
+ try {
751
+ process.kill(-child.pid, signal)
752
+ } catch {
753
+ try { child.kill(signal) } catch { /* ignore */ }
754
+ }
755
+ }
756
+
757
+ const runningCommands = new Map()
758
+
759
+ export function abortRunningCommand(toolCallId) {
760
+ if (!toolCallId) return false
761
+ const stop = runningCommands.get(toolCallId)
762
+ if (!stop) return false
763
+ stop('abort')
764
+ return true
765
+ }
766
+
767
+ export async function toolRunCommand(params, context, runtime = {}) {
768
+ const command = String(params?.command || '')
769
+ if (!command.trim()) {
770
+ const error = new Error('command is required')
771
+ error.statusCode = 400
772
+ throw error
773
+ }
774
+
775
+ const description = String(params?.description || '').trim().slice(0, 500)
776
+ const timeoutMs = clampNumber(params?.timeoutMs, DEFAULT_RUN_COMMAND_TIMEOUT_MS, MIN_RUN_COMMAND_TIMEOUT_MS, MAX_RUN_COMMAND_TIMEOUT_MS)
777
+ const cwd = getToolWorkspaceRoot(context)
778
+ const startedAt = Date.now()
779
+
780
+ if (runtime.signal?.aborted) {
781
+ const details = {
782
+ command,
783
+ description,
784
+ project: context?.project,
785
+ cwd,
786
+ timeoutMs,
787
+ outputFile: null,
788
+ stdout: '',
789
+ stderr: 'Command aborted before start.',
790
+ stdout_preview: '',
791
+ stderr_preview: 'Command aborted before start.',
792
+ stdoutTruncated: false,
793
+ stderrTruncated: false,
794
+ stdout_truncated: false,
795
+ stderr_truncated: false,
796
+ outputTruncated: false,
797
+ truncated: false,
798
+ previewLineLimit: COMMAND_PREVIEW_LINES,
799
+ previewCharLimit: COMMAND_PREVIEW_TOTAL_CHARS,
800
+ previewMode: 'tail',
801
+ durationMs: 0,
802
+ aborted: true,
803
+ }
804
+ const content = formatCommandOutput(command, details.stdout, details.stderr, details)
805
+ return { content: truncateText(content), details }
806
+ }
807
+
808
+ let logStream = null
809
+ let outputFile = null
810
+ let logError = null
811
+ try {
812
+ const log = await createCommandLogStream(command, { cwd, description, toolCallId: runtime.toolCallId })
813
+ logStream = log.stream
814
+ outputFile = log.outputFile
815
+ logStream.on('error', (error) => {
816
+ logError = error?.message || 'Failed to write command log.'
817
+ })
818
+ } catch (error) {
819
+ logError = error?.message || 'Failed to create command log.'
820
+ }
821
+
822
+ return new Promise((resolve) => {
823
+ const child = spawn(command, {
824
+ cwd,
825
+ shell: true,
826
+ stdio: ['ignore', 'pipe', 'pipe'],
827
+ windowsHide: true,
828
+ detached: process.platform !== 'win32',
829
+ })
830
+
831
+ let stdout = ''
832
+ let stderr = ''
833
+ let stdoutTruncated = false
834
+ let stderrTruncated = false
835
+ let timedOut = false
836
+ let aborted = false
837
+ let settled = false
838
+ let updateTimer = null
839
+ let updatePending = false
840
+ let forceKillTimer = null
841
+
842
+ const cleanup = () => {
843
+ clearTimeout(timer)
844
+ if (forceKillTimer) clearTimeout(forceKillTimer)
845
+ if (updateTimer) clearTimeout(updateTimer)
846
+ if (runtime.toolCallId) runningCommands.delete(runtime.toolCallId)
847
+ runtime.signal?.removeEventListener?.('abort', onAbort)
848
+ }
849
+
850
+ const commonDetails = (extra = {}) => {
851
+ const now = Date.now()
852
+ const previews = balanceCommandPreviews(stdout, stderr)
853
+ const stdoutPreview = previews.stdout
854
+ const stderrPreview = previews.stderr
855
+ const stdoutPreviewTruncated = stdoutTruncated || previews.stdoutTruncated
856
+ const stderrPreviewTruncated = stderrTruncated || previews.stderrTruncated
857
+ const truncated = stdoutPreviewTruncated || stderrPreviewTruncated
858
+ return {
859
+ command,
860
+ description,
861
+ project: context?.project,
862
+ cwd,
863
+ timeoutMs,
864
+ outputFile,
865
+ stdout: stdoutPreview,
866
+ stderr: stderrPreview,
867
+ stdout_preview: stdoutPreview,
868
+ stderr_preview: stderrPreview,
869
+ stdoutTruncated: stdoutPreviewTruncated,
870
+ stderrTruncated: stderrPreviewTruncated,
871
+ stdout_truncated: stdoutPreviewTruncated,
872
+ stderr_truncated: stderrPreviewTruncated,
873
+ outputTruncated: truncated,
874
+ truncated,
875
+ previewLineLimit: COMMAND_PREVIEW_LINES,
876
+ previewCharLimit: COMMAND_PREVIEW_TOTAL_CHARS,
877
+ previewMode: 'tail',
878
+ durationMs: now - startedAt,
879
+ toolCallId: runtime.toolCallId,
880
+ logError,
881
+ ...extra,
882
+ }
883
+ }
884
+
885
+ const resolveAfterLogClose = (result) => {
886
+ if (!logStream) {
887
+ resolve(result)
888
+ return
889
+ }
890
+ const details = result.details || {}
891
+ logStream.write([
892
+ '',
893
+ '',
894
+ `[quickforge ${new Date().toISOString()}]`,
895
+ `Exit code: ${details.code ?? 'unknown'}${details.signal ? `, signal: ${details.signal}` : ''}`,
896
+ `Duration: ${formatDurationMs(details.durationMs)}`,
897
+ `Timed out: ${Boolean(details.timedOut)}`,
898
+ `Aborted: ${Boolean(details.aborted)}`,
899
+ 'Command finished.',
900
+ '',
901
+ ].join('\n'))
902
+ logStream.end(() => resolve(result))
903
+ }
904
+
905
+ const finish = ({ code = null, signal = null, error = null } = {}) => {
906
+ if (settled) return
907
+ flushUpdate()
908
+ settled = true
909
+ cleanup()
910
+ const durationMs = Date.now() - startedAt
911
+ if (error) {
912
+ const details = commonDetails({ error: error.message, aborted, timedOut, durationMs })
913
+ resolveAfterLogClose({
914
+ isError: true,
915
+ content: truncateText(formatCommandOutput(command, details.stdout, `Error running command: ${error.message}\n${details.stderr}`.trim(), details)),
916
+ details,
917
+ })
918
+ return
919
+ }
920
+ const details = commonDetails({ code, signal, timedOut, aborted, durationMs })
921
+ const content = formatCommandOutput(command, details.stdout, details.stderr, details)
922
+ resolveAfterLogClose({ content: truncateText(content), details })
923
+ }
924
+
925
+ const stopChild = (reason) => {
926
+ if (reason === 'timeout') timedOut = true
927
+ if (reason === 'abort') aborted = true
928
+ killProcessTree(child, 'SIGTERM')
929
+ forceKillTimer = setTimeout(() => {
930
+ killProcessTree(child, 'SIGKILL')
931
+ }, 1500)
932
+ }
933
+
934
+ if (runtime.toolCallId) runningCommands.set(runtime.toolCallId, stopChild)
935
+
936
+ function onAbort() {
937
+ stopChild('abort')
938
+ finish({ signal: 'SIGTERM' })
939
+ }
940
+
941
+ const runningDetails = () => commonDetails({ running: true })
942
+
943
+ const emitUpdate = () => {
944
+ updateTimer = null
945
+ if (settled || !updatePending) return
946
+ updatePending = false
947
+ const details = runningDetails()
948
+ runtime.onUpdate?.({
949
+ content: [{ type: 'text', text: truncateText(formatCommandOutput(command, details.stdout, details.stderr, details)) }],
950
+ details,
951
+ })
952
+ }
953
+ const flushUpdate = () => {
954
+ if (updateTimer) {
955
+ clearTimeout(updateTimer)
956
+ updateTimer = null
957
+ }
958
+ if (!updatePending) return
959
+ updatePending = false
960
+ const details = runningDetails()
961
+ runtime.onUpdate?.({
962
+ content: [{ type: 'text', text: truncateText(formatCommandOutput(command, details.stdout, details.stderr, details)) }],
963
+ details,
964
+ })
965
+ }
966
+ const scheduleUpdate = () => {
967
+ if (settled) return
968
+ updatePending = true
969
+ if (!updateTimer) updateTimer = setTimeout(emitUpdate, 150)
970
+ }
971
+ const timer = setTimeout(() => {
972
+ stopChild('timeout')
973
+ finish({ signal: 'SIGTERM' })
974
+ }, timeoutMs)
975
+
976
+ runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
977
+
978
+ child.stdout.on('data', (chunk) => {
979
+ if (settled) return
980
+ const text = chunk.toString()
981
+ const nextStdout = tailText(stdout, text)
982
+ stdoutTruncated = stdoutTruncated || nextStdout.truncated
983
+ stdout = nextStdout.text
984
+ if (logStream && !logError) writeCommandLog(logStream, 'stdout', text)
985
+ scheduleUpdate()
986
+ })
987
+ child.stderr.on('data', (chunk) => {
988
+ if (settled) return
989
+ const text = chunk.toString()
990
+ const nextStderr = tailText(stderr, text)
991
+ stderrTruncated = stderrTruncated || nextStderr.truncated
992
+ stderr = nextStderr.text
993
+ if (logStream && !logError) writeCommandLog(logStream, 'stderr', text)
994
+ scheduleUpdate()
995
+ })
996
+ child.on('close', (code, signal) => {
997
+ finish({ code, signal })
998
+ })
999
+ child.on('error', (err) => {
1000
+ finish({ error: err })
1001
+ })
1002
+ })
1003
+ }
1004
+
1005
+ export const toolHandlers = {
1006
+ read_file: toolReadFile,
1007
+ grep_files: toolGrepFiles,
1008
+ write_file: toolWriteFile,
1009
+ edit_file: toolEditFile,
1010
+ run_command: toolRunCommand,
1011
+ activate_skill: toolActivateSkill,
1012
+ read_skill_resource: toolReadSkillResource,
1013
+ }