ethagent 2.2.0 → 2.3.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 (154) hide show
  1. package/package.json +2 -1
  2. package/src/app/FirstRun.tsx +1 -7
  3. package/src/app/FirstRunTimeline.tsx +1 -1
  4. package/src/chat/ChatBottomPane.tsx +20 -11
  5. package/src/chat/ChatScreen.tsx +160 -35
  6. package/src/chat/ConversationStack.tsx +1 -1
  7. package/src/chat/MessageList.tsx +185 -72
  8. package/src/chat/SessionStatus.tsx +3 -1
  9. package/src/chat/chatScreenUtils.ts +11 -15
  10. package/src/chat/chatSessionState.ts +1 -1
  11. package/src/chat/chatTurnOrchestrator.ts +1 -7
  12. package/src/chat/commands.ts +26 -26
  13. package/src/chat/display/DiffView.tsx +193 -0
  14. package/src/chat/display/SyntaxText.tsx +192 -0
  15. package/src/chat/display/toolCallDisplay.ts +103 -0
  16. package/src/chat/display/toolResultDisplay.ts +19 -0
  17. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  18. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  19. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  20. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  21. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  22. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  23. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  24. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  25. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  26. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  27. package/src/chat/views/RewindView.tsx +410 -0
  28. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  29. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  30. package/src/identity/hub/Routes.tsx +13 -13
  31. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  32. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  33. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  34. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  35. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  36. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  37. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  38. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  39. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  40. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  41. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  42. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  43. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  44. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  45. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  46. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  47. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  48. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  49. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  50. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  51. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  57. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  58. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  59. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  60. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  61. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  62. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  63. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  64. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  65. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  66. package/src/identity/hub/identityHubReducer.ts +3 -3
  67. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  68. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  69. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  70. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  71. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  72. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  73. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  74. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  75. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  76. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  77. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  78. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  79. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  80. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  81. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  82. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  83. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  84. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  85. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  87. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  88. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  89. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  90. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  91. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  92. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  93. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  94. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  95. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  96. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  97. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  98. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  99. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  100. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  101. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  102. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  103. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  105. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  106. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  107. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  108. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  109. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  110. package/src/identity/hub/useIdentityHubController.ts +11 -11
  111. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  112. package/src/models/ModelPicker.tsx +5 -3
  113. package/src/models/catalog.ts +2 -1
  114. package/src/models/modelPickerOptions.ts +2 -14
  115. package/src/models/providerDisplay.ts +16 -0
  116. package/src/providers/errors.ts +6 -4
  117. package/src/providers/openai-chat.ts +2 -1
  118. package/src/runtime/sessionMode.ts +1 -1
  119. package/src/runtime/systemPrompt.ts +3 -1
  120. package/src/runtime/toolExecution.ts +9 -6
  121. package/src/runtime/turn.ts +29 -0
  122. package/src/storage/rewind.ts +20 -0
  123. package/src/storage/sessions.ts +2 -1
  124. package/src/tools/bashSafety.ts +7 -3
  125. package/src/tools/bashTool.ts +1 -1
  126. package/src/tools/contracts.ts +3 -0
  127. package/src/tools/deleteFileTool.ts +8 -3
  128. package/src/tools/editTool.ts +10 -5
  129. package/src/tools/fileDiff.ts +261 -0
  130. package/src/tools/privateContinuityEditTool.ts +5 -1
  131. package/src/tools/writeFileTool.ts +8 -3
  132. package/src/ui/Spinner.tsx +25 -3
  133. package/src/ui/TextInput.tsx +2 -2
  134. package/src/ui/theme.ts +17 -0
  135. package/src/utils/clipboard.ts +10 -7
  136. package/src/chat/RewindView.tsx +0 -386
  137. package/src/chat/toolResultDisplay.ts +0 -8
  138. package/src/identity/hub/effects/index.ts +0 -73
  139. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  140. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  141. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  142. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  143. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  144. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  145. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  146. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  147. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  148. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  149. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  150. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  151. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  152. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  153. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  154. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -612,9 +612,11 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
612
612
  ) : (
613
613
  <Select
614
614
  options={[
615
- { value: 'signin', label: 'Sign in Again' },
616
- { value: 'signout', label: 'Sign Out' },
617
- { value: 'cancel', label: 'Back' },
615
+ { value: 'hdr:account', label: 'Account', disabled: true, role: 'section', bold: true },
616
+ { value: 'signin', label: 'Sign in Again', indent: 2 },
617
+ { value: 'signout', label: 'Sign Out', indent: 2 },
618
+ { value: 'hdr:nav', label: 'Navigation', disabled: true, role: 'section', bold: true },
619
+ { value: 'cancel', label: 'Back', indent: 2 },
618
620
  ]}
619
621
  onSubmit={(value) => {
620
622
  if (value === 'signin') {
@@ -2,6 +2,7 @@ import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storag
2
2
  import { getKey } from '../storage/secrets.js'
3
3
  import { loadLocalHfModels } from './huggingface.js'
4
4
  import { hasOpenAIOAuthCredentials } from '../auth/openaiOAuth/credentials.js'
5
+ import { providerDisplayName } from './providerDisplay.js'
5
6
 
6
7
  const OPENAI_OAUTH_MODEL_IDS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'] as const
7
8
 
@@ -85,7 +86,7 @@ export async function discoverProviderModels(
85
86
  if (provider === 'openai' && await hasOpenAIOAuthCredentials()) {
86
87
  return openAIOAuthCatalog()
87
88
  }
88
- return fallbackResult(config, `missing ${provider} API key`)
89
+ return fallbackResult(config, `missing ${providerDisplayName(provider)} API key`)
89
90
  }
90
91
 
91
92
  const baseUrl = provider === 'openai' ? openAIBaseUrlFor(config) : ''
@@ -7,26 +7,14 @@ import { type SelectOption } from '../ui/Select.js'
7
7
  import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
8
8
  import { localModelId, quantizationFromFilename } from './huggingface.js'
9
9
  import type { UncensoredCatalogEntry } from './uncensoredCatalog.js'
10
+ import { cloudProviderDisplayName, providerDisplayName, type CloudProviderId } from './providerDisplay.js'
10
11
 
11
- export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
12
+ export { cloudProviderDisplayName, providerDisplayName, type CloudProviderId }
12
13
 
13
14
  export const MODEL_PICKER_CLOUD_PROVIDERS: CloudProviderId[] = ['openai', 'anthropic', 'gemini']
14
15
  export const LOCAL_MODEL_LINK_HINT = 'Paste a GGUF link'
15
16
  export const LOCAL_MODEL_LINK_EXAMPLE = 'e.g. https://huggingface.co/Qwen/Qwen3-8B-GGUF'
16
17
 
17
- export function cloudProviderDisplayName(provider: CloudProviderId): string {
18
- switch (provider) {
19
- case 'openai': return 'OpenAI'
20
- case 'anthropic': return 'Anthropic'
21
- case 'gemini': return 'Gemini'
22
- }
23
- }
24
-
25
- export function providerDisplayName(provider: ProviderId): string {
26
- if (provider === 'llamacpp') return 'llama.cpp'
27
- return cloudProviderDisplayName(provider)
28
- }
29
-
30
18
  export type LocalHfPickerModel = {
31
19
  id: string
32
20
  displayName: string
@@ -0,0 +1,16 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+
3
+ export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
4
+
5
+ export function cloudProviderDisplayName(provider: CloudProviderId): string {
6
+ switch (provider) {
7
+ case 'openai': return 'OpenAI'
8
+ case 'anthropic': return 'Anthropic'
9
+ case 'gemini': return 'Gemini'
10
+ }
11
+ }
12
+
13
+ export function providerDisplayName(provider: ProviderId): string {
14
+ if (provider === 'llamacpp') return 'llama.cpp'
15
+ return cloudProviderDisplayName(provider)
16
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ProviderId } from '../storage/config.js'
2
2
  import { ProviderError } from './contracts.js'
3
3
  import { formatGeminiRateLimitMessage } from './gemini.js'
4
+ import { providerDisplayName } from '../models/providerDisplay.js'
4
5
 
5
6
  type ErrorBody =
6
7
  | string
@@ -30,19 +31,20 @@ export async function providerErrorFromResponse(
30
31
  && /API[_ ]?key( not valid| not found|_invalid)|invalid api key/i.test(detail)
31
32
  ) {
32
33
  return new ProviderError(
33
- 'gemini: API key rejected — verify your key at https://aistudio.google.com/app/apikey, then run /key gemini to set it again',
34
+ 'Gemini: API key rejected — verify your key at https://aistudio.google.com/app/apikey, then run /key gemini to set it again',
34
35
  )
35
36
  }
36
37
 
37
38
  if (provider !== 'llamacpp') {
39
+ const name = providerDisplayName(provider)
38
40
  if (response.status === 401 || response.status === 403) {
39
- return new ProviderError(`auth failed: check your ${provider} key (/doctor to verify)`)
41
+ return new ProviderError(`auth failed: check your ${name} key (/doctor to verify)`)
40
42
  }
41
43
  if (response.status === 429) {
42
- return new ProviderError(detail || `${provider} rate limit exceeded`, { transient: true })
44
+ return new ProviderError(detail || `${name} rate limit exceeded`, { transient: true })
43
45
  }
44
46
  if (response.status >= 500) {
45
- return new ProviderError(detail || `${provider} server error (${response.status})`, { transient: true })
47
+ return new ProviderError(detail || `${name} server error (${response.status})`, { transient: true })
46
48
  }
47
49
  }
48
50
 
@@ -5,6 +5,7 @@ import { providerErrorFromResponse } from './errors.js'
5
5
  import { fetchWithRetryStreamEvents } from './retry.js'
6
6
  import { iterSseFrames } from './sse.js'
7
7
  import { messageTextContent } from '../utils/messages.js'
8
+ import { providerDisplayName } from '../models/providerDisplay.js'
8
9
 
9
10
  export type OpenAIToolDefinition = {
10
11
  type: 'function'
@@ -369,7 +370,7 @@ function providerNetworkErrorMessage(
369
370
  ): string {
370
371
  const message = (err as Error).message || fallback
371
372
  if (provider !== 'llamacpp') return message
372
- return `${provider} request failed at ${baseUrl}: ${message}`
373
+ return `${providerDisplayName(provider)} request failed at ${baseUrl}: ${message}`
373
374
  }
374
375
 
375
376
  class ContentThinkingParser {
@@ -24,7 +24,7 @@ export function nextSessionMode(mode: SessionMode): SessionMode {
24
24
  }
25
25
 
26
26
  export function sessionModeLabel(mode: SessionMode): string {
27
- return mode === 'plan' ? 'Plan mode' : mode === 'accept-edits' ? 'Accept edits on' : ''
27
+ return mode === 'plan' ? 'plan mode' : mode === 'accept-edits' ? 'accept edits on' : ''
28
28
  }
29
29
 
30
30
  export function modePolicy(mode: PolicyMode): ModePolicy {
@@ -76,7 +76,7 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
76
76
  '**DIRECT REQUESTS**: If the user asks to change directory, list files, or read a file, respond with exactly one matching native tool call. Do not substitute prose or claim the action was taken.',
77
77
  '**EVIDENCE REQUIRED**: Do not claim a path is missing, a directory does not exist, or a file is absent unless you have a `list_directory` or `read_file` result from this conversation that confirms it.',
78
78
  '**TOOL TYPING**: Tool names are NOT shell commands. NEVER pass `list_directory`, `read_file`, `edit_file`, or `change_directory` directly to `run_bash`. Call the matching native tool.',
79
- 'Prefer targeted `read_file` and `edit_file` calls over general `run_bash` operations when both solve the task.',
79
+ '**PREFER NATIVE TOOLS**: If a request can be answered by `list_directory`, `read_file`, `edit_file`, `write_file`, `delete_file`, or `change_directory`, use that tool. Treat reaching for `run_bash` as a flag that you may be doing it wrong — only proceed if the action genuinely needs a real shell.',
80
80
  ...(ctx.mode === 'plan'
81
81
  ? [
82
82
  'Only read/list tools and permission-gated private continuity reads are available in plan mode.',
@@ -99,6 +99,8 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
99
99
  ]
100
100
  : ['No agent identity is linked in this session. Do not attempt private identity continuity edits; ask the user to create or load an agent first.']),
101
101
  'Use `run_bash` **only** when true shell execution is necessary.',
102
+ '**NO BASH EXPLORATION**: Never use `run_bash` to inspect the workspace. That means no `node -e`, no heredocs (`<<EOF`, `<<\'NODE\'`), no `find`, `grep`, `cat`, `head`, `tail`, `ls`, `dir`, `type`, `tree`, or shell loops to read or walk files. Use `list_directory` for directories and `read_file` for files. `run_bash` is reserved for actions that require a real shell: running tests, builds, git operations, launching processes. If you catch yourself writing a one-liner to read or list something, stop and call the native tool instead.',
103
+ '**DISCOVERY BUDGET**: For exploratory or "tell me about" questions, target ≤5 tool calls total. If 5 calls of the same kind have not given you enough to answer, the question does not need more depth — answer from what you have. Do not recursively scan, walk every subdirectory, or write deeper scripts to be more thorough than the user asked for. Stop and reply.',
102
104
  'Never use `run_bash` to produce conversational text. Do not call `echo`, `printf`, or similar to emit your reply — write it as your assistant text. Bash is for actions that need a real shell, not for generating words.',
103
105
  '**CWD CONTINUITY**: The working directory below is authoritative. After `change_directory` succeeds, use the new path as the base for subsequent actions.',
104
106
  'Do not lag behind the CWD. Edit/read relative to the *current* working directory.',
@@ -17,10 +17,7 @@ import type {
17
17
  import { setCwd as setRuntimeCwd } from './cwd.js'
18
18
  import type { EthagentConfig } from '../storage/config.js'
19
19
  import type { SessionMessage } from '../storage/sessions.js'
20
- import {
21
- summarizeToolInput,
22
- toolResultContentForRow,
23
- } from '../chat/chatScreenUtils.js'
20
+ import { toolResultContentForRow, toolResultDiffForRow } from '../chat/chatScreenUtils.js'
24
21
  import type { MessageRow } from '../chat/MessageList.js'
25
22
  import { modePolicy, toPermissionMode, type SessionMode } from './sessionMode.js'
26
23
 
@@ -239,7 +236,7 @@ export async function runPendingToolUses(args: {
239
236
  id: rowId,
240
237
  name: toolUse.name,
241
238
  summary: toolUse.name,
242
- input: summarizeToolInput(toolUse.input),
239
+ input: toolUse.input,
243
240
  },
244
241
  ])
245
242
  await args.persistTurnMessage({
@@ -282,9 +279,15 @@ async function recordToolResult(
282
279
  ): Promise<void> {
283
280
  const isError = !result.ok
284
281
  const resultContent = toolResultContentForRow(toolUse.name, result.content, isError)
282
+ const diff = toolResultDiffForRow(result.content, isError)
285
283
  args.updateRows(prev => prev.map(row =>
286
284
  row.role === 'tool_call' && row.id === rowId
287
- ? { ...row, result: { content: resultContent, summary: result.summary, isError } }
285
+ ? {
286
+ ...row,
287
+ result: diff
288
+ ? { content: resultContent, summary: result.summary, isError, diff }
289
+ : { content: resultContent, summary: result.summary, isError },
290
+ }
288
291
  : row,
289
292
  ))
290
293
  await args.persistTurnMessage({
@@ -56,6 +56,7 @@ function normalize(event: StreamEvent): ProviderTurnEvent {
56
56
  }
57
57
 
58
58
  export const MAX_CONTINUATION_NUDGES = 3
59
+ export const MAX_TOOL_USES_PER_TURN = 25
59
60
 
60
61
  export type ContinuationNudgeReason =
61
62
  | 'continuation'
@@ -63,6 +64,7 @@ export type ContinuationNudgeReason =
63
64
  | 'tool_state_claim'
64
65
  | 'tool_protocol_fake'
65
66
  | 'tool_delegation'
67
+ | 'tool_budget'
66
68
  | 'private_continuity_tool'
67
69
  | 'private_continuity_tool_repair'
68
70
  | 'reasoning_only'
@@ -82,6 +84,9 @@ const TOOL_PROTOCOL_FAKE_NUDGE_TEXT =
82
84
  const TOOL_DELEGATION_NUDGE_TEXT =
83
85
  'Do not ask the user to run native tools. You have access to the tools in this environment. Make exactly one native tool call now.'
84
86
 
87
+ const TOOL_BUDGET_NUDGE_TEXT =
88
+ 'You have reached the tool-call budget for this turn. Do not call any more tools. Produce your final answer now using only what you already know from earlier tool results.'
89
+
85
90
  const PRIVATE_CONTINUITY_NUDGE_TEXT =
86
91
  'SOUL.md and MEMORY.md are existing private identity-vault scaffold files. Do not search workspace folders, read plans/, create files, or overwrite them. If exact private text is needed for a surgical removal or targeted replacement, call read_private_continuity_file with {"file":"MEMORY.md"} or {"file":"SOUL.md"}. If the user wants private continuity changed, call propose_private_continuity_edit. For memory/preferences use {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}. For persona use {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.'
87
92
 
@@ -164,6 +169,7 @@ export async function* runRuntimeTurn(
164
169
  let continuationNudges = 0
165
170
  let iterationIndex = 0
166
171
  let priorIterationHadTools = false
172
+ let cumulativeToolUseCount = 0
167
173
  const toolEvidenceThisTurn: ToolEvidence[] = []
168
174
 
169
175
  // eslint-disable-next-line no-constant-condition
@@ -395,6 +401,29 @@ export async function* runRuntimeTurn(
395
401
  return
396
402
  }
397
403
 
404
+ if (cumulativeToolUseCount + pendingToolUses.length > MAX_TOOL_USES_PER_TURN) {
405
+ if (continuationNudges < maxContinuationNudges) {
406
+ continuationNudges += 1
407
+ yield {
408
+ type: 'continuation_nudge',
409
+ attempt: continuationNudges,
410
+ reason: 'tool_budget',
411
+ }
412
+ workingMessages = [
413
+ ...await rebuildMessages(),
414
+ { role: 'user', content: TOOL_BUDGET_NUDGE_TEXT },
415
+ ]
416
+ continue
417
+ }
418
+ yield {
419
+ type: 'error',
420
+ message: `tool budget exceeded (${MAX_TOOL_USES_PER_TURN} max per turn); ask again with a narrower request`,
421
+ }
422
+ yield doneEvent(false, stopReason)
423
+ return
424
+ }
425
+ cumulativeToolUseCount += pendingToolUses.length
426
+
398
427
  const batch = await runToolBatch(pendingToolUses)
399
428
  for (const completed of batch.completedTools) {
400
429
  toolEvidenceThisTurn.push({
@@ -131,6 +131,26 @@ export async function listRewindEntries(
131
131
  .slice(offset, offset + limit)
132
132
  }
133
133
 
134
+ export async function groupRewindEntriesByTurn(
135
+ workspaceRoot: string,
136
+ sessionId: string,
137
+ ): Promise<Map<string, RewindEntry[]>> {
138
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
139
+ const snapshots = await loadSnapshots()
140
+ const grouped = new Map<string, RewindEntry[]>()
141
+ for (const snapshot of snapshots) {
142
+ if (isIdentityMarkdownSnapshot(snapshot)) continue
143
+ if (!isSnapshotWithinScope(snapshot, normalizedWorkspaceRoot)) continue
144
+ if (snapshot.sessionId !== sessionId) continue
145
+ if (!snapshot.turnId) continue
146
+ const entry = toEntry(snapshot)
147
+ const bucket = grouped.get(snapshot.turnId)
148
+ if (bucket) bucket.push(entry)
149
+ else grouped.set(snapshot.turnId, [entry])
150
+ }
151
+ return grouped
152
+ }
153
+
134
154
  export async function rewindWorkspaceEditsByEntryIds(
135
155
  workspaceRoot: string,
136
156
  entryIds: string[],
@@ -6,6 +6,7 @@ import type { Message } from '../providers/contracts.js'
6
6
  import { getCwd } from '../runtime/cwd.js'
7
7
  import type { SessionMode } from '../runtime/sessionMode.js'
8
8
  import { atomicWriteText } from './atomicWrite.js'
9
+ import { stripFileChangeResultDiff } from '../tools/fileDiff.js'
9
10
  import {
10
11
  isUserCorrectionOfToolState,
11
12
  looksLikeToolStateClaim,
@@ -282,7 +283,7 @@ export function sessionMessagesToProviderMessages(
282
283
  content: [{
283
284
  type: 'tool_result',
284
285
  toolUseId: message.toolUseId,
285
- content: message.content,
286
+ content: stripFileChangeResultDiff(message.content),
286
287
  isError: message.isError,
287
288
  }],
288
289
  })
@@ -98,16 +98,16 @@ export function assessBashCommand(command: string): BashSafetyAssessment {
98
98
  const triggeredChecks = RISKY_PATTERN_CHECKS.filter(check => check.pattern.test(command)).map(check => check.message)
99
99
 
100
100
  const warning = triggeredChecks.length > 0
101
- ? `warning: ${triggeredChecks[0]}. reusable approval is limited for this command.`
101
+ ? `Warning: ${sentenceCase(triggeredChecks[0] ?? 'command is risky')}. Reusable approval is limited for this command.`
102
102
  : highRisk
103
- ? `warning: ${firstToken} is a high-impact command. reusable approval is limited for this command.`
103
+ ? `Warning: ${sentenceCase(firstToken ?? '')} is a high-impact command. Reusable approval is limited for this command.`
104
104
  : undefined
105
105
 
106
106
  return {
107
107
  warning,
108
108
  canPersistExact: triggeredChecks.length === 0 && !nonPersistable,
109
109
  canPersistPrefix: triggeredChecks.length === 0 && !highRisk && Boolean(firstToken),
110
- commandPrefix: firstToken,
110
+ commandPrefix: firstToken ?? '',
111
111
  }
112
112
  }
113
113
 
@@ -180,3 +180,7 @@ function normalizeCommandToken(token: string): string {
180
180
  .toLowerCase()
181
181
  .replace(/[^a-z0-9_.:-]/g, '') ?? ''
182
182
  }
183
+
184
+ function sentenceCase(value: string): string {
185
+ return value ? value[0]!.toUpperCase() + value.slice(1) : value
186
+ }
@@ -39,7 +39,7 @@ export const bashTool: Tool<typeof schema> = {
39
39
  command: input.command,
40
40
  commandPrefix: safety.commandPrefix,
41
41
  cwd,
42
- title: 'allow shell command?',
42
+ title: 'Allow shell command?',
43
43
  subtitle: `${input.command}\n${cwd}`,
44
44
  warning: safety.warning,
45
45
  canPersistExact: safety.canPersistExact,
@@ -23,6 +23,7 @@ export type PermissionRequest =
23
23
  subtitle: string
24
24
  before: string
25
25
  after: string
26
+ diff: string
26
27
  changeSummary: string
27
28
  }
28
29
  | {
@@ -34,6 +35,7 @@ export type PermissionRequest =
34
35
  subtitle: string
35
36
  before: string
36
37
  after: string
38
+ diff: string
37
39
  changeSummary: string
38
40
  }
39
41
  | {
@@ -68,6 +70,7 @@ export type PermissionRequest =
68
70
  subtitle: string
69
71
  before: string
70
72
  after: string
73
+ diff: string
71
74
  changeSummary: string
72
75
  }
73
76
  | {
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { Tool } from './contracts.js'
6
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
6
7
  import { resolveWorkspacePath } from './readTool.js'
7
8
 
8
9
  const schema = z.object({
@@ -35,6 +36,7 @@ export const deleteFileTool: Tool<typeof schema> = {
35
36
  subtitle: prepared.fullPath,
36
37
  before: preview(prepared.before),
37
38
  after: '(deleted)',
39
+ diff: renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: '' }),
38
40
  changeSummary: `delete ${prepared.relativePath}`,
39
41
  }
40
42
  },
@@ -58,9 +60,12 @@ export const deleteFileTool: Tool<typeof schema> = {
58
60
  return {
59
61
  ok: true,
60
62
  summary: `deleted ${prepared.relativePath}`,
61
- content: rewindWarning
62
- ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
63
- : `deleted ${prepared.fullPath}`,
63
+ content: formatFileChangeResult(
64
+ rewindWarning
65
+ ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
66
+ : `deleted ${prepared.fullPath}`,
67
+ renderUnifiedFileDiff({ filePath: prepared.relativePath, before: prepared.before, after: '' }),
68
+ ),
64
69
  }
65
70
  },
66
71
  }
@@ -5,6 +5,7 @@ import { recordRewindSnapshot } from '../storage/rewind.js'
5
5
  import type { EthagentConfig } from '../storage/config.js'
6
6
  import type { Tool } from './contracts.js'
7
7
  import { applyRequestedEdit } from './editUtils.js'
8
+ import { formatFileChangeResult, renderUnifiedFileDiff } from './fileDiff.js'
8
9
  import { resolveWorkspacePath } from './readTool.js'
9
10
 
10
11
  const schema = z.object({
@@ -44,15 +45,16 @@ export const editTool: Tool<typeof schema> = {
44
45
  subtitle: fullPath,
45
46
  before: applied.previewBefore,
46
47
  after: applied.previewAfter,
48
+ diff: renderUnifiedFileDiff({ filePath: relativePath, before: applied.before, after: applied.after }),
47
49
  changeSummary: applied.summary,
48
50
  }
49
51
  },
50
52
  async execute(input, context) {
51
- const { fullPath, applied, existedBefore, before } = await prepareEdit(input, context)
53
+ const { fullPath, relativePath, applied, existedBefore, before } = await prepareEdit(input, context)
52
54
  const rewindWarning = await tryRecordRewindSnapshot({
53
55
  workspaceRoot: context.workspaceRoot,
54
56
  filePath: fullPath,
55
- relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
57
+ relativePath,
56
58
  existedBefore,
57
59
  previousContent: before,
58
60
  changeSummary: applied.summary,
@@ -68,9 +70,12 @@ export const editTool: Tool<typeof schema> = {
68
70
  return {
69
71
  ok: true,
70
72
  summary: applied.summary,
71
- content: rewindWarning
72
- ? `updated ${fullPath}\nwarning: ${rewindWarning}`
73
- : `updated ${fullPath}`,
73
+ content: formatFileChangeResult(
74
+ rewindWarning
75
+ ? `updated ${fullPath}\nwarning: ${rewindWarning}`
76
+ : `updated ${fullPath}`,
77
+ renderUnifiedFileDiff({ filePath: relativePath, before: applied.before, after: applied.after }),
78
+ ),
74
79
  }
75
80
  },
76
81
  }