@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,311 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import {
4
+ AlertTriangle,
5
+ Check,
6
+ CircleCheck,
7
+ CircleX,
8
+ Info,
9
+ LoaderCircle,
10
+ X,
11
+ } from 'lucide-react'
12
+ import { cn } from '../lib/utils'
13
+ import { useUi } from '../store/ui'
14
+
15
+ export function Modal({
16
+ open,
17
+ onClose,
18
+ children,
19
+ className,
20
+ labelledBy,
21
+ wide = false,
22
+ }: {
23
+ open: boolean
24
+ onClose: () => void
25
+ children: ReactNode
26
+ className?: string
27
+ labelledBy?: string
28
+ wide?: boolean
29
+ }) {
30
+ const panelRef = useRef<HTMLDivElement>(null)
31
+ const restoreRef = useRef<HTMLElement | null>(null)
32
+ const closeRef = useRef(onClose)
33
+ closeRef.current = onClose
34
+
35
+ useEffect(() => {
36
+ if (!open) return
37
+ restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
38
+ const panel = panelRef.current
39
+ const focusables = () =>
40
+ Array.from(
41
+ panel?.querySelectorAll<HTMLElement>(
42
+ 'button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
43
+ ) ?? [],
44
+ ).filter((element) => element.offsetParent !== null)
45
+
46
+ const timer = window.setTimeout(() => {
47
+ const target = panel?.querySelector<HTMLElement>('[data-autofocus]') ?? focusables()[0]
48
+ ;(target ?? panel)?.focus()
49
+ }, 30)
50
+
51
+ const onKeyDown = (event: KeyboardEvent) => {
52
+ if (event.key === 'Escape') {
53
+ event.preventDefault()
54
+ closeRef.current()
55
+ return
56
+ }
57
+ if (event.key !== 'Tab') return
58
+ const items = focusables()
59
+ if (items.length === 0) return
60
+ const first = items[0]
61
+ const last = items[items.length - 1]
62
+ if (event.shiftKey && document.activeElement === first) {
63
+ event.preventDefault()
64
+ last.focus()
65
+ } else if (!event.shiftKey && document.activeElement === last) {
66
+ event.preventDefault()
67
+ first.focus()
68
+ }
69
+ }
70
+
71
+ document.addEventListener('keydown', onKeyDown)
72
+ const previousOverflow = document.body.style.overflow
73
+ document.body.style.overflow = 'hidden'
74
+ return () => {
75
+ window.clearTimeout(timer)
76
+ document.removeEventListener('keydown', onKeyDown)
77
+ document.body.style.overflow = previousOverflow
78
+ restoreRef.current?.focus()
79
+ }
80
+ }, [open])
81
+
82
+ if (!open) return null
83
+
84
+ return createPortal(
85
+ <div className="fixed inset-0 z-[80] flex items-end justify-center p-0 sm:items-center sm:p-6">
86
+ <button
87
+ type="button"
88
+ aria-label="Close dialog"
89
+ tabIndex={-1}
90
+ onClick={onClose}
91
+ className="modal-scrim absolute inset-0 cursor-default bg-black/35 backdrop-blur-[3px] dark:bg-black/60"
92
+ />
93
+ <div
94
+ ref={panelRef}
95
+ role="dialog"
96
+ aria-modal="true"
97
+ aria-labelledby={labelledBy}
98
+ tabIndex={-1}
99
+ className={cn(
100
+ 'modal-panel relative flex max-h-[92dvh] w-full flex-col overflow-hidden rounded-t-2xl border border-line surface-lift shadow-float sm:max-h-[86dvh] sm:rounded-xl',
101
+ wide ? 'sm:w-[min(920px,94vw)]' : 'sm:w-[min(760px,94vw)]',
102
+ className,
103
+ )}
104
+ >
105
+ {children}
106
+ </div>
107
+ </div>,
108
+ document.body,
109
+ )
110
+ }
111
+
112
+ export function ModalHeader({
113
+ title,
114
+ description,
115
+ onClose,
116
+ id,
117
+ actions,
118
+ }: {
119
+ title: string
120
+ description?: string
121
+ onClose: () => void
122
+ id?: string
123
+ actions?: ReactNode
124
+ }) {
125
+ return (
126
+ <div className="flex items-start justify-between gap-4 border-b border-line px-5 py-4 sm:px-6">
127
+ <div className="min-w-0">
128
+ <h2 id={id} className="text-[15px] font-semibold tracking-[-0.02em] text-ink">
129
+ {title}
130
+ </h2>
131
+ {description && <p className="mt-1 text-[12.5px] leading-relaxed text-muted">{description}</p>}
132
+ </div>
133
+ <div className="flex shrink-0 items-center gap-1">
134
+ {actions}
135
+ <button type="button" className="icon-button" onClick={onClose} aria-label="Close">
136
+ <X size={16} />
137
+ </button>
138
+ </div>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ export function Spinner({ size = 14, className }: { size?: number; className?: string }) {
144
+ return <LoaderCircle size={size} className={cn('animate-spin text-current', className)} aria-hidden="true" />
145
+ }
146
+
147
+ export function Switch({
148
+ checked,
149
+ onChange,
150
+ label,
151
+ disabled = false,
152
+ }: {
153
+ checked: boolean
154
+ onChange: (checked: boolean) => void
155
+ label: string
156
+ disabled?: boolean
157
+ }) {
158
+ return (
159
+ <button
160
+ type="button"
161
+ role="switch"
162
+ aria-label={label}
163
+ aria-checked={checked}
164
+ disabled={disabled}
165
+ onClick={() => onChange(!checked)}
166
+ className={cn(
167
+ 'relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full border transition-colors duration-200 disabled:opacity-40',
168
+ checked ? 'border-accent bg-accent' : 'border-line bg-raised',
169
+ )}
170
+ >
171
+ <span
172
+ className={cn(
173
+ 'pointer-events-none block h-[16px] w-[16px] rounded-full bg-white shadow-sm transition-transform duration-300 ease-spring',
174
+ checked ? 'translate-x-[19px]' : 'translate-x-[3px]',
175
+ )}
176
+ />
177
+ </button>
178
+ )
179
+ }
180
+
181
+ export function Segmented<T extends string>({
182
+ options,
183
+ value,
184
+ onChange,
185
+ disabled,
186
+ className,
187
+ size = 'sm',
188
+ }: {
189
+ options: Array<{ value: T; label: string; title?: string }>
190
+ value: T
191
+ onChange: (value: T) => void
192
+ disabled?: boolean
193
+ className?: string
194
+ size?: 'sm' | 'md'
195
+ }) {
196
+ return (
197
+ <div
198
+ className={cn(
199
+ 'inline-flex items-center rounded-lg bg-raised p-0.5',
200
+ size === 'md' ? 'text-[12.5px]' : 'text-[11.5px]',
201
+ disabled && 'opacity-50',
202
+ className,
203
+ )}
204
+ >
205
+ {options.map((option) => (
206
+ <button
207
+ key={option.value}
208
+ type="button"
209
+ title={option.title}
210
+ disabled={disabled}
211
+ onClick={() => onChange(option.value)}
212
+ className={cn(
213
+ 'rounded-md font-medium transition-all duration-200 disabled:cursor-not-allowed',
214
+ size === 'md' ? 'px-3 py-1.5' : 'px-2.5 py-1',
215
+ value === option.value
216
+ ? 'bg-panel text-ink shadow-[0_1px_2px_rgba(20,22,30,0.08)]'
217
+ : 'text-muted hover:text-ink',
218
+ )}
219
+ >
220
+ {option.label}
221
+ </button>
222
+ ))}
223
+ </div>
224
+ )
225
+ }
226
+
227
+ export function Kbd({ children }: { children: ReactNode }) {
228
+ return (
229
+ <kbd className="rounded border border-line bg-raised px-1.5 py-0.5 font-sans text-[10px] font-medium text-faint">
230
+ {children}
231
+ </kbd>
232
+ )
233
+ }
234
+
235
+ const toneStyles = {
236
+ info: { icon: Info, className: 'text-accent' },
237
+ success: { icon: CircleCheck, className: 'text-success' },
238
+ error: { icon: CircleX, className: 'text-danger' },
239
+ }
240
+
241
+ export function ToastHost() {
242
+ const toast = useUi((state) => state.toast)
243
+ const dismiss = useUi((state) => state.dismissToast)
244
+
245
+ useEffect(() => {
246
+ if (!toast) return
247
+ const timer = window.setTimeout(() => dismiss(toast.id), 4500)
248
+ return () => window.clearTimeout(timer)
249
+ }, [toast, dismiss])
250
+
251
+ if (!toast) return null
252
+ const tone = toneStyles[toast.tone]
253
+ const Icon = tone.icon
254
+ return createPortal(
255
+ <div
256
+ className="pointer-events-none fixed bottom-56 left-1/2 z-[100] flex w-[min(92vw,440px)] -translate-x-1/2 animate-scalein items-start gap-2.5 rounded-xl border border-line surface-lift px-3.5 py-3 text-[12.5px] text-ink shadow-float sm:bottom-48"
257
+ role="status"
258
+ >
259
+ <Icon size={16} className={cn('mt-0.5 shrink-0', tone.className)} />
260
+ <p className="flex-1 leading-relaxed">{toast.message}</p>
261
+ <button type="button" className="pointer-events-auto icon-button -mr-1 -mt-1 h-6 w-6" onClick={() => dismiss(toast.id)} aria-label="Dismiss">
262
+ <X size={13} />
263
+ </button>
264
+ </div>,
265
+ document.body,
266
+ )
267
+ }
268
+
269
+ export function ConfirmDialog() {
270
+ const confirm = useUi((state) => state.confirm)
271
+ const resolve = useUi((state) => state.resolveConfirm)
272
+ const danger = confirm?.tone === 'danger'
273
+
274
+ return (
275
+ <Modal open={Boolean(confirm)} onClose={() => resolve(false)} className="sm:w-[420px]">
276
+ <div className="p-5 sm:p-6">
277
+ <div className="flex items-start gap-3">
278
+ <span
279
+ className={cn(
280
+ 'mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full',
281
+ danger ? 'bg-danger/10 text-danger' : 'bg-accent-soft text-accent',
282
+ )}
283
+ >
284
+ <AlertTriangle size={17} />
285
+ </span>
286
+ <div className="min-w-0">
287
+ <h2 className="text-[15px] font-semibold tracking-[-0.01em]">{confirm?.title}</h2>
288
+ <p className="mt-1.5 text-[12.5px] leading-relaxed text-muted">{confirm?.body}</p>
289
+ </div>
290
+ </div>
291
+ <div className="mt-6 flex justify-end gap-2">
292
+ <button type="button" className="chip" onClick={() => resolve(false)}>
293
+ Cancel
294
+ </button>
295
+ <button
296
+ type="button"
297
+ data-autofocus
298
+ onClick={() => resolve(true)}
299
+ className={cn(
300
+ 'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-medium text-on-accent transition-all active:scale-95',
301
+ danger ? 'bg-danger hover:bg-danger/90' : 'bg-accent hover:bg-accent/90',
302
+ )}
303
+ >
304
+ <Check size={14} />
305
+ {confirm?.confirmLabel ?? 'Confirm'}
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </Modal>
310
+ )
311
+ }
@@ -0,0 +1,45 @@
1
+ import { Context } from 'cordis'
2
+ import './types'
3
+ import { SessionService } from './services/sessions'
4
+ import { SystemPromptService } from './services/system-prompt'
5
+ import { ToolService } from './services/tools'
6
+ import { ApprovalService } from './services/approval'
7
+ import { LlmService } from './services/llm'
8
+ import { CompactionService } from './services/compaction'
9
+ import { SubagentService } from './services/subagents'
10
+ import { UiService } from './services/ui'
11
+ import { AgentLoopService } from './services/agent-loop'
12
+
13
+ import * as toolFs from './plugins/tools/fs'
14
+ import * as toolShell from './plugins/tools/shell'
15
+ import * as toolGrep from './plugins/tools/grep'
16
+ import * as toolGit from './plugins/tools/git'
17
+ import * as toolWeb from './plugins/tools/web'
18
+ import * as toolSubagent from './plugins/tools/subagent'
19
+
20
+ export function createAppKernel(): Context {
21
+ const ctx = new Context()
22
+
23
+ // 1. Mount Core Services
24
+ ctx.plugin(SessionService)
25
+ ctx.plugin(SystemPromptService)
26
+ ctx.plugin(ToolService)
27
+ ctx.plugin(ApprovalService)
28
+ ctx.plugin(LlmService)
29
+ ctx.plugin(CompactionService)
30
+ ctx.plugin(SubagentService)
31
+ ctx.plugin(UiService)
32
+ ctx.plugin(AgentLoopService)
33
+
34
+ // 2. Mount First-Party Tool Plugins
35
+ ctx.plugin(toolFs)
36
+ ctx.plugin(toolShell)
37
+ ctx.plugin(toolGrep)
38
+ ctx.plugin(toolGit)
39
+ ctx.plugin(toolWeb)
40
+ ctx.plugin(toolSubagent)
41
+
42
+ return ctx
43
+ }
44
+
45
+ export const kernel = createAppKernel()
@@ -0,0 +1,219 @@
1
+ import type { Context } from 'cordis'
2
+ import { executeDaemonTool } from '../../../lib/daemon'
3
+ import { useWorkspace } from '../../../store/workspace'
4
+ import type { ToolResult } from '../../types'
5
+
6
+ const MAX_TOOL_OUTPUT = 28_000
7
+
8
+ function truncate(value: string, limit = MAX_TOOL_OUTPUT) {
9
+ if (value.length <= limit) return value
10
+ return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
11
+ }
12
+
13
+ function record(value: unknown): Record<string, unknown> {
14
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
15
+ }
16
+
17
+ function textValue(value: unknown): string {
18
+ return typeof value === 'string' ? value : ''
19
+ }
20
+
21
+ function numberValue(value: unknown): number | undefined {
22
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
23
+ }
24
+
25
+ function requireString(args: Record<string, unknown>, key: string): string {
26
+ const value = args[key]
27
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing string argument "${key}".`)
28
+ return value
29
+ }
30
+
31
+ function optionalString(args: Record<string, unknown>, key: string): string | undefined {
32
+ const value = args[key]
33
+ return typeof value === 'string' ? value : undefined
34
+ }
35
+
36
+ function numberArg(args: Record<string, unknown>, key: string, fallback: number): number {
37
+ const value = args[key]
38
+ if (typeof value === 'number' && Number.isFinite(value)) return value
39
+ if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value)
40
+ return fallback
41
+ }
42
+
43
+ function formatList(result: Record<string, unknown>): string {
44
+ const entries = Array.isArray(result.entries) ? result.entries : []
45
+ if (entries.length === 0) return 'This folder is empty.'
46
+ const lines = entries.map((raw) => {
47
+ const entry = record(raw)
48
+ const path = textValue(entry.path)
49
+ if (entry.type === 'dir') return `${path}/`
50
+ if (entry.type === 'link') return `${path} -> link`
51
+ return `${path}${numberValue(entry.size) !== undefined ? ` (${numberValue(entry.size)} B)` : ''}`
52
+ })
53
+ const suffix = result.truncated === true ? '\n… listing truncated. Narrow the path or reduce depth.' : ''
54
+ return `${textValue(result.root) ? `Root: ${textValue(result.root)}\n` : ''}${lines.join('\n')}${suffix}`
55
+ }
56
+
57
+ async function refreshWorkspace() {
58
+ await useWorkspace.getState().refresh().catch(() => undefined)
59
+ }
60
+
61
+ export const name = 'tool-fs'
62
+ export const inject = ['tools']
63
+
64
+ export function apply(ctx: Context) {
65
+ const unregisters = [
66
+ ctx.tools.register({
67
+ name: 'read_file',
68
+ description: 'Read a text file from the workspace. Use offset and limit for large files.',
69
+ parameters: {
70
+ type: 'object',
71
+ properties: {
72
+ path: { type: 'string', description: 'Relative path from the workspace root.' },
73
+ offset: { type: 'number', description: '1-based first line. Defaults to 1.' },
74
+ limit: { type: 'number', description: 'Maximum lines. Defaults to 400.' },
75
+ },
76
+ required: ['path'],
77
+ additionalProperties: false,
78
+ },
79
+ allowedInMode: () => true,
80
+ async execute(args, exec): Promise<ToolResult> {
81
+ const path = requireString(args, 'path')
82
+ const result = record((await executeDaemonTool('read_file', { path }, exec.mode, exec.signal)).result)
83
+ if (result.binary === true) {
84
+ return { output: `${path} is binary (${numberValue(result.size) ?? 0} bytes).`, isError: false }
85
+ }
86
+ const offset = Math.max(1, Math.round(numberArg(args, 'offset', 1)))
87
+ const limit = Math.min(Math.max(1, Math.round(numberArg(args, 'limit', 400))), 2000)
88
+ const lines = textValue(result.content).split('\n')
89
+ const slice = lines.slice(offset - 1, offset - 1 + limit)
90
+ const remaining = lines.length - (offset - 1 + limit)
91
+ const header = `${textValue(result.path) || path} · ${lines.length} lines${result.truncated === true ? ' · file clipped by daemon' : ''}`
92
+ const footer = remaining > 0 ? `\n… ${remaining} more lines. Read again with offset ${offset + limit}.` : ''
93
+ return { output: truncate(`${header}\n────\n${slice.join('\n')}${footer}`), isError: false }
94
+ },
95
+ }),
96
+
97
+ ctx.tools.register({
98
+ name: 'list_files',
99
+ description: 'List files and folders below a workspace path.',
100
+ parameters: {
101
+ type: 'object',
102
+ properties: {
103
+ path: { type: 'string', description: 'Folder path. Defaults to the workspace root.' },
104
+ depth: { type: 'number', description: 'Recursion depth, 1–8. Defaults to 3.' },
105
+ },
106
+ additionalProperties: false,
107
+ },
108
+ allowedInMode: () => true,
109
+ async execute(args, exec): Promise<ToolResult> {
110
+ const result = record(
111
+ (
112
+ await executeDaemonTool(
113
+ 'list_files',
114
+ {
115
+ path: optionalString(args, 'path') ?? '',
116
+ depth: Math.min(Math.max(1, numberArg(args, 'depth', 3)), 8),
117
+ },
118
+ exec.mode,
119
+ exec.signal,
120
+ )
121
+ ).result,
122
+ )
123
+ return { output: formatList(result), isError: false }
124
+ },
125
+ }),
126
+
127
+ ctx.tools.register({
128
+ name: 'write_file',
129
+ description: 'Create a file or replace its full contents. Prefer edit_file for small changes.',
130
+ parameters: {
131
+ type: 'object',
132
+ properties: {
133
+ path: { type: 'string', description: 'Relative file path.' },
134
+ content: { type: 'string', description: 'Complete file content.' },
135
+ },
136
+ required: ['path', 'content'],
137
+ additionalProperties: false,
138
+ },
139
+ allowedInMode: (mode) => mode === 'build',
140
+ async execute(args, exec): Promise<ToolResult> {
141
+ const path = requireString(args, 'path')
142
+ const content = requireString(args, 'content')
143
+ const result = record((await executeDaemonTool('write_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
144
+ await refreshWorkspace()
145
+ const verb = result.created === true ? 'Created' : 'Updated'
146
+ return {
147
+ output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
148
+ isError: false,
149
+ }
150
+ },
151
+ }),
152
+
153
+ ctx.tools.register({
154
+ name: 'rewrite_file',
155
+ description: 'Replace the complete contents of an existing file. Use when most of the file changes.',
156
+ parameters: {
157
+ type: 'object',
158
+ properties: {
159
+ path: { type: 'string', description: 'Relative file path.' },
160
+ content: { type: 'string', description: 'The new complete file content.' },
161
+ },
162
+ required: ['path', 'content'],
163
+ additionalProperties: false,
164
+ },
165
+ allowedInMode: (mode) => mode === 'build',
166
+ async execute(args, exec): Promise<ToolResult> {
167
+ const path = requireString(args, 'path')
168
+ const content = requireString(args, 'content')
169
+ const result = record((await executeDaemonTool('rewrite_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
170
+ await refreshWorkspace()
171
+ return {
172
+ output: `Rewrote ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
173
+ isError: false,
174
+ }
175
+ },
176
+ }),
177
+
178
+ ctx.tools.register({
179
+ name: 'edit_file',
180
+ description:
181
+ 'Replace an exact text occurrence in a file. oldText must match whitespace exactly and be unique unless replaceAll is true.',
182
+ parameters: {
183
+ type: 'object',
184
+ properties: {
185
+ path: { type: 'string', description: 'Relative file path.' },
186
+ oldText: { type: 'string', description: 'Exact text to replace.' },
187
+ newText: { type: 'string', description: 'Replacement text.' },
188
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence. Defaults to false.' },
189
+ },
190
+ required: ['path', 'oldText', 'newText'],
191
+ additionalProperties: false,
192
+ },
193
+ allowedInMode: (mode) => mode === 'build',
194
+ async execute(args, exec): Promise<ToolResult> {
195
+ const path = requireString(args, 'path')
196
+ const oldText = requireString(args, 'oldText')
197
+ const newText = typeof args.newText === 'string' ? args.newText : ''
198
+ const result = record(
199
+ (
200
+ await executeDaemonTool(
201
+ 'edit_file',
202
+ { path, oldText, newText, replaceAll: args.replaceAll === true },
203
+ exec.mode,
204
+ exec.signal,
205
+ exec.turnId,
206
+ )
207
+ ).result,
208
+ )
209
+ await refreshWorkspace()
210
+ return {
211
+ output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}.`,
212
+ isError: false,
213
+ }
214
+ },
215
+ }),
216
+ ]
217
+
218
+ return () => unregisters.forEach((fn) => fn())
219
+ }
@@ -0,0 +1,83 @@
1
+ import type { Context } from 'cordis'
2
+ import { executeDaemonTool } from '../../../lib/daemon'
3
+ import { useWorkspace } from '../../../store/workspace'
4
+ import type { ToolResult } from '../../types'
5
+
6
+ const MAX_TOOL_OUTPUT = 28_000
7
+
8
+ function truncate(value: string, limit = MAX_TOOL_OUTPUT) {
9
+ if (value.length <= limit) return value
10
+ return `${value.slice(0, limit)}\n… truncated (${value.length - limit} more characters)`
11
+ }
12
+
13
+ function record(value: unknown): Record<string, unknown> {
14
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
15
+ }
16
+
17
+ function textValue(value: unknown): string {
18
+ return typeof value === 'string' ? value : ''
19
+ }
20
+
21
+ function numberValue(value: unknown): number | undefined {
22
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
23
+ }
24
+
25
+ function requireString(args: Record<string, unknown>, key: string): string {
26
+ const value = args[key]
27
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing string argument "${key}".`)
28
+ return value
29
+ }
30
+
31
+ function formatGit(result: Record<string, unknown>): string {
32
+ const parts: string[] = []
33
+ if (textValue(result.current)) parts.push(`Current branch: ${textValue(result.current)}`)
34
+ if (textValue(result.branches)) parts.push(textValue(result.branches))
35
+ if (textValue(result.stdout)) parts.push(textValue(result.stdout))
36
+ if (textValue(result.stderr)) parts.push(`stderr:\n${textValue(result.stderr)}`)
37
+ const code = numberValue(result.code)
38
+ if (code !== undefined && code !== 0) parts.push(`exit code: ${code}`)
39
+ return parts.filter(Boolean).join('\n') || 'Git completed with no output.'
40
+ }
41
+
42
+ export const name = 'tool-git'
43
+ export const inject = ['tools']
44
+
45
+ export function apply(ctx: Context) {
46
+ return ctx.tools.register({
47
+ name: 'git',
48
+ description: 'Inspect or change git state. In Plan mode only status, diff, log, show and branch are accepted.',
49
+ parameters: {
50
+ type: 'object',
51
+ properties: {
52
+ action: {
53
+ type: 'string',
54
+ enum: ['status', 'diff', 'log', 'show', 'branch', 'add', 'commit', 'init', 'checkout'],
55
+ },
56
+ path: { type: 'string', description: 'Limit diff to this file.' },
57
+ staged: { type: 'boolean', description: 'Diff the staged changes.' },
58
+ ref: { type: 'string', description: 'Commit or ref for show.' },
59
+ limit: { type: 'number', description: 'Log limit.' },
60
+ paths: { type: 'array', items: { type: 'string' }, description: 'Files to stage.' },
61
+ message: { type: 'string', description: 'Commit message.' },
62
+ branch: { type: 'string', description: 'Branch to create or check out.' },
63
+ create: { type: 'boolean', description: 'Create a new branch. Defaults to true.' },
64
+ },
65
+ required: ['action'],
66
+ additionalProperties: false,
67
+ },
68
+ allowedInMode: () => true,
69
+ async execute(args, exec): Promise<ToolResult> {
70
+ const action = requireString(args, 'action')
71
+ const gitArgs: Record<string, unknown> = { ...args }
72
+ delete gitArgs.action
73
+ const result = record((await executeDaemonTool('git', { action, ...gitArgs }, exec.mode, exec.signal)).result)
74
+ if (action === 'add' || action === 'commit' || action === 'init' || action === 'checkout') {
75
+ await useWorkspace.getState().loadGit().catch(() => undefined)
76
+ }
77
+ return {
78
+ output: truncate(formatGit(result)),
79
+ isError: (numberValue(result.code) ?? 0) !== 0,
80
+ }
81
+ },
82
+ })
83
+ }