ethagent 0.2.1 → 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.
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 +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -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,160 @@
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 { EthagentConfig } from '../storage/config.js'
6
+ import type { Tool } from './contracts.js'
7
+ import { applyRequestedEdit } from './editUtils.js'
8
+ import { resolveWorkspacePath } from './readTool.js'
9
+
10
+ const schema = z.object({
11
+ path: z.string().min(1),
12
+ oldText: z.string().optional(),
13
+ newText: z.string(),
14
+ replaceAll: z.boolean().optional(),
15
+ replaceWholeFile: z.boolean().optional(),
16
+ })
17
+
18
+ export const editTool: Tool<typeof schema> = {
19
+ name: 'edit_file',
20
+ kind: 'edit',
21
+ description: 'Edit a workspace text file. Provide oldText and newText for targeted replacement, or just newText only for ordinary whole-file workspace edits. Do not use for private SOUL.md or MEMORY.md when an identity is linked; use propose_private_continuity_edit instead.',
22
+ inputSchema: schema,
23
+ inputSchemaJson: {
24
+ type: 'object',
25
+ properties: {
26
+ path: { type: 'string', description: 'Path to the file to edit.' },
27
+ oldText: { type: 'string', description: 'Exact text to find and replace. Prefer this for existing files. Omit only for ordinary whole-file workspace edits.' },
28
+ newText: { type: 'string', description: 'Replacement text, or entire file contents if oldText is omitted.' },
29
+ replaceAll: { type: 'boolean', description: 'Replace every exact oldText match. Prefer false unless you are certain.' },
30
+ },
31
+ required: ['path', 'newText'],
32
+ },
33
+ parse(input) {
34
+ return schema.parse(input)
35
+ },
36
+ async buildPermissionRequest(input, context) {
37
+ const { fullPath, relativePath, applied } = await prepareEdit(input, context)
38
+ return {
39
+ kind: 'edit',
40
+ path: fullPath,
41
+ relativePath,
42
+ directoryPath: path.dirname(fullPath),
43
+ title: 'allow file edit?',
44
+ subtitle: fullPath,
45
+ before: applied.previewBefore,
46
+ after: applied.previewAfter,
47
+ changeSummary: applied.summary,
48
+ }
49
+ },
50
+ async execute(input, context) {
51
+ const { fullPath, applied, existedBefore, before } = await prepareEdit(input, context)
52
+ const rewindWarning = await tryRecordRewindSnapshot({
53
+ workspaceRoot: context.workspaceRoot,
54
+ filePath: fullPath,
55
+ relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
56
+ existedBefore,
57
+ previousContent: before,
58
+ changeSummary: applied.summary,
59
+ createdAt: new Date().toISOString(),
60
+ sessionId: context.checkpoint?.sessionId,
61
+ turnId: context.checkpoint?.turnId,
62
+ messageRole: context.checkpoint?.messageRole,
63
+ promptSnippet: context.checkpoint?.promptSnippet,
64
+ checkpointLabel: context.checkpoint?.checkpointLabel,
65
+ })
66
+ await fs.mkdir(path.dirname(fullPath), { recursive: true })
67
+ await fs.writeFile(fullPath, applied.after, 'utf8')
68
+ return {
69
+ ok: true,
70
+ summary: applied.summary,
71
+ content: rewindWarning
72
+ ? `updated ${fullPath}\nwarning: ${rewindWarning}`
73
+ : `updated ${fullPath}`,
74
+ }
75
+ },
76
+ }
77
+
78
+ async function tryRecordRewindSnapshot(
79
+ snapshot: Parameters<typeof recordRewindSnapshot>[0],
80
+ ): Promise<string | undefined> {
81
+ try {
82
+ await recordRewindSnapshot(snapshot)
83
+ return undefined
84
+ } catch (error: unknown) {
85
+ const message = (error as Error).message || 'rewind checkpoint could not be recorded'
86
+ return `rewind checkpoint was not recorded (${message})`
87
+ }
88
+ }
89
+
90
+ async function prepareEdit(input: z.infer<typeof schema>, context: { workspaceRoot: string; config?: EthagentConfig }) {
91
+ assertSafeEditPath(input.path)
92
+ assertNotPrivateContinuityWorkspacePath(input.path, context.config, 'edit_file')
93
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path)
94
+ await assertEditableFileTarget(fullPath)
95
+ const { content: before, existed } = await readOptionalTextFile(fullPath)
96
+ const applied = applyRequestedEdit(
97
+ input.path,
98
+ before,
99
+ input.oldText,
100
+ input.newText,
101
+ input.replaceAll ?? false,
102
+ input.replaceWholeFile ?? false,
103
+ )
104
+ return {
105
+ fullPath,
106
+ relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
107
+ existedBefore: existed,
108
+ before,
109
+ applied,
110
+ }
111
+ }
112
+
113
+ async function assertEditableFileTarget(fullPath: string): Promise<void> {
114
+ try {
115
+ const stats = await fs.stat(fullPath)
116
+ if (stats.isDirectory()) {
117
+ throw new Error('edit_file path points to a directory; provide a file path')
118
+ }
119
+ } catch (error: unknown) {
120
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
121
+ throw error
122
+ }
123
+ }
124
+
125
+ function assertSafeEditPath(requestedPath: string): void {
126
+ const trimmed = requestedPath.trim()
127
+ if (trimmed !== requestedPath || trimmed.length === 0) {
128
+ throw new Error('edit_file path must be a clean workspace-relative file path')
129
+ }
130
+
131
+ if (/[|;&<>`]/.test(trimmed)) {
132
+ throw new Error('edit_file path must not contain shell operators')
133
+ }
134
+
135
+ if (/^(?:rm|del|erase|rmdir|remove-item|mkdir|type|cat|echo|copy|move|mv|cp)\b/i.test(trimmed)) {
136
+ throw new Error('edit_file path looks like a shell command; pass only the file path')
137
+ }
138
+ }
139
+
140
+ function assertNotPrivateContinuityWorkspacePath(
141
+ requestedPath: string,
142
+ config: EthagentConfig | undefined,
143
+ toolName: string,
144
+ ): void {
145
+ if (!config?.identity) return
146
+ const basename = path.basename(requestedPath.replaceAll('\\', '/')).toUpperCase()
147
+ if (basename !== 'SOUL.MD' && basename !== 'MEMORY.MD') return
148
+ throw new Error(
149
+ `${toolName} must not create or overwrite ${basename}; use propose_private_continuity_edit to patch the existing identity-vault scaffold`,
150
+ )
151
+ }
152
+
153
+ async function readOptionalTextFile(fullPath: string): Promise<{ content: string; existed: boolean }> {
154
+ try {
155
+ return { content: await fs.readFile(fullPath, 'utf8'), existed: true }
156
+ } catch (error: unknown) {
157
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { content: '', existed: false }
158
+ throw error
159
+ }
160
+ }
@@ -0,0 +1,170 @@
1
+ const LEFT_SINGLE_CURLY_QUOTE = '\u2018'
2
+ const RIGHT_SINGLE_CURLY_QUOTE = '\u2019'
3
+ const LEFT_DOUBLE_CURLY_QUOTE = '\u201c'
4
+ const RIGHT_DOUBLE_CURLY_QUOTE = '\u201d'
5
+
6
+ export type AppliedEdit = {
7
+ before: string
8
+ after: string
9
+ summary: string
10
+ previewBefore: string
11
+ previewAfter: string
12
+ }
13
+
14
+ export function applyRequestedEdit(
15
+ filePath: string,
16
+ before: string,
17
+ oldText: string | undefined,
18
+ newText: string,
19
+ replaceAll = false,
20
+ _replaceWholeFile = false,
21
+ ): AppliedEdit {
22
+ if (!oldText) {
23
+ if (newText.length === 0) {
24
+ throw new Error('edit_file newText is empty; empty whole-file writes are not valid unless replacing a specific oldText range')
25
+ }
26
+ return {
27
+ before,
28
+ after: newText,
29
+ summary: before.length === 0 ? `create ${filePath}` : `replace entire ${filePath}`,
30
+ previewBefore: previewText(before),
31
+ previewAfter: previewText(newText),
32
+ }
33
+ }
34
+
35
+ if (replaceAll) {
36
+ const matchCount = countOccurrences(before, oldText)
37
+ if (matchCount === 0) throw new Error('oldText was not found in the file')
38
+ return {
39
+ before,
40
+ after: before.replaceAll(oldText, () => newText),
41
+ summary: `replace ${matchCount} match${matchCount === 1 ? '' : 'es'} in ${filePath}`,
42
+ previewBefore: previewText(oldText),
43
+ previewAfter: previewText(newText),
44
+ }
45
+ }
46
+
47
+ const actualOldText = findUniqueEditableMatch(before, oldText)
48
+ if (!actualOldText) throw new Error('oldText was not found in the file')
49
+ if (countOccurrences(before, actualOldText) > 1) {
50
+ throw new Error('oldText matched multiple locations; provide more context or use replaceAll')
51
+ }
52
+
53
+ const adjustedNewText = preserveQuoteStyle(oldText, actualOldText, newText)
54
+ return {
55
+ before,
56
+ after: replaceSingleOccurrence(before, actualOldText, adjustedNewText),
57
+ summary: `edit ${filePath}`,
58
+ previewBefore: previewText(actualOldText),
59
+ previewAfter: previewText(adjustedNewText),
60
+ }
61
+ }
62
+
63
+ function replaceSingleOccurrence(content: string, search: string, replace: string): string {
64
+ const index = content.indexOf(search)
65
+ if (index === -1) throw new Error('oldText was not found in the file')
66
+ return `${content.slice(0, index)}${replace}${content.slice(index + search.length)}`
67
+ }
68
+
69
+ function findUniqueEditableMatch(fileContent: string, searchText: string): string | null {
70
+ const exactCount = countOccurrences(fileContent, searchText)
71
+ if (exactCount === 1) return searchText
72
+ if (exactCount > 1) return searchText
73
+
74
+ const normalizedSearch = normalizeQuotes(searchText)
75
+ const normalizedFile = normalizeQuotes(fileContent)
76
+ const firstIndex = normalizedFile.indexOf(normalizedSearch)
77
+ if (firstIndex === -1) return null
78
+
79
+ const secondIndex = normalizedFile.indexOf(normalizedSearch, firstIndex + normalizedSearch.length)
80
+ if (secondIndex !== -1) return null
81
+
82
+ return fileContent.slice(firstIndex, firstIndex + searchText.length)
83
+ }
84
+
85
+ function countOccurrences(content: string, search: string): number {
86
+ if (!search) return 0
87
+ let count = 0
88
+ let offset = 0
89
+ while (true) {
90
+ const index = content.indexOf(search, offset)
91
+ if (index === -1) return count
92
+ count += 1
93
+ offset = index + search.length
94
+ }
95
+ }
96
+
97
+ function normalizeQuotes(text: string): string {
98
+ return text
99
+ .replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
100
+ .replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
101
+ .replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
102
+ .replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')
103
+ }
104
+
105
+ function preserveQuoteStyle(oldText: string, actualOldText: string, newText: string): string {
106
+ if (oldText === actualOldText) return newText
107
+
108
+ let result = newText
109
+ if (containsCurlyDoubleQuotes(actualOldText)) result = applyCurlyDoubleQuotes(result)
110
+ if (containsCurlySingleQuotes(actualOldText)) result = applyCurlySingleQuotes(result)
111
+ return result
112
+ }
113
+
114
+ function containsCurlyDoubleQuotes(text: string): boolean {
115
+ return text.includes(LEFT_DOUBLE_CURLY_QUOTE) || text.includes(RIGHT_DOUBLE_CURLY_QUOTE)
116
+ }
117
+
118
+ function containsCurlySingleQuotes(text: string): boolean {
119
+ return text.includes(LEFT_SINGLE_CURLY_QUOTE) || text.includes(RIGHT_SINGLE_CURLY_QUOTE)
120
+ }
121
+
122
+ function applyCurlyDoubleQuotes(text: string): string {
123
+ const chars = [...text]
124
+ const out: string[] = []
125
+
126
+ for (let index = 0; index < chars.length; index += 1) {
127
+ const char = chars[index]
128
+ if (char !== '"') {
129
+ out.push(char!)
130
+ continue
131
+ }
132
+ out.push(isOpeningContext(chars, index) ? LEFT_DOUBLE_CURLY_QUOTE : RIGHT_DOUBLE_CURLY_QUOTE)
133
+ }
134
+
135
+ return out.join('')
136
+ }
137
+
138
+ function applyCurlySingleQuotes(text: string): string {
139
+ const chars = [...text]
140
+ const out: string[] = []
141
+
142
+ for (let index = 0; index < chars.length; index += 1) {
143
+ const char = chars[index]
144
+ if (char !== "'") {
145
+ out.push(char!)
146
+ continue
147
+ }
148
+
149
+ const prev = index > 0 ? chars[index - 1] : undefined
150
+ const next = index < chars.length - 1 ? chars[index + 1] : undefined
151
+ if (prev && next && /\p{L}/u.test(prev) && /\p{L}/u.test(next)) {
152
+ out.push(RIGHT_SINGLE_CURLY_QUOTE)
153
+ continue
154
+ }
155
+
156
+ out.push(isOpeningContext(chars, index) ? LEFT_SINGLE_CURLY_QUOTE : RIGHT_SINGLE_CURLY_QUOTE)
157
+ }
158
+
159
+ return out.join('')
160
+ }
161
+
162
+ function isOpeningContext(chars: string[], index: number): boolean {
163
+ if (index === 0) return true
164
+ return [' ', '\t', '\n', '\r', '(', '[', '{'].includes(chars[index - 1] ?? '')
165
+ }
166
+
167
+ function previewText(text: string, max = 700): string {
168
+ if (text.length <= max) return text
169
+ return `${text.slice(0, max - 3)}...`
170
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import type { Tool } from './contracts.js'
5
+ import { resolveWorkspacePath } from './readTool.js'
6
+
7
+ const schema = z.object({
8
+ path: z.string().optional(),
9
+ })
10
+
11
+ export const listDirectoryTool: Tool<typeof schema> = {
12
+ name: 'list_directory',
13
+ kind: 'read',
14
+ description: 'List files and folders in the current workspace. Use this first when you need to discover existing files before reading or editing them.',
15
+ inputSchema: schema,
16
+ inputSchemaJson: {
17
+ type: 'object',
18
+ properties: {
19
+ path: { type: 'string', description: 'Optional directory path relative to the current workspace.' },
20
+ },
21
+ required: [],
22
+ },
23
+ parse(input) {
24
+ return schema.parse(input)
25
+ },
26
+ async buildPermissionRequest(input, context) {
27
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path ?? '.')
28
+ const relativePath = path.relative(context.workspaceRoot, fullPath) || '.'
29
+ return {
30
+ kind: 'read',
31
+ path: fullPath,
32
+ relativePath,
33
+ directoryPath: fullPath,
34
+ title: 'allow directory listing?',
35
+ subtitle: fullPath,
36
+ }
37
+ },
38
+ async execute(input, context) {
39
+ const fullPath = resolveWorkspacePath(context.workspaceRoot, input.path ?? '.')
40
+ const entries = await fs.readdir(fullPath, { withFileTypes: true })
41
+ const lines = entries
42
+ .sort((left, right) => {
43
+ if (left.isDirectory() && !right.isDirectory()) return -1
44
+ if (!left.isDirectory() && right.isDirectory()) return 1
45
+ return left.name.localeCompare(right.name)
46
+ })
47
+ .map(entry => `${entry.isDirectory() ? '[dir]' : ' '} ${entry.name}`)
48
+ const relativePath = path.relative(context.workspaceRoot, fullPath) || '.'
49
+ return {
50
+ ok: true,
51
+ summary: `listed ${relativePath}`,
52
+ content: lines.length > 0 ? lines.join('\n') : '(empty directory)',
53
+ }
54
+ },
55
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod'
2
+ import type { Tool } from './contracts.js'
3
+ import { normalizeNameForMcp } from '../mcp/names.js'
4
+
5
+ const ListMcpResourcesInput = z.object({
6
+ server: z.string().min(1).optional(),
7
+ })
8
+
9
+ const ReadMcpResourceInput = z.object({
10
+ server: z.string().min(1),
11
+ uri: z.string().min(1),
12
+ })
13
+
14
+ export const listMcpResourcesTool: Tool<typeof ListMcpResourcesInput> = {
15
+ name: 'list_mcp_resources',
16
+ kind: 'mcp',
17
+ readOnly: true,
18
+ description: 'List resources exposed by connected MCP servers.',
19
+ inputSchema: ListMcpResourcesInput,
20
+ inputSchemaJson: {
21
+ type: 'object',
22
+ properties: {
23
+ server: { type: 'string', description: 'Optional MCP server name. Omit to list resources from every connected server.' },
24
+ },
25
+ },
26
+ parse(input) {
27
+ return ListMcpResourcesInput.parse(input)
28
+ },
29
+ async buildPermissionRequest(input) {
30
+ const serverName = input.server ?? '*'
31
+ return {
32
+ kind: 'mcp',
33
+ title: 'allow MCP resource listing?',
34
+ subtitle: input.server ? `list resources from ${input.server}` : 'list resources from all connected MCP servers',
35
+ serverName,
36
+ normalizedServerName: normalizeNameForMcp(serverName),
37
+ toolName: 'list_mcp_resources',
38
+ toolKey: 'list_mcp_resources',
39
+ readOnly: true,
40
+ destructive: false,
41
+ openWorld: false,
42
+ canPersistServer: Boolean(input.server),
43
+ }
44
+ },
45
+ async execute(input, context) {
46
+ if (!context.mcp) return { ok: false, summary: 'MCP unavailable', content: 'MCP runtime is not available.' }
47
+ return {
48
+ ok: true,
49
+ summary: input.server ? `listed MCP resources from ${input.server}` : 'listed MCP resources',
50
+ content: await context.mcp.listResources(input.server),
51
+ }
52
+ },
53
+ }
54
+
55
+ export const readMcpResourceTool: Tool<typeof ReadMcpResourceInput> = {
56
+ name: 'read_mcp_resource',
57
+ kind: 'mcp',
58
+ readOnly: true,
59
+ description: 'Read a specific resource from a connected MCP server.',
60
+ inputSchema: ReadMcpResourceInput,
61
+ inputSchemaJson: {
62
+ type: 'object',
63
+ properties: {
64
+ server: { type: 'string', description: 'The connected MCP server name.' },
65
+ uri: { type: 'string', description: 'The resource URI to read.' },
66
+ },
67
+ required: ['server', 'uri'],
68
+ },
69
+ parse(input) {
70
+ return ReadMcpResourceInput.parse(input)
71
+ },
72
+ async buildPermissionRequest(input) {
73
+ return {
74
+ kind: 'mcp',
75
+ title: 'allow MCP resource read?',
76
+ subtitle: `${input.server} / ${input.uri}`,
77
+ serverName: input.server,
78
+ normalizedServerName: normalizeNameForMcp(input.server),
79
+ toolName: 'read_mcp_resource',
80
+ toolKey: `read_mcp_resource:${normalizeNameForMcp(input.server)}`,
81
+ readOnly: true,
82
+ destructive: false,
83
+ openWorld: false,
84
+ canPersistServer: true,
85
+ }
86
+ },
87
+ async execute(input, context) {
88
+ if (!context.mcp) return { ok: false, summary: 'MCP unavailable', content: 'MCP runtime is not available.' }
89
+ return {
90
+ ok: true,
91
+ summary: `read MCP resource ${input.uri}`,
92
+ content: await context.mcp.readResource(input.server, input.uri, context.abortSignal),
93
+ }
94
+ },
95
+ }
@@ -0,0 +1,85 @@
1
+ import path from 'node:path'
2
+ import type { PermissionDecision, PermissionRequest, SessionPermissionRule } from './contracts.js'
3
+
4
+ export function buildPermissionRule(
5
+ decision: PermissionDecision,
6
+ request: PermissionRequest,
7
+ ): SessionPermissionRule | undefined {
8
+ switch (decision) {
9
+ case 'allow-kind-project':
10
+ if (request.kind === 'read' || request.kind === 'edit' || request.kind === 'write' || request.kind === 'cd') return { kind: request.kind, scope: 'kind' }
11
+ return undefined
12
+ case 'allow-path-project':
13
+ if (request.kind === 'read' || request.kind === 'edit' || request.kind === 'write' || request.kind === 'cd') {
14
+ return { kind: request.kind, scope: 'path', path: request.path }
15
+ }
16
+ return undefined
17
+ case 'allow-directory-project':
18
+ if (request.kind === 'read' || request.kind === 'edit' || request.kind === 'write' || request.kind === 'cd') {
19
+ return { kind: request.kind, scope: 'directory', path: request.directoryPath }
20
+ }
21
+ return undefined
22
+ case 'allow-command-project':
23
+ if (request.kind === 'bash' && request.canPersistExact) {
24
+ return { kind: 'bash', scope: 'command', command: request.command, cwd: request.cwd }
25
+ }
26
+ return undefined
27
+ case 'allow-command-prefix-project':
28
+ if (request.kind === 'bash' && request.canPersistPrefix && request.commandPrefix) {
29
+ return { kind: 'bash', scope: 'prefix', commandPrefix: request.commandPrefix, cwd: request.cwd }
30
+ }
31
+ return undefined
32
+ case 'allow-mcp-tool-project':
33
+ if (request.kind === 'mcp') return { kind: 'mcp', scope: 'tool', toolKey: request.toolKey }
34
+ return undefined
35
+ case 'allow-mcp-server-project':
36
+ if (request.kind === 'mcp' && request.canPersistServer) {
37
+ return { kind: 'mcp', scope: 'server', normalizedServerName: request.normalizedServerName }
38
+ }
39
+ return undefined
40
+ default:
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ export function matchPermissionRule(
46
+ rules: SessionPermissionRule[],
47
+ request: PermissionRequest,
48
+ ): SessionPermissionRule | undefined {
49
+ return rules.find(rule => matchesPermissionRule(rule, request))
50
+ }
51
+
52
+ export function shouldPersistPermissionDecision(decision: PermissionDecision): boolean {
53
+ return decision !== 'allow-once' && decision !== 'deny'
54
+ }
55
+
56
+ function matchesPermissionRule(rule: SessionPermissionRule, request: PermissionRequest): boolean {
57
+ if (rule.kind !== request.kind) return false
58
+
59
+ if (request.kind === 'read' || request.kind === 'edit' || request.kind === 'write' || request.kind === 'delete' || request.kind === 'cd') {
60
+ if (request.kind === 'delete') return false
61
+ if (rule.scope === 'kind') return true
62
+ if (rule.scope === 'path') return rule.path === request.path
63
+ if (rule.scope === 'directory') {
64
+ return request.path === rule.path || request.path.startsWith(`${rule.path}${path.sep}`)
65
+ }
66
+ return false
67
+ }
68
+
69
+ if (request.kind === 'bash') {
70
+ if (rule.scope === 'command') return rule.command === request.command && rule.cwd === request.cwd
71
+ if (rule.scope === 'prefix') {
72
+ const normalizedCommand = request.command.trim()
73
+ return (
74
+ rule.cwd === request.cwd &&
75
+ (normalizedCommand === rule.commandPrefix || normalizedCommand.startsWith(`${rule.commandPrefix} `))
76
+ )
77
+ }
78
+ }
79
+
80
+ if (request.kind === 'mcp') {
81
+ if (rule.scope === 'tool') return rule.toolKey === request.toolKey
82
+ if (rule.scope === 'server') return rule.normalizedServerName === request.normalizedServerName
83
+ }
84
+ return false
85
+ }