ethagent 1.1.0 → 1.1.2

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.
package/README.md CHANGED
@@ -3,118 +3,61 @@
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 identity setup, then model 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.
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.
67
33
 
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.
34
+ Agents can also be looked up by token ID on [8004scan](https://8004scan.io/).
84
35
 
85
36
  ## Models
86
37
 
87
38
  ethagent works with OpenAI, Anthropic, Gemini, and local GGUF models served through a llama.cpp-compatible endpoint.
88
39
 
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.
90
-
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
+ 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.
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
+ Cloud API keys live in the OS keyring when available, with an encrypted local file under `~/.ethagent` as fallback.
94
43
 
95
- ## Tools And Workspace
44
+ ## Tools & Sessions
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.
46
+ File ops, shell, clipboard, and MCP tools, all permissioned. Managed edits support `/rewind`. Sessions support `/resume`, `/compact`, and `/export`.
98
47
 
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.
48
+ `Shift+Tab` cycles Plan, Accept-Edits, and Chat modes. `Alt+T` toggles reasoning display.
102
49
 
103
50
  ## Privacy
104
51
 
105
52
  Public information includes token ownership, tokenURI metadata, public discovery files, and IPFS CIDs.
106
53
 
107
- Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for decrypting continuity.
54
+ Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for decryption.
108
55
 
109
56
  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
57
 
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
114
-
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.
58
+ 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.
116
59
 
117
- Before resetting, use **Publish Snapshot Now** if local continuity changes should become the current recoverable state.
60
+ `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
61
 
119
62
  ## Architecture
120
63
 
@@ -129,10 +72,6 @@ Before resetting, use **Publish Snapshot Now** if local continuity changes shoul
129
72
 
130
73
  The ERC-8004 token is the durable handle. The machine, model, and local session can change around it.
131
74
 
132
- ## Links
133
-
134
75
  [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
76
 
136
- ## License
137
-
138
- MIT
77
+ MIT License.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A privacy-first AI agent with a portable Ethereum identity",
5
5
  "type": "module",
6
6
  "main": "bin/ethagent.js",
@@ -23,14 +23,16 @@
23
23
  },
24
24
  "keywords": [
25
25
  "ethereum",
26
- "agent",
27
- "ai",
28
- "privacy-first",
26
+ "erc-8004",
27
+ "ai-agent",
28
+ "cli",
29
+ "privacy",
29
30
  "ipfs",
30
- "ERC-8004",
31
- "onchain",
32
- "offline",
33
- "ens"
31
+ "wallet",
32
+ "llm",
33
+ "gguf",
34
+ "mcp",
35
+ "identity"
34
36
  ],
35
37
  "repository": {
36
38
  "type": "git",
@@ -289,6 +289,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
289
289
  <TextInput
290
290
  isSecret
291
291
  placeholder={provider === 'openai' ? 'sk-...' : 'paste key and press enter'}
292
+ chromeWidth={4}
292
293
  validate={v => v.trim().length >= 8 ? null : 'key looks too short'}
293
294
  onSubmit={async value => {
294
295
  const trimmed = value.trim()
@@ -326,6 +327,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
326
327
  <TextInput
327
328
  initialValue={defaultModel}
328
329
  placeholder={defaultModel}
330
+ chromeWidth={4}
329
331
  onSubmit={model => goTo({
330
332
  kind: 'saving',
331
333
  config: withFirstRunIdentity({
@@ -143,6 +143,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
143
143
  const [mode, setMode] = useState<SessionMode>('chat')
144
144
  const [pendingPlan, setPendingPlan] = useState<PendingPlan | null>(null)
145
145
  const [compactionUi, setCompactionUi] = useState<CompactionUiState | null>(null)
146
+ const [canScrollTranscript, setCanScrollTranscript] = useState(false)
146
147
  const [sessionId, setSessionId] = useState<string>(() => newSessionId())
147
148
  const [sessionKey, setSessionKey] = useState<number>(0)
148
149
  const [cwd, setCwd] = useState<string>(() => syncCwdFromProcess())
@@ -1094,6 +1095,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1094
1095
  : `open failed: ${result.error}`,
1095
1096
  result.ok ? 'dim' : 'error',
1096
1097
  )
1098
+ if (result.ok) setContinuityEditReview(prev => prev ? { ...prev, editorOpened: true } : null)
1097
1099
  return
1098
1100
  }
1099
1101
  setContinuityEditReview(null)
@@ -1398,9 +1400,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1398
1400
  <Text color={theme.dim}> · </Text>
1399
1401
  </>
1400
1402
  )}
1401
- <Text color={theme.dim}>
1402
- {'pgup/pgdn scroll · alt+p model · alt+i identity'}
1403
- </Text>
1403
+ <Text color={theme.dim}>{chatFooterShortcutText(canScrollTranscript)}</Text>
1404
1404
  </Box>
1405
1405
  )
1406
1406
  const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} updateNotice={updateNotice ?? null} />
@@ -1466,10 +1466,15 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1466
1466
  )}
1467
1467
  sessionKey={sessionKey}
1468
1468
  onVisibleReasoningIdsChange={updateVisibleReasoningIds}
1469
+ onTranscriptScrollabilityChange={setCanScrollTranscript}
1469
1470
  />
1470
1471
  )
1471
1472
  }
1472
1473
 
1474
+ export function chatFooterShortcutText(canScrollTranscript: boolean): string {
1475
+ return `${canScrollTranscript ? 'pgup/pgdn scroll · ' : ''}alt+p model · alt+i identity`
1476
+ }
1477
+
1473
1478
  function formatContextLabel(usage: ContextUsage): string {
1474
1479
  if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
1475
1480
  return `Estimated context: ${usage.percent}% used`
@@ -8,6 +8,7 @@ export type ContinuityEditReviewState = {
8
8
  file: 'SOUL.md' | 'MEMORY.md'
9
9
  filePath: string
10
10
  summary: string
11
+ editorOpened?: boolean
11
12
  }
12
13
 
13
14
  export type ContinuityEditReviewAction = 'open' | 'save-publish' | 'later'
@@ -28,6 +29,11 @@ export const ContinuityEditReviewView: React.FC<{
28
29
  <Text color={theme.textSubtle}>review file</Text>
29
30
  <Text color={theme.text}>{review.filePath}</Text>
30
31
  </Box>
32
+ {review.editorOpened && (
33
+ <Box marginTop={1}>
34
+ <Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
35
+ </Box>
36
+ )}
31
37
  <Box marginTop={1} flexDirection="column">
32
38
  <Text color={theme.textSubtle}>saved locally</Text>
33
39
  <Text color={theme.dim}>Previous version saved in identity history. /rewind does not restore identity continuity.</Text>
@@ -12,6 +12,7 @@ type ConversationStackProps = {
12
12
  status?: React.ReactNode
13
13
  sessionKey: number
14
14
  onVisibleReasoningIdsChange?: (ids: string[]) => void
15
+ onTranscriptScrollabilityChange?: (canScroll: boolean) => void
15
16
  }
16
17
 
17
18
  export const ConversationStack: React.FC<ConversationStackProps> = ({
@@ -23,6 +24,7 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
23
24
  status,
24
25
  sessionKey,
25
26
  onVisibleReasoningIdsChange,
27
+ onTranscriptScrollabilityChange,
26
28
  }) => {
27
29
  return (
28
30
  <Box flexDirection="column" padding={1}>
@@ -33,6 +35,7 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
33
35
  active={transcriptActive}
34
36
  bottomVariant={bottomVariant}
35
37
  onVisibleReasoningIdsChange={onVisibleReasoningIdsChange}
38
+ onScrollabilityChange={onTranscriptScrollabilityChange}
36
39
  />
37
40
  <Box marginTop={1} width="100%">
38
41
  {bottom}
@@ -21,6 +21,7 @@ type TranscriptViewProps = {
21
21
  active?: boolean
22
22
  bottomVariant?: 'prompt' | 'overlay'
23
23
  onVisibleReasoningIdsChange?: (ids: string[]) => void
24
+ onScrollabilityChange?: (canScroll: boolean) => void
24
25
  }
25
26
 
26
27
  const PROMPT_RESERVED_LINES = 11
@@ -33,6 +34,7 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
33
34
  active = true,
34
35
  bottomVariant = 'prompt',
35
36
  onVisibleReasoningIdsChange,
37
+ onScrollabilityChange,
36
38
  }) => {
37
39
  const { stdout } = useStdout()
38
40
  const columns = stdout.columns ?? process.stdout.columns ?? 80
@@ -89,6 +91,10 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
89
91
  onVisibleReasoningIdsChange?.(visibleReasoningIds)
90
92
  }, [onVisibleReasoningIdsChange, visibleReasoningIds])
91
93
 
94
+ useEffect(() => {
95
+ onScrollabilityChange?.(metrics.maxScrollTop > 0)
96
+ }, [metrics.maxScrollTop, onScrollabilityChange])
97
+
92
98
  useAppInput((_input, key) => {
93
99
  if (key.pageUp) {
94
100
  const target = promptScrollTopForPageUp(
@@ -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
  }]
@@ -24,7 +24,7 @@ export function isNewerVersion(candidate: string, current: string): boolean {
24
24
 
25
25
  export function formatUpdateNotice(currentVersion: string, latestVersion: string): string | null {
26
26
  if (!isNewerVersion(latestVersion, currentVersion)) return null
27
- return `update available: ethagent ${currentVersion} -> ${latestVersion}; run npm i -g ethagent`
27
+ return `✨ update available: ethagent ${currentVersion} -> ${latestVersion} · run npm i -g ethagent@latest`
28
28
  }
29
29
 
30
30
  export async function checkForUpdates(
@@ -381,8 +381,8 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
381
381
  ? `opened ${kind === 'soul' ? 'SOUL.md' : kind === 'memory' ? 'MEMORY.md' : 'skills.json'} with ${result.method}.`
382
382
  : `open failed: ${result.error}`
383
383
  setStep(kind === 'skills'
384
- ? { kind: 'continuity-public', notice: message }
385
- : { kind: 'continuity-private', notice: message })
384
+ ? { kind: 'continuity-public', notice: message, editorOpened: result.ok }
385
+ : { kind: 'continuity-private', notice: message, editorOpened: result.ok })
386
386
  } catch (err: unknown) {
387
387
  errorStep(err, kind === 'skills' ? { kind: 'continuity-public' } : { kind: 'continuity-private' })
388
388
  }
@@ -609,6 +609,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
609
609
  ready={continuityReady}
610
610
  notice={step.notice}
611
611
  footer={footer}
612
+ editorOpened={step.editorOpened}
612
613
  onOpenSoul={() => { void openContinuityFile('soul') }}
613
614
  onOpenMemory={() => { void openContinuityFile('memory') }}
614
615
  onBack={back}
@@ -625,6 +626,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
625
626
  ready={continuityReady}
626
627
  notice={step.notice}
627
628
  footer={footer}
629
+ editorOpened={step.editorOpened}
628
630
  onEditProfile={() => openPublicProfileEdit({ kind: 'continuity-public' })}
629
631
  onOpenSkills={() => { void openContinuityFile('skills') }}
630
632
  onBack={back}
@@ -6,6 +6,7 @@ import {
6
6
  } from '../registry/erc8004.js'
7
7
  import { AgentStateOwnerMismatchError } from '../crypto/backupEnvelope.js'
8
8
  import { ContinuitySnapshotOwnerMismatchError } from '../continuity/envelope.js'
9
+ import type { ContinuityWorkingTreeStatus } from '../continuity/storage.js'
9
10
  import { resolveSelectedNetwork } from '../registry/registryConfig.js'
10
11
 
11
12
  export const PREFLIGHT_AGENT_URI = 'ipfs://bafybeigdyrztma2dbfczw7q6ooozbxlqzyw5r7w4f3qw2axvvxqg3w6y7q'
@@ -214,6 +215,85 @@ export function identitySummaryRows(
214
215
  ]
215
216
  }
216
217
 
218
+ export type LocalChangeStatusView = {
219
+ label: string
220
+ detail: string
221
+ tone: 'ok' | 'warn' | 'dim'
222
+ files: string[]
223
+ hasLocalChanges: boolean
224
+ }
225
+
226
+ export function changedContinuitySnapshotFiles(
227
+ workingStatus?: ContinuityWorkingTreeStatus | null,
228
+ ): string[] {
229
+ if (!workingStatus?.localContentHashes || !workingStatus.publishedContentHashes) return []
230
+ const files: Array<keyof typeof workingStatus.localContentHashes> = ['SOUL.md', 'MEMORY.md', 'skills.json']
231
+ return files.filter(file => workingStatus.localContentHashes?.[file] !== workingStatus.publishedContentHashes?.[file])
232
+ }
233
+
234
+ export function localChangeStatusView(
235
+ workingStatus?: ContinuityWorkingTreeStatus | null,
236
+ ): LocalChangeStatusView {
237
+ if (!workingStatus) {
238
+ return {
239
+ label: 'Local Changes',
240
+ detail: 'Checking status...',
241
+ tone: 'dim',
242
+ files: [],
243
+ hasLocalChanges: false,
244
+ }
245
+ }
246
+
247
+ if (workingStatus.publishState === 'published') {
248
+ return {
249
+ label: 'Local Changes',
250
+ detail: 'None detected',
251
+ tone: 'ok',
252
+ files: [],
253
+ hasLocalChanges: false,
254
+ }
255
+ }
256
+
257
+ if (workingStatus.publishState === 'local-changes') {
258
+ const files = changedContinuitySnapshotFiles(workingStatus)
259
+ return {
260
+ label: 'Local Changes',
261
+ detail: files.length > 0 ? `Detected: ${files.join(', ')}` : 'Detected: local files differ from saved snapshot',
262
+ tone: 'warn',
263
+ files,
264
+ hasLocalChanges: true,
265
+ }
266
+ }
267
+
268
+ if (workingStatus.publishState === 'not-published') {
269
+ return {
270
+ label: 'Local Changes',
271
+ detail: 'Snapshot not saved yet',
272
+ tone: 'warn',
273
+ files: [],
274
+ hasLocalChanges: false,
275
+ }
276
+ }
277
+
278
+ if (workingStatus.publishState === 'verify-needed') {
279
+ return {
280
+ label: 'Local Changes',
281
+ detail: 'Unable to verify saved snapshot',
282
+ tone: 'warn',
283
+ files: [],
284
+ hasLocalChanges: false,
285
+ }
286
+ }
287
+
288
+ return {
289
+ label: 'Local Changes',
290
+ detail: 'Local files not restored',
291
+ tone: 'warn',
292
+ files: [],
293
+ hasLocalChanges: false,
294
+ }
295
+ }
296
+
217
297
  export type IdentityDetailSection = {
218
298
  title: string
219
299
  rows: Array<{
@@ -31,8 +31,8 @@ export type Step =
31
31
  | { kind: 'restore-authorizing'; cid: string; apiUrl: string; envelope: RestorableBackupEnvelope; candidate: Erc8004AgentCandidate; purpose?: RestorePurpose }
32
32
  | { kind: 'rebackup-signing'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; pinataJwt?: string; profileUpdates?: ProfileUpdates; returnTo?: Step }
33
33
  | { kind: 'rebackup-storage'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; error?: string; pinataJwt?: string; profileUpdates?: ProfileUpdates; returnTo?: Step }
34
- | { kind: 'continuity-private'; notice?: string }
35
- | { kind: 'continuity-public'; notice?: string }
34
+ | { kind: 'continuity-private'; notice?: string; editorOpened?: boolean }
35
+ | { kind: 'continuity-public'; notice?: string; editorOpened?: boolean }
36
36
  | { kind: 'rebackup-confirm'; back: Step }
37
37
  | { kind: 'recovery-refetch-confirm'; back: Step }
38
38
  | { kind: 'recovery-refetching'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; back: Step }
@@ -17,6 +17,7 @@ type CommonProps = {
17
17
  workingStatus?: ContinuityWorkingTreeStatus | null
18
18
  ready: boolean
19
19
  notice?: string
20
+ editorOpened?: boolean
20
21
  footer: React.ReactNode
21
22
  onBack: () => void
22
23
  }
@@ -42,6 +43,7 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
42
43
  workingStatus,
43
44
  ready,
44
45
  notice,
46
+ editorOpened,
45
47
  footer,
46
48
  onOpenSoul,
47
49
  onOpenMemory,
@@ -51,6 +53,11 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
51
53
  <IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact />
52
54
  <PrivateRows identity={identity} ready={ready} />
53
55
  <SaveFromHubHint workingStatus={workingStatus} />
56
+ {editorOpened && (
57
+ <Box marginTop={1}>
58
+ <Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
59
+ </Box>
60
+ )}
54
61
  <Box marginTop={1}>
55
62
  <Select<PrivateAction>
56
63
  options={[
@@ -75,11 +82,16 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
75
82
  export const PublicSkillsScreen: React.FC<CommonProps & {
76
83
  onEditProfile: () => void
77
84
  onOpenSkills: () => void
78
- }> = ({ identity, config, workingStatus, notice, footer, onEditProfile, onOpenSkills, onBack }) => (
85
+ }> = ({ identity, config, workingStatus, notice, editorOpened, footer, onEditProfile, onOpenSkills, onBack }) => (
79
86
  <Surface title="Public Profile" subtitle={notice ?? 'Manage public metadata, skills.json, and the agent card.'} footer={footer}>
80
87
  <IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact />
81
88
  <PublicProfileRows identity={identity} />
82
89
  <SaveFromHubHint workingStatus={workingStatus} />
90
+ {editorOpened && (
91
+ <Box marginTop={1}>
92
+ <Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
93
+ </Box>
94
+ )}
83
95
  <Box marginTop={1}>
84
96
  <Select<PublicAction>
85
97
  options={[
@@ -135,7 +147,7 @@ const PublicProfileRows: React.FC<{ identity?: EthagentIdentity }> = ({ identity
135
147
  function privateSubtitle(ready: boolean): string {
136
148
  return ready
137
149
  ? 'SOUL.md and MEMORY.md are private local files on this machine.'
138
- : 'Use "Refetch Latest Snapshot" from the hub menu to recover files.'
150
+ : 'Use "Refetch Latest Snapshot" from the Identity Hub menu to recover files.'
139
151
  }
140
152
 
141
153
  function readStateString(state: Record<string, unknown> | undefined, key: string): string {
@@ -120,6 +120,7 @@ export const EditProfileFlow: React.FC<EditProfileFlowProps> = ({
120
120
  initialValue={currentDescription}
121
121
  placeholder="description"
122
122
  allowEmpty
123
+ multiline
123
124
  onSubmit={value => onDescriptionSubmit(value.trim())}
124
125
  onCancel={onBack}
125
126
  />
@@ -22,7 +22,7 @@ export const ErrorScreen: React.FC<ErrorScreenProps> = ({ error, back, footer, o
22
22
  { value: 'back', role: 'section', prefix: '--', label: 'Recovery' },
23
23
  { value: 'back', label: 'Go Back', hint: 'Return to the previous identity step' },
24
24
  { value: 'close', role: 'section', prefix: '--', label: 'Exit' },
25
- { value: 'close', label: 'Close Hub', hint: 'Return to chat without retrying', role: 'utility' },
25
+ { value: 'close', label: 'Close Identity Hub', hint: 'Return to chat without retrying', role: 'utility' },
26
26
  ]}
27
27
  hintLayout="inline"
28
28
  onSubmit={choice => {
@@ -2,10 +2,22 @@ import React from 'react'
2
2
  import { Box, Text } from 'ink'
3
3
  import { theme } from '../../../ui/theme.js'
4
4
  import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js'
5
- import { identitySummaryRows, lastBackupLabel } from '../identityHubModel.js'
5
+ import {
6
+ identitySummaryRows,
7
+ lastBackupLabel,
8
+ localChangeStatusView,
9
+ type LocalChangeStatusView,
10
+ } from '../identityHubModel.js'
6
11
 
7
12
  import type { ContinuityWorkingTreeStatus } from '../../continuity/storage.js'
8
13
 
14
+ type SummaryRow = {
15
+ label: string
16
+ value: string
17
+ tone: 'ok' | 'dim' | 'warn'
18
+ highlight?: boolean
19
+ }
20
+
9
21
  export const IdentitySummary: React.FC<{
10
22
  identity?: EthagentIdentity
11
23
  config?: EthagentConfig
@@ -25,24 +37,10 @@ export const IdentitySummary: React.FC<{
25
37
  : ''
26
38
 
27
39
  const row = (label: string) => rows.find(item => item.label === label)
28
-
29
- const needsBackup = workingStatus?.publishState === 'local-changes' || workingStatus?.publishState === 'not-published' || workingStatus?.publishState === 'verify-needed'
30
- let changedFiles: string[] = []
31
- if (needsBackup) {
32
- if (workingStatus?.localContentHashes && workingStatus?.publishedContentHashes) {
33
- if (workingStatus.localContentHashes['SOUL.md'] !== workingStatus.publishedContentHashes['SOUL.md']) changedFiles.push('SOUL.md')
34
- if (workingStatus.localContentHashes['MEMORY.md'] !== workingStatus.publishedContentHashes['MEMORY.md']) changedFiles.push('MEMORY.md')
35
- if (workingStatus.localContentHashes['skills.json'] !== workingStatus.publishedContentHashes['skills.json']) changedFiles.push('skills.json')
36
- } else {
37
- changedFiles = ['SOUL.md', 'MEMORY.md', 'skills.json']
38
- }
39
- }
40
+ const localChangeStatus = localChangeStatusView(workingStatus)
41
+ const lastSavedRow: SummaryRow = { label: 'Last Saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' : 'ok' }
40
42
 
41
- const lastSavedRow = needsBackup
42
- ? { label: 'Unsaved', value: changedFiles.length > 0 ? changedFiles.join(', ') : 'Markdown files', tone: 'warn' as const, highlight: true }
43
- : { label: 'Last Saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' as const : 'ok' as const }
44
-
45
- const summaryRows = [
43
+ const summaryRows: SummaryRow[] = [
46
44
  { label: 'Token', value: row('token')?.value ?? 'Not Created', tone: row('token')?.tone ?? 'dim', highlight: true },
47
45
  { label: 'Network', value: row('network')?.value ?? 'Unknown', tone: row('network')?.tone ?? 'dim' },
48
46
  { label: 'Owner', value: row('owner')?.value ?? 'Not Connected', tone: row('owner')?.tone ?? 'dim' },
@@ -57,18 +55,36 @@ export const IdentitySummary: React.FC<{
57
55
  <Box flexDirection="column">
58
56
  <Text color={theme.accentPrimary} bold>{stateName || 'Active Agent'}</Text>
59
57
  {summaryRows.map(row => {
60
- const valueColor = row.tone === 'warn' ? 'red' : (row.tone === 'ok' ? theme.text : theme.dim)
58
+ const valueColor = row.tone === 'warn' ? '#e87070' : (row.tone === 'ok' ? theme.text : theme.dim)
61
59
  return (
62
60
  <Text key={row.label}>
63
61
  <Text color={theme.dim}>{row.label.padEnd(12)}</Text>
64
- <Text color={valueColor} bold={row.highlight}>{displayValue(row.value)}</Text>
62
+ <Text color={valueColor} bold={row.highlight ?? false}>{displayValue(row.value)}</Text>
65
63
  </Text>
66
64
  )
67
65
  })}
66
+ <Box marginTop={1}>
67
+ <LocalChangeStatusLine status={localChangeStatus} />
68
+ </Box>
68
69
  </Box>
69
70
  )
70
71
  }
71
72
 
73
+ const LocalChangeStatusLine: React.FC<{ status: LocalChangeStatusView }> = ({ status }) => {
74
+ if (status.hasLocalChanges) {
75
+ return (
76
+ <Text color="#e87070" bold>
77
+ Local changes detected
78
+ {status.files.length > 0 ? `: ${status.files.join(', ')}` : ''}
79
+ </Text>
80
+ )
81
+ }
82
+
83
+ const color = status.tone === 'ok' ? theme.accentMint : status.tone === 'warn' ? theme.accentPeach : theme.dim
84
+ const label = status.detail === 'None detected' ? 'No local changes detected' : status.detail
85
+ return <Text color={color}>{label}</Text>
86
+ }
87
+
72
88
  function displayValue(value: string): string {
73
89
  const mapped = DISPLAY_VALUES[value]
74
90
  return mapped ?? value