ethagent 2.4.0 → 3.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 (98) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +155 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +3 -1
  8. package/src/chat/ChatScreen.tsx +7 -1
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +1 -1
  12. package/src/chat/chatTurnOrchestrator.ts +59 -0
  13. package/src/chat/input/ChatInput.tsx +3 -0
  14. package/src/chat/input/textCursor.ts +13 -3
  15. package/src/chat/transcript/TranscriptView.tsx +7 -5
  16. package/src/chat/transcript/transcriptViewport.ts +88 -17
  17. package/src/chat/views/PermissionPrompt.tsx +26 -26
  18. package/src/chat/views/PermissionsView.tsx +18 -12
  19. package/src/chat/views/RewindView.tsx +3 -1
  20. package/src/cli/ResetConfirmView.tsx +24 -9
  21. package/src/identity/continuity/editor.ts +27 -2
  22. package/src/identity/continuity/envelope.ts +125 -0
  23. package/src/identity/continuity/publicSkills.ts +37 -1
  24. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  25. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  26. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  27. package/src/identity/continuity/skills/scaffold.ts +52 -0
  28. package/src/identity/continuity/skills/types.ts +30 -0
  29. package/src/identity/continuity/storage/defaults.ts +28 -47
  30. package/src/identity/continuity/storage/files.ts +1 -0
  31. package/src/identity/continuity/storage/paths.ts +1 -0
  32. package/src/identity/continuity/storage/scaffold.ts +25 -23
  33. package/src/identity/continuity/storage/status.ts +34 -5
  34. package/src/identity/continuity/storage/types.ts +3 -2
  35. package/src/identity/continuity/storage.ts +3 -0
  36. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  37. package/src/identity/hub/Routes.tsx +5 -3
  38. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  39. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  40. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  41. package/src/identity/hub/continuity/effects.ts +36 -5
  42. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  43. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  44. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  45. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  46. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  47. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  48. package/src/identity/hub/continuity/snapshot.ts +3 -0
  49. package/src/identity/hub/continuity/state.ts +3 -2
  50. package/src/identity/hub/continuity/vault.ts +42 -10
  51. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  52. package/src/identity/hub/identityHubReducer.ts +21 -0
  53. package/src/identity/hub/profile/effects.ts +16 -3
  54. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  55. package/src/identity/hub/restore/apply.ts +12 -1
  56. package/src/identity/hub/restore/recovery.ts +11 -1
  57. package/src/identity/hub/restore/resolve.ts +1 -1
  58. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  59. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  60. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  61. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  62. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  63. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  64. package/src/identity/hub/shared/effects/sync.ts +16 -3
  65. package/src/identity/hub/shared/model/copy.ts +2 -4
  66. package/src/identity/hub/transfer/effects.ts +15 -2
  67. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  68. package/src/identity/hub/useIdentityHubController.ts +5 -1
  69. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  70. package/src/mcp/manager.ts +1 -1
  71. package/src/models/ModelPicker.tsx +89 -84
  72. package/src/models/llamacpp.ts +160 -11
  73. package/src/models/llamacppPreflight.ts +1 -16
  74. package/src/models/modelPickerOptions.ts +43 -37
  75. package/src/providers/contracts.ts +1 -0
  76. package/src/providers/openai-chat.ts +50 -9
  77. package/src/providers/openai-responses.ts +19 -4
  78. package/src/runtime/toolExecution.ts +4 -3
  79. package/src/runtime/turn.ts +61 -30
  80. package/src/tools/changeDirectoryTool.ts +1 -1
  81. package/src/tools/contracts.ts +10 -0
  82. package/src/tools/deleteFileTool.ts +1 -1
  83. package/src/tools/editTool.ts +1 -1
  84. package/src/tools/listDirectoryTool.ts +1 -1
  85. package/src/tools/listSkillFilesTool.ts +77 -0
  86. package/src/tools/listSkillsTool.ts +68 -0
  87. package/src/tools/mcpResourceTools.ts +2 -2
  88. package/src/tools/privateContinuityReadTool.ts +1 -1
  89. package/src/tools/readSkillTool.ts +107 -0
  90. package/src/tools/readTool.ts +1 -1
  91. package/src/tools/registry.ts +6 -0
  92. package/src/tools/writeFileTool.ts +22 -2
  93. package/src/ui/Spinner.tsx +1 -1
  94. package/src/identity/continuity/localBackup.ts +0 -249
  95. package/src/identity/continuity/zipWriter.ts +0 -95
  96. package/src/identity/hub/continuity/index.ts +0 -7
  97. package/src/identity/hub/ens/index.ts +0 -11
  98. package/src/identity/hub/restore/index.ts +0 -22
@@ -7,10 +7,10 @@ import { Surface } from '../ui/Surface.js'
7
7
  import { ProgressBar } from '../ui/ProgressBar.js'
8
8
  import { theme } from '../ui/theme.js'
9
9
  import {
10
- buildLlamaCppRunner,
11
10
  DEFAULT_LLAMA_HOST,
12
11
  detectLlamaCpp,
13
12
  installLlamaCppRunner,
13
+ killRogueLlamaProcesses,
14
14
  setLlamaCppServerPath,
15
15
  startLlamaCppServer,
16
16
  stopLlamaCppServer,
@@ -80,6 +80,7 @@ type ModelPickerProps = {
80
80
  currentModel: string
81
81
  contextFit?: ModelPickerContextFit | null
82
82
  featuredHfRepo?: string
83
+ localOnly?: boolean
83
84
  onPick: (selection: ModelPickerSelection) => void
84
85
  onCancel: () => void
85
86
  }
@@ -126,12 +127,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
126
127
  currentModel,
127
128
  contextFit,
128
129
  featuredHfRepo,
130
+ localOnly = false,
129
131
  onPick,
130
132
  onCancel,
131
133
  }) => {
132
134
  const [state, setState] = useState<State>({ kind: 'loading' })
133
135
  const hfAbortRef = useRef<AbortController | null>(null)
134
136
  const oauthServiceRef = useRef<OpenAIOAuthService | null>(null)
137
+ const dismissToList = (data: LoadedData) => () => {
138
+ if (localOnly) {
139
+ onCancel()
140
+ } else {
141
+ setState({ kind: 'list', data })
142
+ }
143
+ }
135
144
 
136
145
  useEffect(() => {
137
146
  let cancelled = false
@@ -193,8 +202,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
193
202
 
194
203
  if (state.kind === 'loading') {
195
204
  return (
196
- <Surface title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model'} subtitle="Loading providers and models.">
197
- <Spinner label="loading providers..." />
205
+ <Surface
206
+ title={localOnly ? 'Local Model' : (contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model')}
207
+ subtitle="Loading providers and models."
208
+ footer="esc back"
209
+ >
210
+ <Spinner label={localOnly ? 'loading local models...' : 'loading providers...'} />
211
+ <Box marginTop={1}>
212
+ <Select<'cancel'>
213
+ options={[{ value: 'cancel', label: 'Back', hint: 'Return to the previous screen', role: 'utility' }]}
214
+ hintLayout="inline"
215
+ onSubmit={() => onCancel()}
216
+ onCancel={() => onCancel()}
217
+ />
218
+ </Box>
198
219
  </Surface>
199
220
  )
200
221
  }
@@ -210,7 +231,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
210
231
  label="Model Link"
211
232
  placeholder={LOCAL_MODEL_LINK_HINT}
212
233
  onSubmit={value => void inspectHfInput(state, value, setState)}
213
- onCancel={() => setState({ kind: 'list', data: state.data })}
234
+ onCancel={dismissToList(state.data)}
214
235
  />
215
236
  {state.error ? <Text color={theme.accentError}>{state.error}</Text> : null}
216
237
  </Surface>
@@ -273,10 +294,12 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
273
294
  </Box>
274
295
  <Select<'download' | 'downloadWithMmproj' | 'pick' | 'cancel'>
275
296
  options={[
297
+ { value: 'download', role: 'section', label: 'Download' },
276
298
  ...(mmproj ? [{ value: 'downloadWithMmproj' as const, label: `Download Model + Vision Encoder (+${formatBytes(mmproj.sizeBytes)}) · recommended`, disabled: !canDownload }] : []),
277
299
  { value: 'download', label: mmproj ? 'Download Without Vision Encoder' : 'Download This Model', disabled: !canDownload },
300
+ { value: 'pick', role: 'section', label: 'Navigation' },
278
301
  { value: 'pick', label: 'Pick Another File' },
279
- { value: 'cancel', label: 'Cancel' },
302
+ { value: 'cancel', label: 'Cancel', role: 'utility' },
280
303
  ]}
281
304
  onSubmit={choice => {
282
305
  if (choice === 'download') void startHfDownload(state, setState, hfAbortRef, onPick)
@@ -284,9 +307,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
284
307
  void startHfDownload({ ...state, plan: { ...plan, includeMmproj: true } }, setState, hfAbortRef, onPick)
285
308
  }
286
309
  else if (choice === 'pick') void inspectHfInput({ kind: 'hfInput', data: state.data }, plan.repoId, setState)
287
- else setState({ kind: 'list', data: state.data })
310
+ else dismissToList(state.data)()
288
311
  }}
289
- onCancel={() => setState({ kind: 'list', data: state.data })}
312
+ onCancel={dismissToList(state.data)}
290
313
  />
291
314
  </Surface>
292
315
  )
@@ -326,9 +349,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
326
349
  onSubmit={choice => {
327
350
  if (choice === 'add') void downloadMmprojAndContinue(state, setState, onPick)
328
351
  else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
329
- else setState({ kind: 'list', data: state.data })
352
+ else dismissToList(state.data)()
330
353
  }}
331
- onCancel={() => setState({ kind: 'list', data: state.data })}
354
+ onCancel={dismissToList(state.data)}
332
355
  />
333
356
  </Surface>
334
357
  )
@@ -359,9 +382,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
359
382
  onSubmit={choice => {
360
383
  if (choice === 'retry') setState({ kind: 'mmprojOffer', data: state.data, model: state.model })
361
384
  else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
362
- else setState({ kind: 'list', data: state.data })
385
+ else dismissToList(state.data)()
363
386
  }}
364
- onCancel={() => setState({ kind: 'list', data: state.data })}
387
+ onCancel={dismissToList(state.data)}
365
388
  />
366
389
  </Surface>
367
390
  )
@@ -381,9 +404,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
381
404
  ]}
382
405
  onSubmit={choice => {
383
406
  if (choice === 'use') void startAndPickHfModel(state.model, state, setState, onPick)
384
- else setState({ kind: 'list', data: state.data })
407
+ else dismissToList(state.data)()
385
408
  }}
386
- onCancel={() => setState({ kind: 'list', data: state.data })}
409
+ onCancel={dismissToList(state.data)}
387
410
  />
388
411
  </Surface>
389
412
  )
@@ -399,9 +422,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
399
422
  ]}
400
423
  onSubmit={choice => {
401
424
  if (choice === 'retry') setState({ kind: 'hfInput', data: state.data, error: state.input ? undefined : state.message })
402
- else setState({ kind: 'list', data: state.data })
425
+ else dismissToList(state.data)()
403
426
  }}
404
- onCancel={() => setState({ kind: 'list', data: state.data })}
427
+ onCancel={dismissToList(state.data)}
405
428
  />
406
429
  </Surface>
407
430
  )
@@ -431,7 +454,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
431
454
  const target = targets.find(item => `${item.kind}:${item.id}` === value)
432
455
  if (target) setState({ kind: 'localUninstallConfirm', data: state.data, target })
433
456
  }}
434
- onCancel={() => setState({ kind: 'list', data: state.data })}
457
+ onCancel={dismissToList(state.data)}
435
458
  />
436
459
  )}
437
460
  </Surface>
@@ -477,8 +500,8 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
477
500
  <Surface title="Local Model Uninstalled" subtitle={state.modelName} footer="enter back to picker · esc close">
478
501
  <Select<'back'>
479
502
  options={[{ value: 'back', label: 'Back To Picker' }]}
480
- onSubmit={() => setState({ kind: 'list', data: state.data })}
481
- onCancel={() => setState({ kind: 'list', data: state.data })}
503
+ onSubmit={dismissToList(state.data)}
504
+ onCancel={dismissToList(state.data)}
482
505
  />
483
506
  </Surface>
484
507
  )
@@ -494,9 +517,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
494
517
  ]}
495
518
  onSubmit={choice => {
496
519
  if (choice === 'retry') void uninstallLocalModel({ kind: 'localUninstallConfirm', data: state.data, target: state.target }, setState)
497
- else setState({ kind: 'list', data: state.data })
520
+ else dismissToList(state.data)()
498
521
  }}
499
- onCancel={() => setState({ kind: 'list', data: state.data })}
522
+ onCancel={dismissToList(state.data)}
500
523
  />
501
524
  </Surface>
502
525
  )
@@ -525,9 +548,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
525
548
  if (choice === 'download') setState({ kind: 'hfInput', data: state.data })
526
549
  else if (choice === 'install') void installRunnerAndStart(state, setState, onPick)
527
550
  else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
528
- else setState({ kind: 'list', data: state.data })
551
+ else dismissToList(state.data)()
529
552
  }}
530
- onCancel={() => setState({ kind: 'list', data: state.data })}
553
+ onCancel={dismissToList(state.data)}
531
554
  />
532
555
  </Surface>
533
556
  )
@@ -546,15 +569,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
546
569
  const options = buildRunnerRecoveryOptions(state.result)
547
570
  return (
548
571
  <Surface title="Runner Setup Needs Attention" subtitle={state.result.message} tone="error" footer="enter select · esc back">
549
- <Select<'retry' | 'build' | 'path' | 'back'>
572
+ <Select<'stop-and-retry' | 'path' | 'back'>
550
573
  options={options}
574
+ hintLayout="inline"
551
575
  onSubmit={choice => {
552
- if (choice === 'retry') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
553
- else if (choice === 'build') void buildRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
576
+ if (choice === 'stop-and-retry') {
577
+ void (async () => {
578
+ await killRogueLlamaProcesses()
579
+ await installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
580
+ })()
581
+ }
554
582
  else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
555
- else setState({ kind: 'list', data: state.data })
583
+ else dismissToList(state.data)()
556
584
  }}
557
- onCancel={() => setState({ kind: 'list', data: state.data })}
585
+ onCancel={dismissToList(state.data)}
558
586
  />
559
587
  </Surface>
560
588
  )
@@ -604,9 +632,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
604
632
  if (choice === 'retry') void startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
605
633
  else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
606
634
  else if (choice === 'install') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
607
- else setState({ kind: 'list', data: state.data })
635
+ else dismissToList(state.data)()
608
636
  }}
609
- onCancel={() => setState({ kind: 'list', data: state.data })}
637
+ onCancel={dismissToList(state.data)}
610
638
  />
611
639
  </Surface>
612
640
  )
@@ -630,7 +658,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
630
658
  placeholder={providerKeyPlaceholder(provider)}
631
659
  isSecret
632
660
  onSubmit={(value) => void submitKey(state, value, currentConfig, setState)}
633
- onCancel={() => setState({ kind: 'list', data: state.data })}
661
+ onCancel={dismissToList(state.data)}
634
662
  />
635
663
  )}
636
664
  {error ? <Text color={theme.accentError}>{error}</Text> : null}
@@ -652,9 +680,11 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
652
680
  ) : (
653
681
  <Select
654
682
  options={[
683
+ { value: 'edit', role: 'section', label: 'Credential' },
655
684
  { value: 'edit', label: 'Replace Stored API Key' },
656
685
  { value: 'delete', label: 'Remove Stored API Key' },
657
- { value: 'cancel', label: 'Back' },
686
+ { value: 'cancel', role: 'section', label: 'Navigation' },
687
+ { value: 'cancel', label: 'Back', role: 'utility' },
658
688
  ]}
659
689
  onSubmit={(value) => {
660
690
  if (value === 'edit') {
@@ -667,7 +697,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
667
697
  }
668
698
  void deleteKey(state, currentConfig, setState, onPick, currentProvider)
669
699
  }}
670
- onCancel={() => setState({ kind: 'list', data: state.data })}
700
+ onCancel={dismissToList(state.data)}
671
701
  />
672
702
  )}
673
703
  {error ? <Text color={theme.accentError}>{error}</Text> : null}
@@ -705,7 +735,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
705
735
  }
706
736
  void signOutOAuth(state, currentConfig, setState, onPick, currentProvider)
707
737
  }}
708
- onCancel={() => setState({ kind: 'list', data: state.data })}
738
+ onCancel={dismissToList(state.data)}
709
739
  />
710
740
  )}
711
741
  {error ? <Text color={theme.accentError}>{error}</Text> : null}
@@ -731,9 +761,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
731
761
  onSubmit={choice => {
732
762
  if (choice === 'retry') void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
733
763
  else if (choice === 'apikey') setState({ kind: 'keyEntry', provider: 'openai', action: 'set', data: state.data, submitting: false })
734
- else setState({ kind: 'list', data: state.data })
764
+ else dismissToList(state.data)()
735
765
  }}
736
- onCancel={() => setState({ kind: 'list', data: state.data })}
766
+ onCancel={dismissToList(state.data)}
737
767
  />
738
768
  </Surface>
739
769
  )
@@ -795,11 +825,12 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
795
825
  options={options}
796
826
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
797
827
  maxVisible={12}
828
+ hintLayout="inline"
798
829
  onSubmit={(value) => {
799
830
  const parsed = parseFullCatalogValue(value)
800
831
  if (parsed) onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
801
832
  }}
802
- onCancel={() => setState({ kind: 'list', data: state.data })}
833
+ onCancel={dismissToList(state.data)}
803
834
  />
804
835
  </Surface>
805
836
  )
@@ -825,9 +856,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
825
856
  onSubmit={choice => {
826
857
  if (choice === 'retry') void openLocalCatalog(state.data, setState)
827
858
  else if (choice === 'paste') setState({ kind: 'hfInput', data: state.data })
828
- else setState({ kind: 'list', data: state.data })
859
+ else dismissToList(state.data)()
829
860
  }}
830
- onCancel={() => setState({ kind: 'list', data: state.data })}
861
+ onCancel={dismissToList(state.data)}
831
862
  />
832
863
  </Surface>
833
864
  )
@@ -846,28 +877,30 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
846
877
  options={options}
847
878
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
848
879
  maxVisible={12}
849
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
850
- onCancel={() => setState({ kind: 'list', data: state.data })}
880
+ hintLayout="inline"
881
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef, localOnly)}
882
+ onCancel={dismissToList(state.data)}
851
883
  />
852
884
  </Surface>
853
885
  )
854
886
  }
855
887
 
856
888
  const { data } = state
857
- const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit })
889
+ const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit }, { localOnly })
858
890
  const initialIndex = localOrCloudOptionIndex(options, currentProvider, currentModel)
859
891
 
860
892
  return (
861
893
  <Surface
862
- title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model'}
863
- subtitle={contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files · cloud providers'}
864
- footer="enter select · esc close · /models lists installed models"
894
+ title={localOnly ? 'Local Model' : (contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model')}
895
+ subtitle={localOnly ? 'Downloaded GGUF files and curated catalog' : (contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files · cloud providers')}
896
+ footer={localOnly ? 'enter select · esc back' : 'enter select · esc close · /models lists installed models'}
865
897
  >
866
898
  <Select
867
899
  options={options}
868
900
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
869
901
  maxVisible={10}
870
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
902
+ hintLayout="inline"
903
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef, localOnly)}
871
904
  onCancel={onCancel}
872
905
  />
873
906
  </Surface>
@@ -882,6 +915,7 @@ function handleSubmit(
882
915
  onCancel: () => void,
883
916
  currentConfig: EthagentConfig,
884
917
  oauthServiceRef: React.MutableRefObject<OpenAIOAuthService | null>,
918
+ localOnly: boolean = false,
885
919
  ): void {
886
920
  if (value.startsWith('hdr:')) return
887
921
  if (value === 'cancel') {
@@ -889,7 +923,8 @@ function handleSubmit(
889
923
  return
890
924
  }
891
925
  if (value === 'back' && state.kind === 'localCatalog') {
892
- setState({ kind: 'list', data: state.data })
926
+ if (localOnly) onCancel()
927
+ else setState({ kind: 'list', data: state.data })
893
928
  return
894
929
  }
895
930
  if (value.startsWith('hf:')) {
@@ -1304,24 +1339,13 @@ export function buildHfFileOptions(
1304
1339
  }
1305
1340
 
1306
1341
  function buildRunnerRecoveryOptions(
1307
- result: Extract<LlamaCppInstallResult, { ok: false }>,
1308
- ): SelectOption<'retry' | 'build' | 'path' | 'back'>[] {
1309
- const options: SelectOption<'retry' | 'build' | 'path' | 'back'>[] = []
1310
- if (result.recovery.includes('source-build')) {
1311
- options.push({
1312
- value: 'build',
1313
- label: 'Build Local Runner',
1314
- hint: 'Uses git and CMake if installed',
1315
- })
1316
- }
1317
- if (result.recovery.includes('runner-path')) {
1318
- options.push({ value: 'path', label: 'Use Existing Runner Path' })
1319
- }
1320
- if (result.recovery.includes('retry-install')) {
1321
- options.push({ value: 'retry', label: 'Retry Automatic Install' })
1322
- }
1323
- options.push({ value: 'back', label: 'Back To Picker' })
1324
- return options
1342
+ _result: Extract<LlamaCppInstallResult, { ok: false }>,
1343
+ ): SelectOption<'stop-and-retry' | 'path' | 'back'>[] {
1344
+ return [
1345
+ { value: 'stop-and-retry', label: 'Stop and Retry', hint: 'Stop background runners and try the install again' },
1346
+ { value: 'path', label: 'Use Existing Runner Path' },
1347
+ { value: 'back', label: 'Back To Picker' },
1348
+ ]
1325
1349
  }
1326
1350
 
1327
1351
  function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, { ok: false }>): string {
@@ -1338,8 +1362,6 @@ function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, {
1338
1362
  return result.message
1339
1363
  case 'runner-not-installed':
1340
1364
  return 'this machine still needs a local runner'
1341
- case 'untracked-server':
1342
- return result.message
1343
1365
  }
1344
1366
  }
1345
1367
 
@@ -1554,16 +1576,7 @@ async function downloadMmprojAndContinue(
1554
1576
  setState({ kind: 'mmprojError', data: state.data, model: state.model, message: 'projector downloaded but path was not persisted' })
1555
1577
  return
1556
1578
  }
1557
- const stopResult = await stopLlamaCppServer().catch(() => null)
1558
- if (stopResult && stopResult.ok && stopResult.reason === 'untracked-server') {
1559
- setState({
1560
- kind: 'mmprojError',
1561
- data: state.data,
1562
- model: updated,
1563
- message: 'Vision encoder downloaded, but a llama-server is already running and ethagent did not launch it. Quit ethagent, stop the external llama-server (taskkill /F /IM llama-server.exe on Windows, pkill llama-server on macOS or Linux), then reopen ethagent to load the projector.',
1564
- })
1565
- return
1566
- }
1579
+ await stopLlamaCppServer().catch(() => null)
1567
1580
  const data = { ...state.data, hfModels: await loadHfPickerModels() }
1568
1581
  await startAndPickHfModel(updated, { kind: 'mmprojOffer', data, model: updated }, setState, onPick)
1569
1582
  }
@@ -1617,14 +1630,6 @@ async function installRunnerAndStart(
1617
1630
  await runRunnerSetup(state, setState, onPick, installLlamaCppRunner)
1618
1631
  }
1619
1632
 
1620
- async function buildRunnerAndStart(
1621
- state: Extract<State, { kind: 'localRunnerSetup' }>,
1622
- setState: (s: State) => void,
1623
- onPick: (sel: ModelPickerSelection) => void,
1624
- ): Promise<void> {
1625
- await runRunnerSetup(state, setState, onPick, buildLlamaCppRunner)
1626
- }
1627
-
1628
1633
  async function runRunnerSetup(
1629
1634
  state: Extract<State, { kind: 'localRunnerSetup' }>,
1630
1635
  setState: (s: State) => void,
@@ -50,7 +50,6 @@ export type LlamaCppStartFailureCode =
50
50
  | 'spawn-failed'
51
51
  | 'runner-exited'
52
52
  | 'readiness-timeout'
53
- | 'untracked-server'
54
53
 
55
54
  export type LlamaCppStartResult =
56
55
  | { ok: true; alreadyRunning: boolean }
@@ -73,6 +72,9 @@ type LlamaCppStartDeps = {
73
72
  access?: typeof fs.access
74
73
  binaryPath?: string
75
74
  spawnImpl?: (command: string, args: readonly string[], options: NonNullable<Parameters<typeof spawn>[2]>) => ReturnType<typeof spawn>
75
+ killRogue?: (host: string) => Promise<KillRogueResult>
76
+ rogueDrainTimeoutMs?: number
77
+ rogueDrainPollMs?: number
76
78
  }
77
79
 
78
80
  export type LocalRunnerConfig = {
@@ -369,16 +371,22 @@ export async function startLlamaCppServer(args: {
369
371
  deps?: LlamaCppStartDeps
370
372
  }): Promise<LlamaCppStartResult> {
371
373
  const host = args.host ?? DEFAULT_LLAMA_HOST
372
- const initialStatus = await servedModelStatus(host, args.modelAlias)
373
- if (initialStatus.state === 'ready') {
374
- if (args.mmprojPath) {
375
- const pid = await readPidFile()
376
- if (!pid) {
377
- return startFailure('untracked-server', {
378
- detail: 'A llama-server is already serving this alias but ethagent did not launch it, so we cannot apply the vision projector. Stop the external process and reopen ethagent.',
374
+ let initialStatus = await servedModelStatus(host, args.modelAlias)
375
+ if (initialStatus.state === 'ready' && args.mmprojPath) {
376
+ const pid = await readPidFile()
377
+ if (!pid) {
378
+ await (args.deps?.killRogue ?? killRogueLlamaProcesses)(host).catch(() => null)
379
+ const drained = await waitForHostDown(host, args.deps?.rogueDrainTimeoutMs ?? 6000, args.deps?.rogueDrainPollMs ?? 200)
380
+ if (!drained) {
381
+ return startFailure('different-model-running', {
382
+ servedModels: initialStatus.models,
383
+ detail: 'another process is holding the local model port and could not be stopped automatically',
379
384
  })
380
385
  }
386
+ initialStatus = await servedModelStatus(host, args.modelAlias)
381
387
  }
388
+ }
389
+ if (initialStatus.state === 'ready') {
382
390
  return { ok: true, alreadyRunning: true }
383
391
  }
384
392
  if (initialStatus.state === 'different') {
@@ -561,6 +569,17 @@ export async function stopLlamaCppServer(args: {
561
569
  return { ok: true, stopped: true }
562
570
  }
563
571
 
572
+ async function waitForHostDown(host: string, timeoutMs: number, pollMs: number): Promise<boolean> {
573
+ const deadline = Date.now() + timeoutMs
574
+ while (Date.now() < deadline) {
575
+ const { up } = await fetchServedModels(host, 800)
576
+ if (!up) return true
577
+ await new Promise<void>(resolve => setTimeout(resolve, pollMs))
578
+ }
579
+ const { up } = await fetchServedModels(host, 800)
580
+ return !up
581
+ }
582
+
564
583
  async function servedModelStatus(host: string, modelAlias: string): Promise<
565
584
  | { state: 'not-up'; models: string[] }
566
585
  | { state: 'ready'; models: string[] }
@@ -572,6 +591,136 @@ async function servedModelStatus(host: string, modelAlias: string): Promise<
572
591
  return { state: 'different', models }
573
592
  }
574
593
 
594
+ export type KillRogueResult = { killed: number; errors: string[] }
595
+
596
+ export async function killRogueLlamaProcesses(host?: string): Promise<KillRogueResult> {
597
+ const result: KillRogueResult = { killed: 0, errors: [] }
598
+ try {
599
+ await stopLlamaCppServer({ timeoutMs: 1500 })
600
+ } catch (err: unknown) {
601
+ result.errors.push(`tracked stop failed: ${(err as Error).message}`)
602
+ }
603
+ const platform = os.platform()
604
+ const portOutcome = await killProcessOnPort(platform, host ?? DEFAULT_LLAMA_HOST)
605
+ result.killed += portOutcome.killed
606
+ if (portOutcome.error) result.errors.push(portOutcome.error)
607
+ const targets = platform === 'win32'
608
+ ? ['llama-server.exe', 'llama-cli.exe']
609
+ : ['llama-server', 'llama-cli']
610
+ for (const target of targets) {
611
+ const outcome = await runKillCommand(platform, target)
612
+ result.killed += outcome.killed
613
+ if (outcome.error) result.errors.push(outcome.error)
614
+ }
615
+ await clearPidFile()
616
+ return result
617
+ }
618
+
619
+ export async function killProcessOnPort(
620
+ platform: NodeJS.Platform,
621
+ host: string,
622
+ ): Promise<{ killed: number; error?: string }> {
623
+ const port = extractHostPort(host)
624
+ if (!port) return { killed: 0, error: 'no port to scan' }
625
+ const pids = await listListeningPids(platform, port)
626
+ if (pids.length === 0) return { killed: 0 }
627
+ let killed = 0
628
+ const errors: string[] = []
629
+ for (const pid of pids) {
630
+ const outcome = await killByPid(platform, pid)
631
+ if (outcome.killed) killed++
632
+ if (outcome.error) errors.push(outcome.error)
633
+ }
634
+ return errors.length > 0 ? { killed, error: errors.join('; ') } : { killed }
635
+ }
636
+
637
+ function extractHostPort(host: string): number | null {
638
+ try {
639
+ const url = new URL(host)
640
+ if (url.port) return Number.parseInt(url.port, 10)
641
+ return url.protocol === 'https:' ? 443 : 80
642
+ } catch {
643
+ return null
644
+ }
645
+ }
646
+
647
+ async function listListeningPids(platform: NodeJS.Platform, port: number): Promise<number[]> {
648
+ if (platform === 'win32') {
649
+ const result = await runCommand('netstat', ['-ano', '-p', 'tcp'], 4000)
650
+ if (!result) return []
651
+ return parseNetstatPids(result.stdout, port)
652
+ }
653
+ const result = await runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], 4000)
654
+ if (!result || result.code !== 0) return []
655
+ return result.stdout.split(/\r?\n/).map(line => Number.parseInt(line.trim(), 10)).filter(n => Number.isInteger(n) && n > 0)
656
+ }
657
+
658
+ export function parseNetstatPids(output: string, port: number): number[] {
659
+ const pids: number[] = []
660
+ const seen = new Set<number>()
661
+ const portSuffix = `:${port}`
662
+ for (const raw of output.split(/\r?\n/)) {
663
+ const line = raw.trim()
664
+ if (!line || !line.toUpperCase().includes('LISTENING')) continue
665
+ const cols = line.split(/\s+/)
666
+ if (cols.length < 5) continue
667
+ const local = cols[1] ?? ''
668
+ if (!local.endsWith(portSuffix)) continue
669
+ const pid = Number.parseInt(cols[cols.length - 1] ?? '', 10)
670
+ if (!Number.isInteger(pid) || pid <= 0) continue
671
+ if (pid === process.pid) continue
672
+ if (seen.has(pid)) continue
673
+ seen.add(pid)
674
+ pids.push(pid)
675
+ }
676
+ return pids
677
+ }
678
+
679
+ async function killByPid(platform: NodeJS.Platform, pid: number): Promise<{ killed: boolean; error?: string }> {
680
+ return new Promise(resolve => {
681
+ const cmd = platform === 'win32' ? 'taskkill' : 'kill'
682
+ const args = platform === 'win32' ? ['/F', '/T', '/PID', String(pid)] : ['-9', String(pid)]
683
+ const child = spawn(cmd, args, { stdio: 'ignore' })
684
+ child.on('error', err => resolve({ killed: false, error: `${cmd} ${pid}: ${err.message}` }))
685
+ child.on('close', code => {
686
+ if (code === 0) {
687
+ resolve({ killed: true })
688
+ return
689
+ }
690
+ resolve({ killed: false, error: `${cmd} ${pid} exited ${code}` })
691
+ })
692
+ })
693
+ }
694
+
695
+ async function runKillCommand(
696
+ platform: NodeJS.Platform,
697
+ target: string,
698
+ ): Promise<{ killed: number; error?: string }> {
699
+ return new Promise(resolve => {
700
+ const cmd = platform === 'win32' ? 'taskkill' : 'pkill'
701
+ const args = platform === 'win32'
702
+ ? ['/F', '/T', '/IM', target]
703
+ : ['-f', target]
704
+ const child = spawn(cmd, args, { stdio: 'ignore' })
705
+ child.on('error', err => resolve({ killed: 0, error: `${cmd} ${target}: ${err.message}` }))
706
+ child.on('close', code => {
707
+ if (code === 0) {
708
+ resolve({ killed: 1 })
709
+ return
710
+ }
711
+ if (platform === 'win32' && code === 128) {
712
+ resolve({ killed: 0 })
713
+ return
714
+ }
715
+ if (platform !== 'win32' && code === 1) {
716
+ resolve({ killed: 0 })
717
+ return
718
+ }
719
+ resolve({ killed: 0, error: `${cmd} ${target} exited ${code}` })
720
+ })
721
+ })
722
+ }
723
+
575
724
  function startFailure(
576
725
  code: LlamaCppStartFailureCode,
577
726
  options: { detail?: string; servedModels?: string[] } = {},
@@ -593,15 +742,15 @@ function startFailureMessage(code: LlamaCppStartFailureCode, servedModels: strin
593
742
  case 'model-file-missing':
594
743
  return detail ? `model file not found: ${detail}` : 'model file was not found'
595
744
  case 'different-model-running':
596
- return `a different local model is already running (${servedModels.join(', ')}); stop it before switching models`
745
+ return servedModels.length > 0
746
+ ? `a different local model is already running (${servedModels.join(', ')}); stop it before switching models`
747
+ : detail ?? 'a different local model is already running; stop it before switching models'
597
748
  case 'spawn-failed':
598
749
  return 'local runner could not be started'
599
750
  case 'runner-exited':
600
751
  return 'local runner closed before becoming ready'
601
752
  case 'readiness-timeout':
602
753
  return 'local runner is still loading or did not answer in time'
603
- case 'untracked-server':
604
- return detail ?? 'a llama-server is already running and ethagent did not launch it; cannot apply the vision encoder until that process is stopped'
605
754
  }
606
755
  }
607
756