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.
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 +30 -8
  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,1575 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { Box, Text, useApp } from 'ink'
5
+ import type { EthagentConfig } from '../storage/config.js'
6
+ import type { Provider, Message } from '../providers/contracts.js'
7
+ import { createProvider } from '../providers/registry.js'
8
+ import { approximateTokens } from '../utils/messages.js'
9
+ import {
10
+ dispatchSlash,
11
+ parseSlash,
12
+ getSlashSuggestions,
13
+ type SlashContext,
14
+ } from './commands.js'
15
+ import { theme } from '../ui/theme.js'
16
+ import { BrandSplash } from '../ui/BrandSplash.js'
17
+ import { SessionStatus, formatTokens } from './SessionStatus.js'
18
+ import { formatModelDisplayName } from '../models/modelDisplay.js'
19
+ import { toggleReasoningRow, type MessageRow } from './MessageList.js'
20
+ import { ConversationStack } from './ConversationStack.js'
21
+ import { ModelPicker, type ModelPickerSelection } from '../models/ModelPicker.js'
22
+ import type { ModelPickerContextFit } from '../models/modelPickerOptions.js'
23
+ import type { CopyResult } from '../utils/clipboard.js'
24
+ import { useKeybinding, useRegisterKeybindingContext } from '../app/keybindings/KeybindingProvider.js'
25
+ import { useCancelRequest } from '../app/hooks/useCancelRequest.js'
26
+ import { useExitOnCtrlC } from '../app/hooks/useExitOnCtrlC.js'
27
+ import {
28
+ appendSessionMessage,
29
+ clearAllSessions,
30
+ ensureSessionMetadata,
31
+ loadSession,
32
+ loadSessionMetadata,
33
+ newSessionId,
34
+ updateSessionActivity,
35
+ } from '../storage/sessions.js'
36
+ import type { SessionMessage } from '../storage/sessions.js'
37
+ import { loadPermissionRules, savePermissionRule } from '../storage/permissions.js'
38
+ import { appendHistory, readHistory } from '../storage/history.js'
39
+ import {
40
+ compactTranscript,
41
+ contextUsage,
42
+ contextUsageFromTokens,
43
+ summarizeTranscriptLocally,
44
+ shouldConfirmContextUsage,
45
+ type ContextUsage,
46
+ } from '../runtime/compaction.js'
47
+ import { saveConfig } from '../storage/config.js'
48
+ import { getCwd as getRuntimeCwd, setCwd as setRuntimeCwd, syncCwdFromProcess } from '../runtime/cwd.js'
49
+ import { executeToolWithPermissions } from '../runtime/toolExecution.js'
50
+ import { nextSessionMode, sessionModeLabel, type PermissionMode, type SessionMode } from '../runtime/sessionMode.js'
51
+ import type {
52
+ PermissionDecision,
53
+ PermissionRequest,
54
+ SessionPermissionRule,
55
+ } from '../tools/contracts.js'
56
+ import {
57
+ buildBaseMessages,
58
+ formatBytes,
59
+ sessionMessagesToRows,
60
+ type TurnCheckpoint,
61
+ } from './chatScreenUtils.js'
62
+ import { ChatBottomPane, type ContextLimitState, type CopyPickerState, type IdentityOverlayState, type Overlay } from './ChatBottomPane.js'
63
+ import { setTokenIdentity, getIdentityStatus } from '../storage/identity.js'
64
+ import type { IdentityHubResult } from '../identity/hub/IdentityHub.js'
65
+ import {
66
+ buildResumedSessionState,
67
+ promptHistoryFromSessionMessages,
68
+ resolveModelSelection,
69
+ restoreConversationState,
70
+ } from './chatSessionState.js'
71
+ import { runStreamingTurn } from './chatTurnOrchestrator.js'
72
+ import { ensureLlamaCppRunnerReady } from '../models/llamacppPreflight.js'
73
+ import type { PlanApprovalAction } from './PlanApprovalView.js'
74
+ import type { ContextLimitAction } from './ContextLimitView.js'
75
+ import type { ContinuityEditReviewAction, ContinuityEditReviewState } from './ContinuityEditReviewView.js'
76
+ import { openFileInEditor } from '../identity/continuity/editor.js'
77
+ import { EMPTY_MCP_SNAPSHOT, McpManager, type McpSnapshot } from '../mcp/manager.js'
78
+
79
+ type ChatScreenProps = {
80
+ config: EthagentConfig
81
+ onReplaceConfig?: (next: EthagentConfig) => void
82
+ }
83
+
84
+ type PendingPlan = {
85
+ text: string
86
+ cwd: string
87
+ sessionId: string
88
+ provider: string
89
+ model: string
90
+ contextLabel: string
91
+ awaitingApproval: boolean
92
+ }
93
+
94
+ type CompactionKind = 'conversation' | 'plan'
95
+
96
+ type CompactionUiState = {
97
+ kind: CompactionKind
98
+ progressRowId: string
99
+ sourceSessionId: string
100
+ startedAt: number
101
+ stage: string
102
+ controller: AbortController
103
+ }
104
+
105
+ let rowIdSeq = 0
106
+ const nextRowId = (): string => `row-${++rowIdSeq}`
107
+ const nowIso = (): string => new Date().toISOString()
108
+ const STREAM_FLUSH_MS = 120
109
+ const CONTEXT_CONFIRM_PERCENT = 90
110
+ const MAX_PROMPT_HISTORY = 500
111
+ const MAX_HANDOFF_SUMMARY_CHARS = 12_000
112
+
113
+ function compressHome(cwd: string): string {
114
+ const home = os.homedir()
115
+ if (cwd === home) return '~'
116
+ if (cwd.startsWith(home + path.sep)) return '~' + cwd.slice(home.length).replace(/\\/g, '/')
117
+ return cwd.replace(/\\/g, '/')
118
+ }
119
+
120
+ async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: true } | { ok: false; message: string }> {
121
+ if (config.provider === 'llamacpp') return ensureLlamaCppRunnerReady(config)
122
+ return { ok: true }
123
+ }
124
+
125
+ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig }) => {
126
+ useRegisterKeybindingContext('Chat')
127
+ const { exit } = useApp()
128
+ const [config, setConfig] = useState<EthagentConfig>(initialConfig)
129
+ const [rows, setRows] = useState<MessageRow[]>([])
130
+ const [history, setHistory] = useState<string[]>([])
131
+ const [streaming, setStreaming] = useState(false)
132
+ const [queuedInputs, setQueuedInputs] = useState<string[]>([])
133
+ const [turns, setTurns] = useState(0)
134
+ const [approxTokens, setApproxTokens] = useState(0)
135
+ const [overlay, setOverlay] = useState<Overlay>('none')
136
+ const [copyPickerState, setCopyPickerState] = useState<CopyPickerState>(null)
137
+ const [contextLimitState, setContextLimitState] = useState<ContextLimitState>(null)
138
+ const [continuityEditReview, setContinuityEditReview] = useState<ContinuityEditReviewState | null>(null)
139
+ const [modelPickerContextFit, setModelPickerContextFit] = useState<ModelPickerContextFit | null>(null)
140
+ const [identityOverlay, setIdentityOverlay] = useState<IdentityOverlayState | null>(null)
141
+ const [permissionRequest, setPermissionRequest] = useState<PermissionRequest | null>(null)
142
+ const [mode, setMode] = useState<SessionMode>('chat')
143
+ const [pendingPlan, setPendingPlan] = useState<PendingPlan | null>(null)
144
+ const [compactionUi, setCompactionUi] = useState<CompactionUiState | null>(null)
145
+ const [sessionId, setSessionId] = useState<string>(() => newSessionId())
146
+ const [sessionKey, setSessionKey] = useState<number>(0)
147
+ const [cwd, setCwd] = useState<string>(() => syncCwdFromProcess())
148
+ const [statusStartedAt, setStatusStartedAt] = useState<number>(() => Date.now())
149
+ const [activeContextUsage, setActiveContextUsage] = useState<ContextUsage>(() =>
150
+ contextUsageFromTokens(0, initialConfig.provider, initialConfig.model),
151
+ )
152
+ const [mcpSnapshot, setMcpSnapshot] = useState<McpSnapshot>(EMPTY_MCP_SNAPSHOT)
153
+
154
+ const rowsRef = useRef<MessageRow[]>([])
155
+ const visibleReasoningIdsRef = useRef<string[]>([])
156
+ const sessionMessagesRef = useRef<SessionMessage[]>([])
157
+ const sessionIdRef = useRef<string>(sessionId)
158
+ const globalHistoryRef = useRef<string[]>([])
159
+ const historyScopeRef = useRef<'global' | 'session'>('global')
160
+ const cwdRef = useRef<string>(getRuntimeCwd())
161
+ const overlayRef = useRef<Overlay>(overlay)
162
+ const modeRef = useRef<SessionMode>(mode)
163
+ const streamAbortRef = useRef<AbortController | null>(null)
164
+ const providerRef = useRef<Provider>(createProvider(initialConfig))
165
+ const configRef = useRef<EthagentConfig>(initialConfig)
166
+ const prevConfigRef = useRef<EthagentConfig>(initialConfig)
167
+ const compactingRef = useRef<boolean>(false)
168
+ const pendingAssistantTextRef = useRef<string | null>(null)
169
+ const pendingThinkingTextRef = useRef<string | null>(null)
170
+ const streamFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
171
+ const drainingQueueRef = useRef<boolean>(false)
172
+ const permissionResolveRef = useRef<((decision: PermissionDecision) => void) | null>(null)
173
+ const permissionRulesRef = useRef<SessionPermissionRule[]>([])
174
+ const activeCheckpointRef = useRef<TurnCheckpoint | undefined>(undefined)
175
+ const statsSegmentStartRef = useRef<number>(0)
176
+ const pendingPlanRef = useRef<PendingPlan | null>(null)
177
+ const compactionUiRef = useRef<CompactionUiState | null>(null)
178
+ const contextLimitStateRef = useRef<ContextLimitState>(null)
179
+ const pendingContinuityEditReviewRef = useRef<ContinuityEditReviewState | null>(null)
180
+ const contextModelSwitchPromptRef = useRef<string | null>(null)
181
+ const mcpManagerRef = useRef<McpManager | null>(null)
182
+
183
+ useEffect(() => { rowsRef.current = rows }, [rows])
184
+ useEffect(() => { overlayRef.current = overlay }, [overlay])
185
+ useEffect(() => { sessionIdRef.current = sessionId }, [sessionId])
186
+ useEffect(() => { cwdRef.current = cwd }, [cwd])
187
+ useEffect(() => { modeRef.current = mode }, [mode])
188
+ useEffect(() => { pendingPlanRef.current = pendingPlan }, [pendingPlan])
189
+ useEffect(() => { compactionUiRef.current = compactionUi }, [compactionUi])
190
+ useEffect(() => { contextLimitStateRef.current = contextLimitState }, [contextLimitState])
191
+
192
+ useEffect(() => {
193
+ if (prevConfigRef.current === config) return
194
+ prevConfigRef.current = config
195
+ configRef.current = config
196
+ providerRef.current = createProvider(config)
197
+ }, [config])
198
+
199
+ useEffect(() => {
200
+ void (async () => {
201
+ const loaded = await readHistory()
202
+ globalHistoryRef.current = loaded
203
+ if (historyScopeRef.current === 'global') setHistory(loaded)
204
+ })()
205
+ }, [])
206
+
207
+ useEffect(() => {
208
+ void (async () => {
209
+ try {
210
+ permissionRulesRef.current = await loadPermissionRules(cwd)
211
+ } catch {
212
+ permissionRulesRef.current = []
213
+ }
214
+ })()
215
+ }, [cwd])
216
+
217
+ useEffect(() => {
218
+ void ensureSessionMetadata(sessionId, {
219
+ cwd,
220
+ provider: config.provider,
221
+ model: config.model,
222
+ mode,
223
+ })
224
+ }, [config.model, config.provider, cwd, mode, sessionId])
225
+
226
+ useEffect(() => {
227
+ void updateSessionActivity(
228
+ sessionId,
229
+ { cwd, provider: config.provider, model: config.model, mode },
230
+ { lastCwd: cwd, provider: config.provider, model: config.model, mode },
231
+ ).catch(() => {})
232
+ }, [config.model, config.provider, cwd, mode, sessionId])
233
+
234
+ useEffect(() => {
235
+ return () => {
236
+ streamAbortRef.current?.abort()
237
+ compactionUiRef.current?.controller.abort()
238
+ if (streamFlushTimerRef.current) clearTimeout(streamFlushTimerRef.current)
239
+ permissionResolveRef.current?.('deny')
240
+ void mcpManagerRef.current?.close()
241
+ }
242
+ }, [])
243
+
244
+ const updateRows = useCallback((updater: (prev: MessageRow[]) => MessageRow[]) => {
245
+ setRows(prev => updater(prev))
246
+ }, [])
247
+
248
+ const pushNote = useCallback(
249
+ (text: string, kind: 'info' | 'error' | 'dim' = 'info') => {
250
+ updateRows(prev => [...prev, { role: 'note', id: nextRowId(), kind, content: text }])
251
+ },
252
+ [updateRows],
253
+ )
254
+
255
+ useEffect(() => {
256
+ if (!mcpManagerRef.current) {
257
+ mcpManagerRef.current = new McpManager(cwd, setMcpSnapshot)
258
+ }
259
+ void mcpManagerRef.current.refresh(cwd).catch((err: unknown) => {
260
+ pushNote(`MCP refresh failed: ${(err as Error).message}`, 'error')
261
+ })
262
+ }, [cwd, pushNote])
263
+
264
+ const beginCompactionUi = useCallback((kind: CompactionKind, sourceSessionId: string): CompactionUiState => {
265
+ const progressRowId = nextRowId()
266
+ const state: CompactionUiState = {
267
+ kind,
268
+ progressRowId,
269
+ sourceSessionId,
270
+ startedAt: Date.now(),
271
+ stage: 'preparing transcript',
272
+ controller: new AbortController(),
273
+ }
274
+ compactionUiRef.current = state
275
+ setCompactionUi(state)
276
+ updateRows(prev => [
277
+ ...prev,
278
+ {
279
+ role: 'progress',
280
+ id: progressRowId,
281
+ title: kind === 'plan' ? 'summarizing plan context' : 'compacting conversation',
282
+ progress: 0,
283
+ status: state.stage,
284
+ suffix: 'esc to cancel',
285
+ indeterminate: true,
286
+ startedAt: state.startedAt,
287
+ },
288
+ ])
289
+ return state
290
+ }, [updateRows])
291
+
292
+ const updateCompactionStage = useCallback((state: CompactionUiState, stage: string) => {
293
+ setCompactionUi(prev => prev?.progressRowId === state.progressRowId ? { ...prev, stage } : prev)
294
+ updateRows(prev => prev.map(row =>
295
+ row.id === state.progressRowId && row.role === 'progress'
296
+ ? { ...row, status: stage }
297
+ : row,
298
+ ))
299
+ }, [updateRows])
300
+
301
+ const removeCompactionProgress = useCallback((state: CompactionUiState) => {
302
+ updateRows(prev => prev.filter(row => row.id !== state.progressRowId))
303
+ }, [updateRows])
304
+
305
+ const toggleLatestReasoning = useCallback(() => {
306
+ const ids = visibleReasoningIdsRef.current
307
+ updateRows(rows => toggleReasoningRow(rows, ids[ids.length - 1]))
308
+ }, [updateRows])
309
+
310
+ const updateVisibleReasoningIds = useCallback((ids: string[]) => {
311
+ visibleReasoningIdsRef.current = ids
312
+ }, [])
313
+
314
+ const replaceConfig = useCallback(
315
+ (next: EthagentConfig) => {
316
+ configRef.current = next
317
+ providerRef.current = createProvider(next)
318
+ setConfig(next)
319
+ onReplaceConfig?.(next)
320
+ },
321
+ [onReplaceConfig],
322
+ )
323
+
324
+ const clearPendingPlan = useCallback(() => {
325
+ pendingPlanRef.current = null
326
+ setPendingPlan(null)
327
+ if (overlayRef.current === 'planApproval') {
328
+ overlayRef.current = 'none'
329
+ setOverlay('none')
330
+ }
331
+ }, [])
332
+
333
+ const clearContextLimit = useCallback(() => {
334
+ contextLimitStateRef.current = null
335
+ setContextLimitState(null)
336
+ if (overlayRef.current === 'contextLimit') {
337
+ overlayRef.current = 'none'
338
+ setOverlay('none')
339
+ }
340
+ }, [])
341
+
342
+ const openModelPicker = useCallback((contextFit?: ModelPickerContextFit | null, pendingPrompt?: string | null) => {
343
+ contextModelSwitchPromptRef.current = pendingPrompt ?? null
344
+ setModelPickerContextFit(contextFit ?? null)
345
+ overlayRef.current = 'modelPicker'
346
+ setOverlay('modelPicker')
347
+ }, [])
348
+
349
+ const handleModelPickerCancel = useCallback(() => {
350
+ const hadPendingPrompt = contextModelSwitchPromptRef.current !== null
351
+ contextModelSwitchPromptRef.current = null
352
+ setModelPickerContextFit(null)
353
+ overlayRef.current = 'none'
354
+ setOverlay('none')
355
+ if (hadPendingPrompt) pushNote('pending message cancelled.', 'dim')
356
+ }, [pushNote])
357
+
358
+ const changeCwd = useCallback((next: string) => {
359
+ const updated = next === getRuntimeCwd() ? next : setRuntimeCwd(next, cwdRef.current)
360
+ cwdRef.current = updated
361
+ setCwd(updated)
362
+ clearPendingPlan()
363
+ setSessionKey(k => k + 1)
364
+ }, [clearPendingPlan])
365
+
366
+ const clearTranscript = useCallback(() => {
367
+ setRows([])
368
+ setTurns(0)
369
+ setApproxTokens(0)
370
+ setActiveContextUsage(contextUsageFromTokens(0, configRef.current.provider, configRef.current.model))
371
+ setQueuedInputs([])
372
+ clearPendingPlan()
373
+ clearContextLimit()
374
+ contextModelSwitchPromptRef.current = null
375
+ setModelPickerContextFit(null)
376
+ sessionMessagesRef.current = []
377
+ statsSegmentStartRef.current = 0
378
+ historyScopeRef.current = 'global'
379
+ setHistory(globalHistoryRef.current)
380
+ setStatusStartedAt(Date.now())
381
+ const nextId = newSessionId()
382
+ sessionIdRef.current = nextId
383
+ setSessionId(nextId)
384
+ setSessionKey(k => k + 1)
385
+ }, [clearContextLimit, clearPendingPlan])
386
+
387
+ const doExit = useCallback(() => {
388
+ streamAbortRef.current?.abort()
389
+ exit()
390
+ }, [exit])
391
+
392
+ const persistSessionMessage = useCallback(
393
+ async (msg: SessionMessage) => {
394
+ sessionMessagesRef.current = [...sessionMessagesRef.current, msg]
395
+ try {
396
+ await appendSessionMessage(sessionIdRef.current, msg, {
397
+ cwd: cwdRef.current,
398
+ provider: configRef.current.provider,
399
+ model: configRef.current.model,
400
+ mode: modeRef.current,
401
+ })
402
+ } catch {
403
+ }
404
+ },
405
+ [],
406
+ )
407
+
408
+ const refreshVisibleStats = useCallback(
409
+ (messages: SessionMessage[], providerSupportsTools: boolean, cwdForStats: string, configForStats: EthagentConfig, modeForStats: SessionMode): ContextUsage => {
410
+ const built = buildBaseMessages(messages, configForStats, providerSupportsTools, cwdForStats, modeForStats)
411
+ const tokens = approximateTokens(built)
412
+ const usage = contextUsageFromTokens(tokens, configForStats.provider, configForStats.model)
413
+ setTurns(messages.filter(message => message.role === 'user').length)
414
+ setApproxTokens(tokens)
415
+ setActiveContextUsage(usage)
416
+ return usage
417
+ },
418
+ [],
419
+ )
420
+
421
+ const warnIfContextPressure = useCallback(
422
+ (usage: ContextUsage, configForUsage: EthagentConfig) => {
423
+ if (!shouldConfirmContextUsage(usage, CONTEXT_CONFIRM_PERCENT)) return
424
+ const action = usage.percent >= 100
425
+ ? 'New requests will ask you to summarize into a new conversation, switch models, ignore and send, or cancel.'
426
+ : 'Run /compact before continuing, keep the next prompt short, switch models, or choose to send despite the warning.'
427
+ pushNote(
428
+ `current transcript is ${usage.percent}% of ${configForUsage.model}'s context (~${formatTokens(usage.usedTokens)} / ${formatTokens(usage.windowTokens)}). ${action}`,
429
+ usage.percent >= 100 ? 'error' : 'dim',
430
+ )
431
+ },
432
+ [pushNote],
433
+ )
434
+
435
+ const applyConfigChange = useCallback(
436
+ (next: EthagentConfig): ContextUsage => {
437
+ replaceConfig(next)
438
+ const usage = refreshVisibleStats(sessionMessagesRef.current, providerRef.current.supportsTools, cwdRef.current, next, modeRef.current)
439
+ warnIfContextPressure(usage, next)
440
+ return usage
441
+ },
442
+ [refreshVisibleStats, replaceConfig, warnIfContextPressure],
443
+ )
444
+
445
+ const attachActiveTurn = useCallback(<T extends SessionMessage>(message: T): T => {
446
+ const turnId = activeCheckpointRef.current?.turnId
447
+ if (!turnId) return message
448
+ return { ...message, turnId } as T
449
+ }, [])
450
+
451
+ const runCompaction = useCallback(
452
+ async (): Promise<boolean> => {
453
+ if (compactingRef.current) return false
454
+ const sourceSessionId = sessionIdRef.current
455
+ const sourceMessages = sessionMessagesRef.current
456
+ const priorMessages: Message[] = buildBaseMessages(
457
+ sourceMessages,
458
+ configRef.current,
459
+ providerRef.current.supportsTools,
460
+ cwdRef.current,
461
+ modeRef.current,
462
+ )
463
+ if (priorMessages.length <= 5) {
464
+ pushNote('not enough turns to compact yet.', 'dim')
465
+ return false
466
+ }
467
+ compactingRef.current = true
468
+ const compaction = beginCompactionUi('conversation', sourceSessionId)
469
+ try {
470
+ const result = await compactTranscript(providerRef.current, priorMessages, {
471
+ signal: compaction.controller.signal,
472
+ onStage: stage => updateCompactionStage(compaction, stage),
473
+ })
474
+ if (!result.ok && result.cancelled) {
475
+ removeCompactionProgress(compaction)
476
+ pushNote('compaction cancelled.', 'dim')
477
+ return false
478
+ }
479
+ const summary = result.ok
480
+ ? normalizeHandoffSummary(result.summary)
481
+ : normalizeHandoffSummary(summarizeTranscriptLocally(priorMessages, result.reason))
482
+ if (!result.ok) {
483
+ pushNote(`provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
484
+ }
485
+
486
+ updateCompactionStage(compaction, 'saving summarized conversation')
487
+ const nextSessionId = newSessionId()
488
+ const createdAt = nowIso()
489
+ const summaryMessage: SessionMessage = {
490
+ role: 'user',
491
+ synthetic: true,
492
+ content: [
493
+ `Conversation handoff from ${sourceSessionId.slice(0, 8)}:`,
494
+ '',
495
+ summary,
496
+ ].join('\n'),
497
+ createdAt,
498
+ }
499
+ const acknowledgement: SessionMessage = {
500
+ role: 'assistant',
501
+ content: 'Ready to continue from this summary.',
502
+ createdAt: nowIso(),
503
+ model: configRef.current.model,
504
+ }
505
+
506
+ const context = {
507
+ cwd: cwdRef.current,
508
+ provider: configRef.current.provider,
509
+ model: configRef.current.model,
510
+ mode: modeRef.current,
511
+ }
512
+ await ensureSessionMetadata(nextSessionId, context)
513
+ await updateSessionActivity(
514
+ nextSessionId,
515
+ context,
516
+ { compactedFromSessionId: sourceSessionId },
517
+ )
518
+ await appendSessionMessage(nextSessionId, summaryMessage, context)
519
+ await appendSessionMessage(nextSessionId, acknowledgement, context)
520
+
521
+ updateCompactionStage(compaction, 'opening summarized conversation')
522
+ const nextMessages = [summaryMessage, acknowledgement]
523
+ compactionUiRef.current = null
524
+ setCompactionUi(null)
525
+ sessionIdRef.current = nextSessionId
526
+ setSessionId(nextSessionId)
527
+ sessionMessagesRef.current = nextMessages
528
+ historyScopeRef.current = 'session'
529
+ setHistory(promptHistoryFromSessionMessages(nextMessages))
530
+ statsSegmentStartRef.current = 0
531
+ setRows([
532
+ {
533
+ role: 'note',
534
+ id: nextRowId(),
535
+ kind: 'dim',
536
+ content: `kept ${sourceSessionId.slice(0, 8)} saved; summarized into ${nextSessionId.slice(0, 8)}.`,
537
+ },
538
+ ...sessionMessagesToRows(nextMessages, nextRowId),
539
+ ])
540
+ setQueuedInputs([])
541
+ setStatusStartedAt(Date.now())
542
+ refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
543
+ setSessionKey(key => key + 1)
544
+ return true
545
+ } catch (err: unknown) {
546
+ removeCompactionProgress(compaction)
547
+ if (compaction.controller.signal.aborted) {
548
+ pushNote('compaction cancelled.', 'dim')
549
+ } else {
550
+ pushNote(`compact error: ${(err as Error).message}`, 'error')
551
+ }
552
+ return false
553
+ } finally {
554
+ compactingRef.current = false
555
+ compactionUiRef.current = null
556
+ setCompactionUi(null)
557
+ }
558
+ },
559
+ [beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, updateCompactionStage],
560
+ )
561
+
562
+ const assistantTurns = useCallback((): string[] => {
563
+ const out: string[] = []
564
+ for (const message of sessionMessagesRef.current) {
565
+ if (message.role === 'assistant' && message.content) out.push(message.content)
566
+ }
567
+ return out
568
+ }, [])
569
+
570
+ const buildSlashContext = useCallback(
571
+ (): SlashContext => ({
572
+ config: configRef.current,
573
+ turns,
574
+ approxTokens,
575
+ contextUsage: activeContextUsage,
576
+ startedAt: statusStartedAt,
577
+ sessionId: sessionIdRef.current,
578
+ cwd,
579
+ sessionMessages: () => sessionMessagesRef.current,
580
+ mode,
581
+ assistantTurns,
582
+ onReplaceConfig: applyConfigChange,
583
+ onChangeCwd: changeCwd,
584
+ onClear: clearTranscript,
585
+ onExit: doExit,
586
+ onResumeRequest: () => setOverlay('resume'),
587
+ onModelPickerRequest: () => openModelPicker(),
588
+ onRewindRequest: () => setOverlay('rewind'),
589
+ onPermissionsRequest: () => setOverlay('permissions'),
590
+ onCompactRequest: () => { void runCompaction() },
591
+ onIdentityRequest: action => {
592
+ void (async () => {
593
+ const status = await getIdentityStatus(configRef.current)
594
+ const initialAction = action === 'create' || action === 'load' ? action : undefined
595
+ setIdentityOverlay({
596
+ initialAction,
597
+ existing: status ? { address: status.address } : null,
598
+ })
599
+ setOverlay('identity')
600
+ })()
601
+ },
602
+ onCopyPickerRequest: (turnText, turnLabel) => {
603
+ setCopyPickerState({ turnText, turnLabel })
604
+ setOverlay('copyPicker')
605
+ },
606
+ mcp: mcpManagerRef.current ?? undefined,
607
+ }),
608
+ [
609
+ turns,
610
+ approxTokens,
611
+ statusStartedAt,
612
+ assistantTurns,
613
+ applyConfigChange,
614
+ changeCwd,
615
+ clearTranscript,
616
+ doExit,
617
+ openModelPicker,
618
+ runCompaction,
619
+ cwd,
620
+ mode,
621
+ activeContextUsage,
622
+ ],
623
+ )
624
+
625
+ const requestPermission = useCallback(
626
+ async (request: PermissionRequest): Promise<PermissionDecision> => {
627
+ setPermissionRequest(request)
628
+ setOverlay('permission')
629
+ return await new Promise<PermissionDecision>(resolve => {
630
+ permissionResolveRef.current = resolve
631
+ })
632
+ },
633
+ [],
634
+ )
635
+
636
+ const resolvePermission = useCallback((decision: PermissionDecision) => {
637
+ const resolve = permissionResolveRef.current
638
+ permissionResolveRef.current = null
639
+ setPermissionRequest(null)
640
+ setOverlay('none')
641
+ resolve?.(decision)
642
+ }, [])
643
+
644
+ const executeTool = useCallback(
645
+ async (
646
+ name: string,
647
+ input: Record<string, unknown>,
648
+ permissionMode: PermissionMode,
649
+ ): Promise<{ result: { ok: boolean; summary: string; content: string }; sessionRule?: SessionPermissionRule; persistRule?: boolean }> => {
650
+ const outcome = await executeToolWithPermissions({
651
+ name,
652
+ input,
653
+ permissionMode,
654
+ cwd: cwdRef.current,
655
+ config: configRef.current,
656
+ checkpoint: activeCheckpointRef.current,
657
+ abortSignal: streamAbortRef.current?.signal,
658
+ dynamicTools: mcpManagerRef.current?.getTools() ?? [],
659
+ mcp: mcpManagerRef.current ?? undefined,
660
+ getPermissionRules: () => permissionRulesRef.current,
661
+ requestPermission,
662
+ onDirectoryChange: next => {
663
+ cwdRef.current = next
664
+ setCwd(next)
665
+ },
666
+ })
667
+ const review = privateContinuityEditReviewFromToolResult(name, input, outcome.result)
668
+ if (review) pendingContinuityEditReviewRef.current = review
669
+ return outcome
670
+ },
671
+ [requestPermission],
672
+ )
673
+
674
+ const applySessionRule = useCallback(
675
+ async (sessionRule?: SessionPermissionRule, persistRule?: boolean) => {
676
+ if (!sessionRule) return
677
+ permissionRulesRef.current = [...permissionRulesRef.current, sessionRule]
678
+ if (!persistRule) return
679
+ try {
680
+ await savePermissionRule(cwdRef.current, sessionRule)
681
+ } catch (error: unknown) {
682
+ pushNote(`failed to save permission rule: ${(error as Error).message}`, 'error')
683
+ }
684
+ },
685
+ [pushNote],
686
+ )
687
+
688
+ const runStream = useCallback(
689
+ async (userText: string, modeOverride?: SessionMode) => {
690
+ const activeMode = modeOverride ?? mode
691
+ const turnProvider = createProvider(configRef.current, {
692
+ mode: activeMode,
693
+ dynamicTools: mcpManagerRef.current?.getTools() ?? [],
694
+ })
695
+ const controller = new AbortController()
696
+ streamAbortRef.current = controller
697
+ let planCandidate: PendingPlan | null = null
698
+ const result = await runStreamingTurn({
699
+ provider: turnProvider,
700
+ mode: activeMode,
701
+ sessionId: sessionIdRef.current,
702
+ userText,
703
+ streamFlushMs: STREAM_FLUSH_MS,
704
+ controller,
705
+ nextRowId,
706
+ nowIso,
707
+ getConfig: () => configRef.current,
708
+ getCwd: () => cwdRef.current,
709
+ getDisplayCwd: () => compressHome(cwdRef.current),
710
+ getSessionMessages: () => sessionMessagesRef.current,
711
+ setActiveCheckpoint: checkpoint => { activeCheckpointRef.current = checkpoint },
712
+ setStreaming,
713
+ updateRows,
714
+ pushNote,
715
+ persistTurnMessage: message => persistSessionMessage(attachActiveTurn(message)),
716
+ executeTool,
717
+ applySessionRule,
718
+ preflightProvider: () => ensureLocalProviderReady(configRef.current),
719
+ onPlanReady: plan => {
720
+ planCandidate = {
721
+ text: plan,
722
+ cwd: cwdRef.current,
723
+ sessionId: sessionIdRef.current,
724
+ provider: configRef.current.provider,
725
+ model: configRef.current.model,
726
+ contextLabel: formatContextLabel(
727
+ contextUsage(buildBaseMessages(sessionMessagesRef.current, configRef.current, turnProvider.supportsTools, cwdRef.current, activeMode), configRef.current.provider, configRef.current.model),
728
+ ),
729
+ awaitingApproval: true,
730
+ }
731
+ pendingPlanRef.current = planCandidate
732
+ setPendingPlan(planCandidate)
733
+ },
734
+ pendingAssistantTextRef,
735
+ pendingThinkingTextRef,
736
+ streamFlushTimerRef,
737
+ })
738
+ refreshVisibleStats(sessionMessagesRef.current, turnProvider.supportsTools, cwdRef.current, configRef.current, activeMode)
739
+ streamAbortRef.current = null
740
+ if (
741
+ result.finishedNormally &&
742
+ activeMode === 'plan' &&
743
+ planCandidate &&
744
+ pendingPlanRef.current === planCandidate &&
745
+ overlayRef.current === 'none'
746
+ ) {
747
+ overlayRef.current = 'planApproval'
748
+ setOverlay('planApproval')
749
+ }
750
+ },
751
+ [applySessionRule, attachActiveTurn, executeTool, mode, persistSessionMessage, pushNote, refreshVisibleStats, updateRows],
752
+ )
753
+
754
+ const pullInFlight = false
755
+
756
+ useEffect(() => {
757
+ if (overlay !== 'none' || streaming || pullInFlight || compactionUi) return
758
+ const pending = pendingContinuityEditReviewRef.current
759
+ if (!pending) return
760
+ pendingContinuityEditReviewRef.current = null
761
+ setContinuityEditReview(pending)
762
+ overlayRef.current = 'continuityEditReview'
763
+ setOverlay('continuityEditReview')
764
+ }, [compactionUi, overlay, pullInFlight, streaming])
765
+
766
+ const projectedUsageForInput = useCallback((userText: string, modeOverride?: SessionMode): ContextUsage => {
767
+ const activeMode = modeOverride ?? modeRef.current
768
+ const turnProvider = createProvider(configRef.current, {
769
+ mode: activeMode,
770
+ dynamicTools: mcpManagerRef.current?.getTools() ?? [],
771
+ })
772
+ const projectedMessages: SessionMessage[] = [
773
+ ...sessionMessagesRef.current,
774
+ { role: 'user', content: userText, createdAt: nowIso() },
775
+ ]
776
+ return contextUsage(
777
+ buildBaseMessages(projectedMessages, configRef.current, turnProvider.supportsTools, cwdRef.current, activeMode),
778
+ configRef.current.provider,
779
+ configRef.current.model,
780
+ )
781
+ }, [])
782
+
783
+ const showContextLimitForPrompt = useCallback((prompt: string): ContextUsage => {
784
+ contextModelSwitchPromptRef.current = null
785
+ setModelPickerContextFit(null)
786
+ const projected = projectedUsageForInput(prompt)
787
+ contextLimitStateRef.current = { usage: projected, prompt }
788
+ setContextLimitState(contextLimitStateRef.current)
789
+ overlayRef.current = 'contextLimit'
790
+ setOverlay('contextLimit')
791
+ return projected
792
+ }, [projectedUsageForInput])
793
+
794
+ const continuePendingPromptAfterModelSwitch = useCallback(
795
+ async (prompt: string | null) => {
796
+ if (!prompt) {
797
+ setModelPickerContextFit(null)
798
+ return
799
+ }
800
+ contextModelSwitchPromptRef.current = null
801
+ setModelPickerContextFit(null)
802
+ const projected = projectedUsageForInput(prompt)
803
+ if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
804
+ contextLimitStateRef.current = { usage: projected, prompt }
805
+ setContextLimitState(contextLimitStateRef.current)
806
+ overlayRef.current = 'contextLimit'
807
+ setOverlay('contextLimit')
808
+ pushNote(
809
+ `selected model is still ${projected.percent}% of its context (~${formatTokens(projected.usedTokens)} / ${formatTokens(projected.windowTokens)}).`,
810
+ projected.percent >= 100 ? 'error' : 'dim',
811
+ )
812
+ return
813
+ }
814
+ await runStream(prompt)
815
+ },
816
+ [projectedUsageForInput, pushNote, runStream],
817
+ )
818
+
819
+ const handleSubmit = useCallback(
820
+ async (value: string) => {
821
+ const trimmed = value.trim()
822
+ if (!trimmed) return
823
+
824
+ setHistory(h => appendPromptHistoryEntry(h, value))
825
+ globalHistoryRef.current = appendPromptHistoryEntry(globalHistoryRef.current, value)
826
+ void appendHistory(value)
827
+
828
+ if (streaming || pullInFlight || compactionUiRef.current) {
829
+ if (parseSlash(value)) {
830
+ pushNote('slash commands cannot be queued. wait for the current task to finish.', 'dim')
831
+ return
832
+ }
833
+ setQueuedInputs(prev => [...prev, value])
834
+ return
835
+ }
836
+
837
+ if (parseSlash(value)) {
838
+ const ctx = buildSlashContext()
839
+ const result = await dispatchSlash(value, ctx)
840
+ if (result && result.kind === 'note') {
841
+ pushNote(result.text, result.variant ?? 'info')
842
+ }
843
+ if (result && result.kind === 'submit') {
844
+ const projected = projectedUsageForInput(result.text)
845
+ if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
846
+ showContextLimitForPrompt(result.text)
847
+ return
848
+ }
849
+ await runStream(result.text)
850
+ }
851
+ return
852
+ }
853
+
854
+ const projected = projectedUsageForInput(value)
855
+ if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
856
+ showContextLimitForPrompt(value)
857
+ return
858
+ }
859
+
860
+ await runStream(value)
861
+ },
862
+ [buildSlashContext, pullInFlight, projectedUsageForInput, pushNote, runStream, showContextLimitForPrompt, streaming],
863
+ )
864
+
865
+ const handleContextLimitCancel = useCallback(() => {
866
+ clearContextLimit()
867
+ pushNote('pending message cancelled.', 'dim')
868
+ }, [clearContextLimit, pushNote])
869
+
870
+ const handleContextLimitAction = useCallback(
871
+ async (action: ContextLimitAction) => {
872
+ const state = contextLimitStateRef.current
873
+ if (!state) {
874
+ clearContextLimit()
875
+ return
876
+ }
877
+ const prompt = state.prompt
878
+ clearContextLimit()
879
+ if (action === 'cancel') {
880
+ pushNote('pending message cancelled.', 'dim')
881
+ return
882
+ }
883
+ if (action === 'switchModel') {
884
+ openModelPicker(
885
+ { usedTokens: state.usage.usedTokens, thresholdPercent: CONTEXT_CONFIRM_PERCENT },
886
+ prompt,
887
+ )
888
+ return
889
+ }
890
+ if (action === 'compact') {
891
+ const compacted = await runCompaction()
892
+ if (!compacted) return
893
+ setHistory(h => appendPromptHistoryEntry(h, prompt))
894
+ }
895
+ if (action === 'send') {
896
+ pushNote(
897
+ 'sending despite context warning; this may hit provider rate/context limits faster or degrade model/tool behavior.',
898
+ 'dim',
899
+ )
900
+ }
901
+ await runStream(prompt)
902
+ },
903
+ [clearContextLimit, openModelPicker, pushNote, runCompaction, runStream],
904
+ )
905
+
906
+ const handleCancelActive = useCallback(() => {
907
+ if (streaming && streamAbortRef.current) {
908
+ streamAbortRef.current.abort()
909
+ return
910
+ }
911
+ compactionUiRef.current?.controller.abort()
912
+ }, [streaming])
913
+
914
+ useCancelRequest({
915
+ abortSignal: streaming ? streamAbortRef.current?.signal : compactionUi?.controller.signal,
916
+ onCancel: handleCancelActive,
917
+ isActive: overlay === 'none',
918
+ })
919
+
920
+ const exitState = useExitOnCtrlC({
921
+ isActive: overlay === 'none',
922
+ onInterrupt: () => {
923
+ if (streaming && streamAbortRef.current) {
924
+ streamAbortRef.current.abort()
925
+ return true
926
+ }
927
+ if (compactionUiRef.current) {
928
+ compactionUiRef.current.controller.abort()
929
+ return true
930
+ }
931
+ return false
932
+ },
933
+ onExit: doExit,
934
+ })
935
+
936
+ useKeybinding(
937
+ 'chat:modelPicker',
938
+ () => { if (overlay === 'none') openModelPicker() },
939
+ { context: 'Chat', isActive: overlay === 'none' },
940
+ )
941
+
942
+ useKeybinding(
943
+ 'chat:identityHub',
944
+ () => {
945
+ if (overlay !== 'none') return
946
+ setIdentityOverlay({
947
+ initialAction: undefined,
948
+ existing: configRef.current.identity ? { address: configRef.current.identity.address } : null,
949
+ })
950
+ setOverlay('identity')
951
+ },
952
+ { context: 'Chat', isActive: overlay === 'none' },
953
+ )
954
+
955
+ useKeybinding(
956
+ 'chat:toggleReasoning',
957
+ () => { if (overlay === 'none') toggleLatestReasoning() },
958
+ { context: 'Chat', isActive: overlay === 'none' },
959
+ )
960
+
961
+ useKeybinding(
962
+ 'chat:cycleMode',
963
+ () => {
964
+ if (overlay !== 'none') return
965
+ const nextMode = nextSessionMode(mode)
966
+ modeRef.current = nextMode
967
+ setMode(nextMode)
968
+ if (nextMode !== 'plan') clearPendingPlan()
969
+ },
970
+ { context: 'Chat', isActive: overlay === 'none' },
971
+ )
972
+
973
+ useKeybinding(
974
+ 'app:redraw',
975
+ () => setSessionKey(k => k + 1),
976
+ { context: 'Global' },
977
+ )
978
+
979
+ const handleModelPick = useCallback(
980
+ async (sel: ModelPickerSelection) => {
981
+ const pendingPrompt = contextModelSwitchPromptRef.current
982
+ overlayRef.current = 'none'
983
+ setOverlay('none')
984
+ const resolution = resolveModelSelection(sel, configRef.current)
985
+ if (resolution.kind === 'noop') {
986
+ if (pendingPrompt) showContextLimitForPrompt(pendingPrompt)
987
+ return
988
+ }
989
+ try {
990
+ await saveConfig(resolution.config)
991
+ applyConfigChange(resolution.config)
992
+ pushNote(resolution.notice, resolution.tone)
993
+ await continuePendingPromptAfterModelSwitch(pendingPrompt)
994
+ } catch (err: unknown) {
995
+ pushNote(`provider switch failed: ${(err as Error).message}`, 'error')
996
+ if (pendingPrompt) showContextLimitForPrompt(pendingPrompt)
997
+ }
998
+ },
999
+ [applyConfigChange, continuePendingPromptAfterModelSwitch, pushNote, showContextLimitForPrompt],
1000
+ )
1001
+
1002
+ const handleResumePick = useCallback(
1003
+ async (id: string) => {
1004
+ setOverlay('none')
1005
+ try {
1006
+ const [loaded, metadata] = await Promise.all([loadSession(id), loadSessionMetadata(id)])
1007
+ if (loaded.length === 0) {
1008
+ pushNote('session was empty.', 'error')
1009
+ return
1010
+ }
1011
+ const resumed = buildResumedSessionState({
1012
+ messages: loaded,
1013
+ metadata,
1014
+ fallbackCwd: cwd,
1015
+ nextRowId,
1016
+ })
1017
+ const resumedCwd = resumed.cwd
1018
+ if (resumedCwd) {
1019
+ try {
1020
+ const updated = setRuntimeCwd(resumedCwd)
1021
+ cwdRef.current = updated
1022
+ setCwd(updated)
1023
+ } catch {
1024
+ cwdRef.current = resumedCwd
1025
+ setCwd(resumedCwd)
1026
+ }
1027
+ }
1028
+ clearPendingPlan()
1029
+ clearContextLimit()
1030
+ modeRef.current = resumed.mode
1031
+ setMode(resumed.mode)
1032
+ sessionIdRef.current = id
1033
+ setSessionId(id)
1034
+ sessionMessagesRef.current = loaded
1035
+ historyScopeRef.current = 'session'
1036
+ setHistory(resumed.promptHistory)
1037
+ statsSegmentStartRef.current = 0
1038
+ setStatusStartedAt(resumed.statusStartedAt)
1039
+ setRows(resumed.rows)
1040
+ refreshVisibleStats(loaded, providerRef.current.supportsTools, resumedCwd, configRef.current, resumed.mode)
1041
+ setSessionKey(k => k + 1)
1042
+ } catch (err: unknown) {
1043
+ pushNote(`resume failed: ${(err as Error).message}`, 'error')
1044
+ }
1045
+ },
1046
+ [clearContextLimit, clearPendingPlan, cwd, pushNote, refreshVisibleStats],
1047
+ )
1048
+
1049
+ const handleResumeClearAll = useCallback(
1050
+ async () => {
1051
+ await clearAllSessions()
1052
+ clearTranscript()
1053
+ overlayRef.current = 'none'
1054
+ setOverlay('none')
1055
+ pushNote('cleared saved chat logs and resume context from this machine.', 'dim')
1056
+ },
1057
+ [clearTranscript, pushNote],
1058
+ )
1059
+
1060
+ const handleIdentityResult = useCallback(
1061
+ (result: IdentityHubResult) => {
1062
+ setOverlay('none')
1063
+ setIdentityOverlay(null)
1064
+ if (result.kind === 'updated') {
1065
+ applyConfigChange(result.config)
1066
+ pushNote(result.message, 'info')
1067
+ return
1068
+ }
1069
+ if (result.kind === 'token') {
1070
+ void (async () => {
1071
+ try {
1072
+ const nextConfig = await setTokenIdentity(configRef.current, result.identity)
1073
+ applyConfigChange(nextConfig)
1074
+ pushNote(`identity saved · ERC-8004 #${result.identity.agentId}`, 'info')
1075
+ } catch (err: unknown) {
1076
+ pushNote(`identity save failed: ${(err as Error).message}`, 'error')
1077
+ }
1078
+ })()
1079
+ }
1080
+ },
1081
+ [applyConfigChange, pushNote],
1082
+ )
1083
+
1084
+ const handleContinuityEditReviewAction = useCallback(
1085
+ async (action: ContinuityEditReviewAction) => {
1086
+ const review = continuityEditReview
1087
+ if (!review) return
1088
+ if (action === 'open') {
1089
+ const result = await openFileInEditor(review.filePath)
1090
+ pushNote(
1091
+ result.ok
1092
+ ? `opened ${review.file} with ${result.method}.`
1093
+ : `open failed: ${result.error}`,
1094
+ result.ok ? 'dim' : 'error',
1095
+ )
1096
+ return
1097
+ }
1098
+ setContinuityEditReview(null)
1099
+ if (action === 'save-publish') {
1100
+ const status = await getIdentityStatus(configRef.current)
1101
+ setIdentityOverlay({
1102
+ initialAction: 'save-snapshot',
1103
+ existing: status ? { address: status.address } : null,
1104
+ })
1105
+ overlayRef.current = 'identity'
1106
+ setOverlay('identity')
1107
+ pushNote('opening snapshot approval.', 'dim')
1108
+ return
1109
+ }
1110
+ overlayRef.current = 'none'
1111
+ setOverlay('none')
1112
+ pushNote('snapshot not published yet.', 'dim')
1113
+ },
1114
+ [continuityEditReview, pushNote],
1115
+ )
1116
+
1117
+ const handleContinuityEditReviewCancel = useCallback(() => {
1118
+ setContinuityEditReview(null)
1119
+ overlayRef.current = 'none'
1120
+ setOverlay('none')
1121
+ pushNote('snapshot not published yet.', 'dim')
1122
+ }, [pushNote])
1123
+
1124
+ const handleCopyDone = useCallback(
1125
+ (result: CopyResult, label: string) => {
1126
+ setOverlay('none')
1127
+ setCopyPickerState(null)
1128
+ if (result.ok) {
1129
+ pushNote(`copied ${label} via ${result.method}.`, 'dim')
1130
+ } else {
1131
+ pushNote(`copy failed: ${result.error}`, 'error')
1132
+ }
1133
+ },
1134
+ [pushNote],
1135
+ )
1136
+
1137
+ const handleCopyCancel = useCallback(() => {
1138
+ setOverlay('none')
1139
+ setCopyPickerState(null)
1140
+ pushNote('copy cancelled.', 'dim')
1141
+ }, [pushNote])
1142
+
1143
+ const handleRestoreConversation = useCallback((turnId: string) => {
1144
+ const restored = restoreConversationState(sessionMessagesRef.current, turnId, nextRowId)
1145
+ sessionMessagesRef.current = restored.messages
1146
+ setRows(restored.rows)
1147
+ historyScopeRef.current = 'session'
1148
+ setHistory(restored.promptHistory)
1149
+ if (restored.truncated) {
1150
+ setQueuedInputs([])
1151
+ statsSegmentStartRef.current = Math.min(statsSegmentStartRef.current, restored.messages.length)
1152
+ refreshVisibleStats(restored.messages, providerRef.current.supportsTools, cwdRef.current, configRef.current, mode)
1153
+ setSessionKey(key => key + 1)
1154
+ return
1155
+ }
1156
+ refreshVisibleStats(restored.messages, providerRef.current.supportsTools, cwdRef.current, configRef.current, mode)
1157
+ }, [mode, refreshVisibleStats])
1158
+
1159
+ const startFreshImplementationContext = useCallback(() => {
1160
+ const nextSessionId = newSessionId()
1161
+ sessionMessagesRef.current = []
1162
+ statsSegmentStartRef.current = 0
1163
+ sessionIdRef.current = nextSessionId
1164
+ historyScopeRef.current = 'global'
1165
+ setHistory(globalHistoryRef.current)
1166
+ setSessionId(nextSessionId)
1167
+ setRows([])
1168
+ setTurns(0)
1169
+ setApproxTokens(0)
1170
+ setQueuedInputs([])
1171
+ setStatusStartedAt(Date.now())
1172
+ setSessionKey(key => key + 1)
1173
+ }, [])
1174
+
1175
+ const startSummarizedPlanImplementationContext = useCallback(
1176
+ async (plan: string): Promise<boolean> => {
1177
+ if (compactingRef.current) return false
1178
+
1179
+ const sourceSessionId = sessionIdRef.current
1180
+ const priorMessages = buildBaseMessages(
1181
+ sessionMessagesRef.current,
1182
+ configRef.current,
1183
+ providerRef.current.supportsTools,
1184
+ cwdRef.current,
1185
+ modeRef.current,
1186
+ )
1187
+
1188
+ if (priorMessages.length <= 5) {
1189
+ startFreshImplementationContext()
1190
+ pushNote('not enough planning context to summarize; starting a plan-only implementation conversation.', 'dim')
1191
+ return true
1192
+ }
1193
+
1194
+ compactingRef.current = true
1195
+ const compaction = beginCompactionUi('plan', sourceSessionId)
1196
+ try {
1197
+ const result = await compactTranscript(providerRef.current, priorMessages, {
1198
+ signal: compaction.controller.signal,
1199
+ onStage: stage => updateCompactionStage(compaction, stage),
1200
+ })
1201
+ if (!result.ok && result.cancelled) {
1202
+ removeCompactionProgress(compaction)
1203
+ pushNote('plan context summary cancelled.', 'dim')
1204
+ return false
1205
+ }
1206
+ const summary = result.ok
1207
+ ? normalizeHandoffSummary(result.summary)
1208
+ : normalizeHandoffSummary(summarizeTranscriptLocally(priorMessages, result.reason))
1209
+ if (!result.ok) {
1210
+ pushNote(`provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
1211
+ }
1212
+
1213
+ updateCompactionStage(compaction, 'saving summarized conversation')
1214
+ const nextSessionId = newSessionId()
1215
+ const createdAt = nowIso()
1216
+ const nextMessages = buildPlanTransferSeedMessages({
1217
+ sourceSessionId,
1218
+ summary,
1219
+ plan,
1220
+ createdAt,
1221
+ })
1222
+ const context = {
1223
+ cwd: cwdRef.current,
1224
+ provider: configRef.current.provider,
1225
+ model: configRef.current.model,
1226
+ mode: modeRef.current,
1227
+ }
1228
+
1229
+ await ensureSessionMetadata(nextSessionId, context)
1230
+ await updateSessionActivity(nextSessionId, context, { compactedFromSessionId: sourceSessionId })
1231
+ for (const message of nextMessages) {
1232
+ await appendSessionMessage(nextSessionId, message, context)
1233
+ }
1234
+
1235
+ updateCompactionStage(compaction, 'opening summarized conversation')
1236
+ compactionUiRef.current = null
1237
+ setCompactionUi(null)
1238
+ sessionIdRef.current = nextSessionId
1239
+ setSessionId(nextSessionId)
1240
+ sessionMessagesRef.current = nextMessages
1241
+ historyScopeRef.current = 'session'
1242
+ setHistory(promptHistoryFromSessionMessages(nextMessages))
1243
+ statsSegmentStartRef.current = 0
1244
+ setRows([
1245
+ {
1246
+ role: 'note',
1247
+ id: nextRowId(),
1248
+ kind: 'dim',
1249
+ content: `kept ${sourceSessionId.slice(0, 8)} saved; transferred plan into ${nextSessionId.slice(0, 8)}.`,
1250
+ },
1251
+ ...sessionMessagesToRows(nextMessages, nextRowId),
1252
+ ])
1253
+ setQueuedInputs([])
1254
+ setStatusStartedAt(Date.now())
1255
+ refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
1256
+ setSessionKey(key => key + 1)
1257
+ return true
1258
+ } catch (err: unknown) {
1259
+ removeCompactionProgress(compaction)
1260
+ if (compaction.controller.signal.aborted) {
1261
+ pushNote('plan context summary cancelled.', 'dim')
1262
+ } else {
1263
+ pushNote(`context summary error: ${(err as Error).message}`, 'error')
1264
+ }
1265
+ return false
1266
+ } finally {
1267
+ compactingRef.current = false
1268
+ compactionUiRef.current = null
1269
+ setCompactionUi(null)
1270
+ }
1271
+ },
1272
+ [beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, startFreshImplementationContext, updateCompactionStage],
1273
+ )
1274
+
1275
+ const handlePlanApprovalCancel = useCallback(() => {
1276
+ const plan = pendingPlanRef.current
1277
+ if (plan) {
1278
+ const next = { ...plan, awaitingApproval: false }
1279
+ pendingPlanRef.current = next
1280
+ setPendingPlan(next)
1281
+ }
1282
+ if (overlayRef.current === 'planApproval') {
1283
+ overlayRef.current = 'none'
1284
+ setOverlay('none')
1285
+ }
1286
+ }, [])
1287
+
1288
+ const handlePlanApproval = useCallback(
1289
+ async (action: PlanApprovalAction) => {
1290
+ const plan = pendingPlanRef.current
1291
+ if (!plan) {
1292
+ handlePlanApprovalCancel()
1293
+ return
1294
+ }
1295
+ if (plan.cwd !== cwdRef.current || plan.sessionId !== sessionIdRef.current) {
1296
+ clearPendingPlan()
1297
+ pushNote('dismissed stale plan approval because the workspace changed.', 'dim')
1298
+ return
1299
+ }
1300
+ if (action === 'continue') {
1301
+ handlePlanApprovalCancel()
1302
+ return
1303
+ }
1304
+
1305
+ const nextMode: SessionMode = 'accept-edits'
1306
+ if (action === 'apply-summary') {
1307
+ const transferred = await startSummarizedPlanImplementationContext(plan.text)
1308
+ if (!transferred) return
1309
+ }
1310
+ clearPendingPlan()
1311
+ modeRef.current = nextMode
1312
+ setMode(nextMode)
1313
+ await runStream(buildPlanImplementationPrompt(plan.text), nextMode)
1314
+ },
1315
+ [
1316
+ clearPendingPlan,
1317
+ handlePlanApprovalCancel,
1318
+ pushNote,
1319
+ runStream,
1320
+ startSummarizedPlanImplementationContext,
1321
+ ],
1322
+ )
1323
+
1324
+ const busy = pullInFlight || Boolean(compactionUi)
1325
+ const slashSuggestions = useMemo(
1326
+ () => getSlashSuggestions(mcpManagerRef.current?.getPromptSuggestions() ?? []),
1327
+ [mcpSnapshot],
1328
+ )
1329
+
1330
+ useEffect(() => {
1331
+ const plan = pendingPlanRef.current
1332
+ if (!plan?.awaitingApproval) return
1333
+ if (mode !== 'plan' || overlay !== 'none' || streaming || pullInFlight || compactionUi) return
1334
+ if (plan.cwd !== cwdRef.current || plan.sessionId !== sessionIdRef.current) {
1335
+ clearPendingPlan()
1336
+ return
1337
+ }
1338
+ overlayRef.current = 'planApproval'
1339
+ setOverlay('planApproval')
1340
+ }, [clearPendingPlan, compactionUi, mode, overlay, pullInFlight, streaming])
1341
+
1342
+ useEffect(() => {
1343
+ if (overlay !== 'none') return
1344
+ if (streaming || pullInFlight || compactionUi || queuedInputs.length === 0 || drainingQueueRef.current) return
1345
+ drainingQueueRef.current = true
1346
+ const next = queuedInputs[0]
1347
+ setQueuedInputs(prev => prev.slice(1))
1348
+ void (async () => {
1349
+ if (!next) return
1350
+ const projected = projectedUsageForInput(next)
1351
+ if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
1352
+ showContextLimitForPrompt(next)
1353
+ return
1354
+ }
1355
+ await runStream(next)
1356
+ })().finally(() => {
1357
+ drainingQueueRef.current = false
1358
+ })
1359
+ }, [compactionUi, overlay, projectedUsageForInput, pullInFlight, pushNote, queuedInputs, runStream, showContextLimitForPrompt, streaming])
1360
+
1361
+ const contextLine = `${config.provider} · ${formatModelDisplayName(config.provider, config.model, { maxLength: 24 })} · ${compressHome(cwd)}`
1362
+ const tipLine = streaming
1363
+ ? 'tip: you can keep typing and press enter to queue the next message · shift+enter for newline'
1364
+ : 'tip: type /help to get started · shift+enter for newline'
1365
+
1366
+ const placeholderHints = useMemo(() => {
1367
+ if (compactionUi) return ['compaction in progress · esc to cancel']
1368
+ return []
1369
+ }, [compactionUi])
1370
+
1371
+ const exitHint = exitState.pending ? 'ctrl+c again to quit' : null
1372
+ const runtimeModeLabel = sessionModeLabel(mode)
1373
+ const modeColor =
1374
+ mode === 'plan'
1375
+ ? theme.accentLavender
1376
+ : mode === 'accept-edits'
1377
+ ? theme.accentPeach
1378
+ : theme.accentMint
1379
+ const footerRight = (
1380
+ <Box flexDirection="row">
1381
+ {exitHint ? (
1382
+ <>
1383
+ <Text color={theme.accentPrimary}>{exitHint}</Text>
1384
+ <Text color={theme.dim}> · </Text>
1385
+ </>
1386
+ ) : null}
1387
+ {runtimeModeLabel ? (
1388
+ <>
1389
+ <Text color={modeColor}>{runtimeModeLabel}</Text>
1390
+ <Text color={theme.dim}> (</Text>
1391
+ <Text color={theme.accentMint}>shift+tab to cycle</Text>
1392
+ <Text color={theme.dim}>) · </Text>
1393
+ </>
1394
+ ) : (
1395
+ <>
1396
+ <Text color={theme.accentMint}>shift+tab to cycle</Text>
1397
+ <Text color={theme.dim}> · </Text>
1398
+ </>
1399
+ )}
1400
+ <Text color={theme.dim}>
1401
+ {'pgup/pgdn scroll · alt+p model · alt+i identity'}
1402
+ </Text>
1403
+ </Box>
1404
+ )
1405
+ const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} />
1406
+ return (
1407
+ <ConversationStack
1408
+ header={header}
1409
+ rows={rows}
1410
+ transcriptActive={overlay === 'none'}
1411
+ bottomVariant={overlay === 'none' ? 'prompt' : 'overlay'}
1412
+ bottom={(
1413
+ <ChatBottomPane
1414
+ overlay={overlay}
1415
+ config={config}
1416
+ sessionId={sessionId}
1417
+ cwd={cwd}
1418
+ currentSessionId={sessionId}
1419
+ copyPickerState={copyPickerState}
1420
+ contextLimitState={contextLimitState}
1421
+ continuityEditReview={continuityEditReview}
1422
+ modelPickerContextFit={modelPickerContextFit}
1423
+ permissionRequest={permissionRequest}
1424
+ history={history}
1425
+ busy={busy}
1426
+ streaming={streaming}
1427
+ activity={null}
1428
+ placeholderHints={placeholderHints}
1429
+ queuedInputs={queuedInputs}
1430
+ slashSuggestions={slashSuggestions}
1431
+ planApprovalContextLabel={pendingPlan?.contextLabel ?? formatContextLabel(activeContextUsage)}
1432
+ footerRight={footerRight}
1433
+ handleModelPick={handleModelPick}
1434
+ handleModelPickerCancel={handleModelPickerCancel}
1435
+ handleResumePick={handleResumePick}
1436
+ handleResumeClearAll={handleResumeClearAll}
1437
+ identityOverlay={identityOverlay}
1438
+ handleIdentityResult={handleIdentityResult}
1439
+ handleRestoreConversation={handleRestoreConversation}
1440
+ handleCopyDone={handleCopyDone}
1441
+ handleCopyCancel={handleCopyCancel}
1442
+ resolvePermission={resolvePermission}
1443
+ handlePlanApproval={handlePlanApproval}
1444
+ handlePlanApprovalCancel={handlePlanApprovalCancel}
1445
+ handleContextLimitAction={handleContextLimitAction}
1446
+ handleContextLimitCancel={handleContextLimitCancel}
1447
+ handleContinuityEditReviewAction={handleContinuityEditReviewAction}
1448
+ handleContinuityEditReviewCancel={handleContinuityEditReviewCancel}
1449
+ onPermissionRulesChanged={rules => { permissionRulesRef.current = rules }}
1450
+ onConfigChange={replaceConfig}
1451
+ handleSubmit={handleSubmit}
1452
+ setOverlay={setOverlay}
1453
+ pushNote={pushNote}
1454
+ />
1455
+ )}
1456
+ status={(
1457
+ <SessionStatus
1458
+ provider={config.provider}
1459
+ model={config.model}
1460
+ turns={turns}
1461
+ approxTokens={approxTokens}
1462
+ startedAt={statusStartedAt}
1463
+ contextUsage={activeContextUsage}
1464
+ />
1465
+ )}
1466
+ sessionKey={sessionKey}
1467
+ onVisibleReasoningIdsChange={updateVisibleReasoningIds}
1468
+ />
1469
+ )
1470
+ }
1471
+
1472
+ function formatContextLabel(usage: ContextUsage): string {
1473
+ if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
1474
+ return `Estimated context: ${usage.percent}% used`
1475
+ }
1476
+
1477
+ export function buildPlanImplementationPrompt(plan: string): string {
1478
+ return [
1479
+ 'Implement the approved plan below.',
1480
+ '',
1481
+ 'Use native ethagent tools directly. Do not translate tool names into shell commands.',
1482
+ 'For workspace inspection, call list_directory and read_file directly.',
1483
+ 'For file creation or edits, call edit_file directly.',
1484
+ 'Use run_bash only for an actual shell command that cannot be performed by a narrower native tool, such as starting a local server after files exist.',
1485
+ 'Ignore any plan wording that says to execute file work as a Bash script or directly in the terminal; the native tools above are authoritative.',
1486
+ 'Read the relevant files before editing, make the required changes, and verify the result when possible.',
1487
+ '',
1488
+ plan,
1489
+ ].join('\n')
1490
+ }
1491
+
1492
+ export function buildPlanTransferSeedMessages(args: {
1493
+ sourceSessionId: string
1494
+ summary: string
1495
+ plan: string
1496
+ createdAt: string
1497
+ }): SessionMessage[] {
1498
+ return [
1499
+ {
1500
+ role: 'user',
1501
+ synthetic: true,
1502
+ content: [
1503
+ `Planning handoff from ${args.sourceSessionId.slice(0, 8)}:`,
1504
+ '',
1505
+ args.summary.trim(),
1506
+ ].join('\n'),
1507
+ createdAt: args.createdAt,
1508
+ },
1509
+ {
1510
+ role: 'user',
1511
+ synthetic: true,
1512
+ content: [
1513
+ 'Approved plan to implement:',
1514
+ '',
1515
+ args.plan.trim(),
1516
+ ].join('\n'),
1517
+ createdAt: args.createdAt,
1518
+ },
1519
+ ]
1520
+ }
1521
+
1522
+ function appendPromptHistoryEntry(history: string[], value: string): string[] {
1523
+ const prompt = value.trim()
1524
+ if (!prompt) return history
1525
+ const next = history[history.length - 1] === prompt ? history : [...history, prompt]
1526
+ return next.length > MAX_PROMPT_HISTORY ? next.slice(-MAX_PROMPT_HISTORY) : next
1527
+ }
1528
+
1529
+ function normalizeHandoffSummary(summary: string): string {
1530
+ const trimmed = summary.trim()
1531
+ if (trimmed.length <= MAX_HANDOFF_SUMMARY_CHARS) return trimmed
1532
+ return [
1533
+ trimmed.slice(0, MAX_HANDOFF_SUMMARY_CHARS - 96).trimEnd(),
1534
+ '',
1535
+ '[handoff truncated to keep the resumed conversation responsive]',
1536
+ ].join('\n')
1537
+ }
1538
+
1539
+ export function privateContinuityEditReviewFromToolResult(
1540
+ name: string,
1541
+ input: Record<string, unknown>,
1542
+ result: { ok: boolean; summary: string; content: string },
1543
+ ): ContinuityEditReviewState | null {
1544
+ if (name !== 'propose_private_continuity_edit' || !result.ok) return null
1545
+ const file = normalizePrivateContinuityFile(input.file)
1546
+ if (!file) return null
1547
+ const filePath = extractReviewFilePath(result.content)
1548
+ if (!filePath) return null
1549
+ return {
1550
+ file,
1551
+ filePath,
1552
+ summary: result.summary,
1553
+ }
1554
+ }
1555
+
1556
+ function normalizePrivateContinuityFile(value: unknown): ContinuityEditReviewState['file'] | null {
1557
+ if (typeof value !== 'string') return null
1558
+ if (/^soul\.md$/i.test(value.trim())) return 'SOUL.md'
1559
+ if (/^memory\.md$/i.test(value.trim())) return 'MEMORY.md'
1560
+ return null
1561
+ }
1562
+
1563
+ function extractReviewFilePath(content: string): string | null {
1564
+ for (const line of content.split(/\r?\n/)) {
1565
+ const review = line.match(/^(?:[-*]\s+)?review file:\s*(.+)$/i)
1566
+ if (review?.[1]?.trim()) return cleanReviewFilePath(review[1])
1567
+ const updated = line.match(/^(?:[-*]\s+)?updated local private continuity file\s+(.+)$/i)
1568
+ if (updated?.[1]?.trim()) return cleanReviewFilePath(updated[1])
1569
+ }
1570
+ return null
1571
+ }
1572
+
1573
+ function cleanReviewFilePath(value: string): string {
1574
+ return value.trim().replace(/^`+|`+$/g, '').trim()
1575
+ }