ethagent 1.0.9 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +18 -81
  2. package/package.json +1 -2
  3. package/src/chat/ChatScreen.tsx +5 -4
  4. package/src/chat/ContinuityEditReviewView.tsx +3 -3
  5. package/src/chat/chatTurnOrchestrator.ts +1 -1
  6. package/src/chat/commands.ts +5 -5
  7. package/src/cli/ResetConfirmView.tsx +12 -13
  8. package/src/cli/main.tsx +27 -30
  9. package/src/cli/reset.ts +7 -8
  10. package/src/cli/updateNotice.ts +52 -0
  11. package/src/identity/continuity/envelope.ts +11 -5
  12. package/src/identity/continuity/storage.ts +1 -1
  13. package/src/identity/hub/IdentityHub.tsx +6 -7
  14. package/src/identity/hub/identityHubModel.ts +12 -12
  15. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +39 -34
  16. package/src/identity/hub/screens/CreateFlow.tsx +4 -4
  17. package/src/identity/hub/screens/DetailsScreen.tsx +2 -2
  18. package/src/identity/hub/screens/EditProfileFlow.tsx +5 -5
  19. package/src/identity/hub/screens/ErrorScreen.tsx +2 -2
  20. package/src/identity/hub/screens/IdentitySummary.tsx +32 -12
  21. package/src/identity/hub/screens/MenuScreen.tsx +17 -17
  22. package/src/identity/hub/screens/NetworkScreen.tsx +7 -3
  23. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +9 -7
  24. package/src/identity/hub/screens/RestoreFlow.tsx +2 -2
  25. package/src/identity/hub/screens/StorageCredentialScreen.tsx +5 -5
  26. package/src/identity/wallet/wallet-page/wallet.html +1202 -1082
  27. package/src/models/ModelPicker.tsx +71 -71
  28. package/src/models/llamacppPreflight.ts +1 -1
  29. package/src/models/modelPickerOptions.ts +22 -22
  30. package/src/storage/factoryReset.ts +17 -20
  31. package/src/tools/privateContinuityEditTool.ts +1 -1
  32. package/src/ui/BrandSplash.tsx +7 -4
package/README.md CHANGED
@@ -3,118 +3,59 @@
3
3
 
4
4
  A privacy-first AI agent with a portable Ethereum identity.
5
5
 
6
- ethagent is a terminal agent for coding and project work. It gives the agent a wallet-owned ERC-8004 identity, keeps private continuity encrypted, and publishes public capability metadata as structured JSON so other applications and agents can understand what it can do.
7
-
8
- The identity stays portable. The model can change. The private memory stays under wallet-gated encryption.
6
+ ethagent binds an AI agent to a wallet-owned ERC-8004 token. Persona and memory stay encrypted under your wallet signature and pinned to IPFS. Public skills publish as machine-readable JSON so other agents can discover what it does. Swap models, switch machines, restore the same agent from one onchain pointer.
9
7
 
10
8
  ## Install
11
9
 
12
10
  ethagent requires Node.js 20 or newer. Install it from npm with `npm install -g ethagent`, then start it with `ethagent`.
13
11
 
14
- On first run, ethagent guides you through model setup and identity setup. You can use a local GGUF model through llama.cpp, or connect OpenAI, Anthropic, or Gemini.
15
-
16
- ## What It Does
17
-
18
- * Runs an AI coding agent in your terminal.
19
- * Switches between cloud models and local GGUF models.
20
- * Creates or loads a wallet-owned ERC-8004 agent identity.
21
- * Encrypts private continuity before IPFS pinning.
22
- * Publishes public `skills.json` and Agent Card metadata for discovery.
23
- * Restores the same agent on another machine from the onchain record.
24
- * Supports workspace tools, managed edit rewind, session resume, context compaction, and MCP servers.
25
-
26
12
  ## First Run
27
13
 
28
- Start with `ethagent`. The setup flow asks for a model path first, then offers identity setup.
29
-
30
- You can create a new ERC-8004 agent with a browser wallet, load an agent token you already own, or skip identity setup and add it later from the Identity Hub.
31
-
32
- Use `Alt+P` to switch models and `Alt+I` to open the Identity Hub. Inside the agent, `/help` shows the live command list for the version you are running.
14
+ First run walks model setup, then identity setup. You can use a local GGUF model through llama.cpp, or connect OpenAI, Anthropic, or Gemini.
33
15
 
34
- ## Identity Hub
16
+ Create a new ERC-8004 agent with a browser wallet, load an agent token you already own, or skip identity setup and add it later from the Identity Hub.
35
17
 
36
- The Identity Hub is where the portable identity is managed.
18
+ Use `Alt+P` to swap models and `Alt+I` to open the Identity Hub. Inside the agent, `/help` shows the live command list for the version you are running.
37
19
 
38
- | Area | What It Controls |
39
- | --- | --- |
40
- | Public Metadata | Profile name, description, image, `skills.json`, and Agent Card. |
41
- | Private Local Files | `SOUL.md`, `MEMORY.md`, and the local copy of `skills.json`. |
42
- | Recovery | Publishing the current encrypted snapshot or refetching the latest one from chain. |
43
- | Storage | The Pinata JWT used to pin continuity and metadata to IPFS. |
44
- | Agent Token | Registry, owner, token, URI, CID, and copyable identity values. |
45
-
46
- The hub is a recovery panel, not a history archive. The current tokenURI is the source of truth for the latest published state.
47
-
48
- ## Continuity
20
+ ## Identity & Continuity
49
21
 
50
- Each identity gets a local continuity vault under `~/.ethagent/continuity`.
22
+ The Identity Hub manages the portable identity. Each identity gets a local continuity vault under `~/.ethagent/continuity`.
51
23
 
52
24
  | File | Visibility | Purpose |
53
25
  | --- | --- | --- |
54
26
  | `SOUL.md` | Private | Persona, boundaries, standing instructions, and identity framing. |
55
27
  | `MEMORY.md` | Private | Durable preferences, project context, decisions, and operating notes. |
56
- | `skills.json` | Public | Machine-readable capabilities, input modes, output modes, and discovery metadata. |
57
-
58
- `SOUL.md` and `MEMORY.md` are encrypted before they are pinned to IPFS. They are not published as plaintext in token metadata.
59
-
60
- `skills.json` is public by design. It uses schema `ethagent.public-skills.v1` and is meant to be easy for other agents, apps, and scanners to parse.
61
-
62
- ## Recovery
28
+ | `skills.json` | Public | Machine-readable capabilities under schema `ethagent.public-skills.v1`, easy for other agents and apps to parse. |
63
29
 
64
- **Publish Snapshot Now** encrypts the current private continuity, pins the public discovery files, writes current registration metadata, and updates the ERC-8004 tokenURI.
30
+ `SOUL.md` and `MEMORY.md` are encrypted before they reach IPFS. They are never published as plaintext in token metadata.
65
31
 
66
- **Refetch Latest Snapshot** reads the current tokenURI from chain, downloads the encrypted continuity envelope, asks the owner wallet to sign the decrypt challenge, and restores local continuity files from the published state.
67
-
68
- Publishing replaces the current onchain pointer. Registration metadata contains the current CIDs only.
69
-
70
- ## Public Discovery
71
-
72
- The public registration metadata is intentionally compact. It describes the current state of the agent, not its history.
73
-
74
- It can include:
75
-
76
- * Agent name, description, and image.
77
- * Current encrypted continuity CID.
78
- * Current public `skills.json` CID.
79
- * Current Agent Card CID.
80
- * Service entries with canonical `endpoint` values.
81
- * Registry linkage through `registrations[]`.
82
-
83
- This is enough for baseline agent-to-agent discovery and delegation without exposing private memory or installing executable code.
32
+ **Save Snapshot Now** encrypts the current private continuity, pins the public discovery files, writes registration metadata, and updates the ERC-8004 tokenURI. **Refetch Latest Snapshot** reads the current tokenURI from chain, asks the owner wallet to sign the decrypt challenge, and restores local files from the published state. The current tokenURI is the source of truth.
84
33
 
85
34
  ## Models
86
35
 
87
36
  ethagent works with OpenAI, Anthropic, Gemini, and local GGUF models served through a llama.cpp-compatible endpoint.
88
37
 
89
- The model picker can discover provider models, manage cloud API keys, recommend GGUF files for the machine, and start or reconnect to a local runner when supported.
38
+ The model picker discovers provider models, manages API keys, recommends GGUF files for your machine, and starts or attaches to a local runner. The featured local model is [Qwen3.5-9B-Uncensored](https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive); other Hugging Face GGUF models work by repo ID or URL.
90
39
 
91
- The featured local model is [Qwen3.5-9B-Uncensored](https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive). You can also add other Hugging Face GGUF models by repo ID or URL.
40
+ Cloud API keys live in the OS keyring when available, with an encrypted local file under `~/.ethagent` as fallback.
92
41
 
93
- Cloud API keys are stored in the OS keyring when available. If a keyring is unavailable, ethagent uses an encrypted local file under `~/.ethagent`.
42
+ ## Tools & Sessions
94
43
 
95
- ## Tools And Workspace
44
+ File ops, shell, clipboard, and MCP tools, all permissioned. Managed edits support `/rewind`. Sessions support `/resume`, `/compact`, and `/export`.
96
45
 
97
- ethagent is built for real project work. It can read files, edit files, write new files, delete files, inspect directories, run shell commands, copy text to the clipboard, and connect MCP tools.
98
-
99
- Tool use is permissioned. Managed edits are tracked so recent workspace changes can be rewound from inside the agent.
100
-
101
- Sessions are local. You can resume prior sessions, export a transcript, compact older context, and review saved project permissions.
46
+ `Shift+Tab` cycles Plan, Accept-Edits, and Chat modes. `Alt+T` toggles reasoning display.
102
47
 
103
48
  ## Privacy
104
49
 
105
50
  Public information includes token ownership, tokenURI metadata, public discovery files, and IPFS CIDs.
106
51
 
107
- Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for decrypting continuity.
52
+ Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for decryption.
108
53
 
109
54
  Continuity snapshots use an EIP-191 wallet signature as unlock material and encrypt with ML-KEM-1024, HKDF-SHA256, and AES-256-GCM. The unlock signature does not submit a transaction, spend funds, or grant token approval.
110
55
 
111
- If an ERC-8004 token is transferred, the new holder can see public metadata and encrypted backup CIDs. They cannot decrypt private continuity that was encrypted for the previous owner wallet.
112
-
113
- ## Local Reset
56
+ If an ERC-8004 token is transferred, the new holder can see public metadata and encrypted backup CIDs. They cannot decrypt private continuity that was sealed for the previous owner wallet.
114
57
 
115
- `ethagent reset` deletes local ethagent data from this machine while preserving installed local model assets. It does not burn or transfer ERC-8004 tokens, remove public IPFS content, or mutate onchain metadata.
116
-
117
- Before resetting, use **Publish Snapshot Now** if local continuity changes should become the current recoverable state.
58
+ `ethagent reset` deletes local ethagent data from this machine while preserving installed local model assets. It does not burn or transfer ERC-8004 tokens, remove public IPFS content, or mutate onchain metadata. Run **Save Snapshot Now** before resetting if local changes should become the recoverable state.
118
59
 
119
60
  ## Architecture
120
61
 
@@ -129,10 +70,6 @@ Before resetting, use **Publish Snapshot Now** if local continuity changes shoul
129
70
 
130
71
  The ERC-8004 token is the durable handle. The machine, model, and local session can change around it.
131
72
 
132
- ## Links
133
-
134
73
  [npm](https://www.npmjs.com/package/ethagent) · [GitHub](https://github.com/baairon/ethagent) · [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) · [soul.md](https://soul.md/)
135
74
 
136
- ## License
137
-
138
- MIT
75
+ MIT License.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "1.0.9",
3
+ "version": "1.1.1",
4
4
  "description": "A privacy-first AI agent with a portable Ethereum identity",
5
5
  "type": "module",
6
6
  "main": "bin/ethagent.js",
@@ -47,7 +47,6 @@
47
47
  "ink": "^6.8.0",
48
48
  "react": "^19.2.4",
49
49
  "tsx": "^4.21.0",
50
- "update-notifier": "^7.3.1",
51
50
  "viem": "^2.48.4",
52
51
  "zod": "^3.25.76"
53
52
  },
@@ -79,6 +79,7 @@ import { EMPTY_MCP_SNAPSHOT, McpManager, type McpSnapshot } from '../mcp/manager
79
79
  type ChatScreenProps = {
80
80
  config: EthagentConfig
81
81
  onReplaceConfig?: (next: EthagentConfig) => void
82
+ updateNotice?: string | null
82
83
  }
83
84
 
84
85
  type PendingPlan = {
@@ -122,7 +123,7 @@ async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: t
122
123
  return { ok: true }
123
124
  }
124
125
 
125
- export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig }) => {
126
+ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig, updateNotice }) => {
126
127
  useRegisterKeybindingContext('Chat')
127
128
  const { exit } = useApp()
128
129
  const [config, setConfig] = useState<EthagentConfig>(initialConfig)
@@ -1109,7 +1110,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1109
1110
  }
1110
1111
  overlayRef.current = 'none'
1111
1112
  setOverlay('none')
1112
- pushNote('snapshot not published yet.', 'dim')
1113
+ pushNote('snapshot not saved yet.', 'dim')
1113
1114
  },
1114
1115
  [continuityEditReview, pushNote],
1115
1116
  )
@@ -1118,7 +1119,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1118
1119
  setContinuityEditReview(null)
1119
1120
  overlayRef.current = 'none'
1120
1121
  setOverlay('none')
1121
- pushNote('snapshot not published yet.', 'dim')
1122
+ pushNote('snapshot not saved yet.', 'dim')
1122
1123
  }, [pushNote])
1123
1124
 
1124
1125
  const handleCopyDone = useCallback(
@@ -1402,7 +1403,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1402
1403
  </Text>
1403
1404
  </Box>
1404
1405
  )
1405
- const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} />
1406
+ const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} updateNotice={updateNotice ?? null} />
1406
1407
  return (
1407
1408
  <ConversationStack
1408
1409
  header={header}
@@ -19,7 +19,7 @@ export const ContinuityEditReviewView: React.FC<{
19
19
  }> = ({ review, onSelect, onCancel }) => (
20
20
  <Surface
21
21
  title="Private Continuity Updated"
22
- subtitle="Review the file, then publish an encrypted snapshot."
22
+ subtitle="Review the file, then save an encrypted snapshot."
23
23
  footer="enter select · esc later"
24
24
  >
25
25
  <Box flexDirection="column">
@@ -37,8 +37,8 @@ export const ContinuityEditReviewView: React.FC<{
37
37
  <Select<ContinuityEditReviewAction>
38
38
  options={[
39
39
  { value: 'open', label: `open ${review.file}`, hint: 'review the edited private file now' },
40
- { value: 'save-publish', label: 'publish snapshot now', hint: 'go directly to wallet approval' },
41
- { value: 'later', label: 'later', hint: 'keep the local draft unpublished' },
40
+ { value: 'save-publish', label: 'save snapshot now', hint: 'go directly to wallet approval' },
41
+ { value: 'later', label: 'later', hint: 'keep the local draft unsaved' },
42
42
  ]}
43
43
  onSubmit={onSelect}
44
44
  onCancel={onCancel}
@@ -665,7 +665,7 @@ export async function buildIdentityContinuityContextMessages(
665
665
  content: [
666
666
  '<identity_continuity_files>',
667
667
  `Automatic identity continuity load failed: ${(err as Error).message}`,
668
- 'If the user asks about continuity, surface this failure and route them to the identity hub.',
668
+ 'If the user asks about continuity, surface this failure and route them to the Identity Hub.',
669
669
  '</identity_continuity_files>',
670
670
  ].join('\n'),
671
671
  }]
@@ -154,7 +154,7 @@ const COMMANDS: CommandSpec[] = [
154
154
  if (installed.length === 0) {
155
155
  return {
156
156
  kind: 'note',
157
- text: 'no local model files downloaded. open alt+p and choose "add local model file".',
157
+ text: 'No local model files downloaded. Open Alt+P and choose "Add Local Model File".',
158
158
  }
159
159
  }
160
160
  const lines = installed.map(m => {
@@ -190,7 +190,7 @@ const COMMANDS: CommandSpec[] = [
190
190
  return {
191
191
  kind: 'note',
192
192
  variant: 'error',
193
- text: `'${name}' is not downloaded. open alt+p and choose "view full catalog" or "add local model file".`,
193
+ text: `'${name}' is not downloaded. Open Alt+P and choose "View Full Catalog" or "Add Local Model File".`,
194
194
  }
195
195
  }
196
196
  } else {
@@ -364,7 +364,7 @@ async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashRes
364
364
  return {
365
365
  kind: 'note',
366
366
  variant: 'dim',
367
- text: 'no local model files downloaded. press alt+p and choose "add local model file".',
367
+ text: 'No local model files downloaded. Press Alt+P and choose "Add Local Model File".',
368
368
  }
369
369
  }
370
370
  const lines = installed.map(model => {
@@ -382,8 +382,8 @@ async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashRes
382
382
  kind: 'note',
383
383
  variant: 'dim',
384
384
  text: link
385
- ? `alt+p opened. choose "add local model file" and paste: ${link}`
386
- : 'alt+p opened. choose "add local model file" and paste the model URL or repo id.',
385
+ ? `Alt+P opened. Choose "Add Local Model File" and paste: ${link}`
386
+ : 'Alt+P opened. Choose "Add Local Model File" and paste the model URL or repo ID.',
387
387
  }
388
388
  }
389
389
 
@@ -16,27 +16,26 @@ export const ResetConfirmView: React.FC<{
16
16
  }
17
17
 
18
18
  return (
19
- <Surface title="reset ethagent?" subtitle="are you sure? this only affects this machine." footer="enter select · esc cancel">
19
+ <Surface title="Reset Local Data?" subtitle="Deletes this machine's ethagent data. Models and onchain records stay." footer="enter select · esc cancel">
20
20
  <Box flexDirection="column">
21
- <Section title="will delete" lines={[
21
+ <Section title="Deletes" lines={[
22
+ 'Identity files, sessions, history, credentials',
22
23
  localDataLine(plan.deletePaths.length),
23
- 'identity metadata, markdown vaults, sessions, prompt history',
24
- 'rewind history, permissions, credentials',
25
24
  ]} />
26
- <Section title="will keep" lines={[
27
- 'installed local LLM assets',
28
- ...(plan.preservedPaths.length > 0 ? [`${plan.preservedPaths.length} local model path${plan.preservedPaths.length === 1 ? '' : 's'}`] : ['no local model assets found']),
25
+ <Section title="Keeps" lines={[
26
+ 'Local GGUF models and llama.cpp runners',
27
+ ...(plan.preservedPaths.length > 0 ? [`${plan.preservedPaths.length} local model path${plan.preservedPaths.length === 1 ? '' : 's'}`] : ['No local model assets found']),
29
28
  ]} />
30
- <Section title="not touched" lines={[
31
- 'onchain agent tokens',
32
- 'IPFS-pinned snapshots and public metadata',
29
+ <Section title="Not Touched" lines={[
30
+ 'ERC-8004 tokens and onchain records',
31
+ 'IPFS snapshots and public metadata',
33
32
  ]} />
34
33
  </Box>
35
34
  <Box marginTop={1}>
36
35
  <Select<'confirm' | 'cancel'>
37
36
  options={[
38
- { value: 'confirm', label: 'reset local data', hint: 'delete local ethagent data now' },
39
- { value: 'cancel', label: 'cancel', hint: 'leave local data unchanged' },
37
+ { value: 'confirm', label: 'Reset Local Data', hint: 'Delete local ethagent data now' },
38
+ { value: 'cancel', label: 'Cancel', hint: 'Leave local data unchanged' },
40
39
  ]}
41
40
  onSubmit={choice => finish(choice === 'confirm')}
42
41
  onCancel={() => finish(false)}
@@ -56,6 +55,6 @@ const Section: React.FC<{ title: string; lines: string[] }> = ({ title, lines })
56
55
  )
57
56
 
58
57
  function localDataLine(count: number): string {
59
- if (count === 0) return 'no local ethagent data found'
58
+ if (count === 0) return 'No local ethagent data found'
60
59
  return `${count} local path${count === 1 ? '' : 's'} under ~/.ethagent`
61
60
  }
package/src/cli/main.tsx CHANGED
@@ -12,29 +12,10 @@ import { AppInputProvider, useAppInput } from '../app/input/AppInputProvider.js'
12
12
  import { loadConfig, type EthagentConfig } from '../storage/config.js'
13
13
  import { runResetCommand } from './reset.js'
14
14
  import { runPreviewCommand } from './preview.js'
15
+ import { checkForUpdates } from './updateNotice.js'
16
+ import { Spinner } from '../ui/Spinner.js'
15
17
 
16
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
17
- const pkgPath = path.resolve(__dirname, '..', '..', 'package.json')
18
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
19
-
20
- async function checkForUpdates() {
21
- try {
22
- const res = await fetch('https://registry.npmjs.org/ethagent/latest', { signal: AbortSignal.timeout(1200) })
23
- const { version: latest } = await res.json() as { version: string }
24
- if (latest && latest !== pkg.version) {
25
- const line1 = ` Update available: ${theme.accentPrimary}${pkg.version}${theme.text} -> ${theme.accentMint}${latest}${theme.text} `
26
- const line2 = ` Run ${theme.accentPeach}npm install -g ethagent${theme.text} to update `
27
- const border = theme.dim + '─'.repeat(Math.max(line1.length - 20, line2.length - 20) + 10) + theme.text
28
-
29
- process.stdout.write(`\n${theme.dim}┌${border}┐${theme.text}\n`)
30
- process.stdout.write(`${theme.dim}│${theme.text} ${line1} ${theme.dim}│${theme.text}\n`)
31
- process.stdout.write(`${theme.dim}│${theme.text} ${line2} ${theme.dim}│${theme.text}\n`)
32
- process.stdout.write(`${theme.dim}└${border}┘${theme.text}\n\n`)
33
- }
34
- } catch {
35
- // Silent fail for offline/timeout
36
- }
37
- }
38
19
 
39
20
  function readVersion(): string {
40
21
  try {
@@ -70,17 +51,24 @@ type AppPhase =
70
51
  | { kind: 'cancelled' }
71
52
  | { kind: 'error'; message: string }
72
53
 
73
- const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCode }) => {
54
+ const MIN_STARTUP_SPINNER_MS = 480
55
+
56
+ function delay(ms: number): Promise<void> {
57
+ return new Promise(resolve => setTimeout(resolve, ms))
58
+ }
59
+
60
+ const AppRoot: React.FC<{ setExitCode: (code: number) => void; currentVersion: string }> = ({ setExitCode, currentVersion }) => {
74
61
  const [phase, setPhase] = useState<AppPhase>({ kind: 'loading' })
62
+ const [updateNotice, setUpdateNotice] = useState<string | null>(null)
75
63
  const { exit } = useApp()
76
64
 
77
65
  useEffect(() => {
78
66
  if (phase.kind !== 'loading') return
79
67
  let cancelled = false
80
- loadConfig()
68
+ Promise.all([loadConfig(), delay(MIN_STARTUP_SPINNER_MS)])
81
69
  .then(config => {
82
70
  if (cancelled) return
83
- setPhase(config ? { kind: 'ready', config } : { kind: 'setup' })
71
+ setPhase(config[0] ? { kind: 'ready', config: config[0] } : { kind: 'setup' })
84
72
  })
85
73
  .catch((err: unknown) => {
86
74
  if (cancelled) return
@@ -89,6 +77,16 @@ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCod
89
77
  return () => { cancelled = true }
90
78
  }, [phase])
91
79
 
80
+ useEffect(() => {
81
+ let cancelled = false
82
+ void checkForUpdates(currentVersion)
83
+ .then(notice => {
84
+ if (!cancelled) setUpdateNotice(notice)
85
+ })
86
+ .catch(() => {})
87
+ return () => { cancelled = true }
88
+ }, [currentVersion])
89
+
92
90
  useEffect(() => {
93
91
  if (phase.kind === 'cancelled') {
94
92
  setExitCode(1)
@@ -111,7 +109,7 @@ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCod
111
109
  if (phase.kind === 'loading') {
112
110
  return (
113
111
  <Box padding={1}>
114
- <Text color={theme.dim}>Preparing session...</Text>
112
+ <Spinner label="Starting ethagent..." showElapsed={false} />
115
113
  </Box>
116
114
  )
117
115
  }
@@ -141,16 +139,17 @@ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCod
141
139
  <ChatScreen
142
140
  config={phase.config}
143
141
  onReplaceConfig={next => setPhase({ kind: 'ready', config: next })}
142
+ updateNotice={updateNotice}
144
143
  />
145
144
  )
146
145
  }
147
146
 
148
- async function runDefault(): Promise<number> {
147
+ async function runDefault(currentVersion: string): Promise<number> {
149
148
  let exitCode = 0
150
149
  const instance = render(
151
150
  <AppInputProvider>
152
151
  <KeybindingProvider>
153
- <AppRoot setExitCode={code => { exitCode = code }} />
152
+ <AppRoot setExitCode={code => { exitCode = code }} currentVersion={currentVersion} />
154
153
  </KeybindingProvider>
155
154
  </AppInputProvider>,
156
155
  {
@@ -169,9 +168,7 @@ async function main(): Promise<number> {
169
168
  const argv = process.argv.slice(2)
170
169
  const [cmd, ...rest] = argv
171
170
 
172
- await checkForUpdates()
173
-
174
- if (!cmd) return runDefault()
171
+ if (!cmd) return runDefault(readVersion())
175
172
  if (cmd === '--version' || cmd === '-v') {
176
173
  process.stdout.write(`ethagent ${readVersion()}\n`)
177
174
  return 0
package/src/cli/reset.ts CHANGED
@@ -37,7 +37,7 @@ export async function runResetCommand(args: string[] = [], io: ResetCommandIO =
37
37
  ? await readTextConfirmation(plan, io)
38
38
  : await readInkConfirmation(plan, io)
39
39
  if (!confirmed) {
40
- write('factory reset cancelled.\n')
40
+ write('Reset Cancelled.\n')
41
41
  return 1
42
42
  }
43
43
  } else {
@@ -46,12 +46,12 @@ export async function runResetCommand(args: string[] = [], io: ResetCommandIO =
46
46
 
47
47
  const result = await runFactoryReset({ clearSecrets: io.clearSecrets })
48
48
  write([
49
- 'factory reset complete.',
50
- `deleted ${result.deletedPaths.length} local path${result.deletedPaths.length === 1 ? '' : 's'}.`,
51
- `cleared ${result.clearedSecretAccounts.length} known secret account${result.clearedSecretAccounts.length === 1 ? '' : 's'}.`,
49
+ 'Reset Complete.',
50
+ `Deleted ${result.deletedPaths.length} local path${result.deletedPaths.length === 1 ? '' : 's'}.`,
51
+ `Cleared ${result.clearedSecretAccounts.length} secret account${result.clearedSecretAccounts.length === 1 ? '' : 's'}.`,
52
52
  result.preservedPaths.length > 0
53
- ? `preserved local LLM assets: ${result.preservedPaths.length} path${result.preservedPaths.length === 1 ? '' : 's'}.`
54
- : 'no local model assets were present.',
53
+ ? `Kept ${result.preservedPaths.length} local model path${result.preservedPaths.length === 1 ? '' : 's'}.`
54
+ : 'Kept no local model assets.',
55
55
  '',
56
56
  ].join('\n'))
57
57
  return 0
@@ -90,7 +90,6 @@ async function readInkConfirmation(plan: FactoryResetPlan, io: ResetCommandIO):
90
90
 
91
91
  async function readConfirmation(io: ResetCommandIO): Promise<string> {
92
92
  if (io.readConfirmation) {
93
- ;(io.write ?? (text => { processStdout.write(text) }))('type confirm to wipe local ethagent data: ')
94
93
  return io.readConfirmation()
95
94
  }
96
95
 
@@ -99,7 +98,7 @@ async function readConfirmation(io: ResetCommandIO): Promise<string> {
99
98
  output: io.output ?? processStdout,
100
99
  })
101
100
  try {
102
- return await rl.question('type confirm to wipe local ethagent data: ')
101
+ return await rl.question('Confirm reset: ')
103
102
  } finally {
104
103
  rl.close()
105
104
  }
@@ -0,0 +1,52 @@
1
+ type RegistryResponse = {
2
+ ok?: boolean
3
+ json: () => Promise<unknown>
4
+ }
5
+
6
+ type RegistryFetch = (url: string, init: { signal: AbortSignal }) => Promise<RegistryResponse>
7
+
8
+ const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/ethagent/latest'
9
+
10
+ export function compareVersions(left: string, right: string): number {
11
+ const a = parseVersion(left)
12
+ const b = parseVersion(right)
13
+ if (!a || !b) return 0
14
+ for (let i = 0; i < 3; i++) {
15
+ if (a[i] > b[i]) return 1
16
+ if (a[i] < b[i]) return -1
17
+ }
18
+ return 0
19
+ }
20
+
21
+ export function isNewerVersion(candidate: string, current: string): boolean {
22
+ return compareVersions(candidate, current) > 0
23
+ }
24
+
25
+ export function formatUpdateNotice(currentVersion: string, latestVersion: string): string | null {
26
+ if (!isNewerVersion(latestVersion, currentVersion)) return null
27
+ return `update available: ethagent ${currentVersion} -> ${latestVersion}; run npm i -g ethagent`
28
+ }
29
+
30
+ export async function checkForUpdates(
31
+ currentVersion: string,
32
+ options: { fetchImpl?: RegistryFetch; timeoutMs?: number } = {},
33
+ ): Promise<string | null> {
34
+ const fetchImpl = options.fetchImpl ?? fetch
35
+ const timeoutMs = options.timeoutMs ?? 1200
36
+ try {
37
+ const res = await fetchImpl(REGISTRY_LATEST_URL, { signal: AbortSignal.timeout(timeoutMs) })
38
+ if (res.ok === false) return null
39
+ const body = await res.json()
40
+ if (!body || typeof body !== 'object') return null
41
+ const latest = (body as { version?: unknown }).version
42
+ return typeof latest === 'string' ? formatUpdateNotice(currentVersion, latest) : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ function parseVersion(value: string): [number, number, number] | null {
49
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim())
50
+ if (!match) return null
51
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
52
+ }
@@ -79,15 +79,21 @@ export class ContinuitySnapshotOwnerMismatchError extends Error {
79
79
  }
80
80
  }
81
81
 
82
+ export const CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES = [
83
+ 'ethagent: save or restore identity files',
84
+ 'Action: encrypt or decrypt local identity files',
85
+ 'Private: SOUL.md, MEMORY.md',
86
+ 'Public: skills.json, public profile',
87
+ 'Safety: no transaction, spending, or approvals',
88
+ 'Version: 1',
89
+ ] as const
90
+
82
91
  export function createContinuitySnapshotChallenge(ownerAddress: string): string {
83
92
  const checksum = toChecksumAddress(ownerAddress)
84
93
  return [
85
- 'ethagent private continuity',
94
+ CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES[0],
86
95
  `Owner: ${checksum}`,
87
- 'Purpose: unlock the encrypted SOUL.md and MEMORY.md snapshot for this device',
88
- 'Scope: read and restore private agent continuity only',
89
- 'Safety: this signature does not send a transaction, spend funds, or grant token approval',
90
- 'Version: 1',
96
+ ...CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES.slice(1),
91
97
  ].join('\n')
92
98
  }
93
99
 
@@ -287,7 +287,7 @@ export function defaultContinuityFiles(identity: EthagentIdentity, now = new Dat
287
287
  '## Private Instructions',
288
288
  '',
289
289
  '- Keep owner-specific standing instructions in this file.',
290
- '- Do not publish this file directly; use encrypted snapshot backup from Identity Hub.',
290
+ '- Do not share this file directly; save it via the Identity Hub encrypted snapshot.',
291
291
  '- Public capabilities belong in skills.json.',
292
292
  '',
293
293
  '## Boundaries',
@@ -196,7 +196,8 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
196
196
 
197
197
  useEffect(() => {
198
198
  let cancelled = false
199
- if (!identity || step.kind !== 'menu') return
199
+ if (!identity) return
200
+ if (step.kind !== 'menu' && step.kind !== 'continuity-private' && step.kind !== 'continuity-public') return
200
201
 
201
202
  const checkStatus = async () => {
202
203
  try {
@@ -591,7 +592,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
591
592
  return (
592
593
  <WalletApprovalScreen
593
594
  title="Refetch Latest Snapshot"
594
- subtitle="Wallet approval decrypts the latest published snapshot and overwrites local SOUL.md, MEMORY.md, and skills.json."
595
+ subtitle="Wallet approval decrypts the latest saved snapshot and overwrites local SOUL.md, MEMORY.md, and skills.json."
595
596
  walletSession={walletSession}
596
597
  label={restoreProgress?.label ?? 'fetching latest snapshot from chain...'}
597
598
  onCancel={() => setStep(step.back)}
@@ -604,13 +605,12 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
604
605
  <PrivateContinuityScreen
605
606
  identity={identity}
606
607
  config={config}
608
+ workingStatus={workingStatus}
607
609
  ready={continuityReady}
608
610
  notice={step.notice}
609
- canBackup={canRebackup}
610
611
  footer={footer}
611
612
  onOpenSoul={() => { void openContinuityFile('soul') }}
612
613
  onOpenMemory={() => { void openContinuityFile('memory') }}
613
- onBackup={() => setStep({ kind: 'rebackup-confirm', back: { kind: 'continuity-private' } })}
614
614
  onBack={back}
615
615
  />
616
616
  )
@@ -621,13 +621,12 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
621
621
  <PublicSkillsScreen
622
622
  identity={identity}
623
623
  config={config}
624
+ workingStatus={workingStatus}
624
625
  ready={continuityReady}
625
626
  notice={step.notice}
626
- canPublish={canRebackup}
627
627
  footer={footer}
628
628
  onEditProfile={() => openPublicProfileEdit({ kind: 'continuity-public' })}
629
629
  onOpenSkills={() => { void openContinuityFile('skills') }}
630
- onPublish={() => setStep({ kind: 'rebackup-confirm', back: { kind: 'continuity-public' } })}
631
630
  onBack={back}
632
631
  />
633
632
  )
@@ -784,7 +783,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
784
783
 
785
784
  async function readPublishedPublicSkills(identity: EthagentIdentity): Promise<string> {
786
785
  const cid = identity.publicSkills?.cid
787
- if (!cid) throw new Error('no published public skills CID')
786
+ if (!cid) throw new Error('no saved public skills CID')
788
787
  return new TextDecoder().decode(await catFromIpfs(
789
788
  identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL,
790
789
  cid,