@bibobonking/opensol 0.0.0-stage → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) 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/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,109 @@
1
+ import type {
2
+ AssistantMessage,
3
+ Message,
4
+ ToolMessage,
5
+ UserMessage,
6
+ } from '../types'
7
+ import { findModel } from '../store/models'
8
+ import { useSettings } from '../store/settings'
9
+ import { EMPTY_HANDLERS } from './adapters/shared'
10
+ import { streamModel } from './model'
11
+ import { buildSubagentPrompt } from './prompt'
12
+ import { executeTool } from './tools'
13
+
14
+ const MAX_SUBAGENT_ROUNDS = 6
15
+
16
+ function id(prefix: string) {
17
+ const raw =
18
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
19
+ ? crypto.randomUUID()
20
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`
21
+ return `${prefix}_${raw}`
22
+ }
23
+
24
+ function abortError() {
25
+ return new DOMException('Aborted', 'AbortError')
26
+ }
27
+
28
+ /**
29
+ * Runs a short read-only investigation for the main agent. It shares the
30
+ * selected model and the encrypted daemon credentials, but never edits files.
31
+ */
32
+ export async function runSubagent(
33
+ task: string,
34
+ context: string,
35
+ signal: AbortSignal,
36
+ ): Promise<string> {
37
+ const cleanTask = task.trim()
38
+ if (!cleanTask) return 'The subagent was not given a task.'
39
+ if (signal.aborted) throw abortError()
40
+
41
+ const settings = useSettings.getState()
42
+ const model = findModel(settings.provider, settings.model)
43
+ const system = buildSubagentPrompt(settings)
44
+ const now = Date.now()
45
+ const first: UserMessage = {
46
+ id: id('sub'),
47
+ role: 'user',
48
+ content: [
49
+ `Task: ${cleanTask}`,
50
+ context.trim() ? `\nContext from the main agent:\n${context.trim()}` : '',
51
+ '\nInvestigate now. Return a concise report with file paths and evidence.',
52
+ ]
53
+ .filter(Boolean)
54
+ .join('\n'),
55
+ createdAt: now,
56
+ }
57
+ const history: Message[] = [first]
58
+ let lastContent = ''
59
+
60
+ for (let round = 0; round < MAX_SUBAGENT_ROUNDS; round += 1) {
61
+ if (signal.aborted) throw abortError()
62
+ const result = await streamModel({
63
+ settings,
64
+ model,
65
+ history,
66
+ system,
67
+ mode: 'plan',
68
+ signal,
69
+ handlers: EMPTY_HANDLERS,
70
+ })
71
+ if (result.content.trim()) lastContent = result.content.trim()
72
+
73
+ const assistant: AssistantMessage = {
74
+ id: id('submsg'),
75
+ role: 'assistant',
76
+ content: result.content,
77
+ reasoning: result.reasoning,
78
+ thinkingBlocks: result.thinkingBlocks,
79
+ toolCalls: result.toolCalls,
80
+ round,
81
+ createdAt: Date.now(),
82
+ }
83
+ history.push(assistant)
84
+
85
+ if (result.toolCalls.length === 0) return assistant.content.trim() || 'The subagent returned no report.'
86
+ if (signal.aborted) throw abortError()
87
+
88
+ for (const call of result.toolCalls) {
89
+ const output = await executeTool(call.name, call.args, settings, 'plan', signal)
90
+ const toolMessage: ToolMessage = {
91
+ id: id('subtool'),
92
+ role: 'tool',
93
+ call,
94
+ status: output.isError ? 'error' : 'done',
95
+ result: output.output,
96
+ round,
97
+ createdAt: Date.now(),
98
+ }
99
+ history.push(toolMessage)
100
+ }
101
+ }
102
+
103
+ return [
104
+ lastContent,
105
+ `The subagent stopped after ${MAX_SUBAGENT_ROUNDS} investigation rounds. Continue with the evidence above or ask a narrower question.`,
106
+ ]
107
+ .filter(Boolean)
108
+ .join('\n\n')
109
+ }
@@ -0,0 +1,541 @@
1
+ import type { AgentMode, AppSettings, ToolName } from '../types'
2
+ import { executeDaemonTool } from '../lib/daemon'
3
+ import { useWorkspace } from '../store/workspace'
4
+ import { kernel } from '../core/kernel'
5
+
6
+ export interface JsonSchema {
7
+ type: 'object'
8
+ properties: Record<string, unknown>
9
+ required?: string[]
10
+ additionalProperties?: boolean
11
+ }
12
+
13
+ export interface ToolSchema {
14
+ name: ToolName
15
+ description: string
16
+ parameters: JsonSchema
17
+ }
18
+
19
+ const MAX_TOOL_OUTPUT = 28_000
20
+ const READ_ONLY_TOOLS = new Set<ToolName>([
21
+ 'read_file',
22
+ 'list_files',
23
+ 'grep',
24
+ 'web_search',
25
+ 'read_url',
26
+ ])
27
+
28
+ function truncate(value: string, limit = MAX_TOOL_OUTPUT) {
29
+ if (value.length <= limit) return value
30
+ return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
31
+ }
32
+
33
+ function record(value: unknown): Record<string, unknown> {
34
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
35
+ }
36
+
37
+ function textValue(value: unknown): string {
38
+ return typeof value === 'string' ? value : ''
39
+ }
40
+
41
+ function numberValue(value: unknown): number | undefined {
42
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
43
+ }
44
+
45
+ function requireString(args: Record<string, unknown>, key: string): string {
46
+ const value = args[key]
47
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing string argument "${key}".`)
48
+ return value
49
+ }
50
+
51
+ function optionalString(args: Record<string, unknown>, key: string): string | undefined {
52
+ const value = args[key]
53
+ return typeof value === 'string' ? value : undefined
54
+ }
55
+
56
+ function numberArg(args: Record<string, unknown>, key: string, fallback: number): number {
57
+ const value = args[key]
58
+ if (typeof value === 'number' && Number.isFinite(value)) return value
59
+ if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value)
60
+ return fallback
61
+ }
62
+
63
+ export function toolAllowedInMode(name: ToolName, mode: AgentMode): boolean {
64
+ if (mode === 'build') return true
65
+ return READ_ONLY_TOOLS.has(name) || name === 'shell' || name === 'git' || name === 'subagent'
66
+ }
67
+
68
+ export function getToolSchemas(
69
+ settings: AppSettings,
70
+ mode: AgentMode = 'build',
71
+ { subagent = false } = {},
72
+ ): ToolSchema[] {
73
+ if (kernel.tools) {
74
+ return kernel.tools.schemas(settings, mode, { subagent })
75
+ }
76
+ const tools: ToolSchema[] = [
77
+ {
78
+ name: 'shell',
79
+ description:
80
+ 'Run a shell command in the workspace root. The working directory resets between calls. Prefer dedicated file and git tools for edits. In Plan mode only read-only commands are accepted.',
81
+ parameters: {
82
+ type: 'object',
83
+ properties: {
84
+ command: { type: 'string', description: 'The shell command to run.' },
85
+ timeoutMs: { type: 'number', description: 'Timeout in milliseconds (max 600000).' },
86
+ },
87
+ required: ['command'],
88
+ additionalProperties: false,
89
+ },
90
+ },
91
+ {
92
+ name: 'read_file',
93
+ description: 'Read a text file from the workspace. Use offset and limit for large files.',
94
+ parameters: {
95
+ type: 'object',
96
+ properties: {
97
+ path: { type: 'string', description: 'Relative path from the workspace root.' },
98
+ offset: { type: 'number', description: '1-based first line. Defaults to 1.' },
99
+ limit: { type: 'number', description: 'Maximum lines. Defaults to 400.' },
100
+ },
101
+ required: ['path'],
102
+ additionalProperties: false,
103
+ },
104
+ },
105
+ {
106
+ name: 'list_files',
107
+ description: 'List files and folders below a workspace path.',
108
+ parameters: {
109
+ type: 'object',
110
+ properties: {
111
+ path: { type: 'string', description: 'Folder path. Defaults to the workspace root.' },
112
+ depth: { type: 'number', description: 'Recursion depth, 1–8. Defaults to 3.' },
113
+ },
114
+ additionalProperties: false,
115
+ },
116
+ },
117
+ {
118
+ name: 'write_file',
119
+ description: 'Create a file or replace its full contents. Prefer edit_file for small changes.',
120
+ parameters: {
121
+ type: 'object',
122
+ properties: {
123
+ path: { type: 'string', description: 'Relative file path.' },
124
+ content: { type: 'string', description: 'Complete file content.' },
125
+ },
126
+ required: ['path', 'content'],
127
+ additionalProperties: false,
128
+ },
129
+ },
130
+ {
131
+ name: 'edit_file',
132
+ description:
133
+ 'Replace an exact text occurrence in a file. oldText must match whitespace exactly and be unique unless replaceAll is true.',
134
+ parameters: {
135
+ type: 'object',
136
+ properties: {
137
+ path: { type: 'string', description: 'Relative file path.' },
138
+ oldText: { type: 'string', description: 'Exact text to replace.' },
139
+ newText: { type: 'string', description: 'Replacement text.' },
140
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence. Defaults to false.' },
141
+ },
142
+ required: ['path', 'oldText', 'newText'],
143
+ additionalProperties: false,
144
+ },
145
+ },
146
+ {
147
+ name: 'rewrite_file',
148
+ description: 'Replace the complete contents of an existing file. Use when most of the file changes.',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {
152
+ path: { type: 'string', description: 'Relative file path.' },
153
+ content: { type: 'string', description: 'The new complete file content.' },
154
+ },
155
+ required: ['path', 'content'],
156
+ additionalProperties: false,
157
+ },
158
+ },
159
+ {
160
+ name: 'grep',
161
+ description: 'Search workspace contents with a regular expression. Returns path, line and matching text.',
162
+ parameters: {
163
+ type: 'object',
164
+ properties: {
165
+ pattern: { type: 'string', description: 'Regular expression to search for.' },
166
+ path: { type: 'string', description: 'File or folder. Defaults to the workspace root.' },
167
+ ignoreCase: { type: 'boolean', description: 'Case-insensitive search. Defaults to false.' },
168
+ maxResults: { type: 'number', description: 'Maximum matches. Defaults to 80.' },
169
+ },
170
+ required: ['pattern'],
171
+ additionalProperties: false,
172
+ },
173
+ },
174
+ {
175
+ name: 'web_search',
176
+ description: 'Search the web for current information. Returns titles, URLs and snippets. Cite URLs you use.',
177
+ parameters: {
178
+ type: 'object',
179
+ properties: {
180
+ query: { type: 'string', description: 'Search query.' },
181
+ },
182
+ required: ['query'],
183
+ additionalProperties: false,
184
+ },
185
+ },
186
+ {
187
+ name: 'read_url',
188
+ description: 'Read a public web page as plain text.',
189
+ parameters: {
190
+ type: 'object',
191
+ properties: {
192
+ url: { type: 'string', description: 'The http(s) URL to read.' },
193
+ },
194
+ required: ['url'],
195
+ additionalProperties: false,
196
+ },
197
+ },
198
+ {
199
+ name: 'git',
200
+ description:
201
+ 'Inspect or change git state. In Plan mode only status, diff, log, show and branch are accepted.',
202
+ parameters: {
203
+ type: 'object',
204
+ properties: {
205
+ action: {
206
+ type: 'string',
207
+ enum: ['status', 'diff', 'log', 'show', 'branch', 'add', 'commit', 'init', 'checkout'],
208
+ },
209
+ path: { type: 'string', description: 'Limit diff to this file.' },
210
+ staged: { type: 'boolean', description: 'Diff the staged changes.' },
211
+ ref: { type: 'string', description: 'Commit or ref for show.' },
212
+ limit: { type: 'number', description: 'Log limit.' },
213
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files to stage.' },
214
+ message: { type: 'string', description: 'Commit message.' },
215
+ branch: { type: 'string', description: 'Branch to create or check out.' },
216
+ create: { type: 'boolean', description: 'Create a new branch. Defaults to true.' },
217
+ },
218
+ required: ['action'],
219
+ additionalProperties: false,
220
+ },
221
+ },
222
+ ]
223
+
224
+ if (!subagent) {
225
+ tools.push({
226
+ name: 'subagent',
227
+ description:
228
+ 'Delegate a focused investigation to a read-only subagent. Use it to explore a large area, trace a bug or verify assumptions without changing files. The subagent returns a concise report.',
229
+ parameters: {
230
+ type: 'object',
231
+ properties: {
232
+ task: { type: 'string', description: 'The specific question or investigation to perform.' },
233
+ context: { type: 'string', description: 'Optional context, constraints or files to start from.' },
234
+ },
235
+ required: ['task'],
236
+ additionalProperties: false,
237
+ },
238
+ })
239
+ }
240
+
241
+ return tools.filter((tool) => {
242
+ if (!settings.tools[tool.name]?.enabled) return false
243
+ if (!toolAllowedInMode(tool.name, mode)) return false
244
+ if ((tool.name === 'web_search' || tool.name === 'read_url') && !settings.webSearchEnabled) return false
245
+ return true
246
+ })
247
+ }
248
+
249
+ export interface ToolResult {
250
+ output: string
251
+ isError: boolean
252
+ }
253
+
254
+ async function refreshTree() {
255
+ await useWorkspace.getState().refresh({ treeOnly: true }).catch(() => undefined)
256
+ }
257
+
258
+ async function refreshWorkspace() {
259
+ await useWorkspace.getState().refresh().catch(() => undefined)
260
+ }
261
+
262
+ function formatList(result: Record<string, unknown>): string {
263
+ const entries = Array.isArray(result.entries) ? result.entries : []
264
+ if (entries.length === 0) return 'This folder is empty.'
265
+ const lines = entries.map((raw) => {
266
+ const entry = record(raw)
267
+ const path = textValue(entry.path)
268
+ if (entry.type === 'dir') return `${path}/`
269
+ if (entry.type === 'link') return `${path} -> link`
270
+ return `${path}${numberValue(entry.size) !== undefined ? ` (${numberValue(entry.size)} B)` : ''}`
271
+ })
272
+ const suffix = result.truncated === true ? '\n… listing truncated. Narrow the path or reduce depth.' : ''
273
+ return `${textValue(result.root) ? `Root: ${textValue(result.root)}\n` : ''}${lines.join('\n')}${suffix}`
274
+ }
275
+
276
+ function formatShell(result: Record<string, unknown>, command: string): string {
277
+ const parts = [`$ ${command}`, textValue(result.stdout)]
278
+ if (textValue(result.stderr)) parts.push(`stderr:\n${textValue(result.stderr)}`)
279
+ const code = numberValue(result.code)
280
+ if (code !== undefined && code !== 0) parts.push(`exit code: ${code}`)
281
+ if (result.timedOut === true) parts.push('The command timed out.')
282
+ return parts.filter((part) => part !== '').join('\n')
283
+ }
284
+
285
+ function formatGrep(result: Record<string, unknown>, pattern: string): string {
286
+ const matches = Array.isArray(result.matches) ? result.matches : []
287
+ if (matches.length === 0) return `No matches for ${pattern}.`
288
+ const lines = matches.map((raw) => {
289
+ const match = record(raw)
290
+ return `${textValue(match.file)}:${numberValue(match.line) ?? 0}: ${textValue(match.text)}`
291
+ })
292
+ const suffix = result.truncated === true ? '\n… more matches exist. Narrow the pattern or path.' : ''
293
+ return lines.join('\n') + suffix
294
+ }
295
+
296
+ function formatGit(result: Record<string, unknown>): string {
297
+ const parts: string[] = []
298
+ if (textValue(result.current)) parts.push(`Current branch: ${textValue(result.current)}`)
299
+ if (textValue(result.branches)) parts.push(textValue(result.branches))
300
+ if (textValue(result.stdout)) parts.push(textValue(result.stdout))
301
+ if (textValue(result.stderr)) parts.push(`stderr:\n${textValue(result.stderr)}`)
302
+ const code = numberValue(result.code)
303
+ if (code !== undefined && code !== 0) parts.push(`exit code: ${code}`)
304
+ return parts.filter(Boolean).join('\n') || 'Git completed with no output.'
305
+ }
306
+
307
+ async function runTool(
308
+ name: Exclude<ToolName, 'subagent'>,
309
+ args: Record<string, unknown>,
310
+ settings: AppSettings,
311
+ mode: AgentMode,
312
+ signal: AbortSignal,
313
+ ): Promise<ToolResult> {
314
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
315
+
316
+ if (name === 'read_file') {
317
+ const path = requireString(args, 'path')
318
+ const result = record((await executeDaemonTool('read_file', { path }, mode, signal)).result)
319
+ if (result.binary === true) {
320
+ return { output: `${path} is binary (${numberValue(result.size) ?? 0} bytes).`, isError: false }
321
+ }
322
+ const offset = Math.max(1, Math.round(numberArg(args, 'offset', 1)))
323
+ const limit = Math.min(Math.max(1, Math.round(numberArg(args, 'limit', 400))), 2000)
324
+ const lines = textValue(result.content).split('\n')
325
+ const slice = lines.slice(offset - 1, offset - 1 + limit)
326
+ const remaining = lines.length - (offset - 1 + limit)
327
+ const header = `${textValue(result.path) || path} · ${lines.length} lines${result.truncated === true ? ' · file clipped by daemon' : ''}`
328
+ const footer = remaining > 0 ? `\n… ${remaining} more lines. Read again with offset ${offset + limit}.` : ''
329
+ return { output: truncate(`${header}\n────\n${slice.join('\n')}${footer}`), isError: false }
330
+ }
331
+
332
+ if (name === 'list_files') {
333
+ const result = record(
334
+ (
335
+ await executeDaemonTool(
336
+ 'list_files',
337
+ {
338
+ path: optionalString(args, 'path') ?? '',
339
+ depth: Math.min(Math.max(1, numberArg(args, 'depth', 3)), 8),
340
+ },
341
+ mode,
342
+ signal,
343
+ )
344
+ ).result,
345
+ )
346
+ return { output: formatList(result), isError: false }
347
+ }
348
+
349
+ if (name === 'write_file' || name === 'rewrite_file') {
350
+ const path = requireString(args, 'path')
351
+ const content = requireString(args, 'content')
352
+ const result = record((await executeDaemonTool(name, { path, content }, mode, signal)).result)
353
+ await refreshWorkspace()
354
+ const verb = result.created === true ? 'Created' : name === 'rewrite_file' ? 'Rewrote' : 'Updated'
355
+ return {
356
+ output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
357
+ isError: false,
358
+ }
359
+ }
360
+
361
+ if (name === 'edit_file') {
362
+ const path = requireString(args, 'path')
363
+ const oldText = requireString(args, 'oldText')
364
+ const newText = typeof args.newText === 'string' ? args.newText : ''
365
+ const result = record(
366
+ (
367
+ await executeDaemonTool(
368
+ 'edit_file',
369
+ { path, oldText, newText, replaceAll: args.replaceAll === true },
370
+ mode,
371
+ signal,
372
+ )
373
+ ).result,
374
+ )
375
+ await refreshWorkspace()
376
+ return {
377
+ output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}.`,
378
+ isError: false,
379
+ }
380
+ }
381
+
382
+ if (name === 'shell') {
383
+ const command = requireString(args, 'command')
384
+ const result = record(
385
+ (
386
+ await executeDaemonTool(
387
+ 'shell',
388
+ { command, timeoutMs: numberArg(args, 'timeoutMs', 120_000) },
389
+ mode,
390
+ signal,
391
+ )
392
+ ).result,
393
+ )
394
+ void refreshTree()
395
+ return {
396
+ output: truncate(formatShell(result, command)),
397
+ isError: (numberValue(result.code) ?? 0) !== 0 || result.timedOut === true,
398
+ }
399
+ }
400
+
401
+ if (name === 'grep') {
402
+ const pattern = requireString(args, 'pattern')
403
+ const result = record(
404
+ (
405
+ await executeDaemonTool(
406
+ 'grep',
407
+ {
408
+ pattern,
409
+ path: optionalString(args, 'path') ?? '',
410
+ ignoreCase: args.ignoreCase === true,
411
+ },
412
+ mode,
413
+ signal,
414
+ )
415
+ ).result,
416
+ )
417
+ const maxResults = Math.min(Math.max(1, Math.round(numberArg(args, 'maxResults', 80))), 200)
418
+ const matches = Array.isArray(result.matches) ? result.matches.slice(0, maxResults) : []
419
+ const clipped = { ...result, matches, truncated: result.truncated === true || (Array.isArray(result.matches) && result.matches.length > maxResults) }
420
+ return { output: truncate(formatGrep(clipped, pattern)), isError: false }
421
+ }
422
+
423
+ if (name === 'web_search') {
424
+ const query = requireString(args, 'query')
425
+ const result = record(
426
+ (
427
+ await executeDaemonTool(
428
+ 'web_search',
429
+ { query, provider: settings.searchProvider },
430
+ mode,
431
+ signal,
432
+ )
433
+ ).result,
434
+ )
435
+ const results = Array.isArray(result.results) ? result.results : []
436
+ if (results.length === 0) return { output: 'No results found.', isError: false }
437
+ const formatted = results
438
+ .map((raw, index) => {
439
+ const item = record(raw)
440
+ return `${index + 1}. ${textValue(item.title)}\n ${textValue(item.url)}${textValue(item.snippet) ? `\n ${textValue(item.snippet)}` : ''}`
441
+ })
442
+ .join('\n\n')
443
+ return {
444
+ output: truncate(`Search: ${query}\nProvider: ${textValue(result.provider) || 'web'}\n\n${formatted}`),
445
+ isError: false,
446
+ }
447
+ }
448
+
449
+ if (name === 'read_url') {
450
+ const url = requireString(args, 'url')
451
+ const result = record((await executeDaemonTool('read_url', { url }, mode, signal)).result)
452
+ const title = textValue(result.title)
453
+ return {
454
+ output: truncate(
455
+ `Source: ${textValue(result.url) || url}${title ? `\nTitle: ${title}` : ''}\n\n${textValue(result.content)}${result.truncated === true ? '\n\n… page truncated.' : ''}`,
456
+ ),
457
+ isError: false,
458
+ }
459
+ }
460
+
461
+ const action = requireString(args, 'action')
462
+ const gitArgs: Record<string, unknown> = { ...args }
463
+ delete gitArgs.action
464
+ const result = record((await executeDaemonTool('git', { action, ...gitArgs }, mode, signal)).result)
465
+ if (action === 'add' || action === 'commit' || action === 'init' || action === 'checkout') {
466
+ await useWorkspace.getState().loadGit().catch(() => undefined)
467
+ }
468
+ return {
469
+ output: truncate(formatGit(result)),
470
+ isError: (numberValue(result.code) ?? 0) !== 0,
471
+ }
472
+ }
473
+
474
+ export async function executeTool(
475
+ name: ToolName,
476
+ args: Record<string, unknown>,
477
+ settings: AppSettings,
478
+ mode: AgentMode,
479
+ signal: AbortSignal,
480
+ ): Promise<ToolResult> {
481
+ if (kernel.tools) {
482
+ return await kernel.tools.execute(name, args, settings, mode, signal)
483
+ }
484
+ if (!settings.tools[name]?.enabled) {
485
+ return { output: 'This tool is disabled in Settings → Tools.', isError: true }
486
+ }
487
+ if (!toolAllowedInMode(name, mode)) {
488
+ return {
489
+ output:
490
+ 'Plan mode is read-only. Do not edit files or change git state. Finish with an implementation plan instead.',
491
+ isError: true,
492
+ }
493
+ }
494
+ if ((name === 'web_search' || name === 'read_url') && !settings.webSearchEnabled) {
495
+ return { output: 'Web access is turned off for this session.', isError: true }
496
+ }
497
+ if (name === 'subagent') {
498
+ return { output: 'Subagents are handled by the agent loop.', isError: true }
499
+ }
500
+ try {
501
+ return await runTool(name, args, settings, mode, signal)
502
+ } catch (error) {
503
+ if (error instanceof DOMException && error.name === 'AbortError') throw error
504
+ return { output: error instanceof Error ? error.message : 'The tool failed.', isError: true }
505
+ }
506
+ }
507
+
508
+ export function toolLabel(name: ToolName): string {
509
+ const labels: Record<ToolName, string> = {
510
+ shell: 'Shell',
511
+ read_file: 'Read file',
512
+ list_files: 'List files',
513
+ write_file: 'Write file',
514
+ edit_file: 'Edit file',
515
+ rewrite_file: 'Rewrite file',
516
+ grep: 'Grep',
517
+ web_search: 'Web search',
518
+ read_url: 'Read URL',
519
+ git: 'Git',
520
+ subagent: 'Subagent',
521
+ }
522
+ return labels[name] ?? 'Tool'
523
+ }
524
+
525
+ export function toolSummary(name: ToolName, args: Record<string, unknown>): string {
526
+ const value =
527
+ name === 'shell'
528
+ ? args.command
529
+ : name === 'grep'
530
+ ? args.pattern
531
+ : name === 'web_search'
532
+ ? args.query
533
+ : name === 'read_url'
534
+ ? args.url
535
+ : name === 'git'
536
+ ? args.action
537
+ : name === 'subagent'
538
+ ? args.task
539
+ : (args.path ?? '')
540
+ return typeof value === 'string' ? value : ''
541
+ }