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,482 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { getConfigDir } from './config.js'
5
+ import type { Message } from '../providers/contracts.js'
6
+ import { getCwd } from '../runtime/cwd.js'
7
+ import type { SessionMode } from '../runtime/sessionMode.js'
8
+ import { atomicWriteText } from './atomicWrite.js'
9
+ import {
10
+ isUserCorrectionOfToolState,
11
+ looksLikeToolStateClaim,
12
+ } from '../runtime/toolClaimGuards.js'
13
+
14
+ export type SessionMessage =
15
+ | { version?: 2; role: 'user'; content: string; createdAt: string; turnId?: string; synthetic?: boolean }
16
+ | { version?: 2; role: 'assistant'; content: string; createdAt: string; model?: string; usage?: { in?: number; out?: number }; turnId?: string; synthetic?: boolean }
17
+ | { version?: 2; role: 'system'; content: string; createdAt: string; turnId?: string; synthetic?: boolean }
18
+ | { version: 2; role: 'tool_use'; toolUseId: string; name: string; input: Record<string, unknown>; createdAt: string; turnId?: string }
19
+ | { version: 2; role: 'tool_result'; toolUseId: string; name: string; content: string; isError?: boolean; createdAt: string; turnId?: string }
20
+
21
+ export type SessionMetadata = {
22
+ id: string
23
+ startedAt: string
24
+ updatedAt: string
25
+ projectRoot: string
26
+ workspaceRoot: string
27
+ lastCwd: string
28
+ provider?: string
29
+ model?: string
30
+ mode?: SessionMode
31
+ firstUserMessage: string
32
+ turnCount: number
33
+ archivedAt?: string
34
+ compactedToSessionId?: string
35
+ compactedFromSessionId?: string
36
+ }
37
+
38
+ export type SessionSummary = SessionMetadata & {
39
+ path: string
40
+ mtimeMs: number
41
+ projectLabel: string
42
+ directoryLabel: string
43
+ }
44
+
45
+ export type SessionWriteContext = {
46
+ cwd: string
47
+ provider?: string
48
+ model?: string
49
+ mode?: SessionMode
50
+ }
51
+
52
+ export type ClearAllSessionsResult = {
53
+ sessionFiles: number
54
+ metadataFiles: number
55
+ }
56
+
57
+ const SessionMetadataSchemaVersion = 1
58
+
59
+ export function getSessionsDir(): string {
60
+ return path.join(getConfigDir(), 'sessions')
61
+ }
62
+
63
+ export function newSessionId(): string {
64
+ return randomUUID()
65
+ }
66
+
67
+ function sessionPath(id: string): string {
68
+ return path.join(getSessionsDir(), `${id}.jsonl`)
69
+ }
70
+
71
+ function sessionMetaPath(id: string): string {
72
+ return path.join(getSessionsDir(), `${id}.meta.json`)
73
+ }
74
+
75
+ async function ensureSessionsDir(): Promise<void> {
76
+ await fs.mkdir(getSessionsDir(), { recursive: true })
77
+ }
78
+
79
+ export async function appendSessionMessage(
80
+ id: string,
81
+ message: SessionMessage,
82
+ context?: SessionWriteContext,
83
+ ): Promise<void> {
84
+ await ensureSessionsDir()
85
+ await fs.appendFile(sessionPath(id), JSON.stringify(message) + '\n', { mode: 0o600 })
86
+ if (context) {
87
+ await updateSessionMetadata(id, message, context)
88
+ }
89
+ }
90
+
91
+ export async function loadSession(id: string): Promise<SessionMessage[]> {
92
+ let raw: string
93
+ try {
94
+ raw = await fs.readFile(sessionPath(id), 'utf8')
95
+ } catch (err: unknown) {
96
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
97
+ throw err
98
+ }
99
+ const out: SessionMessage[] = []
100
+ for (const line of raw.split('\n')) {
101
+ const trimmed = line.trim()
102
+ if (!trimmed) continue
103
+ try {
104
+ out.push(normalizeSessionMessage(JSON.parse(trimmed) as SessionMessage))
105
+ } catch {
106
+ continue
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ export async function loadSessionMetadata(id: string): Promise<SessionMetadata | null> {
113
+ try {
114
+ const raw = await fs.readFile(sessionMetaPath(id), 'utf8')
115
+ return normalizeMetadata(JSON.parse(raw) as Partial<SessionMetadata> & { version?: number }, id)
116
+ } catch (err: unknown) {
117
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
118
+ throw err
119
+ }
120
+ }
121
+
122
+ export async function ensureSessionMetadata(id: string, context: SessionWriteContext): Promise<SessionMetadata> {
123
+ const existing = await loadSessionMetadata(id)
124
+ if (existing) return existing
125
+ const now = new Date().toISOString()
126
+ const metadata: SessionMetadata = {
127
+ id,
128
+ startedAt: now,
129
+ updatedAt: now,
130
+ projectRoot: await detectProjectRoot(context.cwd),
131
+ workspaceRoot: context.cwd,
132
+ lastCwd: context.cwd,
133
+ provider: context.provider,
134
+ model: context.model,
135
+ mode: context.mode,
136
+ firstUserMessage: '',
137
+ turnCount: 0,
138
+ }
139
+ await writeSessionMetadata(metadata)
140
+ return metadata
141
+ }
142
+
143
+ export async function updateSessionActivity(
144
+ id: string,
145
+ context: SessionWriteContext,
146
+ changes: Partial<Pick<SessionMetadata, 'workspaceRoot' | 'lastCwd' | 'provider' | 'model' | 'mode' | 'compactedFromSessionId'>>,
147
+ ): Promise<SessionMetadata> {
148
+ const base = await ensureSessionMetadata(id, context)
149
+ const next: SessionMetadata = {
150
+ ...base,
151
+ updatedAt: new Date().toISOString(),
152
+ projectRoot: changes.workspaceRoot ? await detectProjectRoot(changes.workspaceRoot) : base.projectRoot,
153
+ workspaceRoot: changes.workspaceRoot ?? base.workspaceRoot,
154
+ lastCwd: changes.lastCwd ?? context.cwd,
155
+ provider: changes.provider ?? context.provider ?? base.provider,
156
+ model: changes.model ?? context.model ?? base.model,
157
+ mode: changes.mode ?? context.mode ?? base.mode,
158
+ compactedFromSessionId: changes.compactedFromSessionId ?? base.compactedFromSessionId,
159
+ }
160
+ await writeSessionMetadata(next)
161
+ return next
162
+ }
163
+
164
+ export async function archiveSession(
165
+ id: string,
166
+ context: SessionWriteContext,
167
+ details: { compactedToSessionId?: string } = {},
168
+ ): Promise<SessionMetadata> {
169
+ const base = await ensureSessionMetadata(id, context)
170
+ const next: SessionMetadata = {
171
+ ...base,
172
+ updatedAt: new Date().toISOString(),
173
+ archivedAt: base.archivedAt ?? new Date().toISOString(),
174
+ compactedToSessionId: details.compactedToSessionId ?? base.compactedToSessionId,
175
+ }
176
+ await writeSessionMetadata(next)
177
+ return next
178
+ }
179
+
180
+ export async function listSessions(limit = 50): Promise<SessionSummary[]> {
181
+ try {
182
+ await ensureSessionsDir()
183
+ } catch {
184
+ return []
185
+ }
186
+
187
+ let files: string[]
188
+ try {
189
+ files = await fs.readdir(getSessionsDir())
190
+ } catch {
191
+ return []
192
+ }
193
+
194
+ const sessionIds = files
195
+ .filter(file => file.endsWith('.jsonl'))
196
+ .map(file => file.slice(0, -'.jsonl'.length))
197
+
198
+ const summaries = await Promise.all(sessionIds.map(async id => summarizeSession(id)))
199
+ return summaries
200
+ .filter((value): value is SessionSummary => value !== null)
201
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
202
+ .slice(0, limit)
203
+ }
204
+
205
+ export async function clearAllSessions(): Promise<ClearAllSessionsResult> {
206
+ const dir = getSessionsDir()
207
+ let files: string[]
208
+ try {
209
+ files = await fs.readdir(dir)
210
+ } catch (err: unknown) {
211
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
212
+ return { sessionFiles: 0, metadataFiles: 0 }
213
+ }
214
+ throw err
215
+ }
216
+
217
+ let sessionFiles = 0
218
+ let metadataFiles = 0
219
+ for (const file of files) {
220
+ const isSession = file.endsWith('.jsonl')
221
+ const isMetadata = file.endsWith('.meta.json')
222
+ if (!isSession && !isMetadata) continue
223
+
224
+ const target = path.join(dir, file)
225
+ const relative = path.relative(dir, target)
226
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
227
+ throw new Error(`refusing to delete session path outside storage: ${file}`)
228
+ }
229
+
230
+ await fs.rm(target, { force: true })
231
+ if (isSession) sessionFiles += 1
232
+ else metadataFiles += 1
233
+ }
234
+
235
+ return { sessionFiles, metadataFiles }
236
+ }
237
+
238
+ export type ProviderMessageProjectionOptions = {
239
+ compactToolHistory?: boolean
240
+ preserveTurnId?: string
241
+ }
242
+
243
+ export const TOOL_CORRECTION_CONTEXT_MESSAGE =
244
+ 'The latest user message corrects a prior assistant claim about tool or filesystem state. Treat user correction and tool_result messages as authoritative. Ignore any recent assistant claim about files, directories, cwd, or tool execution unless it is backed by a tool_result, and retry with the appropriate tool.'
245
+
246
+ export function sessionMessagesToProviderMessages(
247
+ messages: SessionMessage[],
248
+ options: ProviderMessageProjectionOptions = {},
249
+ ): Message[] {
250
+ const out: Message[] = []
251
+ const pendingToolUses = new Map<string, { name: string; input: Record<string, unknown> }>()
252
+ const invalidatedAssistantMessages = invalidatedAssistantClaimIndexes(messages)
253
+
254
+ for (const [index, message] of messages.entries()) {
255
+ if (message.role === 'system' || message.role === 'user' || message.role === 'assistant') {
256
+ if (message.role === 'assistant' && invalidatedAssistantMessages.has(index)) continue
257
+ out.push({ role: message.role, content: message.content })
258
+ continue
259
+ }
260
+ if (message.role === 'tool_use') {
261
+ if (shouldCompactToolMessage(message, options)) continue
262
+ pendingToolUses.set(message.toolUseId, { name: message.name, input: message.input })
263
+ continue
264
+ }
265
+ if (shouldCompactToolMessage(message, options)) {
266
+ pendingToolUses.delete(message.toolUseId)
267
+ continue
268
+ }
269
+ const pendingToolUse = pendingToolUses.get(message.toolUseId)
270
+ if (!pendingToolUse) continue
271
+ out.push({
272
+ role: 'assistant',
273
+ content: [{
274
+ type: 'tool_use',
275
+ id: message.toolUseId,
276
+ name: pendingToolUse.name,
277
+ input: pendingToolUse.input,
278
+ }],
279
+ })
280
+ out.push({
281
+ role: 'user',
282
+ content: [{
283
+ type: 'tool_result',
284
+ toolUseId: message.toolUseId,
285
+ content: message.content,
286
+ isError: message.isError,
287
+ }],
288
+ })
289
+ pendingToolUses.delete(message.toolUseId)
290
+ }
291
+
292
+ return out
293
+ }
294
+
295
+ export function latestUserMessageCorrectsToolState(messages: SessionMessage[]): boolean {
296
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
297
+ const message = messages[index]
298
+ if (!message) continue
299
+ if (message.role === 'system') continue
300
+ return message.role === 'user' && isUserCorrectionOfToolState(message.content)
301
+ }
302
+ return false
303
+ }
304
+
305
+ function invalidatedAssistantClaimIndexes(messages: SessionMessage[]): Set<number> {
306
+ const invalidated = new Set<number>()
307
+
308
+ for (let index = 0; index < messages.length; index += 1) {
309
+ const message = messages[index]
310
+ if (message?.role !== 'user' || !isUserCorrectionOfToolState(message.content)) continue
311
+
312
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
313
+ const prior = messages[cursor]
314
+ if (!prior) continue
315
+ if (prior.role === 'user') break
316
+ if (prior.role !== 'assistant') continue
317
+ if (!looksLikeToolStateClaim(prior.content)) continue
318
+ if (hasToolEvidenceBetween(messages, cursor, index)) continue
319
+ invalidated.add(cursor)
320
+ }
321
+ }
322
+
323
+ return invalidated
324
+ }
325
+
326
+ function hasToolEvidenceBetween(messages: SessionMessage[], start: number, end: number): boolean {
327
+ for (let index = start + 1; index < end; index += 1) {
328
+ const message = messages[index]
329
+ if (message?.role === 'tool_result') return true
330
+ }
331
+ return false
332
+ }
333
+
334
+ function shouldCompactToolMessage(
335
+ message: Extract<SessionMessage, { role: 'tool_use' | 'tool_result' }>,
336
+ options: ProviderMessageProjectionOptions,
337
+ ): boolean {
338
+ if (!options.compactToolHistory) return false
339
+ return !message.turnId || message.turnId !== options.preserveTurnId
340
+ }
341
+
342
+ function normalizeSessionMessage(message: SessionMessage): SessionMessage {
343
+ if ('version' in message && message.version === 2) return message
344
+ return message
345
+ }
346
+
347
+ async function summarizeSession(id: string): Promise<SessionSummary | null> {
348
+ const full = sessionPath(id)
349
+ let stat
350
+ try {
351
+ stat = await fs.stat(full)
352
+ } catch {
353
+ return null
354
+ }
355
+
356
+ const metadata = await loadSessionMetadata(id)
357
+ if (metadata) {
358
+ return toSummary(metadata, full, stat.mtimeMs)
359
+ }
360
+
361
+ const messages = await loadSession(id)
362
+ if (messages.length === 0) return null
363
+ const firstUser = messages.find(isNonSyntheticUserMessage)
364
+ if (!firstUser) return null
365
+
366
+ const inferredCwd = getCwd()
367
+ const projectRoot = await detectProjectRoot(inferredCwd)
368
+ const fallback: SessionMetadata = {
369
+ id,
370
+ startedAt: firstUser.createdAt,
371
+ updatedAt: new Date(stat.mtimeMs).toISOString(),
372
+ projectRoot,
373
+ workspaceRoot: inferredCwd,
374
+ lastCwd: inferredCwd,
375
+ firstUserMessage: firstUser.content.slice(0, 120),
376
+ turnCount: messages.filter(isNonSyntheticUserMessage).length,
377
+ }
378
+ return toSummary(fallback, full, stat.mtimeMs)
379
+ }
380
+
381
+ function isNonSyntheticUserMessage(
382
+ message: SessionMessage,
383
+ ): message is Extract<SessionMessage, { role: 'user' }> {
384
+ return message.role === 'user' && !message.synthetic
385
+ }
386
+
387
+ async function updateSessionMetadata(
388
+ id: string,
389
+ message: SessionMessage,
390
+ context: SessionWriteContext,
391
+ ): Promise<void> {
392
+ const current = await ensureSessionMetadata(id, context)
393
+ const next: SessionMetadata = {
394
+ ...current,
395
+ updatedAt: message.createdAt,
396
+ projectRoot: await detectProjectRoot(context.cwd),
397
+ workspaceRoot: current.workspaceRoot || context.cwd,
398
+ lastCwd: context.cwd,
399
+ provider: context.provider ?? current.provider,
400
+ model: context.model ?? current.model,
401
+ mode: context.mode ?? current.mode,
402
+ firstUserMessage: current.firstUserMessage || (message.role === 'user' && !message.synthetic ? message.content.slice(0, 120) : ''),
403
+ turnCount: current.turnCount + (message.role === 'user' && !message.synthetic ? 1 : 0),
404
+ }
405
+ await writeSessionMetadata(next)
406
+ }
407
+
408
+ async function writeSessionMetadata(metadata: SessionMetadata): Promise<void> {
409
+ await ensureSessionsDir()
410
+ const file = sessionMetaPath(metadata.id)
411
+ const payload = {
412
+ version: SessionMetadataSchemaVersion,
413
+ ...metadata,
414
+ }
415
+ await atomicWriteText(file, JSON.stringify(payload, null, 2) + '\n')
416
+ }
417
+
418
+ function normalizeMetadata(
419
+ raw: Partial<SessionMetadata> & { version?: number },
420
+ id: string,
421
+ ): SessionMetadata {
422
+ const cwd = raw.lastCwd || raw.workspaceRoot || getCwd()
423
+ const now = new Date().toISOString()
424
+ return {
425
+ id,
426
+ startedAt: raw.startedAt || now,
427
+ updatedAt: raw.updatedAt || raw.startedAt || now,
428
+ projectRoot: raw.projectRoot || cwd,
429
+ workspaceRoot: raw.workspaceRoot || cwd,
430
+ lastCwd: raw.lastCwd || raw.workspaceRoot || cwd,
431
+ provider: raw.provider,
432
+ model: raw.model,
433
+ mode: raw.mode,
434
+ firstUserMessage: raw.firstUserMessage || '',
435
+ turnCount: raw.turnCount ?? 0,
436
+ archivedAt: raw.archivedAt,
437
+ compactedToSessionId: raw.compactedToSessionId,
438
+ compactedFromSessionId: raw.compactedFromSessionId,
439
+ }
440
+ }
441
+
442
+ function toSummary(metadata: SessionMetadata, fullPath: string, mtimeMs: number): SessionSummary {
443
+ const projectLabel = path.basename(metadata.projectRoot) || metadata.projectRoot
444
+ const directoryLabel = formatDirectoryLabel(metadata.projectRoot, metadata.workspaceRoot, metadata.lastCwd)
445
+ return {
446
+ ...metadata,
447
+ path: fullPath,
448
+ mtimeMs,
449
+ projectLabel,
450
+ directoryLabel,
451
+ }
452
+ }
453
+
454
+ function formatDirectoryLabel(projectRoot: string, workspaceRoot: string, lastCwd: string): string {
455
+ const workspaceRel = path.relative(projectRoot, workspaceRoot)
456
+ const cwdRel = path.relative(workspaceRoot, lastCwd)
457
+ const workspaceLabel = workspaceRel && !workspaceRel.startsWith('..') ? workspaceRel : path.basename(workspaceRoot)
458
+ if (!cwdRel || cwdRel === '') return workspaceLabel || '.'
459
+ if (cwdRel.startsWith('..')) return workspaceLabel || path.basename(lastCwd)
460
+ return workspaceLabel === '.'
461
+ ? `./${cwdRel}`
462
+ : `${workspaceLabel}/${cwdRel}`.replaceAll('\\', '/')
463
+ }
464
+
465
+ async function detectProjectRoot(start: string): Promise<string> {
466
+ let current = path.resolve(start)
467
+ while (true) {
468
+ if (await exists(path.join(current, '.git'))) return current
469
+ const parent = path.dirname(current)
470
+ if (parent === current) return path.resolve(start)
471
+ current = parent
472
+ }
473
+ }
474
+
475
+ async function exists(target: string): Promise<boolean> {
476
+ try {
477
+ await fs.access(target)
478
+ return true
479
+ } catch {
480
+ return false
481
+ }
482
+ }
@@ -0,0 +1,174 @@
1
+ const RISKY_PATTERN_CHECKS: Array<{ pattern: RegExp; message: string }> = [
2
+ { pattern: /[`]/, message: 'contains backtick command substitution' },
3
+ { pattern: /\$\(/, message: 'contains $() command substitution' },
4
+ { pattern: /\$\{/, message: 'contains parameter expansion' },
5
+ { pattern: /(^|[^\\])[|]/, message: 'contains a pipe' },
6
+ { pattern: /(^|[^\\])&&/, message: 'contains && chaining' },
7
+ { pattern: /(^|[^\\])\|\|/, message: 'contains || chaining' },
8
+ { pattern: /(^|[^\\]);/, message: 'contains ; chaining' },
9
+ { pattern: /(^|[^\\])[<>]/, message: 'contains shell redirection' },
10
+ { pattern: /\r|\n/, message: 'contains a newline' },
11
+ { pattern: /<<|<\(|>\(/, message: 'contains heredoc or process substitution syntax' },
12
+ ]
13
+
14
+ const HIGH_RISK_COMMANDS = new Set([
15
+ 'chmod',
16
+ 'chown',
17
+ 'curl',
18
+ 'dd',
19
+ 'del',
20
+ 'diskpart',
21
+ 'erase',
22
+ 'format',
23
+ 'git',
24
+ 'icacls',
25
+ 'mkfs',
26
+ 'powershell',
27
+ 'pwsh',
28
+ 'reg',
29
+ 'rm',
30
+ 'rmdir',
31
+ 'scp',
32
+ 'ssh',
33
+ 'takeown',
34
+ 'wget',
35
+ ])
36
+
37
+ const NON_PERSISTABLE_COMMANDS = new Set([
38
+ 'rm',
39
+ 'rmdir',
40
+ 'del',
41
+ 'erase',
42
+ 'format',
43
+ 'mkfs',
44
+ 'dd',
45
+ 'diskpart',
46
+ 'reg',
47
+ 'powershell',
48
+ 'pwsh',
49
+ ])
50
+
51
+ const NATIVE_TOOL_COMMANDS = new Map([
52
+ ['change_directory', 'Use the change_directory tool directly instead of passing change_directory to run_bash.'],
53
+ ['edit_file', 'Use the edit_file tool directly instead of passing edit_file to run_bash.'],
54
+ ['propose_private_continuity_edit', 'Use the propose_private_continuity_edit tool directly instead of passing it to run_bash.'],
55
+ ['read_private_continuity_file', 'Use the read_private_continuity_file tool directly instead of passing it to run_bash.'],
56
+ ['list_directory', 'Use the list_directory tool directly instead of passing list_directory to run_bash.'],
57
+ ['list_mcp_resources', 'Use the list_mcp_resources tool directly instead of passing it to run_bash.'],
58
+ ['read_mcp_resource', 'Use the read_mcp_resource tool directly instead of passing it to run_bash.'],
59
+ ['read_file', 'Use the read_file tool directly instead of passing read_file to run_bash.'],
60
+ ['run_bash', 'run_bash cannot run itself. Put an actual shell command in the command field.'],
61
+ ])
62
+
63
+ export type BashSafetyAssessment = {
64
+ warning?: string
65
+ canPersistExact: boolean
66
+ canPersistPrefix: boolean
67
+ commandPrefix: string
68
+ }
69
+
70
+ const PROSE_STARTERS = new Set([
71
+ 'a',
72
+ 'an',
73
+ 'and',
74
+ 'for',
75
+ 'here',
76
+ 'i',
77
+ 'it',
78
+ 'lets',
79
+ 'now',
80
+ 'okay',
81
+ 'please',
82
+ 'snake',
83
+ 'sure',
84
+ 'that',
85
+ 'the',
86
+ 'then',
87
+ 'this',
88
+ 'we',
89
+ 'you',
90
+ 'youll',
91
+ ])
92
+
93
+ export function assessBashCommand(command: string): BashSafetyAssessment {
94
+ const trimmed = command.trim()
95
+ const firstToken = extractFirstToken(trimmed)
96
+ const highRisk = firstToken ? HIGH_RISK_COMMANDS.has(firstToken.toLowerCase()) : false
97
+ const nonPersistable = firstToken ? NON_PERSISTABLE_COMMANDS.has(firstToken.toLowerCase()) : false
98
+ const triggeredChecks = RISKY_PATTERN_CHECKS.filter(check => check.pattern.test(command)).map(check => check.message)
99
+
100
+ const warning = triggeredChecks.length > 0
101
+ ? `warning: ${triggeredChecks[0]}. reusable approval is limited for this command.`
102
+ : highRisk
103
+ ? `warning: ${firstToken} is a high-impact command. reusable approval is limited for this command.`
104
+ : undefined
105
+
106
+ return {
107
+ warning,
108
+ canPersistExact: triggeredChecks.length === 0 && !nonPersistable,
109
+ canPersistPrefix: triggeredChecks.length === 0 && !highRisk && Boolean(firstToken),
110
+ commandPrefix: firstToken,
111
+ }
112
+ }
113
+
114
+ export function validateBashCommandInput(command: string): string | undefined {
115
+ const trimmed = command.trim()
116
+ if (!trimmed) return 'command must not be empty'
117
+
118
+ const firstToken = extractFirstToken(trimmed)
119
+ if (!firstToken) return 'command must start with an executable or shell builtin'
120
+
121
+ const normalizedFirstToken = normalizeCommandToken(firstToken)
122
+ if (!normalizedFirstToken) {
123
+ return 'command must start with an executable or shell builtin'
124
+ }
125
+
126
+ if (PROSE_STARTERS.has(normalizedFirstToken)) {
127
+ return 'command must be an actual shell command, not explanatory prose'
128
+ }
129
+
130
+ const nativeToolMessage = NATIVE_TOOL_COMMANDS.get(normalizedFirstToken)
131
+ if (nativeToolMessage) {
132
+ return `command must be an actual shell command, not an ethagent tool name. ${nativeToolMessage}`
133
+ }
134
+
135
+ if (
136
+ /\b(you can|you should|you need|run the following command|written in|under the|to run(?: the game)?|copy and paste|save (?:it|this))/i.test(trimmed)
137
+ ) {
138
+ return 'command must be an actual shell command, not explanatory prose'
139
+ }
140
+
141
+ const words = trimmed.split(/\s+/).filter(Boolean)
142
+ const hasShellSyntax = /[|&;<>]/.test(trimmed)
143
+ if (!hasShellSyntax && /[.!?]$/.test(trimmed) && words.length >= 4) {
144
+ return 'command must be an actual shell command, not explanatory prose'
145
+ }
146
+
147
+ return undefined
148
+ }
149
+
150
+ function extractFirstToken(command: string): string {
151
+ const trimmed = command.trim()
152
+ if (trimmed.startsWith('"')) {
153
+ const end = trimmed.indexOf('"', 1)
154
+ if (end > 1) return trimmed.slice(0, end + 1)
155
+ }
156
+ if (trimmed.startsWith("'")) {
157
+ const end = trimmed.indexOf("'", 1)
158
+ if (end > 1) return trimmed.slice(0, end + 1)
159
+ }
160
+ const match = trimmed.match(/^([^\s"'`]+)/)
161
+ return match?.[1] ?? ''
162
+ }
163
+
164
+ function normalizeCommandToken(token: string): string {
165
+ return token
166
+ .trim()
167
+ .replace(/^["']|["']$/g, '')
168
+ .replace(/\\/g, '/')
169
+ .split('/')
170
+ .at(-1)
171
+ ?.replace(/\.(exe|cmd|bat|ps1)$/i, '')
172
+ .toLowerCase()
173
+ .replace(/[^a-z0-9_.:-]/g, '') ?? ''
174
+ }