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,701 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { Message, Provider } from '../providers/contracts.js'
4
+ import { directToolUsesForUserText } from '../runtime/toolIntent.js'
5
+ import { toPermissionMode, type SessionMode } from '../runtime/sessionMode.js'
6
+ import { runPendingToolUses } from '../runtime/toolExecution.js'
7
+ import { runRuntimeTurn, type TurnEvent } from '../runtime/turn.js'
8
+ import type { EthagentConfig } from '../storage/config.js'
9
+ import type { SessionMessage } from '../storage/sessions.js'
10
+ import type { SessionPermissionRule, ToolResult } from '../tools/contracts.js'
11
+ import { readContinuityFiles } from '../identity/continuity/storage.js'
12
+ import type { MessageRow } from './MessageList.js'
13
+ import {
14
+ buildBaseMessages,
15
+ createTurnCheckpoint,
16
+ type TurnCheckpoint,
17
+ } from './chatScreenUtils.js'
18
+
19
+ type MutableRef<T> = { current: T }
20
+
21
+ type ExecuteToolResult = {
22
+ result: ToolResult
23
+ sessionRule?: SessionPermissionRule
24
+ persistRule?: boolean
25
+ }
26
+
27
+ export type TurnOrchestratorContext = {
28
+ provider: Provider
29
+ mode: SessionMode
30
+ sessionId: string
31
+ userText: string
32
+ streamFlushMs: number
33
+ controller: AbortController
34
+ nextRowId: () => string
35
+ nowIso: () => string
36
+ getConfig: () => EthagentConfig
37
+ getCwd: () => string
38
+ getDisplayCwd: () => string
39
+ getSessionMessages: () => SessionMessage[]
40
+ setActiveCheckpoint: (checkpoint: TurnCheckpoint | undefined) => void
41
+ setStreaming: (streaming: boolean) => void
42
+ updateRows: (updater: (prev: MessageRow[]) => MessageRow[]) => void
43
+ pushNote: (text: string, kind?: 'info' | 'error' | 'dim') => void
44
+ persistTurnMessage: (message: SessionMessage) => Promise<void>
45
+ executeTool: (
46
+ name: string,
47
+ input: Record<string, unknown>,
48
+ mode: ReturnType<typeof toPermissionMode>,
49
+ ) => Promise<ExecuteToolResult>
50
+ applySessionRule: (rule?: SessionPermissionRule, persistRule?: boolean) => Promise<void>
51
+ preflightProvider?: () => Promise<{ ok: true } | { ok: false; message: string }>
52
+ onPlanReady?: (plan: string) => void
53
+ pendingAssistantTextRef: MutableRef<string | null>
54
+ pendingThinkingTextRef: MutableRef<string | null>
55
+ streamFlushTimerRef: MutableRef<ReturnType<typeof setTimeout> | null>
56
+ }
57
+
58
+ export type StreamingTurnResult = {
59
+ finishedNormally: boolean
60
+ cancelled: boolean
61
+ }
62
+
63
+ /**
64
+ * runStreamingTurn - the UI adapter over runRuntimeTurn.
65
+ *
66
+ * Responsibilities (UI-only; logic lives in runtime/turn.ts):
67
+ * - translate runtime events into Ink MessageRow updates,
68
+ * - flush streaming text to rows on a debounce,
69
+ * - persist SessionMessages on commit boundaries,
70
+ * - drive the tool batch (permission prompts, row pushes, persistence),
71
+ * - surface plan-mode output to the caller,
72
+ * - return a summary of what happened (finishedNormally / cancelled)
73
+ * for the caller to act on.
74
+ */
75
+ export async function runStreamingTurn(
76
+ context: TurnOrchestratorContext,
77
+ ): Promise<StreamingTurnResult> {
78
+ const {
79
+ provider,
80
+ mode,
81
+ sessionId,
82
+ userText,
83
+ streamFlushMs,
84
+ controller,
85
+ nextRowId,
86
+ nowIso,
87
+ getConfig,
88
+ getCwd,
89
+ getSessionMessages,
90
+ setActiveCheckpoint,
91
+ setStreaming,
92
+ updateRows,
93
+ pushNote,
94
+ persistTurnMessage,
95
+ executeTool,
96
+ applySessionRule,
97
+ preflightProvider,
98
+ onPlanReady,
99
+ pendingAssistantTextRef,
100
+ pendingThinkingTextRef,
101
+ streamFlushTimerRef,
102
+ } = context
103
+
104
+ if (mode === 'accept-edits') {
105
+ pushNote(
106
+ provider.supportsTools
107
+ ? 'accept-edits mode: workspace reads/edits auto-allow. private continuity edits and bash still prompt.'
108
+ : 'accept-edits mode selected, but the current provider does not support tools yet.',
109
+ 'dim',
110
+ )
111
+ }
112
+
113
+ setStreaming(true)
114
+ const activeCheckpoint = createTurnCheckpoint(sessionId, userText)
115
+ setActiveCheckpoint(activeCheckpoint)
116
+
117
+ updateRows(prev => [...prev, { role: 'user', id: nextRowId(), content: userText }])
118
+ await persistTurnMessage({
119
+ role: 'user',
120
+ content: userText,
121
+ createdAt: nowIso(),
122
+ turnId: activeCheckpoint.turnId,
123
+ })
124
+
125
+ const mentionContextMessages = await buildFileMentionContextMessages(userText, getCwd())
126
+
127
+ const buildWorking = async (): Promise<Message[]> => {
128
+ const baseMessages = buildWorkingMessages(context, activeCheckpoint.turnId)
129
+ const [baseSystem, ...conversationMessages] = baseMessages
130
+ return [
131
+ ...(baseSystem ? [baseSystem] : []),
132
+ ...await buildIdentityContinuityContextMessages(getConfig()),
133
+ ...conversationMessages,
134
+ ...mentionContextMessages,
135
+ ]
136
+ }
137
+
138
+ // Per-iteration UI scratch. These are reset each time the runtime loop
139
+ // re-enters streaming (new provider call = new assistant row, new accumulator).
140
+ let accumulated = ''
141
+ let thinkingContent = ''
142
+ let thinkingRowId: string | null = null
143
+ let thinkingCursorActive = false
144
+ let assistantId: string | null = null
145
+ let hasPendingToolUse = false
146
+
147
+ const resetIteration = () => {
148
+ accumulated = ''
149
+ thinkingContent = ''
150
+ thinkingRowId = null
151
+ thinkingCursorActive = false
152
+ assistantId = null
153
+ hasPendingToolUse = false
154
+ }
155
+
156
+ const stopThinkingCursor = () => {
157
+ if (!thinkingRowId || !thinkingCursorActive) return
158
+ thinkingCursorActive = false
159
+ updateRows(prev => prev.map(row =>
160
+ row.id === thinkingRowId && row.role === 'thinking'
161
+ ? { ...row, showCursor: false }
162
+ : row,
163
+ ))
164
+ }
165
+
166
+ const ensureAssistantRow = (): string => {
167
+ if (assistantId) return assistantId
168
+ assistantId = nextRowId()
169
+ updateRows(prev => [
170
+ ...prev,
171
+ { role: 'assistant', id: assistantId!, content: '', liveTail: '', streaming: true },
172
+ ])
173
+ return assistantId
174
+ }
175
+
176
+ const flushStreamRows = (immediate = false) => {
177
+ const commit = () => {
178
+ streamFlushTimerRef.current = null
179
+ const nextAssistant = pendingAssistantTextRef.current
180
+ const nextThinking = pendingThinkingTextRef.current
181
+ if (nextAssistant === null && nextThinking === null) return
182
+ updateRows(prev => updateStreamingRows(
183
+ prev,
184
+ assistantId,
185
+ thinkingRowId,
186
+ nextAssistant,
187
+ nextThinking,
188
+ ))
189
+ pendingAssistantTextRef.current = null
190
+ pendingThinkingTextRef.current = null
191
+ }
192
+
193
+ if (immediate) {
194
+ if (streamFlushTimerRef.current) {
195
+ clearTimeout(streamFlushTimerRef.current)
196
+ streamFlushTimerRef.current = null
197
+ }
198
+ commit()
199
+ return
200
+ }
201
+
202
+ if (streamFlushTimerRef.current) return
203
+ streamFlushTimerRef.current = setTimeout(commit, streamFlushMs)
204
+ }
205
+
206
+ const finalizeStreamingRows = () => {
207
+ flushStreamRows(true)
208
+ updateRows(prev => {
209
+ let next = finalizeStreamingRowsById(prev, assistantId, thinkingRowId, accumulated, thinkingContent)
210
+ // If we emitted tool_uses, strip the empty assistant text row - tool_use
211
+ // rows replace it. If the assistant emitted no text at all (pure tool
212
+ // turn), drop the empty row.
213
+ if (assistantId && (hasPendingToolUse || accumulated.length === 0)) {
214
+ next = next.filter(r => r.id !== assistantId)
215
+ }
216
+ return next
217
+ })
218
+ }
219
+
220
+ const discardStreamingRows = () => {
221
+ flushStreamRows(true)
222
+ updateRows(prev => prev.filter(row =>
223
+ !(assistantId && row.id === assistantId)
224
+ && !(thinkingRowId && row.id === thinkingRowId),
225
+ ))
226
+ pendingAssistantTextRef.current = null
227
+ pendingThinkingTextRef.current = null
228
+ }
229
+
230
+ let finishedNormally = false
231
+ let cancelled = false
232
+
233
+ const runToolBatch = async (pendingToolUses: Array<{
234
+ id: string
235
+ name: string
236
+ input: Record<string, unknown>
237
+ }>) => {
238
+ // Persist the assistant tool_use blocks into the session before execution
239
+ // so microcompact / rebuild has them on the next provider call. The actual
240
+ // Message[] sent to the provider is rebuilt from session messages.
241
+ // (This mirrors the pre-Wave-2 behavior, just routed from the event loop.)
242
+ // NOTE: some provider loops keep tool_use blocks inside the assistant
243
+ // message; ethagent stores them as discrete tool_use SessionMessages via
244
+ // runPendingToolUses, which keeps the microcompact model simpler.
245
+ const step = await runPendingToolUses({
246
+ pendingToolUses,
247
+ nextRowId,
248
+ nowIso,
249
+ mode,
250
+ getCwd,
251
+ getConfig,
252
+ turnId: activeCheckpoint.turnId,
253
+ controller,
254
+ updateRows,
255
+ pushNote,
256
+ persistTurnMessage,
257
+ executeTool,
258
+ applySessionRule,
259
+ })
260
+ return step
261
+ }
262
+
263
+ const directToolUses = provider.supportsTools
264
+ ? directToolUsesForUserText(userText)
265
+ : []
266
+ if (directToolUses.length > 0) {
267
+ const step = await runToolBatch(directToolUses)
268
+ const directCancelled = step.cancelled || controller.signal.aborted
269
+ if (directCancelled) pushNote('(cancelled)', 'dim')
270
+ setStreaming(false)
271
+ setActiveCheckpoint(undefined)
272
+ return {
273
+ finishedNormally: !directCancelled,
274
+ cancelled: directCancelled,
275
+ }
276
+ }
277
+
278
+ if (preflightProvider) {
279
+ let preflight: { ok: true } | { ok: false; message: string }
280
+ try {
281
+ preflight = await preflightProvider()
282
+ } catch (err: unknown) {
283
+ preflight = {
284
+ ok: false,
285
+ message: `provider preflight failed: ${(err as Error).message || 'unknown error'}`,
286
+ }
287
+ }
288
+ if (!preflight.ok) {
289
+ pushNote(preflight.message, 'error')
290
+ setStreaming(false)
291
+ setActiveCheckpoint(undefined)
292
+ return {
293
+ finishedNormally: false,
294
+ cancelled: controller.signal.aborted,
295
+ }
296
+ }
297
+ }
298
+
299
+ try {
300
+ for await (const ev of runRuntimeTurn({
301
+ provider,
302
+ signal: controller.signal,
303
+ initialMessages: await buildWorking(),
304
+ rebuildMessages: buildWorking,
305
+ runToolBatch,
306
+ })) {
307
+ cancelled = cancelled || isCancelledEvent(ev)
308
+ await handleEvent(ev, {
309
+ ensureAssistantRow,
310
+ flushStreamRows,
311
+ finalizeStreamingRows,
312
+ discardStreamingRows,
313
+ resetIteration,
314
+ stopThinkingCursor,
315
+ setAccumulated: text => { accumulated = text },
316
+ getAccumulated: () => accumulated,
317
+ setThinkingContent: text => { thinkingContent = text },
318
+ getThinkingContent: () => thinkingContent,
319
+ setThinkingRowId: id => { thinkingRowId = id },
320
+ markThinkingCursorActive: () => { thinkingCursorActive = true },
321
+ getThinkingRowId: () => thinkingRowId,
322
+ markPendingToolUse: () => { hasPendingToolUse = true },
323
+ updateRows,
324
+ pushNote,
325
+ nextRowId,
326
+ pendingAssistantTextRef,
327
+ pendingThinkingTextRef,
328
+ persistTurnMessage,
329
+ nowIso,
330
+ mode,
331
+ onPlanReady,
332
+ turnId: activeCheckpoint.turnId,
333
+ model: getConfig().model,
334
+ onFinishedNormally: () => { finishedNormally = true },
335
+ })
336
+ }
337
+ } catch (err: unknown) {
338
+ if (!controller.signal.aborted) {
339
+ pushNote((err as Error).message || 'stream error', 'error')
340
+ }
341
+ finalizeStreamingRows()
342
+ }
343
+
344
+ if (cancelled || controller.signal.aborted) pushNote('(cancelled)', 'dim')
345
+ setStreaming(false)
346
+ setActiveCheckpoint(undefined)
347
+
348
+ return {
349
+ finishedNormally,
350
+ cancelled,
351
+ }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Event handling: per-event UI translation
356
+ // ---------------------------------------------------------------------------
357
+
358
+ type EventHandlerContext = {
359
+ ensureAssistantRow: () => string
360
+ flushStreamRows: (immediate?: boolean) => void
361
+ finalizeStreamingRows: () => void
362
+ discardStreamingRows: () => void
363
+ resetIteration: () => void
364
+ stopThinkingCursor: () => void
365
+ setAccumulated: (text: string) => void
366
+ getAccumulated: () => string
367
+ setThinkingContent: (text: string) => void
368
+ getThinkingContent: () => string
369
+ setThinkingRowId: (id: string | null) => void
370
+ getThinkingRowId: () => string | null
371
+ markThinkingCursorActive: () => void
372
+ markPendingToolUse: () => void
373
+ updateRows: (updater: (prev: MessageRow[]) => MessageRow[]) => void
374
+ pushNote: (text: string, kind?: 'info' | 'error' | 'dim') => void
375
+ nextRowId: () => string
376
+ pendingAssistantTextRef: MutableRef<string | null>
377
+ pendingThinkingTextRef: MutableRef<string | null>
378
+ persistTurnMessage: (message: SessionMessage) => Promise<void>
379
+ nowIso: () => string
380
+ mode: SessionMode
381
+ onPlanReady?: (plan: string) => void
382
+ turnId: string
383
+ model: string
384
+ onFinishedNormally: () => void
385
+ }
386
+
387
+ function isCancelledEvent(ev: TurnEvent): boolean {
388
+ return ev.type === 'cancelled'
389
+ }
390
+
391
+ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<void> {
392
+ switch (ev.type) {
393
+ case 'iteration_start': {
394
+ // Reset per-iteration scratch so each provider call gets a fresh
395
+ // assistant row, accumulator, and hasPendingToolUse flag. Iteration 0
396
+ // is the initial stream - resetting before anything runs is a no-op,
397
+ // which is fine.
398
+ ctx.resetIteration()
399
+ return
400
+ }
401
+ case 'text': {
402
+ ctx.stopThinkingCursor()
403
+ ctx.ensureAssistantRow()
404
+ const next = ctx.getAccumulated() + ev.delta
405
+ ctx.setAccumulated(next)
406
+ ctx.pendingAssistantTextRef.current = next
407
+ ctx.flushStreamRows()
408
+ return
409
+ }
410
+ case 'thinking': {
411
+ const current = ctx.getThinkingContent()
412
+ const appended = current + ev.delta
413
+ ctx.setThinkingContent(appended)
414
+ if (ctx.getThinkingRowId() === null) {
415
+ const id = ctx.nextRowId()
416
+ ctx.setThinkingRowId(id)
417
+ ctx.markThinkingCursorActive()
418
+ ctx.updateRows(prev => [
419
+ ...prev,
420
+ {
421
+ role: 'thinking',
422
+ id,
423
+ content: '',
424
+ liveTail: appended,
425
+ streaming: true,
426
+ expanded: false,
427
+ showCursor: true,
428
+ },
429
+ ])
430
+ }
431
+ ctx.pendingThinkingTextRef.current = appended
432
+ ctx.flushStreamRows()
433
+ return
434
+ }
435
+ case 'retry': {
436
+ ctx.pushNote(formatRetryStatus(ev), 'dim')
437
+ return
438
+ }
439
+ case 'tool_use_stop': {
440
+ ctx.markPendingToolUse()
441
+ ctx.finalizeStreamingRows()
442
+ return
443
+ }
444
+ case 'assistant_message_committed': {
445
+ // End of a streaming round with no tool_use - finalize rows, persist
446
+ // the assistant text, and hand it to the plan hook if in plan mode.
447
+ ctx.finalizeStreamingRows()
448
+ if (ev.text) {
449
+ await ctx.persistTurnMessage({
450
+ role: 'assistant',
451
+ content: ev.text,
452
+ createdAt: ctx.nowIso(),
453
+ model: ctx.model,
454
+ turnId: ctx.turnId,
455
+ })
456
+ if (ctx.mode === 'plan') ctx.onPlanReady?.(ev.text)
457
+ }
458
+ return
459
+ }
460
+ case 'tool_executed': {
461
+ // Row + session persistence happened inside runPendingToolUses; the
462
+ // event is informational for observers that need it (tests, future
463
+ // instrumentation). No UI side-effect here.
464
+ return
465
+ }
466
+ case 'local_tool_recovery': {
467
+ // The runtime recovered tool calls from local model text output.
468
+ // Discard the streamed assistant rows that contained the JSON blob
469
+ // so they are not persisted or displayed as prose.
470
+ ctx.discardStreamingRows()
471
+ ctx.markPendingToolUse()
472
+ return
473
+ }
474
+ case 'continuation_nudge': {
475
+ // Clean break between provider calls. Corrective nudges suppress the
476
+ // unverified assistant row so it cannot become durable context.
477
+ if (
478
+ ev.reason === 'tool_state_claim' ||
479
+ ev.reason === 'tool_capability' ||
480
+ ev.reason === 'tool_protocol_fake' ||
481
+ ev.reason === 'tool_delegation'
482
+ ) {
483
+ ctx.discardStreamingRows()
484
+ } else {
485
+ ctx.finalizeStreamingRows()
486
+ }
487
+ ctx.resetIteration()
488
+ return
489
+ }
490
+ case 'error': {
491
+ ctx.pushNote(ev.message, 'error')
492
+ if (ev.discardAssistant) {
493
+ ctx.discardStreamingRows()
494
+ } else {
495
+ ctx.finalizeStreamingRows()
496
+ }
497
+ return
498
+ }
499
+ case 'cancelled': {
500
+ ctx.finalizeStreamingRows()
501
+ return
502
+ }
503
+ case 'done': {
504
+ // If we ended mid-iteration (no assistant_message_committed yet) the
505
+ // finalize call from error/cancelled already ran. If we ended after a
506
+ // tool_executed batch, finalize here so the UI settles.
507
+ ctx.finalizeStreamingRows()
508
+ if (ev.finishedNormally) ctx.onFinishedNormally()
509
+ return
510
+ }
511
+ case 'tool_use_start':
512
+ case 'tool_use_delta':
513
+ return
514
+ }
515
+ }
516
+
517
+ function formatRetryStatus(ev: Extract<TurnEvent, { type: 'retry' }>): string {
518
+ const totalAttempts = ev.maxRetries + 1
519
+ const reason = ev.status !== undefined ? `HTTP ${ev.status}` : ev.code ?? ev.reason
520
+ return `provider retry ${ev.nextAttempt}/${totalAttempts} in ${formatRetryDelay(ev.delayMs)} (${reason})`
521
+ }
522
+
523
+ function formatRetryDelay(delayMs: number): string {
524
+ if (delayMs < 1000) return `${Math.max(0, Math.round(delayMs))}ms`
525
+ const seconds = delayMs / 1000
526
+ if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`
527
+ if (seconds < 60) return `${Math.round(seconds)}s`
528
+ const minutes = seconds / 60
529
+ return `${minutes.toFixed(minutes < 10 ? 1 : 0).replace(/\.0$/, '')}m`
530
+ }
531
+
532
+ function updateStreamingRows(
533
+ rows: MessageRow[],
534
+ assistantId: string | null,
535
+ thinkingRowId: string | null,
536
+ assistantText: string | null,
537
+ thinkingText: string | null,
538
+ ): MessageRow[] {
539
+ let next: MessageRow[] | null = null
540
+ if (assistantId && assistantText !== null) {
541
+ const index = findRowIndexById(rows, assistantId)
542
+ const row = rows[index]
543
+ if (row?.role === 'assistant') {
544
+ next = next ?? rows.slice()
545
+ next[index] = { ...row, content: assistantText, liveTail: '' }
546
+ }
547
+ }
548
+ const source = next ?? rows
549
+ if (thinkingRowId && thinkingText !== null) {
550
+ const index = findRowIndexById(source, thinkingRowId)
551
+ const row = source[index]
552
+ if (row?.role === 'thinking') {
553
+ next = next ?? rows.slice()
554
+ next[index] = { ...row, content: thinkingText, liveTail: '' }
555
+ }
556
+ }
557
+ return next ?? rows
558
+ }
559
+
560
+ function finalizeStreamingRowsById(
561
+ rows: MessageRow[],
562
+ assistantId: string | null,
563
+ thinkingRowId: string | null,
564
+ assistantText: string,
565
+ thinkingText: string,
566
+ ): MessageRow[] {
567
+ let next: MessageRow[] | null = null
568
+ if (assistantId) {
569
+ const index = findRowIndexById(rows, assistantId)
570
+ const row = rows[index]
571
+ if (row?.role === 'assistant') {
572
+ next = next ?? rows.slice()
573
+ next[index] = { ...row, content: assistantText || row.content, liveTail: undefined, streaming: false }
574
+ }
575
+ }
576
+ const source = next ?? rows
577
+ if (thinkingRowId) {
578
+ const index = findRowIndexById(source, thinkingRowId)
579
+ const row = source[index]
580
+ if (row?.role === 'thinking') {
581
+ next = next ?? rows.slice()
582
+ next[index] = { ...row, content: thinkingText || row.content, liveTail: undefined, streaming: false, showCursor: false }
583
+ }
584
+ }
585
+ return next ?? rows
586
+ }
587
+
588
+ function findRowIndexById(rows: MessageRow[], id: string): number {
589
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
590
+ if (rows[index]?.id === id) return index
591
+ }
592
+ return -1
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // File-mention context (unchanged from pre-Wave-2 - pure helper)
597
+ // ---------------------------------------------------------------------------
598
+
599
+ async function buildFileMentionContextMessages(
600
+ userText: string,
601
+ cwd: string,
602
+ ): Promise<Message[]> {
603
+ const mentions = extractFileMentions(userText)
604
+ if (mentions.length === 0) return []
605
+
606
+ const lines: string[] = []
607
+ for (const mention of mentions) {
608
+ const resolved = path.resolve(cwd, mention)
609
+ const rel = path.relative(cwd, resolved)
610
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
611
+ lines.push(
612
+ `@${mention} -> outside current workspace; do not use unless the user changes directory or names an allowed path.`,
613
+ )
614
+ continue
615
+ }
616
+ try {
617
+ const stats = await fs.stat(resolved)
618
+ lines.push(`@${mention} -> ${mention} (${stats.isDirectory() ? 'directory' : 'file'})`)
619
+ } catch {
620
+ lines.push(`@${mention} -> unresolved`)
621
+ }
622
+ }
623
+
624
+ return [
625
+ {
626
+ role: 'user',
627
+ content: [
628
+ 'Resolved file mentions for this request:',
629
+ ...lines,
630
+ 'Treat these mentions as authoritative filenames from the user request. Read referenced context files when needed, and edit only the file requested by the user or the target file you have inspected.',
631
+ ].join('\n'),
632
+ },
633
+ ]
634
+ }
635
+
636
+ export async function buildIdentityContinuityContextMessages(
637
+ config: EthagentConfig,
638
+ ): Promise<Message[]> {
639
+ const identity = config.identity
640
+ if (!identity) return []
641
+
642
+ try {
643
+ const privateFiles = await readContinuityFiles(identity)
644
+ return [{
645
+ role: 'system',
646
+ content: [
647
+ '<identity_continuity_files>',
648
+ 'The active identity continuity files have been loaded automatically for this turn.',
649
+ 'SOUL.md is private owner continuity and is the authoritative persona, voice, and standing-behavior layer for this active identity.',
650
+ 'MEMORY.md is private owner continuity for durable preferences, facts, and project context.',
651
+ 'Apply SOUL.md and MEMORY.md over generic ethagent identity/style unless they conflict with safety, tool correctness, developer instructions, or the user\'s latest explicit request. Do not quote private continuity unless necessary.',
652
+ '<SOUL.md visibility="private">',
653
+ privateFiles['SOUL.md'].trimEnd(),
654
+ '</SOUL.md>',
655
+ '',
656
+ '<MEMORY.md visibility="private">',
657
+ privateFiles['MEMORY.md'].trimEnd(),
658
+ '</MEMORY.md>',
659
+ '</identity_continuity_files>',
660
+ ].join('\n'),
661
+ }]
662
+ } catch (err: unknown) {
663
+ return [{
664
+ role: 'system',
665
+ content: [
666
+ '<identity_continuity_files>',
667
+ `Automatic identity continuity load failed: ${(err as Error).message}`,
668
+ 'If the user asks about continuity, surface this failure and route them to the identity hub.',
669
+ '</identity_continuity_files>',
670
+ ].join('\n'),
671
+ }]
672
+ }
673
+ }
674
+
675
+ function extractFileMentions(text: string): string[] {
676
+ const mentions = new Set<string>()
677
+ for (const match of text.matchAll(/@([^\s]+)/g)) {
678
+ const raw = match[1]?.replace(/[),.;:!?]+$/g, '')
679
+ if (!raw || raw.length === 0) continue
680
+ mentions.add(raw.replace(/\\/g, '/'))
681
+ }
682
+ return [...mentions]
683
+ }
684
+
685
+ function buildWorkingMessages(
686
+ context: Pick<
687
+ TurnOrchestratorContext,
688
+ 'getSessionMessages' | 'getConfig' | 'provider' | 'getCwd' | 'mode'
689
+ >,
690
+ preserveTurnId?: string,
691
+ ): Message[] {
692
+ const config = context.getConfig()
693
+ return buildBaseMessages(
694
+ [...context.getSessionMessages()],
695
+ config,
696
+ context.provider.supportsTools,
697
+ context.getCwd(),
698
+ context.mode,
699
+ { preserveTurnId },
700
+ )
701
+ }