ethagent 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|