@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,990 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { lookup } from 'node:dns/promises'
4
+ import {
5
+ chmod,
6
+ lstat,
7
+ mkdir,
8
+ readdir,
9
+ readFile,
10
+ realpath,
11
+ rename,
12
+ rm,
13
+ stat,
14
+ writeFile,
15
+ } from 'node:fs/promises'
16
+ import { isIP } from 'node:net'
17
+ import path from 'node:path'
18
+ import { HttpError } from './http.mjs'
19
+ import { loadState, loadSecretsSafe } from './store.mjs'
20
+ import { describeCheckpoint, dropCheckpoint, readCheckpoint, recordBefore } from './checkpoints.mjs'
21
+
22
+ const MAX_READ = 2 * 1024 * 1024
23
+ const MAX_WRITE = 8 * 1024 * 1024
24
+ const MAX_OUTPUT = 200_000
25
+ const SKIP_DIRS = new Set([
26
+ '.git',
27
+ 'node_modules',
28
+ 'dist',
29
+ 'build',
30
+ '.next',
31
+ '.nuxt',
32
+ '.svelte-kit',
33
+ 'coverage',
34
+ '.venv',
35
+ 'venv',
36
+ '__pycache__',
37
+ '.idea',
38
+ '.turbo',
39
+ '.cache',
40
+ ])
41
+ const TEXT_PROBE = 8000
42
+
43
+ export async function workspaceRoot() {
44
+ const state = await loadState()
45
+ if (!state.activeWorkspace) {
46
+ throw new HttpError(
47
+ 409,
48
+ 'No workspace is open. Choose a folder in Workspace before using tools.',
49
+ 'no_workspace',
50
+ )
51
+ }
52
+ const info = await stat(state.activeWorkspace).catch(() => null)
53
+ if (!info?.isDirectory()) {
54
+ throw new HttpError(
55
+ 409,
56
+ `The workspace folder no longer exists: ${state.activeWorkspace}`,
57
+ 'workspace_missing',
58
+ )
59
+ }
60
+ return realpath(state.activeWorkspace)
61
+ }
62
+
63
+ function relativeLabel(root, absolute) {
64
+ const rel = path.relative(root, absolute)
65
+ return rel ? rel.split(path.sep).join('/') : '.'
66
+ }
67
+
68
+ function cleanRelative(input) {
69
+ if (typeof input !== 'string') throw new HttpError(400, 'A path is required.', 'bad_path')
70
+ if (input.includes('\0')) throw new HttpError(400, 'That path is not valid.', 'bad_path')
71
+ const trimmed = input.trim()
72
+ if (!trimmed || trimmed === '.' || trimmed === './') return ''
73
+ return trimmed.replace(/^\.\//, '').replace(/^\/+/, '')
74
+ }
75
+
76
+ async function insideRoot(root, candidate) {
77
+ const rootReal = root
78
+ const parent = path.dirname(candidate)
79
+ const existing = await realpath(candidate).catch(() => null)
80
+ const anchor = existing ?? (await realpath(parent).catch(() => parent))
81
+ const prefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`
82
+ if (anchor !== rootReal && !anchor.startsWith(prefix)) {
83
+ throw new HttpError(403, 'That path is outside the open workspace.', 'outside_workspace')
84
+ }
85
+ if (existing && existing !== rootReal && !existing.startsWith(prefix)) {
86
+ throw new HttpError(403, 'That path is outside the open workspace.', 'outside_workspace')
87
+ }
88
+ return existing ?? candidate
89
+ }
90
+
91
+ export async function resolveWorkspacePath(input, { allowRoot = true } = {}) {
92
+ const root = await workspaceRoot()
93
+ const rel = cleanRelative(input)
94
+ if (!rel && !allowRoot) {
95
+ throw new HttpError(400, 'Choose a file or folder inside the workspace.', 'bad_path')
96
+ }
97
+ const candidate = path.resolve(root, rel)
98
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
99
+ if (candidate !== root && !candidate.startsWith(prefix)) {
100
+ throw new HttpError(403, 'That path is outside the open workspace.', 'outside_workspace')
101
+ }
102
+ if (/^(.*\/)?\.git(\/|$)/.test(rel)) {
103
+ throw new HttpError(403, 'OpenSol does not edit the .git directory.', 'git_protected')
104
+ }
105
+ const absolute = await insideRoot(root, candidate)
106
+ return { root, absolute, relative: rel || '.' }
107
+ }
108
+
109
+ function displayPath(root, absolute) {
110
+ return relativeLabel(root, absolute)
111
+ }
112
+
113
+ function isProbablyBinary(buffer) {
114
+ const probe = buffer.subarray(0, TEXT_PROBE)
115
+ for (let i = 0; i < probe.length; i += 1) {
116
+ if (probe[i] === 0) return true
117
+ }
118
+ return false
119
+ }
120
+
121
+ function snapshot(buffer) {
122
+ return {
123
+ hash: createHash('sha256').update(buffer).digest('hex').slice(0, 16),
124
+ size: buffer.length,
125
+ }
126
+ }
127
+
128
+ export async function listTree(input = '', { depth = 2, showHidden = false, limit = 1200 } = {}) {
129
+ const root = await workspaceRoot()
130
+ const rel = cleanRelative(input)
131
+ const start = rel ? await resolveWorkspacePath(rel) : { root, absolute: root, relative: '' }
132
+ const info = await stat(start.absolute).catch(() => null)
133
+ if (!info) throw new HttpError(404, `Not found: ${rel || '.'}`, 'not_found')
134
+ if (!info.isDirectory()) {
135
+ return { root, path: rel, entries: [], truncated: false }
136
+ }
137
+
138
+ let count = 0
139
+ let truncated = false
140
+ const entries = []
141
+ const maxDepth = Math.max(0, Math.min(8, depth))
142
+
143
+ async function walk(directory, relativeDir, level) {
144
+ let children
145
+ try {
146
+ children = await readdir(directory, { withFileTypes: true })
147
+ } catch {
148
+ return
149
+ }
150
+ children.sort((a, b) => {
151
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
152
+ return a.name.localeCompare(b.name)
153
+ })
154
+ for (const child of children) {
155
+ if (count >= limit) {
156
+ truncated = true
157
+ return
158
+ }
159
+ if (!showHidden && child.name.startsWith('.')) continue
160
+ if (SKIP_DIRS.has(child.name)) continue
161
+ const absolute = path.join(directory, child.name)
162
+ const relative = relativeDir ? `${relativeDir}/${child.name}` : child.name
163
+ const childInfo = await lstat(absolute).catch(() => null)
164
+ if (!childInfo) continue
165
+ count += 1
166
+ if (childInfo.isSymbolicLink()) {
167
+ entries.push({ name: child.name, path: relative, type: 'link', size: 0, mtime: childInfo.mtimeMs })
168
+ continue
169
+ }
170
+ if (childInfo.isDirectory()) {
171
+ entries.push({ name: child.name, path: relative, type: 'dir', size: 0, mtime: childInfo.mtimeMs })
172
+ if (level + 1 < maxDepth) await walk(absolute, relative, level + 1)
173
+ } else if (childInfo.isFile()) {
174
+ entries.push({
175
+ name: child.name,
176
+ path: relative,
177
+ type: 'file',
178
+ size: childInfo.size,
179
+ mtime: childInfo.mtimeMs,
180
+ })
181
+ }
182
+ }
183
+ }
184
+
185
+ await walk(start.absolute, rel, 0)
186
+ return { root, path: rel || '.', entries, truncated }
187
+ }
188
+
189
+ export async function readWorkspaceFile(input, { maxBytes = MAX_READ } = {}) {
190
+ const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
191
+ const info = await stat(absolute).catch(() => null)
192
+ if (!info) throw new HttpError(404, `File not found: ${relative}`, 'not_found')
193
+ if (!info.isFile()) throw new HttpError(400, `${relative} is not a file.`, 'not_a_file')
194
+ const buffer = await readFile(absolute)
195
+ const binary = isProbablyBinary(buffer)
196
+ const clipped = buffer.subarray(0, maxBytes)
197
+ return {
198
+ path: relative,
199
+ absolute: displayPath(root, absolute),
200
+ size: info.size,
201
+ mtime: info.mtimeMs,
202
+ binary,
203
+ truncated: buffer.length > maxBytes,
204
+ content: binary ? '' : clipped.toString('utf8'),
205
+ base64: binary ? clipped.toString('base64') : '',
206
+ hash: snapshot(buffer).hash,
207
+ }
208
+ }
209
+
210
+ export async function writeWorkspaceFile(input, content, { create = true } = {}) {
211
+ const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
212
+ if (typeof content !== 'string') throw new HttpError(400, 'File content must be text.', 'bad_content')
213
+ if (Buffer.byteLength(content, 'utf8') > MAX_WRITE) {
214
+ throw new HttpError(413, 'That file is too large to write from OpenSol.', 'file_too_large')
215
+ }
216
+ const existing = await stat(absolute).catch(() => null)
217
+ if (!existing && !create) throw new HttpError(404, `File not found: ${relative}`, 'not_found')
218
+ if (existing?.isDirectory()) throw new HttpError(400, `${relative} is a folder.`, 'is_directory')
219
+ await mkdir(path.dirname(absolute), { recursive: true })
220
+ const temp = `${absolute}.${process.pid}.maxcode.tmp`
221
+ await writeFile(temp, content, 'utf8')
222
+ try {
223
+ // Keep the original permissions (e.g. the executable bit) across the atomic swap.
224
+ if (existing) await chmod(temp, existing.mode & 0o777).catch(() => undefined)
225
+ await rename(temp, absolute)
226
+ } catch (error) {
227
+ await rm(temp, { force: true }).catch(() => undefined)
228
+ throw error
229
+ }
230
+ const info = await stat(absolute)
231
+ return {
232
+ path: relative,
233
+ size: info.size,
234
+ mtime: info.mtimeMs,
235
+ created: !existing,
236
+ bytes: info.size,
237
+ hash: snapshot(Buffer.from(content, 'utf8')).hash,
238
+ }
239
+ }
240
+
241
+ export async function editWorkspaceFile(input, oldText, newText, { replaceAll = false } = {}) {
242
+ if (typeof oldText !== 'string' || !oldText) {
243
+ throw new HttpError(400, 'The text to replace is empty.', 'bad_edit')
244
+ }
245
+ if (typeof newText !== 'string') throw new HttpError(400, 'The replacement text is invalid.', 'bad_edit')
246
+ const file = await readWorkspaceFile(input, { maxBytes: MAX_READ })
247
+ if (file.binary) throw new HttpError(400, 'That file is binary.', 'binary_file')
248
+ if (file.truncated) throw new HttpError(400, 'That file is too large to edit precisely.', 'file_too_large')
249
+ const occurrences = file.content.split(oldText).length - 1
250
+ if (occurrences === 0) {
251
+ throw new HttpError(409, `The search text was not found in ${file.path}.`, 'no_match')
252
+ }
253
+ if (occurrences > 1 && !replaceAll) {
254
+ throw new HttpError(
255
+ 409,
256
+ `The search text appears ${occurrences} times in ${file.path}. Add more context or set replaceAll.`,
257
+ 'ambiguous_edit',
258
+ )
259
+ }
260
+ const next = replaceAll
261
+ ? file.content.split(oldText).join(newText)
262
+ : file.content.replace(oldText, () => newText)
263
+ const written = await writeWorkspaceFile(input, next)
264
+ return { ...written, replacements: replaceAll ? occurrences : 1 }
265
+ }
266
+
267
+ export async function createWorkspaceEntry(input, type = 'file', content = '') {
268
+ const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
269
+ if (type === 'dir') {
270
+ const existing = await stat(absolute).catch(() => null)
271
+ if (existing) throw new HttpError(409, `${relative} already exists.`, 'already_exists')
272
+ await mkdir(absolute, { recursive: false })
273
+ return { path: relative, type: 'dir', created: true }
274
+ }
275
+ const existing = await stat(absolute).catch(() => null)
276
+ if (existing) throw new HttpError(409, `${relative} already exists.`, 'already_exists')
277
+ await mkdir(path.dirname(absolute), { recursive: true })
278
+ await writeFile(absolute, typeof content === 'string' ? content : '', 'utf8')
279
+ const info = await stat(absolute)
280
+ return { path: relative, type: 'file', size: info.size, created: true, root: displayPath(root, root) }
281
+ }
282
+
283
+ export async function renameWorkspaceEntry(from, to) {
284
+ const source = await resolveWorkspacePath(from, { allowRoot: false })
285
+ const target = await resolveWorkspacePath(to, { allowRoot: false })
286
+ const existing = await stat(target.absolute).catch(() => null)
287
+ if (existing) throw new HttpError(409, `${target.relative} already exists.`, 'already_exists')
288
+ await mkdir(path.dirname(target.absolute), { recursive: true })
289
+ await rename(source.absolute, target.absolute)
290
+ return { from: source.relative, to: target.relative }
291
+ }
292
+
293
+ export async function deleteWorkspaceEntry(input) {
294
+ const { absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
295
+ const info = await stat(absolute).catch(() => null)
296
+ if (!info) throw new HttpError(404, `${relative} does not exist.`, 'not_found')
297
+ await rm(absolute, { recursive: info.isDirectory(), force: false })
298
+ return { path: relative, deleted: true, type: info.isDirectory() ? 'dir' : 'file' }
299
+ }
300
+
301
+ export async function chmodWorkspaceFile(input, mode) {
302
+ const { absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
303
+ const parsed = Number.parseInt(String(mode), 8)
304
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 0o777) {
305
+ throw new HttpError(400, 'Use a valid octal mode such as 755.', 'bad_mode')
306
+ }
307
+ await chmod(absolute, parsed)
308
+ return { path: relative, mode: parsed.toString(8) }
309
+ }
310
+
311
+ async function walkFiles(root, { maxFiles = 4000, maxBytes = 12 * 1024 * 1024 } = {}) {
312
+ const files = []
313
+ let bytes = 0
314
+ async function walk(directory) {
315
+ let children
316
+ try {
317
+ children = await readdir(directory, { withFileTypes: true })
318
+ } catch {
319
+ return
320
+ }
321
+ for (const child of children) {
322
+ if (files.length >= maxFiles || bytes >= maxBytes) return
323
+ if (child.name.startsWith('.') || SKIP_DIRS.has(child.name)) continue
324
+ const absolute = path.join(directory, child.name)
325
+ if (child.isDirectory()) {
326
+ await walk(absolute)
327
+ } else if (child.isFile()) {
328
+ const info = await stat(absolute).catch(() => null)
329
+ if (!info || info.size > 512 * 1024) continue
330
+ files.push({ absolute, size: info.size })
331
+ bytes += info.size
332
+ }
333
+ }
334
+ }
335
+ await walk(root)
336
+ return files
337
+ }
338
+
339
+ export async function grepWorkspace(pattern, { path: sub = '', ignoreCase = false, maxResults = 200 } = {}) {
340
+ if (typeof pattern !== 'string' || !pattern.trim()) {
341
+ throw new HttpError(400, 'A search pattern is required.', 'bad_pattern')
342
+ }
343
+ const root = await workspaceRoot()
344
+ const start = sub
345
+ ? (await resolveWorkspacePath(sub)).absolute
346
+ : root
347
+ const info = await stat(start).catch(() => null)
348
+ if (!info) throw new HttpError(404, `Not found: ${sub || '.'}`, 'not_found')
349
+
350
+ const rg = await runProcess(
351
+ 'rg',
352
+ [
353
+ '--line-number',
354
+ '--no-heading',
355
+ '--color',
356
+ 'never',
357
+ '--hidden',
358
+ '--glob',
359
+ '!node_modules',
360
+ '--glob',
361
+ '!.git',
362
+ '--glob',
363
+ '!dist',
364
+ '--glob',
365
+ '!build',
366
+ ...(ignoreCase ? ['--ignore-case'] : []),
367
+ '--',
368
+ pattern,
369
+ '.',
370
+ ],
371
+ { cwd: info.isDirectory() ? start : path.dirname(start), timeoutMs: 20_000, maxOutput: 80_000 },
372
+ ).catch(() => null)
373
+
374
+ if (rg) {
375
+ if (rg.code === 1 && !rg.stdout.trim()) {
376
+ return { pattern, matches: [], truncated: false, engine: 'rg' }
377
+ }
378
+ if (rg.code === 0) {
379
+ const lines = rg.stdout.split('\n').filter(Boolean)
380
+ const matches = lines.slice(0, maxResults).map((line) => {
381
+ const [file, lineNumber, ...rest] = line.split(':')
382
+ return { file: file.replace(/^\.\//, ''), line: Number(lineNumber) || 0, text: rest.join(':') }
383
+ })
384
+ return { pattern, matches, truncated: lines.length > maxResults, engine: 'rg' }
385
+ }
386
+ }
387
+
388
+ const files = info.isDirectory() ? await walkFiles(start) : [{ absolute: start, size: info.size }]
389
+ const regex = new RegExp(
390
+ pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
391
+ ignoreCase ? 'i' : '',
392
+ )
393
+ const matches = []
394
+ let truncated = false
395
+ outer: for (const file of files) {
396
+ const buffer = await readFile(file.absolute).catch(() => null)
397
+ if (!buffer || isProbablyBinary(buffer)) continue
398
+ const lines = buffer.toString('utf8').split('\n')
399
+ for (let index = 0; index < lines.length; index += 1) {
400
+ if (!regex.test(lines[index])) continue
401
+ matches.push({
402
+ file: displayPath(root, file.absolute),
403
+ line: index + 1,
404
+ text: lines[index].slice(0, 400),
405
+ })
406
+ if (matches.length >= maxResults) {
407
+ truncated = true
408
+ break outer
409
+ }
410
+ }
411
+ }
412
+ return { pattern, matches, truncated, engine: 'js' }
413
+ }
414
+
415
+ function runProcess(command, args, { cwd, env, timeoutMs = 120_000, maxOutput = MAX_OUTPUT, stdin = '' } = {}) {
416
+ return new Promise((resolve) => {
417
+ const started = Date.now()
418
+ let stdout = ''
419
+ let stderr = ''
420
+ let timedOut = false
421
+ let killed = false
422
+ const child = spawn(command, args, {
423
+ cwd,
424
+ env: { ...process.env, ...(env ?? {}) },
425
+ shell: false,
426
+ detached: process.platform !== 'win32',
427
+ stdio: ['pipe', 'pipe', 'pipe'],
428
+ })
429
+ const terminate = () => {
430
+ killed = true
431
+ try {
432
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL')
433
+ else child.kill('SIGKILL')
434
+ } catch {
435
+ child.kill('SIGKILL')
436
+ }
437
+ }
438
+ const timer = setTimeout(() => {
439
+ timedOut = true
440
+ terminate()
441
+ }, timeoutMs)
442
+
443
+ const collect = (target) => (chunk) => {
444
+ const text = chunk.toString('utf8')
445
+ if (target === 'stdout') {
446
+ if (stdout.length < maxOutput) stdout += text
447
+ } else if (stderr.length < maxOutput) {
448
+ stderr += text
449
+ }
450
+ }
451
+ child.stdout.on('data', collect('stdout'))
452
+ child.stderr.on('data', collect('stderr'))
453
+ child.stdin.on('error', () => undefined)
454
+ if (stdin) child.stdin.end(stdin)
455
+ else child.stdin.end()
456
+
457
+ child.on('error', (error) => {
458
+ clearTimeout(timer)
459
+ resolve({
460
+ stdout,
461
+ stderr: `${stderr}${error.message}`,
462
+ code: 127,
463
+ timedOut,
464
+ killed,
465
+ durationMs: Date.now() - started,
466
+ })
467
+ })
468
+ child.on('close', (code) => {
469
+ clearTimeout(timer)
470
+ const clip = (value) =>
471
+ value.length > maxOutput
472
+ ? `${value.slice(0, maxOutput)}\n… output truncated (${value.length - maxOutput} more characters)`
473
+ : value
474
+ resolve({
475
+ stdout: clip(stdout),
476
+ stderr: clip(stderr),
477
+ code: typeof code === 'number' ? code : -1,
478
+ timedOut,
479
+ killed,
480
+ durationMs: Date.now() - started,
481
+ })
482
+ })
483
+ })
484
+ }
485
+
486
+ function planShellAllowed(command) {
487
+ if (/[;&|><`$(){}[\]\n]/.test(command)) return false
488
+ const tokens = command.trim().split(/\s+/).filter(Boolean)
489
+ if (tokens.length === 0) return false
490
+ const head = tokens[0]
491
+ const safe = new Set([
492
+ 'pwd',
493
+ 'ls',
494
+ 'cat',
495
+ 'head',
496
+ 'tail',
497
+ 'wc',
498
+ 'grep',
499
+ 'rg',
500
+ 'find',
501
+ 'tree',
502
+ 'file',
503
+ 'stat',
504
+ 'du',
505
+ 'df',
506
+ 'date',
507
+ 'whoami',
508
+ 'git',
509
+ ])
510
+ if (!safe.has(head)) return false
511
+ if (head === 'git') {
512
+ const sub = tokens[1] ?? ''
513
+ if (!['status', 'diff', 'log', 'show', 'branch', 'remote', 'rev-parse', 'ls-files'].includes(sub)) {
514
+ return false
515
+ }
516
+ }
517
+ return true
518
+ }
519
+
520
+ export async function runShell(command, { mode = 'build', timeoutMs = 120_000 } = {}) {
521
+ if (typeof command !== 'string' || !command.trim()) {
522
+ throw new HttpError(400, 'A command is required.', 'bad_command')
523
+ }
524
+ if (command.length > 20_000) throw new HttpError(400, 'That command is too long.', 'bad_command')
525
+ if (mode === 'plan' && !planShellAllowed(command)) {
526
+ throw new HttpError(
527
+ 403,
528
+ 'Plan mode is read-only. Use read_file, list_files, grep or a read-only shell command.',
529
+ 'plan_readonly',
530
+ )
531
+ }
532
+ const root = await workspaceRoot()
533
+ const result = await runProcess('/bin/bash', ['-lc', command], {
534
+ cwd: root,
535
+ timeoutMs: Math.max(1000, Math.min(Number(timeoutMs) || 120_000, 600_000)),
536
+ env: { MAXCODE: '1', MAXCODE_WORKSPACE: root },
537
+ })
538
+ return { ...result, command, cwd: root }
539
+ }
540
+
541
+ const GIT_ACTIONS = new Set(['status', 'diff', 'log', 'show', 'branch', 'add', 'commit', 'init', 'checkout'])
542
+
543
+ function safeRef(value) {
544
+ return typeof value === 'string' && /^[A-Za-z0-9._~^/@{}-]+$/.test(value) ? value : ''
545
+ }
546
+
547
+ export async function runGit(action, args = {}, { mode = 'build' } = {}) {
548
+ if (!GIT_ACTIONS.has(action)) throw new HttpError(400, `Unsupported git action: ${action}`, 'bad_git')
549
+ const write = ['add', 'commit', 'init', 'checkout'].includes(action)
550
+ if (mode === 'plan' && write) {
551
+ throw new HttpError(403, 'Plan mode cannot change git state.', 'plan_readonly')
552
+ }
553
+ const root = await workspaceRoot()
554
+ const git = (argv, timeoutMs = 60_000) =>
555
+ runProcess('git', ['-C', root, ...argv], { cwd: root, timeoutMs, maxOutput: 150_000 })
556
+
557
+ if (action === 'status') {
558
+ const porcelain = await git(['status', '--porcelain=v1', '-b'])
559
+ return { action, ...porcelain, cwd: root }
560
+ }
561
+ if (action === 'diff') {
562
+ const argv = ['diff', '--no-color']
563
+ if (args.staged === true) argv.push('--staged')
564
+ const file = typeof args.path === 'string' ? args.path.trim() : ''
565
+ if (file) {
566
+ const resolved = await resolveWorkspacePath(file, { allowRoot: false })
567
+ argv.push('--', resolved.absolute)
568
+ }
569
+ const result = await git(argv)
570
+ return { action, ...result, cwd: root }
571
+ }
572
+ if (action === 'log') {
573
+ const limit = Math.max(1, Math.min(Number(args.limit) || 30, 100))
574
+ return { action, ...(await git(['log', `--max-count=${limit}`, '--pretty=format:%h %ad %s', '--date=short'])), cwd: root }
575
+ }
576
+ if (action === 'show') {
577
+ const ref = safeRef(args.ref) || 'HEAD'
578
+ return { action, ...(await git(['show', '--stat', '--oneline', ref])), cwd: root }
579
+ }
580
+ if (action === 'branch') {
581
+ const current = await git(['branch', '--show-current'])
582
+ const all = await git(['branch', '--all', '--no-color'])
583
+ return { action, current: current.stdout.trim(), branches: all.stdout, cwd: root, code: all.code }
584
+ }
585
+ if (action === 'add') {
586
+ const paths = Array.isArray(args.paths) ? args.paths : []
587
+ if (paths.length === 0) throw new HttpError(400, 'Choose files to stage.', 'bad_git')
588
+ const resolved = []
589
+ for (const entry of paths.slice(0, 200)) {
590
+ resolved.push((await resolveWorkspacePath(String(entry), { allowRoot: false })).absolute)
591
+ }
592
+ return { action, ...(await git(['add', '--', ...resolved])), cwd: root }
593
+ }
594
+ if (action === 'commit') {
595
+ const message = typeof args.message === 'string' ? args.message.trim() : ''
596
+ if (!message) throw new HttpError(400, 'A commit message is required.', 'bad_git')
597
+ if (message.length > 4000) throw new HttpError(400, 'That commit message is too long.', 'bad_git')
598
+ return { action, ...(await git(['commit', '-m', message])), cwd: root }
599
+ }
600
+ if (action === 'init') {
601
+ return { action, ...(await git(['init'])), cwd: root }
602
+ }
603
+ const branch = typeof args.branch === 'string' ? args.branch.trim() : ''
604
+ if (!branch || !/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith('-')) {
605
+ throw new HttpError(400, 'Use a simple branch name.', 'bad_git')
606
+ }
607
+ const argv = args.create === false ? ['checkout', branch] : ['checkout', '-b', branch]
608
+ return { action, ...(await git(argv)), cwd: root }
609
+ }
610
+
611
+ export async function gitSummary() {
612
+ const root = await workspaceRoot().catch(() => '')
613
+ if (!root) return { available: false, root: '', branch: '', entries: [], error: 'No workspace is open.' }
614
+ const check = await runProcess('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], {
615
+ cwd: root,
616
+ timeoutMs: 8000,
617
+ })
618
+ if (check.code !== 0) {
619
+ return { available: false, root, branch: '', entries: [], error: check.stderr.trim() || 'Not a git repository.' }
620
+ }
621
+ const statusRun = await runProcess('git', ['-C', root, 'status', '--porcelain=v1', '-b'], {
622
+ cwd: root,
623
+ timeoutMs: 15_000,
624
+ })
625
+ const lines = statusRun.stdout.split('\n').filter(Boolean)
626
+ const branchLine = lines.find((line) => line.startsWith('## ')) ?? ''
627
+ const branch = branchLine.replace(/^##\s+/, '').split('...')[0] ?? ''
628
+ const entries = lines
629
+ .filter((line) => !line.startsWith('## '))
630
+ .slice(0, 300)
631
+ .map((line) => ({ status: line.slice(0, 2).trim() || '?', path: line.slice(3).trim() }))
632
+ return { available: true, root, branch, entries, error: '' }
633
+ }
634
+
635
+ function decodeEntities(text) {
636
+ // `&amp;` must be decoded last, otherwise "&amp;lt;" is decoded twice into "<".
637
+ return text
638
+ .replace(/&nbsp;/g, ' ')
639
+ .replace(/&lt;/g, '<')
640
+ .replace(/&gt;/g, '>')
641
+ .replace(/&quot;/g, '"')
642
+ .replace(/&#39;|&#x27;/g, "'")
643
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
644
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)))
645
+ .replace(/&amp;/g, '&')
646
+ }
647
+
648
+ function stripHtml(html) {
649
+ return decodeEntities(
650
+ html
651
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
652
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
653
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
654
+ .replace(/<\/(p|div|section|article|li|h[1-6]|tr|pre|blockquote)>/gi, '\n')
655
+ .replace(/<br\s*\/?>/gi, '\n')
656
+ .replace(/<[^>]+>/g, ' ')
657
+ .replace(/[ \t]+/g, ' ')
658
+ .replace(/\n\s*\n\s*\n+/g, '\n\n'),
659
+ ).trim()
660
+ }
661
+
662
+ async function searchKeyFor(provider) {
663
+ const { secrets } = await loadSecretsSafe()
664
+ return {
665
+ apiKey: secrets.search?.apiKey ?? '',
666
+ provider: secrets.search?.provider ?? '',
667
+ }
668
+ }
669
+
670
+ async function duckduckgo(query, signal) {
671
+ const response = await fetch('https://html.duckduckgo.com/html/', {
672
+ method: 'POST',
673
+ headers: {
674
+ 'Content-Type': 'application/x-www-form-urlencoded',
675
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 OpenSol/1.0',
676
+ },
677
+ body: new URLSearchParams({ q: query }).toString(),
678
+ signal,
679
+ })
680
+ if (!response.ok) throw new HttpError(502, `DuckDuckGo returned ${response.status}.`, 'search_failed')
681
+ const html = await response.text()
682
+ const results = []
683
+ const pattern = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
684
+ let match
685
+ while ((match = pattern.exec(html)) && results.length < 8) {
686
+ let url = decodeEntities(match[1])
687
+ if (url.startsWith('//duckduckgo.com/l/?uddg=')) {
688
+ const target = new URL(`https:${url}`).searchParams.get('uddg')
689
+ if (target) url = target
690
+ }
691
+ if (!/^https?:/i.test(url)) continue
692
+ results.push({ title: stripHtml(match[2]), url, snippet: '' })
693
+ }
694
+ return results
695
+ }
696
+
697
+ async function tavily(query, key, signal) {
698
+ const response = await fetch('https://api.tavily.com/search', {
699
+ method: 'POST',
700
+ headers: { 'Content-Type': 'application/json' },
701
+ body: JSON.stringify({ api_key: key, query, search_depth: 'basic', max_results: 6 }),
702
+ signal,
703
+ })
704
+ if (!response.ok) throw new HttpError(502, `Tavily returned ${response.status}.`, 'search_failed')
705
+ const payload = await response.json()
706
+ return (payload.results ?? []).slice(0, 6).map((item) => ({
707
+ title: String(item.title ?? ''),
708
+ url: String(item.url ?? ''),
709
+ snippet: String(item.content ?? ''),
710
+ }))
711
+ }
712
+
713
+ async function brave(query, key, signal) {
714
+ const url = new URL('https://api.search.brave.com/res/v1/web/search')
715
+ url.searchParams.set('q', query)
716
+ url.searchParams.set('count', '8')
717
+ const response = await fetch(url, {
718
+ headers: { Accept: 'application/json', 'X-Subscription-Token': key },
719
+ signal,
720
+ })
721
+ if (!response.ok) throw new HttpError(502, `Brave Search returned ${response.status}.`, 'search_failed')
722
+ const payload = await response.json()
723
+ return (payload.web?.results ?? []).slice(0, 8).map((item) => ({
724
+ title: String(item.title ?? ''),
725
+ url: String(item.url ?? ''),
726
+ snippet: stripHtml(String(item.description ?? '')),
727
+ }))
728
+ }
729
+
730
+ async function serper(query, key, signal) {
731
+ const response = await fetch('https://google.serper.dev/search', {
732
+ method: 'POST',
733
+ headers: { 'Content-Type': 'application/json', 'X-API-KEY': key },
734
+ body: JSON.stringify({ q: query }),
735
+ signal,
736
+ })
737
+ if (!response.ok) throw new HttpError(502, `Serper returned ${response.status}.`, 'search_failed')
738
+ const payload = await response.json()
739
+ return (payload.organic ?? []).slice(0, 8).map((item) => ({
740
+ title: String(item.title ?? ''),
741
+ url: String(item.link ?? ''),
742
+ snippet: String(item.snippet ?? ''),
743
+ }))
744
+ }
745
+
746
+ export async function searchWeb(query, { provider = 'duckduckgo' } = {}) {
747
+ if (typeof query !== 'string' || !query.trim()) {
748
+ throw new HttpError(400, 'A search query is required.', 'bad_query')
749
+ }
750
+ const stored = await searchKeyFor(provider)
751
+ const key = stored.apiKey
752
+ const chosen = key && stored.provider ? stored.provider : provider
753
+ if (chosen !== 'duckduckgo' && !key) {
754
+ throw new HttpError(
755
+ 400,
756
+ `Add a ${chosen} API key in Settings → Tools, or switch to DuckDuckGo.`,
757
+ 'missing_search_key',
758
+ )
759
+ }
760
+ const signal = AbortSignal.timeout(25_000)
761
+ if (chosen === 'tavily') return { provider: chosen, results: await tavily(query, key, signal) }
762
+ if (chosen === 'brave') return { provider: chosen, results: await brave(query, key, signal) }
763
+ if (chosen === 'serper') return { provider: chosen, results: await serper(query, key, signal) }
764
+ return { provider: 'duckduckgo', results: await duckduckgo(query, signal) }
765
+ }
766
+
767
+ function privateAddress(address) {
768
+ if (isIP(address) === 4) {
769
+ const parts = address.split('.').map(Number)
770
+ if (parts[0] === 10 || parts[0] === 127 || parts[0] === 0) return true
771
+ if (parts[0] === 169 && parts[1] === 254) return true
772
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
773
+ if (parts[0] === 192 && parts[1] === 168) return true
774
+ if (parts[0] >= 224) return true
775
+ return false
776
+ }
777
+ if (isIP(address) === 6) {
778
+ const lower = address.toLowerCase()
779
+ if (lower === '::1' || lower === '::') return true
780
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true
781
+ if (lower.startsWith('fe80')) return true
782
+ if (lower.startsWith('::ffff:')) return privateAddress(lower.slice(7))
783
+ return false
784
+ }
785
+ return false
786
+ }
787
+
788
+ async function assertPublicUrl(url) {
789
+ if (!/^https?:$/.test(url.protocol)) throw new HttpError(400, 'Only http and https URLs can be read.', 'bad_url')
790
+ if (url.hostname === 'localhost' || url.hostname.endsWith('.localhost')) {
791
+ throw new HttpError(403, 'That host is private.', 'private_url')
792
+ }
793
+ const literal = isIP(url.hostname)
794
+ const addresses = literal ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }).catch(() => [])
795
+ if (addresses.length === 0) throw new HttpError(400, 'That host could not be resolved.', 'dns_failed')
796
+ if (addresses.some((entry) => privateAddress(entry.address))) {
797
+ throw new HttpError(403, 'That host is on a private network.', 'private_url')
798
+ }
799
+ }
800
+
801
+ export async function readUrl(rawUrl, { maxBytes = 400_000 } = {}) {
802
+ let url
803
+ try {
804
+ url = new URL(String(rawUrl))
805
+ } catch {
806
+ throw new HttpError(400, 'That URL is not valid.', 'bad_url')
807
+ }
808
+ // Follow redirects by hand so every hop is checked against the private-network
809
+ // rules; `redirect: 'follow'` would let a public page bounce into localhost/LAN.
810
+ const signal = AbortSignal.timeout(25_000)
811
+ let response
812
+ for (let hop = 0; ; hop += 1) {
813
+ await assertPublicUrl(url)
814
+ response = await fetch(url, {
815
+ redirect: 'manual',
816
+ headers: {
817
+ Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5',
818
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 OpenSol/1.0',
819
+ },
820
+ signal,
821
+ })
822
+ if (response.status < 300 || response.status >= 400) break
823
+ const location = response.headers.get('location')
824
+ if (!location || hop >= 5) throw new HttpError(502, 'Too many redirects.', 'fetch_failed')
825
+ try {
826
+ url = new URL(location, url)
827
+ } catch {
828
+ throw new HttpError(502, 'The page redirected to an invalid URL.', 'fetch_failed')
829
+ }
830
+ }
831
+ if (!response.ok) {
832
+ throw new HttpError(502, `That page returned HTTP ${response.status}.`, 'fetch_failed')
833
+ }
834
+ const type = response.headers.get('content-type') ?? ''
835
+ if (!/text\/|json|xml|javascript/i.test(type)) {
836
+ throw new HttpError(415, `OpenSol cannot read ${type || 'that content type'}.`, 'unsupported_type')
837
+ }
838
+ const raw = (await response.text()).slice(0, maxBytes)
839
+ const text = /html/i.test(type) ? stripHtml(raw) : raw
840
+ return {
841
+ url: response.url,
842
+ title: (() => {
843
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(raw)
844
+ return match ? stripHtml(match[1]) : ''
845
+ })(),
846
+ content: text.slice(0, 60_000),
847
+ truncated: text.length > 60_000,
848
+ }
849
+ }
850
+
851
+ const INSTRUCTION_FILES = ['OPENSOL.md', 'MAXCODE.md', 'AGENTS.md', 'CLAUDE.md', '.opensol/instructions.md', '.maxcode/instructions.md']
852
+ const MAX_INSTRUCTIONS = 20_000
853
+
854
+ /** The first project instruction file found in the open workspace, if any. */
855
+ export async function readProjectInstructions() {
856
+ const empty = { file: '', content: '', truncated: false }
857
+ try {
858
+ await workspaceRoot()
859
+ } catch {
860
+ return empty
861
+ }
862
+ for (const name of INSTRUCTION_FILES) {
863
+ try {
864
+ const { absolute } = await resolveWorkspacePath(name, { allowRoot: false })
865
+ const info = await stat(absolute)
866
+ if (!info.isFile()) continue
867
+ const buffer = await readFile(absolute)
868
+ if (isProbablyBinary(buffer)) continue
869
+ const text = buffer.toString('utf8')
870
+ return { file: name, content: text.slice(0, MAX_INSTRUCTIONS), truncated: text.length > MAX_INSTRUCTIONS }
871
+ } catch {
872
+ // Missing or unreadable: try the next candidate.
873
+ }
874
+ }
875
+ return empty
876
+ }
877
+
878
+ const FILE_MUTATIONS = new Set(['write_file', 'rewrite_file', 'edit_file'])
879
+
880
+ /** Undo the file edits recorded for one turn. Shell changes are not part of a checkpoint. */
881
+ export async function restoreCheckpoint(id) {
882
+ const checkpoint = await readCheckpoint(id)
883
+ if (!checkpoint) throw new HttpError(404, 'This checkpoint has expired or does not exist.', 'checkpoint_missing')
884
+ const root = await workspaceRoot()
885
+ if (checkpoint.root !== root) {
886
+ throw new HttpError(409, 'This checkpoint belongs to a different workspace.', 'checkpoint_workspace')
887
+ }
888
+ const restored = []
889
+ const removed = []
890
+ const skipped = []
891
+ for (const [relative, entry] of Object.entries(checkpoint.files)) {
892
+ const { absolute } = await resolveWorkspacePath(relative, { allowRoot: false })
893
+ if (entry.skipped) {
894
+ skipped.push(relative)
895
+ continue
896
+ }
897
+ if (!entry.existed) {
898
+ const info = await stat(absolute).catch(() => null)
899
+ if (info?.isFile()) await rm(absolute, { force: true })
900
+ removed.push(relative)
901
+ continue
902
+ }
903
+ await writeWorkspaceFile(relative, entry.content ?? '')
904
+ if (typeof entry.mode === 'number') await chmod(absolute, entry.mode).catch(() => undefined)
905
+ restored.push(relative)
906
+ }
907
+ await dropCheckpoint(id)
908
+ return { id, restored, removed, skipped }
909
+ }
910
+
911
+ export async function checkpointSummary(id) {
912
+ const checkpoint = await readCheckpoint(id)
913
+ return checkpoint ? describeCheckpoint(checkpoint) : null
914
+ }
915
+
916
+ export async function executeTool(name, args = {}, { mode = 'build', checkpoint = '' } = {}) {
917
+ const input = args && typeof args === 'object' ? args : {}
918
+ // The prompt promises the daemon enforces plan mode, so enforce it here and not only in the UI.
919
+ if (mode === 'plan' && FILE_MUTATIONS.has(name)) {
920
+ throw new HttpError(
921
+ 403,
922
+ 'Plan mode is read-only. Use read_file, list_files, grep or a read-only shell command.',
923
+ 'plan_readonly',
924
+ )
925
+ }
926
+ if (checkpoint && FILE_MUTATIONS.has(name)) {
927
+ try {
928
+ const { root, absolute, relative } = await resolveWorkspacePath(String(input.path ?? ''), { allowRoot: false })
929
+ await recordBefore(checkpoint, root, relative, absolute)
930
+ } catch {
931
+ // A failed snapshot must not block the edit; the tool reports its own path errors.
932
+ }
933
+ }
934
+ switch (name) {
935
+ case 'shell':
936
+ return runShell(String(input.command ?? ''), { mode, timeoutMs: Number(input.timeoutMs) })
937
+ case 'read_file':
938
+ return readWorkspaceFile(String(input.path ?? ''))
939
+ case 'list_files': {
940
+ const result = await listTree(String(input.path ?? ''), { depth: Number(input.depth) || 2 })
941
+ return { ...result, files: result.entries.map((entry) => entry.path) }
942
+ }
943
+ case 'write_file':
944
+ return writeWorkspaceFile(String(input.path ?? ''), String(input.content ?? ''))
945
+ case 'rewrite_file':
946
+ return writeWorkspaceFile(String(input.path ?? ''), String(input.content ?? ''))
947
+ case 'edit_file':
948
+ return editWorkspaceFile(String(input.path ?? ''), String(input.oldText ?? ''), String(input.newText ?? ''), {
949
+ replaceAll: input.replaceAll === true,
950
+ })
951
+ case 'grep':
952
+ return grepWorkspace(String(input.pattern ?? ''), {
953
+ path: typeof input.path === 'string' ? input.path : '',
954
+ ignoreCase: input.ignoreCase === true,
955
+ })
956
+ case 'web_search':
957
+ return searchWeb(String(input.query ?? ''), { provider: String(input.provider ?? 'duckduckgo') })
958
+ case 'read_url':
959
+ return readUrl(String(input.url ?? ''))
960
+ case 'git':
961
+ return runGit(String(input.action ?? 'status'), input, { mode })
962
+ default:
963
+ throw new HttpError(400, `Unknown tool: ${name}`, 'unknown_tool')
964
+ }
965
+ }
966
+
967
+ export async function discoverWorkspace(rawPath) {
968
+ const info = await stat(path.resolve(String(rawPath))).catch(() => null)
969
+ if (!info?.isDirectory()) throw new HttpError(400, 'That folder does not exist.', 'bad_workspace')
970
+ const resolved = await realpath(String(rawPath))
971
+ return { path: resolved, name: path.basename(resolved) || resolved }
972
+ }
973
+
974
+ export async function browseDirectories(rawPath) {
975
+ const target = path.resolve(rawPath || process.env.HOME || '/')
976
+ const info = await stat(target).catch(() => null)
977
+ if (!info?.isDirectory()) throw new HttpError(400, 'That folder does not exist.', 'bad_folder')
978
+ const parent = path.dirname(target)
979
+ const children = await readdir(target, { withFileTypes: true }).catch(() => [])
980
+ return {
981
+ path: target,
982
+ parent: parent === target ? '' : parent,
983
+ home: process.env.HOME || '',
984
+ entries: children
985
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
986
+ .sort((a, b) => a.name.localeCompare(b.name))
987
+ .slice(0, 300)
988
+ .map((entry) => ({ name: entry.name, path: path.join(target, entry.name) })),
989
+ }
990
+ }