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,868 @@
1
+ import React, { useEffect, useReducer, useState } from 'react'
2
+ import { Text } from 'ink'
3
+ import { theme } from '../../ui/theme.js'
4
+ import { type EthagentConfig, type EthagentIdentity, type SelectableNetwork } from '../../storage/config.js'
5
+ import { clearIdentity, setTokenIdentity } from '../../storage/identity.js'
6
+ import { copyToClipboard } from '../../utils/clipboard.js'
7
+ import { catFromIpfs, DEFAULT_IPFS_API_URL } from '../storage/ipfs.js'
8
+ import { openImageFilePicker } from '../profile/imagePicker.js'
9
+ import { hasPinataJwt, clearPinataJwt, savePinataJwt } from '../storage/pinataJwt.js'
10
+ import { registryConfigFromConfig } from '../registry/registryConfig.js'
11
+ import { identityHubErrorView, isRegistrationPreflightError, pinataErrorText } from './identityHubModel.js'
12
+ import { identityHubReducer, type ProfileUpdates, type Step } from './identityHubReducer.js'
13
+ import {
14
+ runCreatePreflight,
15
+ runCreateSigning,
16
+ runRestoreConnectWallet,
17
+ runRestoreDiscover,
18
+ runRestoreTokenIdSubmit,
19
+ runRestoreFetch,
20
+ runRestoreAuthorize,
21
+ runRegistrySubmit,
22
+ runRestoreRegistrySubmit,
23
+ runStorageSubmit,
24
+ runRebackupPreflight,
25
+ runRebackupSigning,
26
+ runRebackupStorageSubmit,
27
+ runPublicProfilePreflight,
28
+ runPublicProfileSigning,
29
+ runPublicProfileStorageSubmit,
30
+ runContinuityUnlock,
31
+ runRecoveryRefetch,
32
+ isAgentTokenIdRequiredError,
33
+ type EffectCallbacks,
34
+ type RestoreProgress,
35
+ } from './identityHubEffects.js'
36
+ import {
37
+ continuityVaultRef,
38
+ continuityVaultStatus,
39
+ continuityWorkingTreeStatus,
40
+ ensurePublicSkillsFile,
41
+ type ContinuityWorkingTreeStatus,
42
+ } from '../continuity/storage.js'
43
+ import { openFileInEditor } from '../continuity/editor.js'
44
+ import { listPublishedContinuitySnapshots } from '../continuity/snapshots.js'
45
+ import type { BrowserWalletReady } from '../wallet/browserWallet.js'
46
+ import { MenuScreen } from './screens/MenuScreen.js'
47
+ import { CreateFlow } from './screens/CreateFlow.js'
48
+ import { RestoreFlow } from './screens/RestoreFlow.js'
49
+ import { NetworkScreen } from './screens/NetworkScreen.js'
50
+ import { DetailsScreen } from './screens/DetailsScreen.js'
51
+ import { ErrorScreen } from './screens/ErrorScreen.js'
52
+ import { WalletApprovalScreen } from './screens/WalletApprovalScreen.js'
53
+ import { RebackupStorageScreen } from './screens/RebackupStorageScreen.js'
54
+ import { BusyScreen } from './screens/BusyScreen.js'
55
+ import { EditProfileFlow } from './screens/EditProfileFlow.js'
56
+ import { StorageCredentialScreen } from './screens/StorageCredentialScreen.js'
57
+ import {
58
+ PrivateContinuityScreen,
59
+ PublicSkillsScreen,
60
+ } from './screens/ContinuityDashboardScreen.js'
61
+ import { RecoveryConfirmScreen } from './screens/RecoveryConfirmScreen.js'
62
+ import { chainIdForNetwork, erc8004ConfigForSupportedChain, type Erc8004RegistryConfig } from '../registry/erc8004.js'
63
+
64
+ const MIN_BUSY_ERROR_MS = 2000
65
+
66
+ function isWalletCancelled(err: unknown): boolean {
67
+ if (!err) return false
68
+ const message = err instanceof Error ? err.message : String(err)
69
+ return /browser wallet request was cancelled/i.test(message)
70
+ || /user rejected/i.test(message)
71
+ }
72
+
73
+ function isStorageError(err: unknown): boolean {
74
+ const message = err instanceof Error ? err.message : String(err)
75
+ return /pinata|ipfs|pin|storage/i.test(message)
76
+ }
77
+
78
+ function waitForMinimumBusyTime(startedAt: number): Promise<void> {
79
+ const remaining = MIN_BUSY_ERROR_MS - (Date.now() - startedAt)
80
+ return remaining > 0
81
+ ? new Promise(resolve => setTimeout(resolve, remaining))
82
+ : Promise.resolve()
83
+ }
84
+
85
+ export type IdentityHubResult =
86
+ | { kind: 'token'; identity: EthagentIdentity }
87
+ | { kind: 'updated'; config: EthagentConfig; message: string }
88
+ | { kind: 'skip' }
89
+ | { kind: 'cancel' }
90
+
91
+ type IdentityHubProps = {
92
+ mode: 'first-run' | 'manage'
93
+ config?: EthagentConfig
94
+ cwd?: string
95
+ initialAction?: IdentityHubInitialAction
96
+ onComplete: (result: IdentityHubResult) => void
97
+ onConfigChange?: (config: EthagentConfig) => void
98
+ }
99
+
100
+ export type IdentityHubInitialAction = 'create' | 'load' | 'settings' | 'save-snapshot'
101
+
102
+ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialAction, onComplete, onConfigChange }) => {
103
+ const identity = config?.identity
104
+ const [step, dispatch] = useReducer(identityHubReducer, initialStepForAction(initialAction, config))
105
+ const [walletSession, setWalletSession] = useState<BrowserWalletReady | null>(null)
106
+ const [restoreProgress, setRestoreProgress] = useState<RestoreProgress | null>(null)
107
+ const [jwtSaved, setJwtSaved] = useState<boolean>(false)
108
+ const [copyNotice, setCopyNotice] = useState<string | null>(null)
109
+ const [continuityReady, setContinuityReady] = useState<boolean>(false)
110
+ const [workingStatus, setWorkingStatus] = useState<ContinuityWorkingTreeStatus | null>(null)
111
+ const canRebackup = Boolean(identity?.agentId && (identity?.identityRegistryAddress || config?.erc8004?.identityRegistryAddress))
112
+
113
+ const setStep = (s: Step) => dispatch({ type: 'preflightResolved', step: s })
114
+ const back = () => dispatch({ type: 'back', from: step })
115
+
116
+ useEffect(() => { setWalletSession(null) }, [step.kind])
117
+ useEffect(() => {
118
+ if (step.kind !== 'restore-authorizing') setRestoreProgress(null)
119
+ }, [step.kind])
120
+
121
+ useEffect(() => {
122
+ let cancelled = false
123
+ hasPinataJwt().then(v => { if (!cancelled) setJwtSaved(v) }).catch(() => {})
124
+ return () => { cancelled = true }
125
+ }, [step.kind])
126
+
127
+ useEffect(() => { setCopyNotice(null) }, [step.kind])
128
+
129
+ useEffect(() => {
130
+ let cancelled = false
131
+ if (!identity) {
132
+ setContinuityReady(false)
133
+ return
134
+ }
135
+ if (!step.kind.startsWith('continuity') && step.kind !== 'details' && step.kind !== 'menu') return
136
+ continuityVaultStatus(identity)
137
+ .then(status => { if (!cancelled) setContinuityReady(status.ready) })
138
+ .catch(() => { if (!cancelled) setContinuityReady(false) })
139
+ return () => { cancelled = true }
140
+ }, [identity, step.kind])
141
+
142
+ const completeTokenIdentity = async (nextIdentity: EthagentIdentity, message: string): Promise<void> => {
143
+ if (mode === 'first-run' || !config) {
144
+ onComplete({ kind: 'token', identity: nextIdentity })
145
+ return
146
+ }
147
+ const nextConfig = await setTokenIdentity(config, nextIdentity)
148
+ onComplete({ kind: 'updated', config: nextConfig, message })
149
+ }
150
+
151
+ const callbacks: EffectCallbacks = {
152
+ onStep: setStep,
153
+ onWalletReady: setWalletSession,
154
+ onIdentityComplete: completeTokenIdentity,
155
+ onRestoreProgress: setRestoreProgress,
156
+ }
157
+
158
+ const errorStep = (err: unknown, backStep: Step): void => {
159
+ setStep({ kind: 'error', error: identityHubErrorView(err), back: backStep })
160
+ }
161
+
162
+ const handleStepError = (err: unknown, backStep: Step, softCancel: Step = backStep): void => {
163
+ if (isWalletCancelled(err)) {
164
+ setStep(softCancel)
165
+ return
166
+ }
167
+ errorStep(err, backStep)
168
+ }
169
+
170
+ const resolveRegistryForIdentity = (target: EthagentIdentity): Erc8004RegistryConfig | null => {
171
+ const resolution = registryConfigFromConfig(config)
172
+ if (target.chainId && target.identityRegistryAddress) {
173
+ return {
174
+ chainId: target.chainId,
175
+ rpcUrl: target.rpcUrl ?? resolution.defaultRpcUrl,
176
+ identityRegistryAddress: target.identityRegistryAddress as `0x${string}`,
177
+ }
178
+ }
179
+ if (resolution.config) return resolution.config
180
+ return null
181
+ }
182
+
183
+ const triggerRebackup = (backStep: Step, profileUpdates?: ProfileUpdates): void => {
184
+ if (!identity) return
185
+ const registry = resolveRegistryForIdentity(identity)
186
+ if (!registry) {
187
+ errorStep(new Error('no agent registry configured for this identity'), backStep)
188
+ return
189
+ }
190
+ runRebackupPreflight(identity, registry, callbacks, profileUpdates, backStep)
191
+ .catch((err: unknown) => errorStep(err, backStep))
192
+ }
193
+
194
+ const triggerPublicProfilePublish = (backStep: Step, profileUpdates?: ProfileUpdates): void => {
195
+ if (!identity) return
196
+ const registry = resolveRegistryForIdentity(identity)
197
+ if (!registry) {
198
+ errorStep(new Error('no agent registry configured for this identity'), backStep)
199
+ return
200
+ }
201
+ runPublicProfilePreflight(identity, registry, callbacks, profileUpdates, backStep)
202
+ .catch((err: unknown) => errorStep(err, backStep))
203
+ }
204
+
205
+ useEffect(() => {
206
+ if (step.kind !== 'rebackup-start') return
207
+ triggerRebackup(step.back)
208
+ }, [step])
209
+
210
+ useEffect(() => {
211
+ let cancelled = false
212
+ if (!identity || step.kind !== 'menu') return
213
+
214
+ const checkStatus = async () => {
215
+ try {
216
+ const [latest] = await listPublishedContinuitySnapshots(identity, 1)
217
+ const status = await continuityWorkingTreeStatus(identity, latest)
218
+ if (cancelled) return
219
+ setWorkingStatus(status)
220
+ } catch {
221
+ if (cancelled) return
222
+ setWorkingStatus(null)
223
+ }
224
+ }
225
+
226
+ void checkStatus()
227
+
228
+ return () => {
229
+ cancelled = true
230
+ }
231
+ }, [identity, step.kind])
232
+
233
+ useEffect(() => {
234
+ if (step.kind !== 'create-preflight') return
235
+ let cancelled = false
236
+ const startedAt = Date.now()
237
+ runCreatePreflight(step, config, callbacks)
238
+ .catch(async (err: unknown) => {
239
+ await waitForMinimumBusyTime(startedAt)
240
+ if (!cancelled) errorStep(err, { kind: 'create-network', name: step.name, description: step.description })
241
+ })
242
+ return () => { cancelled = true }
243
+ }, [step])
244
+
245
+ useEffect(() => {
246
+ if (step.kind !== 'create-signing') return
247
+ let cancelled = false
248
+ const backStep: Step = { kind: 'create-network', name: step.name, description: step.description }
249
+ runCreateSigning(step, callbacks)
250
+ .catch((err: unknown) => {
251
+ if (cancelled) return
252
+ if (isRegistrationPreflightError(err)) {
253
+ errorStep(err, backStep)
254
+ return
255
+ }
256
+ if (isStorageError(err)) {
257
+ setStep({
258
+ kind: 'create-storage',
259
+ name: step.name,
260
+ description: step.description,
261
+ registry: step.registry,
262
+ error: pinataErrorText(err),
263
+ pinataJwt: step.pinataJwt,
264
+ })
265
+ return
266
+ }
267
+ handleStepError(err, backStep)
268
+ })
269
+ return () => { cancelled = true }
270
+ }, [step])
271
+
272
+ useEffect(() => {
273
+ if (step.kind !== 'restore-discovering') return
274
+ let cancelled = false
275
+ const startedAt = Date.now()
276
+ runRestoreDiscover(step, config, callbacks)
277
+ .catch(async (err: unknown) => {
278
+ await waitForMinimumBusyTime(startedAt)
279
+ if (cancelled) return
280
+ if (isAgentTokenIdRequiredError(err)) {
281
+ setStep({ kind: 'restore-token-id', ownerHandle: err.ownerAddress, registry: err.registry, error: err.message, purpose: step.purpose })
282
+ return
283
+ }
284
+ errorStep(err, { kind: 'restore-network', ownerHandle: step.ownerHandle, purpose: step.purpose })
285
+ })
286
+ return () => { cancelled = true }
287
+ }, [step])
288
+
289
+ useEffect(() => {
290
+ if (step.kind !== 'restore-wallet') return
291
+ let cancelled = false
292
+ runRestoreConnectWallet(step, callbacks)
293
+ .catch((err: unknown) => { if (!cancelled) handleStepError(err, { kind: 'restore-owner', purpose: step.purpose }) })
294
+ return () => { cancelled = true }
295
+ }, [step])
296
+
297
+ useEffect(() => {
298
+ if (step.kind !== 'restore-fetching') return
299
+ let cancelled = false
300
+ const startedAt = Date.now()
301
+ runRestoreFetch(step, callbacks)
302
+ .catch(async (err: unknown) => {
303
+ await waitForMinimumBusyTime(startedAt)
304
+ if (!cancelled) errorStep(err, { kind: 'restore-network', ownerHandle: step.candidate.ownerAddress, purpose: step.purpose })
305
+ })
306
+ return () => { cancelled = true }
307
+ }, [step])
308
+
309
+ useEffect(() => {
310
+ if (step.kind !== 'restore-authorizing') return
311
+ let cancelled = false
312
+ runRestoreAuthorize(step, callbacks)
313
+ .catch((err: unknown) => {
314
+ if (!cancelled) handleStepError(err, { kind: 'restore-network', ownerHandle: step.candidate.ownerAddress, purpose: step.purpose })
315
+ })
316
+ return () => { cancelled = true }
317
+ }, [step])
318
+
319
+ useEffect(() => {
320
+ if (step.kind !== 'rebackup-signing') return
321
+ let cancelled = false
322
+ runRebackupSigning(step, callbacks)
323
+ .catch((err: unknown) => {
324
+ if (cancelled) return
325
+ if (isStorageError(err)) {
326
+ setStep({
327
+ kind: 'rebackup-storage',
328
+ identity: step.identity,
329
+ registry: step.registry,
330
+ error: pinataErrorText(err),
331
+ pinataJwt: step.pinataJwt,
332
+ profileUpdates: step.profileUpdates,
333
+ returnTo: step.returnTo,
334
+ })
335
+ return
336
+ }
337
+ handleStepError(err, step.returnTo ?? { kind: 'menu' })
338
+ })
339
+ return () => { cancelled = true }
340
+ }, [step])
341
+
342
+ useEffect(() => {
343
+ if (step.kind !== 'public-profile-signing') return
344
+ let cancelled = false
345
+ runPublicProfileSigning(step, callbacks)
346
+ .catch((err: unknown) => {
347
+ if (cancelled) return
348
+ if (isStorageError(err)) {
349
+ setStep({
350
+ kind: 'public-profile-storage',
351
+ identity: step.identity,
352
+ registry: step.registry,
353
+ error: pinataErrorText(err),
354
+ pinataJwt: step.pinataJwt,
355
+ profileUpdates: step.profileUpdates,
356
+ returnTo: step.returnTo,
357
+ })
358
+ return
359
+ }
360
+ handleStepError(err, step.returnTo ?? { kind: 'continuity-public' })
361
+ })
362
+ return () => { cancelled = true }
363
+ }, [step])
364
+
365
+ useEffect(() => {
366
+ if (step.kind !== 'continuity-unlocking') return
367
+ let cancelled = false
368
+ runContinuityUnlock(step, callbacks)
369
+ .then(() => {
370
+ if (!cancelled) setContinuityReady(true)
371
+ })
372
+ .catch((err: unknown) => {
373
+ if (!cancelled) handleStepError(err, { kind: 'continuity-private' })
374
+ })
375
+ return () => { cancelled = true }
376
+ }, [step])
377
+
378
+ useEffect(() => {
379
+ if (step.kind !== 'recovery-refetching') return
380
+ let cancelled = false
381
+ runRecoveryRefetch(step.identity, step.registry, callbacks)
382
+ .then(() => {
383
+ if (!cancelled) setContinuityReady(true)
384
+ })
385
+ .catch((err: unknown) => {
386
+ if (!cancelled) handleStepError(err, { kind: 'menu' })
387
+ })
388
+ return () => { cancelled = true }
389
+ }, [step])
390
+
391
+
392
+ const openContinuityFile = async (kind: 'soul' | 'memory' | 'skills'): Promise<void> => {
393
+ if (!identity) return
394
+ try {
395
+ if (kind === 'skills') {
396
+ await ensurePublicSkillsFile(identity, {
397
+ fallback: () => readPublishedPublicSkills(identity),
398
+ })
399
+ }
400
+ const ref = continuityVaultRef(identity)
401
+ const file = kind === 'soul' ? ref.soulPath : kind === 'memory' ? ref.memoryPath : ref.publicSkillsPath
402
+ const result = await openFileInEditor(file)
403
+ const message = result.ok
404
+ ? `opened ${kind === 'soul' ? 'SOUL.md' : kind === 'memory' ? 'MEMORY.md' : 'skills.json'} with ${result.method}.`
405
+ : `open failed: ${result.error}`
406
+ setStep(kind === 'skills'
407
+ ? { kind: 'continuity-public', notice: message }
408
+ : { kind: 'continuity-private', notice: message })
409
+ } catch (err: unknown) {
410
+ errorStep(err, kind === 'skills' ? { kind: 'continuity-public' } : { kind: 'continuity-private' })
411
+ }
412
+ }
413
+
414
+ const footer = <Text color={theme.dim}>enter select · esc back</Text>
415
+
416
+ if (step.kind === 'menu') {
417
+ return (
418
+ <MenuScreen
419
+ mode={mode}
420
+ config={config}
421
+ identity={identity}
422
+ workingStatus={workingStatus}
423
+ canRebackup={canRebackup}
424
+ footer={footer}
425
+ onCreate={() => {
426
+ if (identity) setStep({ kind: 'replace-confirm', next: 'create' })
427
+ else setStep({ kind: 'create-name' })
428
+ }}
429
+ onLoad={() => {
430
+ setCopyNotice(null)
431
+ setStep({ kind: 'restore-wallet', purpose: identity ? 'switch' : 'restore' })
432
+ }}
433
+ onBackupNow={() => setStep({ kind: 'rebackup-confirm' })}
434
+ onRefetchLatest={() => setStep({ kind: 'recovery-refetch-confirm' })}
435
+ onPublicProfile={() => setStep({ kind: 'continuity-public' })}
436
+ onPrivateMemory={() => setStep({ kind: 'continuity-private' })}
437
+ onCopyValues={() => setStep({ kind: 'details' })}
438
+ onStorageCredential={() => setStep({ kind: 'storage-credential' })}
439
+ onSkip={() => onComplete({ kind: 'skip' })}
440
+ onCancel={() => onComplete({ kind: 'cancel' })}
441
+ />
442
+ )
443
+ }
444
+
445
+ if (isCreateStep(step)) {
446
+ return (
447
+ <CreateFlow
448
+ step={step}
449
+ walletSession={walletSession}
450
+ onSetStep={setStep}
451
+ onNameSubmit={name => setStep({ kind: 'create-description', name })}
452
+ onDescriptionSubmit={(name, description) => setStep({ kind: 'create-network', name, description })}
453
+ onRegistrySubmit={async value => {
454
+ if (step.kind !== 'create-registry') return
455
+ try {
456
+ await runRegistrySubmit(value, step, config, onConfigChange, callbacks)
457
+ } catch (err: unknown) {
458
+ setStep({ kind: 'create-registry', name: step.name, description: step.description, resolution: step.resolution, error: (err as Error).message })
459
+ }
460
+ }}
461
+ onStorageSubmit={async input => {
462
+ if (step.kind !== 'create-storage') return
463
+ try {
464
+ await runStorageSubmit(input, step, callbacks)
465
+ } catch (err: unknown) {
466
+ setStep({
467
+ kind: 'create-storage',
468
+ name: step.name,
469
+ description: step.description,
470
+ registry: step.registry,
471
+ error: (err as Error).message,
472
+ pinataJwt: step.pinataJwt,
473
+ })
474
+ }
475
+ }}
476
+ onStorageError={error => {
477
+ if (step.kind !== 'create-storage') return
478
+ setStep({ ...step, error })
479
+ }}
480
+ onBack={back}
481
+ onMenu={() => setStep({ kind: 'menu' })}
482
+ />
483
+ )
484
+ }
485
+
486
+ if (step.kind === 'create-network') {
487
+ return (
488
+ <NetworkScreen
489
+ subtitle="Choose where to create this agent."
490
+ footer={footer}
491
+ onSelect={(network: SelectableNetwork) => {
492
+ setStep({ kind: 'create-preflight', name: step.name, description: step.description, network })
493
+ }}
494
+ onCancel={back}
495
+ />
496
+ )
497
+ }
498
+
499
+ if (step.kind === 'restore-network') {
500
+ return (
501
+ <NetworkScreen
502
+ subtitle="Choose a network to search for your agents."
503
+ footer={footer}
504
+ onSelect={(network: SelectableNetwork) => {
505
+ try {
506
+ const registry = erc8004ConfigForSupportedChain(chainIdForNetwork(network))
507
+ setStep({ kind: 'restore-discovering', ownerHandle: step.ownerHandle, registry, purpose: step.purpose })
508
+ } catch (err: unknown) {
509
+ errorStep(err, { kind: 'restore-network', ownerHandle: step.ownerHandle, purpose: step.purpose })
510
+ }
511
+ }}
512
+ onCancel={back}
513
+ />
514
+ )
515
+ }
516
+
517
+ if (isRestoreStep(step)) {
518
+ return (
519
+ <RestoreFlow
520
+ step={step}
521
+ config={config}
522
+ walletSession={walletSession}
523
+ restoreProgress={restoreProgress}
524
+ onConnectWallet={() => {
525
+ const purpose = step.kind === 'restore-owner' ? step.purpose : undefined
526
+ setStep({ kind: 'restore-wallet', purpose })
527
+ }}
528
+ onRestoreRegistrySubmit={async value => {
529
+ if (step.kind !== 'restore-registry') return
530
+ try {
531
+ await runRestoreRegistrySubmit(value, step, config, onConfigChange, callbacks)
532
+ } catch (err: unknown) {
533
+ setStep({ kind: 'restore-registry', ownerHandle: step.ownerHandle, error: (err as Error).message, purpose: step.purpose })
534
+ }
535
+ }}
536
+ onTokenIdSubmit={async value => {
537
+ if (step.kind !== 'restore-token-id') return
538
+ try {
539
+ await runRestoreTokenIdSubmit(value, step, callbacks)
540
+ } catch (err: unknown) {
541
+ setStep({ ...step, error: (err as Error).message })
542
+ }
543
+ }}
544
+ onTokenSelect={value => {
545
+ if (step.kind !== 'restore-select-token') return
546
+ const candidate = step.candidates.find(item => item.agentId.toString() === value)
547
+ if (!candidate?.backup?.cid) return
548
+ setStep({ kind: 'restore-fetching', cid: candidate.backup.cid, apiUrl: DEFAULT_IPFS_API_URL, candidate, purpose: step.purpose })
549
+ }}
550
+ onBack={back}
551
+ />
552
+ )
553
+ }
554
+
555
+ if (step.kind === 'details') {
556
+ return (
557
+ <DetailsScreen
558
+ identity={identity}
559
+ config={config}
560
+ copyNotice={copyNotice}
561
+ footer={footer}
562
+ onCopy={async (label, value) => {
563
+ const result = await copyToClipboard(value)
564
+ setCopyNotice(result.ok ? `copied ${label} via ${result.method}.` : `copy failed: ${result.error}`)
565
+ setStep({ kind: 'details' })
566
+ }}
567
+ onBack={back}
568
+ />
569
+ )
570
+ }
571
+
572
+ const openPublicProfileEdit = (backStep: Step): void => {
573
+ if (!identity) return
574
+ const registry = resolveRegistryForIdentity(identity)
575
+ if (!registry) {
576
+ errorStep(new Error('no agent registry configured for this identity'), backStep)
577
+ return
578
+ }
579
+ setStep({ kind: 'edit-profile-name', identity, registry, returnTo: backStep })
580
+ }
581
+
582
+ if (step.kind === 'rebackup-confirm') {
583
+ return (
584
+ <RecoveryConfirmScreen
585
+ mode="publish"
586
+ workingStatus={workingStatus}
587
+ footer={footer}
588
+ onConfirm={() => triggerRebackup({ kind: 'menu' })}
589
+ onBack={back}
590
+ />
591
+ )
592
+ }
593
+
594
+ if (step.kind === 'recovery-refetch-confirm') {
595
+ return (
596
+ <RecoveryConfirmScreen
597
+ mode="refetch"
598
+ workingStatus={workingStatus}
599
+ footer={footer}
600
+ onConfirm={() => {
601
+ if (!identity) return
602
+ const registry = resolveRegistryForIdentity(identity)
603
+ if (!registry) {
604
+ errorStep(new Error('no agent registry configured for this identity'), { kind: 'menu' })
605
+ return
606
+ }
607
+ setStep({ kind: 'recovery-refetching', identity, registry })
608
+ }}
609
+ onBack={back}
610
+ />
611
+ )
612
+ }
613
+
614
+ if (step.kind === 'recovery-refetching') {
615
+ return (
616
+ <WalletApprovalScreen
617
+ title="Refetch Latest Snapshot"
618
+ subtitle="Wallet approval decrypts the latest published snapshot and overwrites local SOUL.md, MEMORY.md, and skills.json."
619
+ walletSession={walletSession}
620
+ label={restoreProgress?.label ?? 'fetching latest snapshot from chain...'}
621
+ onCancel={() => setStep({ kind: 'menu' })}
622
+ />
623
+ )
624
+ }
625
+
626
+ if (step.kind === 'continuity-private') {
627
+ return (
628
+ <PrivateContinuityScreen
629
+ identity={identity}
630
+ config={config}
631
+ ready={continuityReady}
632
+ notice={step.notice}
633
+ canBackup={canRebackup}
634
+ footer={footer}
635
+ onRestore={() => { if (identity) setStep({ kind: 'continuity-unlocking', identity, returnTo: 'private' }) }}
636
+ onOpenSoul={() => { void openContinuityFile('soul') }}
637
+ onOpenMemory={() => { void openContinuityFile('memory') }}
638
+ onBackup={() => triggerRebackup({ kind: 'continuity-private' })}
639
+ onBack={back}
640
+ />
641
+ )
642
+ }
643
+
644
+ if (step.kind === 'continuity-public') {
645
+ return (
646
+ <PublicSkillsScreen
647
+ identity={identity}
648
+ config={config}
649
+ ready={continuityReady}
650
+ notice={step.notice}
651
+ canPublish={canRebackup}
652
+ footer={footer}
653
+ onEditProfile={() => openPublicProfileEdit({ kind: 'continuity-public' })}
654
+ onOpenSkills={() => { void openContinuityFile('skills') }}
655
+ onPublish={() => triggerPublicProfilePublish({ kind: 'continuity-public' })}
656
+ onBack={back}
657
+ />
658
+ )
659
+ }
660
+
661
+ if (step.kind === 'storage-credential' || step.kind === 'storage-credential-input' || step.kind === 'storage-credential-forget-confirm') {
662
+ return (
663
+ <StorageCredentialScreen
664
+ step={step}
665
+ hasCredential={jwtSaved}
666
+ footer={footer}
667
+ onEdit={() => setStep({ kind: 'storage-credential-input' })}
668
+ onForget={() => setStep({ kind: 'storage-credential-forget-confirm' })}
669
+ onConfirmForget={async () => {
670
+ await clearPinataJwt().catch(() => {})
671
+ setJwtSaved(false)
672
+ setCopyNotice('IPFS storage credential removed.')
673
+ setStep({ kind: 'menu' })
674
+ }}
675
+ onSubmit={async input => {
676
+ try {
677
+ await savePinataJwt(input)
678
+ setJwtSaved(true)
679
+ setCopyNotice('IPFS storage credential saved.')
680
+ setStep({ kind: 'menu' })
681
+ } catch (err: unknown) {
682
+ setStep({ kind: 'storage-credential-input', error: (err as Error).message })
683
+ }
684
+ }}
685
+ onCancel={back}
686
+ />
687
+ )
688
+ }
689
+
690
+ if (step.kind === 'edit-profile-name' || step.kind === 'edit-profile-description' || step.kind === 'edit-profile-image') {
691
+ return (
692
+ <EditProfileFlow
693
+ step={step}
694
+ onNameSubmit={name => {
695
+ if (step.kind !== 'edit-profile-name') return
696
+ setStep({ kind: 'edit-profile-description', identity: step.identity, registry: step.registry, name, returnTo: step.returnTo })
697
+ }}
698
+ onDescriptionSubmit={description => {
699
+ if (step.kind !== 'edit-profile-description') return
700
+ setStep({ kind: 'edit-profile-image', identity: step.identity, registry: step.registry, name: step.name, description, returnTo: step.returnTo })
701
+ }}
702
+ onImageSubmit={imagePath => {
703
+ if (step.kind !== 'edit-profile-image') return
704
+ const updates: ProfileUpdates = { name: step.name, description: step.description, ...(imagePath ? { imagePath } : {}) }
705
+ runPublicProfilePreflight(step.identity, step.registry, callbacks, updates, step.returnTo ?? { kind: 'continuity-public' })
706
+ .catch((err: unknown) => errorStep(err, step.returnTo ?? { kind: 'continuity-public' }))
707
+ }}
708
+ onImagePick={() => {
709
+ if (step.kind !== 'edit-profile-image') return
710
+ const imageStep = step
711
+ void openImageFilePicker()
712
+ .then(result => {
713
+ if (!result.ok) {
714
+ setStep({ ...imageStep, error: result.cancelled ? 'image selection cancelled.' : `${result.error}. enter a path manually if needed.` })
715
+ return
716
+ }
717
+ const updates: ProfileUpdates = { name: imageStep.name, description: imageStep.description, imagePath: result.file }
718
+ runPublicProfilePreflight(imageStep.identity, imageStep.registry, callbacks, updates, imageStep.returnTo ?? { kind: 'continuity-public' })
719
+ .catch((err: unknown) => errorStep(err, imageStep.returnTo ?? { kind: 'continuity-public' }))
720
+ })
721
+ .catch((err: unknown) => {
722
+ setStep({ ...imageStep, error: `${(err as Error).message}. enter a path manually if needed.` })
723
+ })
724
+ }}
725
+ onBack={back}
726
+ onMenu={() => setStep(step.returnTo ?? { kind: 'continuity-public' })}
727
+ />
728
+ )
729
+ }
730
+
731
+ if (step.kind === 'rebackup-signing') {
732
+ return (
733
+ <WalletApprovalScreen
734
+ title="Approve Encrypted Snapshot"
735
+ subtitle="Signs, encrypts private SOUL.md and MEMORY.md, pins them, and refreshes recovery metadata."
736
+ walletSession={walletSession}
737
+ label="waiting for wallet approval..."
738
+ onCancel={() => setStep(step.returnTo ?? { kind: 'menu' })}
739
+ />
740
+ )
741
+ }
742
+
743
+ if (step.kind === 'public-profile-signing') {
744
+ return (
745
+ <WalletApprovalScreen
746
+ title="Approve Public Profile"
747
+ subtitle="Pins skills.json and the Agent Card, then updates tokenURI. Private files are not read."
748
+ walletSession={walletSession}
749
+ label="waiting for wallet approval..."
750
+ onCancel={() => setStep(step.returnTo ?? { kind: 'continuity-public' })}
751
+ />
752
+ )
753
+ }
754
+
755
+ if (step.kind === 'rebackup-start') {
756
+ return (
757
+ <BusyScreen
758
+ title="Identity Hub"
759
+ label="preparing encrypted snapshot..."
760
+ onCancel={back}
761
+ />
762
+ )
763
+ }
764
+
765
+ if (step.kind === 'continuity-unlocking') {
766
+ return (
767
+ <WalletApprovalScreen
768
+ title="Restore Memory & Persona"
769
+ subtitle="Wallet approval decrypts the encrypted snapshot into local SOUL.md and MEMORY.md working files."
770
+ walletSession={walletSession}
771
+ label="waiting for wallet approval..."
772
+ onCancel={() => setStep({ kind: 'continuity-private' })}
773
+ />
774
+ )
775
+ }
776
+
777
+
778
+ if (step.kind === 'rebackup-storage' || step.kind === 'public-profile-storage') {
779
+ return (
780
+ <RebackupStorageScreen
781
+ step={step}
782
+ footer={footer}
783
+ onSubmit={async input => {
784
+ try {
785
+ if (step.kind === 'rebackup-storage') {
786
+ await runRebackupStorageSubmit(input, step, callbacks)
787
+ } else {
788
+ await runPublicProfileStorageSubmit(input, step, callbacks)
789
+ }
790
+ } catch (err: unknown) {
791
+ setStep({ ...step, error: (err as Error).message })
792
+ }
793
+ }}
794
+ onCancel={back}
795
+ />
796
+ )
797
+ }
798
+
799
+ if (step.kind === 'restore-wallet') {
800
+ return (
801
+ <WalletApprovalScreen
802
+ title="Connect Wallet"
803
+ subtitle="Select the wallet that owns the agent you want to load."
804
+ walletSession={walletSession}
805
+ label="waiting for wallet..."
806
+ onCancel={back}
807
+ />
808
+ )
809
+ }
810
+
811
+ if (step.kind === 'busy') {
812
+ return (
813
+ <BusyScreen
814
+ title="Identity Hub"
815
+ label={step.label}
816
+ onCancel={back}
817
+ />
818
+ )
819
+ }
820
+
821
+ if (step.kind === 'error') {
822
+ return (
823
+ <ErrorScreen
824
+ error={step.error}
825
+ back={step.back}
826
+ footer={footer}
827
+ onBack={backStep => setStep(backStep)}
828
+ onClose={() => onComplete({ kind: 'cancel' })}
829
+ />
830
+ )
831
+ }
832
+
833
+ return null
834
+ }
835
+
836
+ async function readPublishedPublicSkills(identity: EthagentIdentity): Promise<string> {
837
+ const cid = identity.publicSkills?.cid
838
+ if (!cid) throw new Error('no published public skills CID')
839
+ return new TextDecoder().decode(await catFromIpfs(
840
+ identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL,
841
+ cid,
842
+ ))
843
+ }
844
+
845
+ function isCreateStep(step: Step): step is Extract<Step, { kind: 'replace-confirm' | 'create-name' | 'create-description' | 'create-preflight' | 'create-registry' | 'create-signing' | 'create-storage' }> {
846
+ return step.kind === 'replace-confirm'
847
+ || step.kind === 'create-name'
848
+ || step.kind === 'create-description'
849
+ || step.kind === 'create-preflight'
850
+ || step.kind === 'create-registry'
851
+ || step.kind === 'create-signing'
852
+ || step.kind === 'create-storage'
853
+ }
854
+
855
+ function isRestoreStep(step: Step): step is Exclude<Extract<Step, { kind: `restore-${string}` }>, { kind: 'restore-wallet' | 'restore-network' }> {
856
+ return step.kind.startsWith('restore-') && step.kind !== 'restore-wallet' && step.kind !== 'restore-network'
857
+ }
858
+
859
+ function initialStepForAction(
860
+ action: IdentityHubInitialAction | undefined,
861
+ config: EthagentConfig | undefined,
862
+ ): Step {
863
+ if (action === 'create') return config?.identity ? { kind: 'replace-confirm', next: 'create' } : { kind: 'create-name' }
864
+ if (action === 'load') return { kind: 'restore-wallet', purpose: config?.identity ? 'switch' : 'restore' }
865
+ if (action === 'save-snapshot') return config?.identity ? { kind: 'rebackup-start', back: { kind: 'menu' } } : { kind: 'menu' }
866
+ if (action === 'settings') return { kind: 'menu' }
867
+ return { kind: 'menu' }
868
+ }