ethagent 0.2.1 → 1.0.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,140 @@
1
+ import path from 'node:path'
2
+ import { spawn } from 'node:child_process'
3
+ import { z } from 'zod'
4
+ import type { Tool } from './contracts.js'
5
+ import { assessBashCommand, validateBashCommandInput } from './bashSafety.js'
6
+ import { resolveWorkspacePath } from './readTool.js'
7
+
8
+ const schema = z.object({
9
+ command: z.string().min(1),
10
+ cwd: z.string().optional(),
11
+ })
12
+
13
+ export const bashTool: Tool<typeof schema> = {
14
+ name: 'run_bash',
15
+ kind: 'bash',
16
+ description: 'Run a shell command in the current workspace and return stdout, stderr, and the exit code.',
17
+ inputSchema: schema,
18
+ inputSchemaJson: {
19
+ type: 'object',
20
+ properties: {
21
+ command: { type: 'string', description: 'Shell command to run.' },
22
+ cwd: { type: 'string', description: 'Optional working directory inside the workspace.' },
23
+ },
24
+ required: ['command'],
25
+ },
26
+ parse(input) {
27
+ const parsed = schema.parse(input)
28
+ const validationError = validateBashCommandInput(parsed.command)
29
+ if (validationError) {
30
+ throw new Error(validationError)
31
+ }
32
+ return parsed
33
+ },
34
+ async buildPermissionRequest(input, context) {
35
+ const cwd = input.cwd ? resolveWorkspacePath(context.workspaceRoot, input.cwd) : context.workspaceRoot
36
+ const safety = assessBashCommand(input.command)
37
+ return {
38
+ kind: 'bash',
39
+ command: input.command,
40
+ commandPrefix: safety.commandPrefix,
41
+ cwd,
42
+ title: 'allow shell command?',
43
+ subtitle: `${input.command}\n${cwd}`,
44
+ warning: safety.warning,
45
+ canPersistExact: safety.canPersistExact,
46
+ canPersistPrefix: safety.canPersistPrefix,
47
+ }
48
+ },
49
+ async execute(input, context) {
50
+ const cwd = input.cwd ? resolveWorkspacePath(context.workspaceRoot, input.cwd) : context.workspaceRoot
51
+ const output = await runCommand(input.command, cwd, context.abortSignal)
52
+ const relativeCwd = path.relative(context.workspaceRoot, cwd) || '.'
53
+ const stdout = output.stdout.trim()
54
+ const stderr = output.stderr.trim()
55
+ const parts = [
56
+ `cwd: ${relativeCwd}`,
57
+ `exit: ${output.exitCode}`,
58
+ stdout ? `stdout:\n${truncate(stdout)}` : '',
59
+ stderr ? `stderr:\n${truncate(stderr)}` : '',
60
+ ].filter(Boolean)
61
+ return {
62
+ ok: output.exitCode === 0,
63
+ summary: `ran ${input.command}`,
64
+ content: parts.join('\n\n'),
65
+ }
66
+ },
67
+ }
68
+
69
+ function runCommand(
70
+ command: string,
71
+ cwd: string,
72
+ abortSignal?: AbortSignal,
73
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
74
+ return new Promise((resolve, reject) => {
75
+ if (abortSignal?.aborted) {
76
+ reject(new Error('command cancelled'))
77
+ return
78
+ }
79
+
80
+ const child = spawn(command, {
81
+ cwd,
82
+ shell: true,
83
+ windowsHide: true,
84
+ })
85
+ let settled = false
86
+ const timeout = setTimeout(() => {
87
+ killChildProcessTree(child.pid)
88
+ }, 60_000)
89
+ let stdout = ''
90
+ let stderr = ''
91
+ const onAbort = () => {
92
+ cleanup()
93
+ killChildProcessTree(child.pid)
94
+ if (!settled) {
95
+ settled = true
96
+ reject(new Error('command cancelled'))
97
+ }
98
+ }
99
+ const cleanup = () => {
100
+ clearTimeout(timeout)
101
+ abortSignal?.removeEventListener('abort', onAbort)
102
+ }
103
+
104
+ abortSignal?.addEventListener('abort', onAbort, { once: true })
105
+
106
+ child.stdout.on('data', chunk => { stdout += String(chunk) })
107
+ child.stderr.on('data', chunk => { stderr += String(chunk) })
108
+ child.on('error', error => {
109
+ cleanup()
110
+ if (settled) return
111
+ settled = true
112
+ reject(error)
113
+ })
114
+ child.on('close', code => {
115
+ cleanup()
116
+ if (settled) return
117
+ settled = true
118
+ resolve({ stdout, stderr, exitCode: code ?? 1 })
119
+ })
120
+ })
121
+ }
122
+
123
+ function killChildProcessTree(pid: number | undefined): void {
124
+ if (!pid) return
125
+ if (process.platform === 'win32') {
126
+ const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
127
+ killer.on('error', () => {})
128
+ killer.unref()
129
+ return
130
+ }
131
+ try {
132
+ process.kill(pid, 'SIGTERM')
133
+ } catch {
134
+ }
135
+ }
136
+
137
+ function truncate(text: string, max = 4000): string {
138
+ if (text.length <= max) return text
139
+ return `${text.slice(0, max - 3)}...`
140
+ }
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs/promises'
2
+ import fsSync from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { z } from 'zod'
6
+ import type { Tool } from './contracts.js'
7
+ import { resolveUserPath } from '../runtime/cwd.js'
8
+
9
+ const schema = z.object({
10
+ path: z.string().min(1),
11
+ })
12
+
13
+ export const changeDirectoryTool: Tool<typeof schema> = {
14
+ name: 'change_directory',
15
+ kind: 'cd',
16
+ description: 'Change the current working directory for subsequent tool use.',
17
+ inputSchema: schema,
18
+ inputSchemaJson: {
19
+ type: 'object',
20
+ properties: {
21
+ path: { type: 'string', description: 'Target directory path. May be relative to the current workspace or begin with ~.' },
22
+ },
23
+ required: ['path'],
24
+ },
25
+ parse(input) {
26
+ return schema.parse(input)
27
+ },
28
+ async buildPermissionRequest(input, context) {
29
+ const fullPath = resolveTargetDirectory(context.workspaceRoot, input.path)
30
+ return {
31
+ kind: 'cd',
32
+ path: fullPath,
33
+ relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
34
+ directoryPath: path.dirname(fullPath),
35
+ title: 'allow directory change?',
36
+ subtitle: fullPath,
37
+ }
38
+ },
39
+ async execute(input, context) {
40
+ const fullPath = resolveTargetDirectory(context.workspaceRoot, input.path)
41
+ const stat = await fs.stat(fullPath)
42
+ if (!stat.isDirectory()) throw new Error(`not a directory: ${input.path}`)
43
+ context.changeDirectory?.(fullPath)
44
+ return {
45
+ ok: true,
46
+ summary: `changed directory to ${fullPath}`,
47
+ content: fullPath,
48
+ }
49
+ },
50
+ }
51
+
52
+ function resolveTargetDirectory(workspaceRoot: string, requestedPath: string): string {
53
+ return resolveDirectoryIntent(requestedPath, workspaceRoot)
54
+ }
55
+
56
+ function resolveDirectoryIntent(input: string, workspaceRoot: string): string {
57
+ const normalized = normalizeIntentInput(input)
58
+ if (!normalized) {
59
+ throw new Error('missing directory path')
60
+ }
61
+
62
+ if (looksLikeConcretePath(normalized)) {
63
+ return resolveUserPath(normalized, workspaceRoot)
64
+ }
65
+
66
+ const scoped = resolveScopedPhrase(normalized, workspaceRoot)
67
+ if (scoped) return scoped
68
+
69
+ const direct = resolveDirectoryHint(normalized, workspaceRoot)
70
+ if (direct) return direct
71
+
72
+ return resolveUserPath(normalized, workspaceRoot)
73
+ }
74
+
75
+ function resolveScopedPhrase(input: string, workspaceRoot: string): string | undefined {
76
+ const normalized = simplifyNaturalPhrase(input)
77
+ const parts = normalized
78
+ .split(/\b(?:in|into|inside|under|within)\b/g)
79
+ .map(part => part.trim())
80
+ .filter(Boolean)
81
+ if (parts.length < 2) return undefined
82
+
83
+ const baseHint = parts.at(-1)
84
+ if (!baseHint) return undefined
85
+ const targetHint = parts.slice(0, -1).join(' ').trim()
86
+ const baseDir = resolveDirectoryHint(baseHint, workspaceRoot)
87
+ if (!baseDir) return undefined
88
+ if (!targetHint) return baseDir
89
+
90
+ if (looksLikeConcretePath(targetHint)) {
91
+ return path.resolve(baseDir, targetHint)
92
+ }
93
+
94
+ const match = findNamedChild(baseDir, targetHint)
95
+ if (match) return match
96
+
97
+ const segments = targetHint
98
+ .split(/[\\/]/)
99
+ .map(part => part.trim())
100
+ .filter(Boolean)
101
+ if (segments.length === 0) return baseDir
102
+
103
+ let current = baseDir
104
+ for (const segment of segments) {
105
+ const next = findNamedChild(current, segment)
106
+ if (!next) {
107
+ return path.join(baseDir, ...segments)
108
+ }
109
+ current = next
110
+ }
111
+ return current
112
+ }
113
+
114
+ function resolveDirectoryHint(input: string, workspaceRoot: string): string | undefined {
115
+ const hint = simplifyNaturalPhrase(input)
116
+ if (!hint) return undefined
117
+ if (looksLikeConcretePath(hint)) return resolveUserPath(hint, workspaceRoot)
118
+
119
+ const normalizedHint = hint.toLowerCase()
120
+ const anchors = buildSearchAnchors(workspaceRoot)
121
+
122
+ for (const candidate of anchors) {
123
+ if (path.basename(candidate).toLowerCase() === normalizedHint) return candidate
124
+ }
125
+
126
+ for (const anchor of anchors) {
127
+ const child = findNamedChild(anchor, normalizedHint)
128
+ if (child) return child
129
+ }
130
+
131
+ return undefined
132
+ }
133
+
134
+ function buildSearchAnchors(workspaceRoot: string): string[] {
135
+ const home = os.homedir()
136
+ const seen = new Set<string>()
137
+ const out: string[] = []
138
+ const add = (candidate: string) => {
139
+ const resolved = path.resolve(candidate)
140
+ if (seen.has(resolved)) return
141
+ seen.add(resolved)
142
+ out.push(resolved)
143
+ }
144
+
145
+ for (const dir of ancestorDirectories(workspaceRoot)) add(dir)
146
+ add(home)
147
+ for (const child of safeReadDirectories(home)) add(child)
148
+ return out
149
+ }
150
+
151
+ function ancestorDirectories(start: string): string[] {
152
+ const out: string[] = []
153
+ let current = path.resolve(start)
154
+ while (true) {
155
+ out.push(current)
156
+ const parent = path.dirname(current)
157
+ if (parent === current) break
158
+ current = parent
159
+ }
160
+ return out
161
+ }
162
+
163
+ function findNamedChild(parent: string, nameOrPhrase: string): string | undefined {
164
+ const wanted = simplifyNaturalPhrase(nameOrPhrase).toLowerCase()
165
+ if (!wanted) return undefined
166
+
167
+ const children = safeReadDirectories(parent)
168
+ for (const child of children) {
169
+ if (path.basename(child).toLowerCase() === wanted) return child
170
+ }
171
+
172
+ return undefined
173
+ }
174
+
175
+ function safeReadDirectories(dir: string): string[] {
176
+ try {
177
+ return fsSync.readdirSync(dir, { withFileTypes: true })
178
+ .filter(entry => entry.isDirectory())
179
+ .map(entry => path.join(dir, entry.name))
180
+ } catch {
181
+ return []
182
+ }
183
+ }
184
+
185
+ function normalizeIntentInput(input: string): string {
186
+ return input
187
+ .trim()
188
+ .replace(/\?+$/, '')
189
+ .replace(/^["'`]+|["'`]+$/g, '')
190
+ .replace(/\s+/g, ' ')
191
+ .trim()
192
+ }
193
+
194
+ function simplifyNaturalPhrase(input: string): string {
195
+ return input
196
+ .toLowerCase()
197
+ .replace(/\b(?:the|my)\b/g, ' ')
198
+ .replace(/\b(?:folder|directory)\b/g, ' ')
199
+ .replace(/\s+/g, ' ')
200
+ .trim()
201
+ }
202
+
203
+ function looksLikeConcretePath(input: string): boolean {
204
+ return (
205
+ input.startsWith('~') ||
206
+ /^[A-Za-z]:[\\/]/.test(input) ||
207
+ input.startsWith('/') ||
208
+ input.startsWith('./') ||
209
+ input.startsWith('../') ||
210
+ input.includes('\\') ||
211
+ input.includes('/')
212
+ )
213
+ }
@@ -0,0 +1,179 @@
1
+ import { z } from 'zod'
2
+ import type { EthagentConfig } from '../storage/config.js'
3
+
4
+ import type { McpRuntime } from '../mcp/manager.js'
5
+
6
+ export type ToolKind = 'read' | 'write' | 'edit' | 'delete' | 'bash' | 'cd' | 'private-continuity-read' | 'private-continuity-edit' | 'mcp'
7
+
8
+ export type PermissionRequest =
9
+ | {
10
+ kind: 'read'
11
+ path: string
12
+ relativePath: string
13
+ directoryPath: string
14
+ title: string
15
+ subtitle: string
16
+ }
17
+ | {
18
+ kind: 'write'
19
+ path: string
20
+ relativePath: string
21
+ directoryPath: string
22
+ title: string
23
+ subtitle: string
24
+ before: string
25
+ after: string
26
+ changeSummary: string
27
+ }
28
+ | {
29
+ kind: 'edit'
30
+ path: string
31
+ relativePath: string
32
+ directoryPath: string
33
+ title: string
34
+ subtitle: string
35
+ before: string
36
+ after: string
37
+ changeSummary: string
38
+ }
39
+ | {
40
+ kind: 'private-continuity-read'
41
+ path: string
42
+ relativePath: string
43
+ directoryPath: string
44
+ title: string
45
+ subtitle: string
46
+ file: 'SOUL.md' | 'MEMORY.md'
47
+ range: string
48
+ }
49
+ | {
50
+ kind: 'private-continuity-edit'
51
+ path: string
52
+ relativePath: string
53
+ directoryPath: string
54
+ title: string
55
+ subtitle: string
56
+ file: 'SOUL.md' | 'MEMORY.md'
57
+ before: string
58
+ after: string
59
+ diff: string
60
+ changeSummary: string
61
+ }
62
+ | {
63
+ kind: 'delete'
64
+ path: string
65
+ relativePath: string
66
+ directoryPath: string
67
+ title: string
68
+ subtitle: string
69
+ before: string
70
+ after: string
71
+ changeSummary: string
72
+ }
73
+ | {
74
+ kind: 'bash'
75
+ command: string
76
+ commandPrefix: string
77
+ cwd: string
78
+ title: string
79
+ subtitle: string
80
+ warning?: string
81
+ canPersistExact: boolean
82
+ canPersistPrefix: boolean
83
+ }
84
+ | {
85
+ kind: 'mcp'
86
+ title: string
87
+ subtitle: string
88
+ serverName: string
89
+ normalizedServerName: string
90
+ toolName: string
91
+ toolKey: string
92
+ readOnly: boolean
93
+ destructive: boolean
94
+ openWorld: boolean
95
+ canPersistServer: boolean
96
+ }
97
+ | {
98
+ kind: 'cd'
99
+ path: string
100
+ relativePath: string
101
+ directoryPath: string
102
+ title: string
103
+ subtitle: string
104
+ }
105
+
106
+ export type PermissionMode = 'default' | 'plan' | 'accept-edits'
107
+
108
+ export const SessionPermissionRuleSchema = z.union([
109
+ z.object({ kind: z.literal('read'), scope: z.literal('kind') }),
110
+ z.object({ kind: z.literal('read'), scope: z.literal('path'), path: z.string().min(1) }),
111
+ z.object({ kind: z.literal('read'), scope: z.literal('directory'), path: z.string().min(1) }),
112
+ z.object({ kind: z.literal('edit'), scope: z.literal('kind') }),
113
+ z.object({ kind: z.literal('edit'), scope: z.literal('path'), path: z.string().min(1) }),
114
+ z.object({ kind: z.literal('edit'), scope: z.literal('directory'), path: z.string().min(1) }),
115
+ z.object({ kind: z.literal('write'), scope: z.literal('kind') }),
116
+ z.object({ kind: z.literal('write'), scope: z.literal('path'), path: z.string().min(1) }),
117
+ z.object({ kind: z.literal('write'), scope: z.literal('directory'), path: z.string().min(1) }),
118
+ z.object({ kind: z.literal('delete'), scope: z.literal('kind') }),
119
+ z.object({ kind: z.literal('delete'), scope: z.literal('path'), path: z.string().min(1) }),
120
+ z.object({ kind: z.literal('delete'), scope: z.literal('directory'), path: z.string().min(1) }),
121
+ z.object({ kind: z.literal('cd'), scope: z.literal('kind') }),
122
+ z.object({ kind: z.literal('cd'), scope: z.literal('path'), path: z.string().min(1) }),
123
+ z.object({ kind: z.literal('cd'), scope: z.literal('directory'), path: z.string().min(1) }),
124
+ z.object({ kind: z.literal('bash'), scope: z.literal('command'), command: z.string().min(1), cwd: z.string().min(1) }),
125
+ z.object({ kind: z.literal('bash'), scope: z.literal('prefix'), commandPrefix: z.string().min(1), cwd: z.string().min(1) }),
126
+ z.object({ kind: z.literal('mcp'), scope: z.literal('tool'), toolKey: z.string().min(1) }),
127
+ z.object({ kind: z.literal('mcp'), scope: z.literal('server'), normalizedServerName: z.string().min(1) }),
128
+ ])
129
+
130
+ export type SessionPermissionRule = z.infer<typeof SessionPermissionRuleSchema>
131
+
132
+ export type PermissionDecision =
133
+ | 'allow-once'
134
+ | 'allow-kind-project'
135
+ | 'allow-path-project'
136
+ | 'allow-directory-project'
137
+ | 'allow-command-project'
138
+ | 'allow-command-prefix-project'
139
+ | 'allow-mcp-tool-project'
140
+ | 'allow-mcp-server-project'
141
+ | 'deny'
142
+
143
+ export type ToolResult =
144
+ | { ok: true; summary: string; content: string }
145
+ | { ok: false; summary: string; content: string }
146
+
147
+ export type ToolExecutionContext = {
148
+ workspaceRoot: string
149
+ config?: EthagentConfig
150
+ abortSignal?: AbortSignal
151
+ mcp?: McpRuntime
152
+ changeDirectory?: (next: string) => void
153
+ checkpoint?: {
154
+ sessionId: string
155
+ turnId: string
156
+ messageRole: 'user'
157
+ promptSnippet: string
158
+ checkpointLabel: string
159
+ }
160
+ }
161
+
162
+ export type Tool<Input extends z.ZodTypeAny = z.ZodTypeAny> = {
163
+ name: string
164
+ kind: ToolKind
165
+ description: string
166
+ inputSchema: Input
167
+ inputSchemaJson: {
168
+ type: 'object'
169
+ properties?: Record<string, unknown>
170
+ required?: string[]
171
+ oneOf?: Array<Record<string, unknown>>
172
+ anyOf?: Array<Record<string, unknown>>
173
+ additionalProperties?: boolean
174
+ }
175
+ readOnly?: boolean
176
+ parse(input: Record<string, unknown>): z.infer<Input>
177
+ buildPermissionRequest(input: z.infer<Input>, context: ToolExecutionContext): Promise<PermissionRequest>
178
+ execute(input: z.infer<Input>, context: ToolExecutionContext): Promise<ToolResult>
179
+ }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import { recordRewindSnapshot } from '../storage/rewind.js'
5
+ import type { Tool } from './contracts.js'
6
+ import { resolveWorkspacePath } from './readTool.js'
7
+
8
+ const schema = z.object({
9
+ path: z.string().min(1),
10
+ })
11
+
12
+ export const deleteFileTool: Tool<typeof schema> = {
13
+ name: 'delete_file',
14
+ kind: 'delete',
15
+ description: 'Delete one file in the current workspace. Use this for user requests to remove a file; do not use run_bash for normal file deletion.',
16
+ inputSchema: schema,
17
+ inputSchemaJson: {
18
+ type: 'object',
19
+ properties: {
20
+ path: { type: 'string', description: 'Path to the file to delete.' },
21
+ },
22
+ required: ['path'],
23
+ },
24
+ parse(input) {
25
+ return schema.parse(input)
26
+ },
27
+ async buildPermissionRequest(input, context) {
28
+ const prepared = await prepareDelete(input, context)
29
+ return {
30
+ kind: 'delete',
31
+ path: prepared.fullPath,
32
+ relativePath: prepared.relativePath,
33
+ directoryPath: path.dirname(prepared.fullPath),
34
+ title: 'allow file delete?',
35
+ subtitle: prepared.fullPath,
36
+ before: preview(prepared.before),
37
+ after: '(deleted)',
38
+ changeSummary: `delete ${prepared.relativePath}`,
39
+ }
40
+ },
41
+ async execute(input, context) {
42
+ const prepared = await prepareDelete(input, context)
43
+ const rewindWarning = await tryRecordRewindSnapshot({
44
+ workspaceRoot: context.workspaceRoot,
45
+ filePath: prepared.fullPath,
46
+ relativePath: prepared.relativePath,
47
+ existedBefore: true,
48
+ previousContent: prepared.before,
49
+ changeSummary: `restore deleted ${prepared.relativePath}`,
50
+ createdAt: new Date().toISOString(),
51
+ sessionId: context.checkpoint?.sessionId,
52
+ turnId: context.checkpoint?.turnId,
53
+ messageRole: context.checkpoint?.messageRole,
54
+ promptSnippet: context.checkpoint?.promptSnippet,
55
+ checkpointLabel: context.checkpoint?.checkpointLabel,
56
+ })
57
+ await fs.unlink(prepared.fullPath)
58
+ return {
59
+ ok: true,
60
+ summary: `deleted ${prepared.relativePath}`,
61
+ content: rewindWarning
62
+ ? `deleted ${prepared.fullPath}\nwarning: ${rewindWarning}`
63
+ : `deleted ${prepared.fullPath}`,
64
+ }
65
+ },
66
+ }
67
+
68
+ async function prepareDelete(input: z.infer<typeof schema>, context: { workspaceRoot: string }) {
69
+ assertSafeDeletePath(input.path)
70
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path)
71
+ const stats = await fs.stat(fullPath)
72
+ if (stats.isDirectory()) {
73
+ throw new Error('delete_file path points to a directory; provide a file path')
74
+ }
75
+ const before = await fs.readFile(fullPath, 'utf8')
76
+ return {
77
+ fullPath,
78
+ relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
79
+ before,
80
+ }
81
+ }
82
+
83
+ function assertSafeDeletePath(requestedPath: string): void {
84
+ const trimmed = requestedPath.trim()
85
+ if (trimmed !== requestedPath || trimmed.length === 0) {
86
+ throw new Error('delete_file path must be a clean workspace-relative file path')
87
+ }
88
+ if (/[|;&<>`]/.test(trimmed)) {
89
+ throw new Error('delete_file path must not contain shell operators')
90
+ }
91
+ if (/^(?:rm|del|erase|rmdir|remove-item|mkdir|type|cat|echo|copy|move|mv|cp)\b/i.test(trimmed)) {
92
+ throw new Error('delete_file path looks like a shell command; pass only the file path')
93
+ }
94
+ }
95
+
96
+ async function tryRecordRewindSnapshot(
97
+ snapshot: Parameters<typeof recordRewindSnapshot>[0],
98
+ ): Promise<string | undefined> {
99
+ try {
100
+ await recordRewindSnapshot(snapshot)
101
+ return undefined
102
+ } catch (error: unknown) {
103
+ const message = (error as Error).message || 'rewind checkpoint could not be recorded'
104
+ return `rewind checkpoint was not recorded (${message})`
105
+ }
106
+ }
107
+
108
+ function preview(text: string, max = 1200): string {
109
+ if (text.length <= max) return text
110
+ return `${text.slice(0, max - 3)}...`
111
+ }