@shawnstack/quickforge 1.3.18 → 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 +18 -0
  104. package/server/tools/index.mjs +1013 -911
  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,911 +1,1013 @@
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 formatRipgrepJsonEvent(event) {
240
- if (event?.type !== 'match' && event?.type !== 'context') return null
241
- const data = event.data || {}
242
- const file = ripgrepRelativePath(data.path?.text)
243
- const lineNumber = data.line_number
244
- const line = cleanRipgrepLine(data.lines?.text)
245
- if (!file || !lineNumber) return null
246
- const separator = event.type === 'match' ? ':' : '-'
247
- return `${file}:${lineNumber}${separator} ${line}`
248
- }
249
-
250
- function fallbackDetails(extra = {}) {
251
- return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined && value !== null && value !== ''))
252
- }
253
-
254
- async function grepFilesWithRipgrep(executable, options, context, runtime = {}) {
255
- if (options.regex && !isRegexLikelyRipgrepCompatible(options.query)) {
256
- throw new Error('regex uses JavaScript-only features that ripgrep does not support')
257
- }
258
-
259
- const cwd = getToolWorkspaceRoot(context)
260
- const args = buildRipgrepArgs(options, context)
261
- const matches = []
262
- let stderr = ''
263
- let buffer = ''
264
- let killedForLimit = false
265
- let settled = false
266
-
267
- await new Promise((resolve, reject) => {
268
- if (runtime.signal?.aborted) {
269
- reject(new Error('Search aborted'))
270
- return
271
- }
272
-
273
- const child = spawn(executable.command, args, {
274
- cwd,
275
- shell: false,
276
- stdio: ['ignore', 'pipe', 'pipe'],
277
- windowsHide: true,
278
- })
279
-
280
- const cleanup = () => {
281
- clearTimeout(timer)
282
- runtime.signal?.removeEventListener?.('abort', onAbort)
283
- }
284
- const finish = (error) => {
285
- if (settled) return
286
- settled = true
287
- cleanup()
288
- if (error) reject(error)
289
- else resolve()
290
- }
291
- const stopForLimit = () => {
292
- if (killedForLimit) return
293
- killedForLimit = true
294
- killProcessTree(child, 'SIGTERM')
295
- }
296
- const processLine = (line) => {
297
- if (!line || matches.length >= options.limit) return
298
- if (options.filesWithMatches) {
299
- matches.push(ripgrepRelativePath(line))
300
- } else {
301
- try {
302
- const formatted = formatRipgrepJsonEvent(JSON.parse(line))
303
- if (formatted) matches.push(formatted)
304
- } catch {
305
- // Ignore malformed partial output and let process exit handling decide fallback.
306
- }
307
- }
308
- if (matches.length >= options.limit) stopForLimit()
309
- }
310
- const flushLines = (chunk) => {
311
- buffer += chunk.toString()
312
- const lines = buffer.split(/\r?\n/)
313
- buffer = lines.pop() || ''
314
- for (const line of lines) processLine(line)
315
- }
316
- function onAbort() {
317
- killProcessTree(child, 'SIGTERM')
318
- finish(new Error('Search aborted'))
319
- }
320
-
321
- const timer = setTimeout(() => {
322
- killProcessTree(child, 'SIGTERM')
323
- finish(new Error('ripgrep search timed out'))
324
- }, RIPGREP_TIMEOUT_MS)
325
-
326
- runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
327
- child.stdout.on('data', flushLines)
328
- child.stderr.on('data', (chunk) => {
329
- stderr = truncateText(stderr + chunk.toString(), 2000)
330
- })
331
- child.once('error', finish)
332
- child.once('close', (code) => {
333
- if (buffer) processLine(buffer)
334
- if (killedForLimit || code === 0 || code === 1) {
335
- finish()
336
- } else {
337
- finish(new Error(stderr.trim() || `ripgrep exited with code ${code}`))
338
- }
339
- })
340
- })
341
-
342
- return {
343
- content: matches.length ? truncateText(matches.slice(0, options.limit).join('\n')) : 'No matches found.',
344
- details: {
345
- path: toWorkspaceRelative(options.root, context),
346
- project: context?.project,
347
- query: options.query,
348
- count: matches.length,
349
- limit: options.limit,
350
- backend: 'ripgrep',
351
- ripgrepSource: executable.source,
352
- },
353
- }
354
- }
355
-
356
- async function grepFilesWithNode(options, context, extraDetails = {}) {
357
- const flags = options.caseSensitive ? 'g' : 'gi'
358
- const matcher = options.regex
359
- ? new RegExp(options.query, flags)
360
- : new RegExp(escapeRegExp(options.query), flags)
361
-
362
- const files = await walkFiles(options.root, [], context)
363
- const matches = []
364
-
365
- // Stat and filter files in parallel, then grep in parallel batches.
366
- const candidateResults = await poolMap(files, async (file) => {
367
- try {
368
- const stat = await fs.stat(file)
369
- if (stat.size > 1024 * 1024) return { file, skip: true }
370
- return { file, skip: false }
371
- } catch {
372
- return { file, skip: true }
373
- }
374
- })
375
-
376
- const candidates = candidateResults.filter((r) => !r.skip).map((r) => r.file)
377
-
378
- // Grep with bounded concurrency — short-circuit when limit reached.
379
- let matchCount = 0
380
- const filesWithMatches = new Set()
381
- for (let batchStart = 0; batchStart < candidates.length && matchCount < options.limit; batchStart += 20) {
382
- const batch = candidates.slice(batchStart, batchStart + 20)
383
- const batchMatches = await Promise.all(
384
- batch.map(async (file) => {
385
- if (matchCount >= options.limit) return []
386
- try {
387
- const text = await fs.readFile(file, 'utf8')
388
- const lines = splitLines(text)
389
- const fileMatches = []
390
- for (let index = 0; index < lines.length && (matchCount + fileMatches.length) < options.limit; index++) {
391
- matcher.lastIndex = 0
392
- if (matcher.test(lines[index])) {
393
- const relative = toWorkspaceRelative(file, context)
394
- if (options.filesWithMatches) {
395
- if (!filesWithMatches.has(relative)) {
396
- filesWithMatches.add(relative)
397
- fileMatches.push(relative)
398
- }
399
- } else {
400
- fileMatches.push(`${relative}:${index + 1}: ${lines[index]}`)
401
- }
402
- }
403
- }
404
- return fileMatches
405
- } catch {
406
- return []
407
- }
408
- }),
409
- )
410
- for (const fm of batchMatches) {
411
- if (matchCount >= options.limit) break
412
- for (const m of fm) {
413
- if (matchCount >= options.limit) break
414
- matches.push(m)
415
- matchCount++
416
- }
417
- }
418
- }
419
-
420
- return {
421
- content: matches.length ? truncateText(matches.join('\n')) : 'No matches found.',
422
- details: {
423
- path: toWorkspaceRelative(options.root, context),
424
- project: context?.project,
425
- query: options.query,
426
- count: matches.length,
427
- limit: options.limit,
428
- backend: 'node',
429
- ...fallbackDetails(extraDetails),
430
- },
431
- }
432
- }
433
-
434
- export async function toolGrepFiles(params, context, runtime = {}) {
435
- const options = normalizeGrepParams(params, context)
436
- await assertSafeWorkspacePath(options.root, context)
437
-
438
- const executable = await resolveRipgrepExecutable()
439
- if (executable) {
440
- try {
441
- return await grepFilesWithRipgrep(executable, options, context, runtime)
442
- } catch (error) {
443
- if (runtime.signal?.aborted) throw error
444
- return grepFilesWithNode(options, context, {
445
- fallbackFrom: 'ripgrep',
446
- fallbackReason: error?.message || 'ripgrep unavailable',
447
- })
448
- }
449
- }
450
-
451
- return grepFilesWithNode(options, context, { fallbackReason: 'ripgrep unavailable' })
452
- }
453
-
454
- // --- write_file ---
455
- export async function toolWriteFile(params, context) {
456
- const file = resolveWorkspacePath(params?.path, context)
457
- await assertSafeWorkspacePath(file, context, { forWrite: true })
458
-
459
- const content = String(params?.content ?? '')
460
- const relativePath = toWorkspaceRelative(file, context)
461
- let oldText = ''
462
- let existed = true
463
- try {
464
- oldText = await fs.readFile(file, 'utf8')
465
- } catch (error) {
466
- if (error?.code !== 'ENOENT') throw error
467
- existed = false
468
- }
469
- const diff = createTextDiff(oldText, content, relativePath, { oldExists: existed })
470
-
471
- await fs.mkdir(path.dirname(file), { recursive: true })
472
- await fs.writeFile(file, content, 'utf8')
473
-
474
- return {
475
- content: `${existed ? 'Wrote' : 'Created'} ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
476
- details: { path: relativePath, project: context?.project, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, diff },
477
- }
478
- }
479
-
480
- // --- edit_file ---
481
- function countOccurrences(text, needle) {
482
- if (!needle) return 0
483
- let count = 0
484
- let index = 0
485
- while ((index = text.indexOf(needle, index)) !== -1) {
486
- count++
487
- index += needle.length
488
- }
489
- return count
490
- }
491
-
492
- function detectLineEnding(text) {
493
- return text.includes('\r\n') ? '\r\n' : '\n'
494
- }
495
-
496
- function normalizeLineEndings(text) {
497
- return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
498
- }
499
-
500
- function convertToLineEnding(text, ending) {
501
- const normalized = normalizeLineEndings(text)
502
- return ending === '\r\n' ? normalized.replaceAll('\n', '\r\n') : normalized
503
- }
504
-
505
- export async function toolEditFile(params, context) {
506
- const file = resolveWorkspacePath(params?.path, context)
507
- await assertSafeWorkspacePath(file, context)
508
-
509
- const rawOldText = String(params?.oldText ?? '')
510
- const rawNewText = String(params?.newText ?? '')
511
- const text = await fs.readFile(file, 'utf8')
512
- const lineEnding = detectLineEnding(text)
513
- const oldText = convertToLineEnding(rawOldText, lineEnding)
514
- const newText = convertToLineEnding(rawNewText, lineEnding)
515
- const count = countOccurrences(text, oldText)
516
-
517
- if (count !== 1) {
518
- const rawCount = oldText === rawOldText ? count : countOccurrences(text, rawOldText)
519
- const suffix = rawCount !== count ? ` after normalizing line endings; raw match count was ${rawCount}` : ''
520
- const error = new Error(`oldText must match exactly once; found ${count} matches${suffix}`)
521
- error.statusCode = 400
522
- throw error
523
- }
524
-
525
- const nextText = text.replace(oldText, newText)
526
- const relativePath = toWorkspaceRelative(file, context)
527
- const diff = createTextDiff(text, nextText, relativePath)
528
-
529
- await fs.writeFile(file, nextText, 'utf8')
530
-
531
- return {
532
- content: `Edited ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
533
- details: { path: relativePath, project: context?.project, replaced: count, diff },
534
- }
535
- }
536
-
537
- // --- run_command ---
538
- function activeSkillsForContext(context) {
539
- return mergeSkills(context?.globalSkills, context?.projectSkills)
540
- }
541
-
542
- function activeSkillByName(context, name) {
543
- const skillName = String(name || '')
544
- return activeSkillsForContext(context).find((skill) => skill.name === skillName)
545
- }
546
-
547
- export async function loadSkillToolContext(config = {}) {
548
- const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
549
- const projectSkills = config.workspaceRoot
550
- ? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
551
- : []
552
- return { globalSkills, projectSkills }
553
- }
554
-
555
- // --- activate_skill ---
556
- export async function toolActivateSkill(params, context) {
557
- const skill = activeSkillByName(context, params?.name)
558
- if (!skill) {
559
- const error = new Error(`Unknown or disabled skill: ${params?.name || ''}`)
560
- error.statusCode = 404
561
- throw error
562
- }
563
-
564
- return {
565
- content: truncateText(await formatSkillActivation(skill)),
566
- details: {
567
- skill: skill.name,
568
- source: skill.source,
569
- directory: skill.rootDir,
570
- },
571
- }
572
- }
573
-
574
- // --- read_skill_resource ---
575
- export async function toolReadSkillResource(params, context) {
576
- const skill = activeSkillByName(context, params?.skill)
577
- if (!skill) {
578
- const error = new Error(`Unknown or disabled skill: ${params?.skill || ''}`)
579
- error.statusCode = 404
580
- throw error
581
- }
582
-
583
- const result = await readSkillResource(skill, params?.path, params)
584
- return {
585
- content: truncateText(result.content),
586
- details: result.details,
587
- }
588
- }
589
-
590
- // --- run_command ---
591
- const DEFAULT_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
592
- const MIN_RUN_COMMAND_TIMEOUT_MS = 1000
593
- const MAX_RUN_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
594
- const COMMAND_TAIL_LINES = 100
595
-
596
- function formatDurationMs(durationMs = 0) {
597
- const totalSeconds = Math.max(0, Math.floor(durationMs / 1000))
598
- const minutes = Math.floor(totalSeconds / 60)
599
- const seconds = totalSeconds % 60
600
- if (minutes > 0) return `${minutes}m ${seconds}s (${durationMs}ms)`
601
- return `${seconds}s (${durationMs}ms)`
602
- }
603
-
604
- function tailText(current, chunk, maxLines = COMMAND_TAIL_LINES) {
605
- const lines = (current + chunk).split(/\r?\n/)
606
- if (lines.length <= maxLines) return lines.join('\n')
607
- return lines.slice(lines.length - maxLines).join('\n')
608
- }
609
-
610
- function tailLabel(name, truncated) {
611
- return truncated ? `${name} (last ${COMMAND_TAIL_LINES} lines):` : `${name}:`
612
- }
613
-
614
- function commandStatus(meta = {}) {
615
- if (meta.running) return 'Status: running'
616
- const flags = [
617
- meta.timedOut ? 'timed out' : null,
618
- meta.aborted ? 'aborted' : null,
619
- ].filter(Boolean)
620
- const suffix = flags.length ? ` (${flags.join(', ')})` : ''
621
- return `Exit code: ${meta.code ?? 'unknown'}${meta.signal ? `, signal: ${meta.signal}` : ''}${suffix}`
622
- }
623
-
624
- function formatCommandOutput(command, stdout, stderr, meta = {}) {
625
- const lines = [
626
- `Command: ${command}`,
627
- ]
628
- if (meta.description) lines.push(`Description: ${meta.description}`)
629
- lines.push(commandStatus(meta))
630
- if (typeof meta.durationMs === 'number') lines.push(`Duration: ${formatDurationMs(meta.durationMs)}`)
631
- if (typeof meta.timeoutMs === 'number') lines.push(`Timeout: ${formatDurationMs(meta.timeoutMs)}`)
632
- if (meta.cwd) lines.push(`CWD: ${meta.cwd}`)
633
- if (meta.outputFile) lines.push(`Full output: ${meta.outputFile}`)
634
- if (meta.outputTruncated) lines.push(`Output mode: showing the last ${COMMAND_TAIL_LINES} lines; full output is saved to the log file.`)
635
- if (meta.logError) lines.push(`Log warning: ${meta.logError}`)
636
- lines.push('', tailLabel('STDOUT', meta.stdoutTruncated), stdout || '(empty)', '', tailLabel('STDERR', meta.stderrTruncated), stderr || '(empty)')
637
- return lines.join('\n')
638
- }
639
-
640
- function safeLogFilePart(value, fallback) {
641
- const text = String(value || '').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80)
642
- return text || fallback
643
- }
644
-
645
- async function createCommandLogStream(command, { cwd, description, toolCallId } = {}) {
646
- const dir = path.join(logsDir, 'commands')
647
- await fs.mkdir(dir, { recursive: true })
648
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
649
- const fileName = `${timestamp}_${safeLogFilePart(toolCallId, 'command')}.log`
650
- const outputFile = path.join(dir, fileName)
651
- const stream = createWriteStream(outputFile, { flags: 'wx' })
652
- stream.write([
653
- `Command: ${command}`,
654
- description ? `Description: ${description}` : null,
655
- `CWD: ${cwd}`,
656
- `Started at: ${new Date().toISOString()}`,
657
- '',
658
- ].filter(Boolean).join('\n'))
659
- return { stream, outputFile }
660
- }
661
-
662
- function writeCommandLog(stream, source, chunk) {
663
- stream.write(`\n[${source} ${new Date().toISOString()}]\n`)
664
- stream.write(chunk)
665
- }
666
-
667
- function killProcessTree(child, signal = 'SIGTERM') {
668
- if (!child?.pid) return
669
-
670
- if (process.platform === 'win32') {
671
- const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
672
- stdio: 'ignore',
673
- windowsHide: true,
674
- })
675
- killer.on('error', () => {
676
- try { child.kill(signal) } catch { /* ignore */ }
677
- })
678
- return
679
- }
680
-
681
- try {
682
- process.kill(-child.pid, signal)
683
- } catch {
684
- try { child.kill(signal) } catch { /* ignore */ }
685
- }
686
- }
687
-
688
- const runningCommands = new Map()
689
-
690
- export function abortRunningCommand(toolCallId) {
691
- if (!toolCallId) return false
692
- const stop = runningCommands.get(toolCallId)
693
- if (!stop) return false
694
- stop('abort')
695
- return true
696
- }
697
-
698
- export async function toolRunCommand(params, context, runtime = {}) {
699
- const command = String(params?.command || '')
700
- if (!command.trim()) {
701
- const error = new Error('command is required')
702
- error.statusCode = 400
703
- throw error
704
- }
705
-
706
- const description = String(params?.description || '').trim().slice(0, 500)
707
- const timeoutMs = clampNumber(params?.timeoutMs, DEFAULT_RUN_COMMAND_TIMEOUT_MS, MIN_RUN_COMMAND_TIMEOUT_MS, MAX_RUN_COMMAND_TIMEOUT_MS)
708
- const cwd = getToolWorkspaceRoot(context)
709
- const startedAt = Date.now()
710
-
711
- if (runtime.signal?.aborted) {
712
- const content = formatCommandOutput(command, '', 'Command aborted before start.', {
713
- aborted: true,
714
- cwd,
715
- description,
716
- timeoutMs,
717
- durationMs: 0,
718
- })
719
- return { content: truncateText(content), details: { command, description, project: context?.project, cwd, timeoutMs, durationMs: 0, aborted: true } }
720
- }
721
-
722
- let logStream = null
723
- let outputFile = null
724
- let logError = null
725
- try {
726
- const log = await createCommandLogStream(command, { cwd, description, toolCallId: runtime.toolCallId })
727
- logStream = log.stream
728
- outputFile = log.outputFile
729
- logStream.on('error', (error) => {
730
- logError = error?.message || 'Failed to write command log.'
731
- })
732
- } catch (error) {
733
- logError = error?.message || 'Failed to create command log.'
734
- }
735
-
736
- return new Promise((resolve) => {
737
- const child = spawn(command, {
738
- cwd,
739
- shell: true,
740
- stdio: ['ignore', 'pipe', 'pipe'],
741
- windowsHide: true,
742
- detached: process.platform !== 'win32',
743
- })
744
-
745
- let stdout = ''
746
- let stderr = ''
747
- let stdoutTruncated = false
748
- let stderrTruncated = false
749
- let timedOut = false
750
- let aborted = false
751
- let settled = false
752
- let updateTimer = null
753
- let updatePending = false
754
- let forceKillTimer = null
755
-
756
- const cleanup = () => {
757
- clearTimeout(timer)
758
- if (forceKillTimer) clearTimeout(forceKillTimer)
759
- if (updateTimer) clearTimeout(updateTimer)
760
- if (runtime.toolCallId) runningCommands.delete(runtime.toolCallId)
761
- runtime.signal?.removeEventListener?.('abort', onAbort)
762
- }
763
-
764
- const commonDetails = (extra = {}) => {
765
- const now = Date.now()
766
- return {
767
- command,
768
- description,
769
- project: context?.project,
770
- cwd,
771
- timeoutMs,
772
- outputFile,
773
- stdout,
774
- stderr,
775
- stdoutTruncated,
776
- stderrTruncated,
777
- outputTruncated: stdoutTruncated || stderrTruncated,
778
- durationMs: now - startedAt,
779
- toolCallId: runtime.toolCallId,
780
- logError,
781
- ...extra,
782
- }
783
- }
784
-
785
- const resolveAfterLogClose = (result) => {
786
- if (!logStream) {
787
- resolve(result)
788
- return
789
- }
790
- const details = result.details || {}
791
- logStream.write([
792
- '',
793
- '',
794
- `[quickforge ${new Date().toISOString()}]`,
795
- `Exit code: ${details.code ?? 'unknown'}${details.signal ? `, signal: ${details.signal}` : ''}`,
796
- `Duration: ${formatDurationMs(details.durationMs)}`,
797
- `Timed out: ${Boolean(details.timedOut)}`,
798
- `Aborted: ${Boolean(details.aborted)}`,
799
- 'Command finished.',
800
- '',
801
- ].join('\n'))
802
- logStream.end(() => resolve(result))
803
- }
804
-
805
- const finish = ({ code = null, signal = null, error = null } = {}) => {
806
- if (settled) return
807
- flushUpdate()
808
- settled = true
809
- cleanup()
810
- const durationMs = Date.now() - startedAt
811
- if (error) {
812
- const details = commonDetails({ error: error.message, aborted, timedOut, durationMs })
813
- resolveAfterLogClose({
814
- isError: true,
815
- content: truncateText(formatCommandOutput(command, stdout, `Error running command: ${error.message}\n${stderr}`.trim(), details)),
816
- details,
817
- })
818
- return
819
- }
820
- const details = commonDetails({ code, signal, timedOut, aborted, durationMs })
821
- const content = formatCommandOutput(command, stdout, stderr, details)
822
- resolveAfterLogClose({ content: truncateText(content), details })
823
- }
824
-
825
- const stopChild = (reason) => {
826
- if (reason === 'timeout') timedOut = true
827
- if (reason === 'abort') aborted = true
828
- killProcessTree(child, 'SIGTERM')
829
- forceKillTimer = setTimeout(() => {
830
- killProcessTree(child, 'SIGKILL')
831
- }, 1500)
832
- }
833
-
834
- if (runtime.toolCallId) runningCommands.set(runtime.toolCallId, stopChild)
835
-
836
- function onAbort() {
837
- stopChild('abort')
838
- finish({ signal: 'SIGTERM' })
839
- }
840
-
841
- const runningDetails = () => commonDetails({ running: true })
842
-
843
- const emitUpdate = () => {
844
- updateTimer = null
845
- if (settled || !updatePending) return
846
- updatePending = false
847
- const details = runningDetails()
848
- runtime.onUpdate?.({
849
- content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, details)) }],
850
- details,
851
- })
852
- }
853
- const flushUpdate = () => {
854
- if (updateTimer) {
855
- clearTimeout(updateTimer)
856
- updateTimer = null
857
- }
858
- if (!updatePending) return
859
- updatePending = false
860
- const details = runningDetails()
861
- runtime.onUpdate?.({
862
- content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, details)) }],
863
- details,
864
- })
865
- }
866
- const scheduleUpdate = () => {
867
- if (settled) return
868
- updatePending = true
869
- if (!updateTimer) updateTimer = setTimeout(emitUpdate, 150)
870
- }
871
- const timer = setTimeout(() => {
872
- stopChild('timeout')
873
- finish({ signal: 'SIGTERM' })
874
- }, timeoutMs)
875
-
876
- runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
877
-
878
- child.stdout.on('data', (chunk) => {
879
- if (settled) return
880
- const text = chunk.toString()
881
- stdoutTruncated = stdoutTruncated || (stdout + text).split(/\r?\n/).length > COMMAND_TAIL_LINES
882
- stdout = tailText(stdout, text)
883
- if (logStream && !logError) writeCommandLog(logStream, 'stdout', text)
884
- scheduleUpdate()
885
- })
886
- child.stderr.on('data', (chunk) => {
887
- if (settled) return
888
- const text = chunk.toString()
889
- stderrTruncated = stderrTruncated || (stderr + text).split(/\r?\n/).length > COMMAND_TAIL_LINES
890
- stderr = tailText(stderr, text)
891
- if (logStream && !logError) writeCommandLog(logStream, 'stderr', text)
892
- scheduleUpdate()
893
- })
894
- child.on('close', (code, signal) => {
895
- finish({ code, signal })
896
- })
897
- child.on('error', (err) => {
898
- finish({ error: err })
899
- })
900
- })
901
- }
902
-
903
- export const toolHandlers = {
904
- read_file: toolReadFile,
905
- grep_files: toolGrepFiles,
906
- write_file: toolWriteFile,
907
- edit_file: toolEditFile,
908
- run_command: toolRunCommand,
909
- activate_skill: toolActivateSkill,
910
- read_skill_resource: toolReadSkillResource,
911
- }
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
+
511
+ // --- edit_file ---
512
+ function countOccurrences(text, needle) {
513
+ if (!needle) return 0
514
+ let count = 0
515
+ let index = 0
516
+ while ((index = text.indexOf(needle, index)) !== -1) {
517
+ count++
518
+ index += needle.length
519
+ }
520
+ return count
521
+ }
522
+
523
+ function detectLineEnding(text) {
524
+ return text.includes('\r\n') ? '\r\n' : '\n'
525
+ }
526
+
527
+ function normalizeLineEndings(text) {
528
+ return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
529
+ }
530
+
531
+ function convertToLineEnding(text, ending) {
532
+ const normalized = normalizeLineEndings(text)
533
+ return ending === '\r\n' ? normalized.replaceAll('\n', '\r\n') : normalized
534
+ }
535
+
536
+ export async function toolEditFile(params, context) {
537
+ const file = resolveWorkspacePath(params?.path, context)
538
+ await assertSafeWorkspacePath(file, context)
539
+
540
+ const rawOldText = String(params?.oldText ?? '')
541
+ const rawNewText = String(params?.newText ?? '')
542
+ const text = await fs.readFile(file, 'utf8')
543
+ const lineEnding = detectLineEnding(text)
544
+ const oldText = convertToLineEnding(rawOldText, lineEnding)
545
+ const newText = convertToLineEnding(rawNewText, lineEnding)
546
+ const count = countOccurrences(text, oldText)
547
+
548
+ if (count !== 1) {
549
+ const rawCount = oldText === rawOldText ? count : countOccurrences(text, rawOldText)
550
+ const suffix = rawCount !== count ? ` after normalizing line endings; raw match count was ${rawCount}` : ''
551
+ const error = new Error(`oldText must match exactly once; found ${count} matches${suffix}`)
552
+ error.statusCode = 400
553
+ throw error
554
+ }
555
+
556
+ const nextText = text.replace(oldText, newText)
557
+ const relativePath = toWorkspaceRelative(file, context)
558
+ const diff = createTextDiff(text, nextText, relativePath)
559
+
560
+ await fs.writeFile(file, nextText, 'utf8')
561
+
562
+ return {
563
+ content: `Edited ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
564
+ details: { path: relativePath, project: context?.project, replaced: count, diff },
565
+ }
566
+ }
567
+
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
+ }