ethagent 0.2.1 → 1.0.0

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,810 @@
1
+ import { spawn } from 'node:child_process'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { atomicWriteText } from '../storage/atomicWrite.js'
5
+ import { ensureConfigDir, getConfigDir } from '../storage/config.js'
6
+ import os from 'node:os'
7
+
8
+ export const DEFAULT_LLAMA_HOST = process.env.LLAMACPP_HOST ?? 'http://localhost:8080'
9
+
10
+ export type LlamaCppStatus = {
11
+ binaryPresent: boolean
12
+ binaryPath: string | null
13
+ version: string | null
14
+ serverUp: boolean
15
+ servedModels: string[]
16
+ }
17
+
18
+ type RunResult = {
19
+ code: number
20
+ stdout: string
21
+ stderr: string
22
+ }
23
+
24
+ type RunInstallResult = { ok: true } | { ok: false; message: string; detail?: string }
25
+
26
+ export type LlamaCppInstallPhase = 'checking' | 'installing' | 'finding' | 'building'
27
+ export type LlamaCppInstallRecovery = 'retry-install' | 'source-build' | 'runner-path' | 'back'
28
+
29
+ export type LlamaCppInstallProgress = {
30
+ phase: LlamaCppInstallPhase
31
+ label: string
32
+ progress: number
33
+ }
34
+
35
+ export type LlamaCppInstallResult =
36
+ | { ok: true; serverPath?: string }
37
+ | {
38
+ ok: false
39
+ code: 'install-failed' | 'server-not-found' | 'missing-tools' | 'build-failed'
40
+ message: string
41
+ detail?: string
42
+ recovery: LlamaCppInstallRecovery[]
43
+ candidatePaths?: string[]
44
+ }
45
+
46
+ export type LlamaCppStartFailureCode =
47
+ | 'runner-not-installed'
48
+ | 'model-file-missing'
49
+ | 'different-model-running'
50
+ | 'spawn-failed'
51
+ | 'runner-exited'
52
+ | 'readiness-timeout'
53
+
54
+ export type LlamaCppStartResult =
55
+ | { ok: true; alreadyRunning: boolean }
56
+ | {
57
+ ok: false
58
+ code: LlamaCppStartFailureCode
59
+ message: string
60
+ detail?: string
61
+ servedModels?: string[]
62
+ }
63
+
64
+ export type LlamaCppInstallPlan = {
65
+ command: string
66
+ args: string[]
67
+ label: string
68
+ timeoutMs?: number
69
+ }
70
+
71
+ type LlamaCppStartDeps = {
72
+ access?: typeof fs.access
73
+ binaryPath?: string
74
+ spawnImpl?: (command: string, args: readonly string[], options: NonNullable<Parameters<typeof spawn>[2]>) => ReturnType<typeof spawn>
75
+ }
76
+
77
+ export type LocalRunnerConfig = {
78
+ llamaServerPath?: string
79
+ }
80
+
81
+ function runCommand(cmd: string, args: string[], timeoutMs = 2000): Promise<RunResult | null> {
82
+ return new Promise(resolve => {
83
+ let settled = false
84
+ let child: ReturnType<typeof spawn>
85
+ try {
86
+ child = spawn(cmd, args, { windowsHide: true })
87
+ } catch {
88
+ resolve(null)
89
+ return
90
+ }
91
+
92
+ let stdout = ''
93
+ let stderr = ''
94
+ const timer = setTimeout(() => {
95
+ if (settled) return
96
+ settled = true
97
+ try { child.kill() } catch { void 0 }
98
+ resolve(null)
99
+ }, timeoutMs)
100
+
101
+ child.stdout?.on('data', chunk => { stdout += chunk.toString() })
102
+ child.stderr?.on('data', chunk => { stderr += chunk.toString() })
103
+ child.on('error', () => {
104
+ if (settled) return
105
+ settled = true
106
+ clearTimeout(timer)
107
+ resolve(null)
108
+ })
109
+ child.on('close', code => {
110
+ if (settled) return
111
+ settled = true
112
+ clearTimeout(timer)
113
+ resolve({ code: code ?? -1, stdout, stderr })
114
+ })
115
+ })
116
+ }
117
+
118
+ function runInstallCommand(
119
+ plan: LlamaCppInstallPlan,
120
+ timeoutMs: number,
121
+ ): Promise<RunInstallResult> {
122
+ return new Promise(resolve => {
123
+ let child: ReturnType<typeof spawn>
124
+ try {
125
+ child = spawn(plan.command, plan.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
126
+ } catch (err) {
127
+ resolve({ ok: false, message: (err as Error).message })
128
+ return
129
+ }
130
+
131
+ let settled = false
132
+ const settle = (result: RunInstallResult): void => {
133
+ if (settled) return
134
+ settled = true
135
+ clearTimeout(timer)
136
+ try { child.kill() } catch { void 0 }
137
+ resolve(result)
138
+ }
139
+ const timer = setTimeout(() => settle({ ok: false, message: `${plan.label} timed out` }), timeoutMs)
140
+ let output = ''
141
+ const onData = (chunk: Buffer | string): void => { output += chunk.toString() }
142
+ child.stdout?.on('data', onData)
143
+ child.stderr?.on('data', onData)
144
+ child.on('error', err => settle({ ok: false, message: err.message }))
145
+ child.on('close', code => {
146
+ if (code === 0) settle({ ok: true })
147
+ else settle({
148
+ ok: false,
149
+ message: humanInstallError(plan, code),
150
+ detail: installFailureDetail(code, output),
151
+ })
152
+ })
153
+ })
154
+ }
155
+
156
+ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response | null> {
157
+ const controller = new AbortController()
158
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
159
+ try {
160
+ return await fetch(url, { signal: controller.signal })
161
+ } catch {
162
+ return null
163
+ } finally {
164
+ clearTimeout(timer)
165
+ }
166
+ }
167
+
168
+ export function getLocalRunnerConfigPath(): string {
169
+ return path.join(getConfigDir(), 'local-runner.json')
170
+ }
171
+
172
+ export async function loadLocalRunnerConfig(): Promise<LocalRunnerConfig> {
173
+ try {
174
+ const raw = await fs.readFile(getLocalRunnerConfigPath(), 'utf8')
175
+ const parsed = JSON.parse(raw) as unknown
176
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
177
+ const value = (parsed as { llamaServerPath?: unknown }).llamaServerPath
178
+ return typeof value === 'string' && value.trim() ? { llamaServerPath: value.trim() } : {}
179
+ } catch (err: unknown) {
180
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
181
+ return {}
182
+ }
183
+ }
184
+
185
+ export async function saveLocalRunnerConfig(config: LocalRunnerConfig): Promise<void> {
186
+ await ensureConfigDir()
187
+ await atomicWriteText(getLocalRunnerConfigPath(), JSON.stringify(config, null, 2) + '\n')
188
+ }
189
+
190
+ export async function setLlamaCppServerPath(serverPath: string): Promise<void> {
191
+ await saveLocalRunnerConfig({ llamaServerPath: serverPath.trim() })
192
+ }
193
+
194
+ export async function detectLlamaCppServerBinary(extraCandidates: string[] = []): Promise<{ path: string | null; version: string | null }> {
195
+ const config = await loadLocalRunnerConfig()
196
+ const candidates = [
197
+ ...llamaCppServerCandidates(process.env, process.platform, config.llamaServerPath),
198
+ ...extraCandidates,
199
+ ]
200
+ for (const candidate of candidates) {
201
+ const result = await runCommand(candidate, ['--version'])
202
+ if (!result) continue
203
+ const output = `${result.stdout}\n${result.stderr}`.trim()
204
+ if (result.code === 0 || output.length > 0) {
205
+ return { path: candidate, version: firstLine(output) || 'installed' }
206
+ }
207
+ }
208
+ return { path: null, version: null }
209
+ }
210
+
211
+ export function llamaCppServerCandidates(
212
+ env: NodeJS.ProcessEnv = process.env,
213
+ platform: NodeJS.Platform = process.platform,
214
+ configuredPath?: string,
215
+ ): string[] {
216
+ const candidates: string[] = []
217
+ appendCandidate(candidates, configuredPath)
218
+ appendCandidate(candidates, env.LLAMA_SERVER_PATH)
219
+ appendCandidate(candidates, env.LLAMACPP_SERVER_PATH)
220
+ appendCandidate(candidates, 'llama-server')
221
+ appendCandidate(candidates, 'llama-server.exe')
222
+
223
+ if (platform === 'win32') {
224
+ appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp', 'llama-server.exe') : undefined)
225
+ appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp', 'llama-server.exe') : undefined)
226
+ appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'llama-server.exe') : undefined)
227
+ appendCandidate(candidates, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp', 'llama-server.exe') : undefined)
228
+ appendCandidate(candidates, env['ProgramFiles(x86)'] ? path.join(env['ProgramFiles(x86)'], 'llama.cpp', 'llama-server.exe') : undefined)
229
+ appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims', 'llama-server.exe') : undefined)
230
+ appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp', 'current', 'llama-server.exe') : undefined)
231
+ } else if (platform === 'darwin') {
232
+ appendCandidate(candidates, '/opt/homebrew/bin/llama-server')
233
+ appendCandidate(candidates, '/usr/local/bin/llama-server')
234
+ appendCandidate(candidates, '/opt/local/bin/llama-server')
235
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
236
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
237
+ } else {
238
+ appendCandidate(candidates, '/usr/local/bin/llama-server')
239
+ appendCandidate(candidates, '/usr/bin/llama-server')
240
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
241
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
242
+ }
243
+
244
+ return candidates
245
+ }
246
+
247
+ export function llamaCppInstallPlans(platform: NodeJS.Platform = process.platform): LlamaCppInstallPlan[] {
248
+ if (platform === 'win32') {
249
+ return [
250
+ {
251
+ label: 'winget llama.cpp',
252
+ command: 'winget',
253
+ args: ['install', 'llama.cpp', '--accept-source-agreements', '--accept-package-agreements'],
254
+ },
255
+ {
256
+ label: 'winget llama.cpp exact id',
257
+ command: 'winget',
258
+ args: ['install', '--id', 'ggml.llamacpp', '-e', '--accept-source-agreements', '--accept-package-agreements'],
259
+ },
260
+ ]
261
+ }
262
+ if (platform === 'darwin') {
263
+ return [
264
+ { label: 'brew llama.cpp', command: 'brew', args: ['install', 'llama.cpp'] },
265
+ { label: 'nix llama.cpp', command: 'nix', args: ['profile', 'install', 'nixpkgs#llama-cpp'] },
266
+ { label: 'macports llama.cpp', command: 'port', args: ['install', 'llama.cpp'] },
267
+ ]
268
+ }
269
+ return [
270
+ { label: 'brew llama.cpp', command: 'brew', args: ['install', 'llama.cpp'] },
271
+ { label: 'nix llama.cpp', command: 'nix', args: ['profile', 'install', 'nixpkgs#llama-cpp'] },
272
+ ]
273
+ }
274
+
275
+ export async function installLlamaCppRunner(
276
+ onProgress?: (progress: LlamaCppInstallProgress) => void,
277
+ platform: NodeJS.Platform = process.platform,
278
+ ): Promise<LlamaCppInstallResult> {
279
+ const plans = llamaCppInstallPlans(platform)
280
+ const failures: string[] = []
281
+ onProgress?.({ phase: 'checking', label: 'checking local runner installers', progress: 0.08 })
282
+ for (const plan of plans) {
283
+ onProgress?.({ phase: 'installing', label: installerProgressLabel(plan), progress: 0.34 })
284
+ const result = await runInstallCommand(plan, plan.timeoutMs ?? 10 * 60_000)
285
+ if (result.ok) {
286
+ onProgress?.({ phase: 'finding', label: 'finding llama-server', progress: 0.78 })
287
+ const binary = await findAndPersistLlamaCppServer(platform)
288
+ if (binary.path) return { ok: true, serverPath: binary.path }
289
+ const cliPaths = await discoverLlamaCppCliPaths(process.env, platform)
290
+ return {
291
+ ok: false,
292
+ code: 'server-not-found',
293
+ message: 'llama.cpp installed, but the local server was not found.',
294
+ detail: cliPaths.length > 0
295
+ ? `Found llama-cli, but ethagent needs llama-server to run local chat.\n${cliPaths.slice(0, 3).join('\n')}`
296
+ : 'The package manager finished, but it did not expose llama-server on this machine.',
297
+ recovery: ['source-build', 'runner-path', 'retry-install', 'back'],
298
+ candidatePaths: await discoverLlamaCppServerPaths(process.env, platform),
299
+ }
300
+ }
301
+ failures.push(formatInstallFailure(plan.label, result))
302
+ }
303
+ return {
304
+ ok: false,
305
+ code: 'install-failed',
306
+ message: failures.length > 0
307
+ ? 'ethagent could not install the local runner automatically.'
308
+ : 'no supported local runner installer was found for this platform.',
309
+ detail: failures.join('\n'),
310
+ recovery: ['retry-install', 'source-build', 'runner-path', 'back'],
311
+ }
312
+ }
313
+
314
+ export async function buildLlamaCppRunner(
315
+ onProgress?: (progress: LlamaCppInstallProgress) => void,
316
+ platform: NodeJS.Platform = process.platform,
317
+ ): Promise<LlamaCppInstallResult> {
318
+ return installLlamaCppFromSource(onProgress, platform)
319
+ }
320
+
321
+ export async function isLlamaCppServerUp(host: string = DEFAULT_LLAMA_HOST, timeoutMs = 800): Promise<boolean> {
322
+ const response = await fetchServedModels(host, timeoutMs)
323
+ return response.up
324
+ }
325
+
326
+ export async function listServedModels(host: string = DEFAULT_LLAMA_HOST): Promise<string[]> {
327
+ const response = await fetchServedModels(host, 1500)
328
+ return response.models
329
+ }
330
+
331
+ async function fetchServedModels(host: string = DEFAULT_LLAMA_HOST, timeoutMs = 1500): Promise<{ up: boolean; models: string[] }> {
332
+ const response = await fetchWithTimeout(`${host.replace(/\/+$/, '')}/v1/models`, timeoutMs)
333
+ if (!response || !response.ok) return { up: false, models: [] }
334
+ try {
335
+ const data = await response.json() as { data?: Array<{ id?: unknown }> }
336
+ const models = (data.data ?? [])
337
+ .map(item => typeof item.id === 'string' ? item.id : '')
338
+ .filter(Boolean)
339
+ return { up: true, models }
340
+ } catch {
341
+ return { up: true, models: [] }
342
+ }
343
+ }
344
+
345
+ export async function detectLlamaCpp(host: string = DEFAULT_LLAMA_HOST): Promise<LlamaCppStatus> {
346
+ const [binary, serverUp] = await Promise.all([
347
+ detectLlamaCppServerBinary(),
348
+ isLlamaCppServerUp(host),
349
+ ])
350
+ const servedModels = serverUp ? await listServedModels(host) : []
351
+ return {
352
+ binaryPresent: binary.path !== null,
353
+ binaryPath: binary.path,
354
+ version: binary.version,
355
+ serverUp,
356
+ servedModels,
357
+ }
358
+ }
359
+
360
+ export async function startLlamaCppServer(args: {
361
+ modelPath: string
362
+ modelAlias: string
363
+ host?: string
364
+ ctxSize?: number
365
+ readinessTimeoutMs?: number
366
+ pollMs?: number
367
+ deps?: LlamaCppStartDeps
368
+ }): Promise<LlamaCppStartResult> {
369
+ const host = args.host ?? DEFAULT_LLAMA_HOST
370
+ const initialStatus = await servedModelStatus(host, args.modelAlias)
371
+ if (initialStatus.state === 'ready') return { ok: true, alreadyRunning: true }
372
+ if (initialStatus.state === 'different') {
373
+ return startFailure('different-model-running', {
374
+ servedModels: initialStatus.models,
375
+ })
376
+ }
377
+
378
+ try {
379
+ await (args.deps?.access ?? fs.access)(args.modelPath)
380
+ } catch {
381
+ return startFailure('model-file-missing', { detail: args.modelPath })
382
+ }
383
+
384
+ const binaryPath = args.deps?.binaryPath ?? (await findAndPersistLlamaCppServer()).path
385
+ if (!binaryPath) {
386
+ return startFailure('runner-not-installed')
387
+ }
388
+
389
+ const url = new URL(host)
390
+ const listenHost = url.hostname || '127.0.0.1'
391
+ const port = url.port || (url.protocol === 'https:' ? '443' : '8080')
392
+ const spawnImpl = args.deps?.spawnImpl ?? spawn
393
+ let child: ReturnType<typeof spawn>
394
+ try {
395
+ child = spawnImpl(binaryPath, [
396
+ '-m',
397
+ args.modelPath,
398
+ '--host',
399
+ listenHost,
400
+ '--port',
401
+ port,
402
+ '--alias',
403
+ args.modelAlias,
404
+ '--ctx-size',
405
+ String(args.ctxSize ?? 32768),
406
+ '--jinja',
407
+ ], {
408
+ detached: true,
409
+ stdio: ['ignore', 'pipe', 'pipe'],
410
+ windowsHide: true,
411
+ })
412
+ } catch (err) {
413
+ return startFailure('spawn-failed', { detail: (err as Error).message })
414
+ }
415
+
416
+ const capture = createStartupCapture(child)
417
+ let childFailure: LlamaCppStartResult | null = null
418
+ child.on('error', err => {
419
+ childFailure = startFailure('spawn-failed', { detail: startupDetail(capture(), err.message) })
420
+ })
421
+ child.on('exit', (code, signal) => {
422
+ childFailure ??= startFailure('runner-exited', {
423
+ detail: startupDetail(capture(), `exit ${code ?? 'unknown'}${signal ? ` signal ${signal}` : ''}`),
424
+ })
425
+ })
426
+ child.unref()
427
+
428
+ const ready = await waitForServedModel({
429
+ host,
430
+ modelAlias: args.modelAlias,
431
+ timeoutMs: args.readinessTimeoutMs ?? 90_000,
432
+ pollMs: args.pollMs ?? 500,
433
+ childFailure: () => childFailure,
434
+ })
435
+ if (ready.ok) return { ok: true, alreadyRunning: false }
436
+ if (ready.code === 'readiness-timeout') {
437
+ return startFailure('readiness-timeout', { detail: capture() })
438
+ }
439
+ return ready
440
+ }
441
+
442
+ async function waitForServedModel(args: {
443
+ host: string
444
+ modelAlias: string
445
+ timeoutMs: number
446
+ pollMs: number
447
+ childFailure: () => LlamaCppStartResult | null
448
+ }): Promise<{ ok: true } | Extract<LlamaCppStartResult, { ok: false }>> {
449
+ const deadline = Date.now() + args.timeoutMs
450
+ while (Date.now() < deadline) {
451
+ const status = await servedModelStatus(args.host, args.modelAlias)
452
+ if (status.state === 'ready') return { ok: true }
453
+ if (status.state === 'different') return startFailure('different-model-running', { servedModels: status.models })
454
+ const failure = args.childFailure()
455
+ if (failure && !failure.ok) return failure
456
+ await new Promise<void>(resolve => setTimeout(resolve, args.pollMs))
457
+ }
458
+
459
+ for (let i = 0; i < 3; i++) {
460
+ const status = await servedModelStatus(args.host, args.modelAlias)
461
+ if (status.state === 'ready') return { ok: true }
462
+ if (status.state === 'different') return startFailure('different-model-running', { servedModels: status.models })
463
+ const failure = args.childFailure()
464
+ if (failure && !failure.ok) return failure
465
+ await new Promise<void>(resolve => setTimeout(resolve, args.pollMs))
466
+ }
467
+
468
+ return startFailure('readiness-timeout')
469
+ }
470
+
471
+ async function servedModelStatus(host: string, modelAlias: string): Promise<
472
+ | { state: 'not-up'; models: string[] }
473
+ | { state: 'ready'; models: string[] }
474
+ | { state: 'different'; models: string[] }
475
+ > {
476
+ const { up, models } = await fetchServedModels(host, 1500)
477
+ if (!up) return { state: 'not-up', models }
478
+ if (models.length === 0 || models.includes(modelAlias)) return { state: 'ready', models }
479
+ return { state: 'different', models }
480
+ }
481
+
482
+ function startFailure(
483
+ code: LlamaCppStartFailureCode,
484
+ options: { detail?: string; servedModels?: string[] } = {},
485
+ ): Extract<LlamaCppStartResult, { ok: false }> {
486
+ const servedModels = options.servedModels?.filter(Boolean) ?? []
487
+ return {
488
+ ok: false,
489
+ code,
490
+ message: startFailureMessage(code, servedModels, options.detail),
491
+ detail: options.detail || undefined,
492
+ servedModels: servedModels.length > 0 ? servedModels : undefined,
493
+ }
494
+ }
495
+
496
+ function startFailureMessage(code: LlamaCppStartFailureCode, servedModels: string[], detail?: string): string {
497
+ switch (code) {
498
+ case 'runner-not-installed':
499
+ return 'local model runner is not installed yet'
500
+ case 'model-file-missing':
501
+ return detail ? `model file not found: ${detail}` : 'model file was not found'
502
+ case 'different-model-running':
503
+ return `a different local model is already running (${servedModels.join(', ')}); stop it before switching models`
504
+ case 'spawn-failed':
505
+ return 'local runner could not be started'
506
+ case 'runner-exited':
507
+ return 'local runner closed before becoming ready'
508
+ case 'readiness-timeout':
509
+ return 'local runner is still loading or did not answer in time'
510
+ }
511
+ }
512
+
513
+ function createStartupCapture(child: ReturnType<typeof spawn>): () => string {
514
+ let output = ''
515
+ const capture = (chunk: Buffer | string): void => {
516
+ output = `${output}${chunk.toString()}`.slice(-4000)
517
+ }
518
+ child.stdout?.on('data', capture)
519
+ child.stderr?.on('data', capture)
520
+ return () => summarizeInstallOutput(output) ?? ''
521
+ }
522
+
523
+ function startupDetail(output: string, fallback: string): string {
524
+ return output ? `${fallback}\n${output}` : fallback
525
+ }
526
+
527
+ function firstLine(text: string): string {
528
+ return text.split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? ''
529
+ }
530
+
531
+ function appendCandidate(candidates: string[], candidate: string | undefined): void {
532
+ if (!candidate || candidates.includes(candidate)) return
533
+ candidates.push(candidate)
534
+ }
535
+
536
+ export function llamaCppSearchRoots(
537
+ env: NodeJS.ProcessEnv = process.env,
538
+ platform: NodeJS.Platform = process.platform,
539
+ ): string[] {
540
+ const roots: string[] = []
541
+ if (platform === 'win32') {
542
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Packages') : undefined)
543
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps') : undefined)
544
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp') : undefined)
545
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp') : undefined)
546
+ appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp') : undefined)
547
+ appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'WindowsApps') : undefined)
548
+ appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp') : undefined)
549
+ appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims') : undefined)
550
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
551
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
552
+ return roots
553
+ }
554
+
555
+ appendCandidate(roots, '/opt/homebrew/bin')
556
+ appendCandidate(roots, '/usr/local/bin')
557
+ appendCandidate(roots, '/opt/local/bin')
558
+ appendCandidate(roots, '/usr/bin')
559
+ appendCandidate(roots, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin') : undefined)
560
+ appendCandidate(roots, env.HOME ? path.join(env.HOME, '.local', 'bin') : undefined)
561
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
562
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
563
+ return roots
564
+ }
565
+
566
+ export async function discoverLlamaCppServerPaths(
567
+ env: NodeJS.ProcessEnv = process.env,
568
+ platform: NodeJS.Platform = process.platform,
569
+ ): Promise<string[]> {
570
+ return discoverExecutablePaths(platform === 'win32' ? ['llama-server.exe', 'llama-server'] : ['llama-server'], env, platform)
571
+ }
572
+
573
+ async function discoverLlamaCppCliPaths(
574
+ env: NodeJS.ProcessEnv = process.env,
575
+ platform: NodeJS.Platform = process.platform,
576
+ ): Promise<string[]> {
577
+ return discoverExecutablePaths(platform === 'win32' ? ['llama-cli.exe', 'llama-cli'] : ['llama-cli'], env, platform)
578
+ }
579
+
580
+ async function discoverExecutablePaths(
581
+ names: string[],
582
+ env: NodeJS.ProcessEnv,
583
+ platform: NodeJS.Platform,
584
+ ): Promise<string[]> {
585
+ const found: string[] = []
586
+ const lowered = new Set(names.map(name => name.toLowerCase()))
587
+ for (const root of llamaCppSearchRoots(env, platform)) {
588
+ await walkForExecutable(root, lowered, found, 0, 5)
589
+ if (found.length >= 20) break
590
+ }
591
+ return found
592
+ }
593
+
594
+ async function walkForExecutable(
595
+ dir: string,
596
+ names: Set<string>,
597
+ found: string[],
598
+ depth: number,
599
+ maxDepth: number,
600
+ ): Promise<void> {
601
+ if (depth > maxDepth || found.length >= 20) return
602
+ let entries: Array<import('node:fs').Dirent>
603
+ try {
604
+ entries = await fs.readdir(dir, { withFileTypes: true })
605
+ } catch {
606
+ return
607
+ }
608
+
609
+ for (const entry of entries) {
610
+ if (found.length >= 20) return
611
+ const fullPath = path.join(dir, entry.name)
612
+ const lowerName = entry.name.toLowerCase()
613
+ if ((entry.isFile() || entry.isSymbolicLink()) && names.has(lowerName)) {
614
+ appendCandidate(found, fullPath)
615
+ continue
616
+ }
617
+ if (entry.isDirectory() && shouldDescendRunnerDir(entry.name, depth)) {
618
+ await walkForExecutable(fullPath, names, found, depth + 1, maxDepth)
619
+ }
620
+ }
621
+ }
622
+
623
+ function shouldDescendRunnerDir(name: string, depth: number): boolean {
624
+ const lower = name.toLowerCase()
625
+ if (/(llama|ggml|bin|build|release|debug|current|package|windowsapps|x64|arm64)/.test(lower)) return true
626
+ return depth > 0 && lower.length <= 24
627
+ }
628
+
629
+ async function findAndPersistLlamaCppServer(
630
+ platform: NodeJS.Platform = process.platform,
631
+ ): Promise<{ path: string | null; version: string | null }> {
632
+ const direct = await detectLlamaCppServerBinary()
633
+ if (direct.path) return direct
634
+ const discovered = await discoverLlamaCppServerPaths(process.env, platform)
635
+ const found = await detectLlamaCppServerBinary(discovered)
636
+ if (found.path) {
637
+ await setLlamaCppServerPath(found.path).catch(() => {})
638
+ }
639
+ return found
640
+ }
641
+
642
+ export function summarizeInstallOutput(output: string): string | undefined {
643
+ const lines = output
644
+ .split(/\r?\n/)
645
+ .map(cleanInstallLine)
646
+ .filter(Boolean)
647
+ .filter(line => !/^[\-\\|/_.=\s]+$/.test(line))
648
+ .filter(line => !/^\d+(\.\d+)?\s*(B|KB|MB|GB)\s*\/\s*\d+/i.test(line))
649
+ const unique = [...new Set(lines)]
650
+ return unique.slice(-6).join('\n') || undefined
651
+ }
652
+
653
+ export function humanInstallError(plan: LlamaCppInstallPlan, code: number | null): string {
654
+ if (plan.command === 'winget') return 'Windows could not install the local runner automatically.'
655
+ if (plan.command === 'brew') return 'Homebrew could not install the local runner automatically.'
656
+ if (plan.command === 'nix') return 'Nix could not install the local runner automatically.'
657
+ if (plan.command === 'port') return 'MacPorts could not install the local runner automatically.'
658
+ if (plan.command === 'git') return 'ethagent could not download the local runner source.'
659
+ if (plan.command === 'cmake') return 'ethagent could not build the local runner.'
660
+ return code === null
661
+ ? `${plan.label} did not complete.`
662
+ : `${plan.label} failed with exit code ${code}.`
663
+ }
664
+
665
+ function installFailureDetail(code: number | null, output: string): string | undefined {
666
+ const details = [
667
+ code === null ? undefined : `exit code ${code}`,
668
+ summarizeInstallOutput(output),
669
+ ].filter((item): item is string => Boolean(item))
670
+ return details.join('\n') || undefined
671
+ }
672
+
673
+ function cleanInstallLine(line: string): string {
674
+ return line
675
+ .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
676
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
677
+ .replace(/\s+/g, ' ')
678
+ .trim()
679
+ }
680
+
681
+ function installerProgressLabel(plan: LlamaCppInstallPlan): string {
682
+ if (plan.command === 'winget') return 'installing with Windows package manager'
683
+ if (plan.command === 'brew') return 'installing with Homebrew'
684
+ if (plan.command === 'nix') return 'installing with Nix'
685
+ if (plan.command === 'port') return 'installing with MacPorts'
686
+ return `installing with ${plan.label}`
687
+ }
688
+
689
+ function formatInstallFailure(label: string, result: RunInstallResult): string {
690
+ if (result.ok) return label
691
+ return [label, result.message, result.detail].filter(Boolean).join(': ')
692
+ }
693
+
694
+ function buildFailure(result: RunInstallResult): LlamaCppInstallResult {
695
+ return {
696
+ ok: false,
697
+ code: 'build-failed',
698
+ message: 'ethagent could not build the local runner.',
699
+ detail: result.ok ? undefined : [result.message, result.detail].filter(Boolean).join('\n'),
700
+ recovery: ['runner-path', 'retry-install', 'back'],
701
+ }
702
+ }
703
+
704
+ function sourceBuildServerCandidates(buildDir: string, platform: NodeJS.Platform): string[] {
705
+ const exe = platform === 'win32' ? 'llama-server.exe' : 'llama-server'
706
+ return [
707
+ path.join(buildDir, 'bin', exe),
708
+ path.join(buildDir, 'bin', 'Release', exe),
709
+ path.join(buildDir, 'bin', 'Debug', exe),
710
+ path.join(buildDir, 'Release', exe),
711
+ path.join(buildDir, 'Debug', exe),
712
+ ]
713
+ }
714
+
715
+ async function firstAccessible(candidates: string[]): Promise<string | null> {
716
+ for (const candidate of candidates) {
717
+ try {
718
+ await fs.access(candidate)
719
+ return candidate
720
+ } catch {
721
+ continue
722
+ }
723
+ }
724
+ return null
725
+ }
726
+
727
+ async function installLlamaCppFromSource(
728
+ onProgress?: (progress: LlamaCppInstallProgress) => void,
729
+ platform: NodeJS.Platform = process.platform,
730
+ ): Promise<LlamaCppInstallResult> {
731
+ const root = path.join(getConfigDir(), 'runners')
732
+ const repoDir = path.join(root, 'llama.cpp')
733
+ const buildDir = path.join(repoDir, 'build')
734
+ const serverPath = path.join(buildDir, 'bin', platform === 'win32' ? 'llama-server.exe' : 'llama-server')
735
+ await ensureConfigDir()
736
+ await fs.mkdir(root, { recursive: true })
737
+
738
+ onProgress?.({ phase: 'checking', label: 'checking build tools', progress: 0.08 })
739
+ const hasGit = await runCommand('git', ['--version'])
740
+ if (!hasGit || hasGit.code !== 0) {
741
+ return {
742
+ ok: false,
743
+ code: 'missing-tools',
744
+ message: 'git is required to build the local runner.',
745
+ recovery: ['runner-path', 'retry-install', 'back'],
746
+ }
747
+ }
748
+ const hasCmake = await runCommand('cmake', ['--version'])
749
+ if (!hasCmake || hasCmake.code !== 0) {
750
+ return {
751
+ ok: false,
752
+ code: 'missing-tools',
753
+ message: 'cmake is required to build the local runner.',
754
+ recovery: ['runner-path', 'retry-install', 'back'],
755
+ }
756
+ }
757
+
758
+ try {
759
+ await fs.access(path.join(repoDir, '.git'))
760
+ onProgress?.({ phase: 'building', label: 'updating local runner source', progress: 0.22 })
761
+ const update = await runInstallCommand(
762
+ { label: 'update llama.cpp source', command: 'git', args: ['-C', repoDir, 'pull', '--ff-only'], timeoutMs: 5 * 60_000 },
763
+ 5 * 60_000,
764
+ )
765
+ if (!update.ok) return buildFailure(update)
766
+ } catch {
767
+ onProgress?.({ phase: 'building', label: 'downloading local runner source', progress: 0.22 })
768
+ const clone = await runInstallCommand(
769
+ { label: 'clone llama.cpp source', command: 'git', args: ['clone', '--depth', '1', 'https://github.com/ggml-org/llama.cpp.git', repoDir], timeoutMs: 10 * 60_000 },
770
+ 10 * 60_000,
771
+ )
772
+ if (!clone.ok) return buildFailure(clone)
773
+ }
774
+
775
+ onProgress?.({ phase: 'building', label: 'configuring local runner', progress: 0.48 })
776
+ const configure = await runInstallCommand(
777
+ { label: 'configure llama.cpp', command: 'cmake', args: ['-S', repoDir, '-B', buildDir, '-DCMAKE_BUILD_TYPE=Release'], timeoutMs: 5 * 60_000 },
778
+ 5 * 60_000,
779
+ )
780
+ if (!configure.ok) return buildFailure(configure)
781
+
782
+ onProgress?.({ phase: 'building', label: 'building local runner', progress: 0.68 })
783
+ const build = await runInstallCommand(
784
+ {
785
+ label: 'build llama-server',
786
+ command: 'cmake',
787
+ args: ['--build', buildDir, '--config', 'Release', '--target', 'llama-server', '-j', String(Math.max(1, os.cpus().length - 1))],
788
+ timeoutMs: 30 * 60_000,
789
+ },
790
+ 30 * 60_000,
791
+ )
792
+ if (!build.ok) return buildFailure(build)
793
+
794
+ const builtServerPath = await firstAccessible(sourceBuildServerCandidates(buildDir, platform))
795
+ ?? (await discoverLlamaCppServerPaths(process.env, platform))[0]
796
+ if (builtServerPath) {
797
+ await setLlamaCppServerPath(builtServerPath)
798
+ onProgress?.({ phase: 'finding', label: 'local runner ready', progress: 1 })
799
+ return { ok: true, serverPath: builtServerPath }
800
+ }
801
+
802
+ return {
803
+ ok: false,
804
+ code: 'server-not-found',
805
+ message: 'built the local runner, but llama-server was not found.',
806
+ detail: serverPath,
807
+ recovery: ['runner-path', 'retry-install', 'back'],
808
+ candidatePaths: sourceBuildServerCandidates(buildDir, platform),
809
+ }
810
+ }