ethagent 2.1.1 → 2.3.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 (177) hide show
  1. package/package.json +2 -1
  2. package/src/app/FirstRun.tsx +1 -7
  3. package/src/app/FirstRunTimeline.tsx +1 -1
  4. package/src/auth/openaiOAuth/credentials.ts +47 -0
  5. package/src/auth/openaiOAuth/crypto.ts +23 -0
  6. package/src/auth/openaiOAuth/index.ts +238 -0
  7. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  8. package/src/auth/openaiOAuth/listener.ts +151 -0
  9. package/src/auth/openaiOAuth/refresh.ts +70 -0
  10. package/src/auth/openaiOAuth/shared.ts +115 -0
  11. package/src/chat/ChatBottomPane.tsx +20 -11
  12. package/src/chat/ChatScreen.tsx +160 -35
  13. package/src/chat/ConversationStack.tsx +1 -1
  14. package/src/chat/MessageList.tsx +185 -72
  15. package/src/chat/SessionStatus.tsx +3 -1
  16. package/src/chat/chatScreenUtils.ts +11 -15
  17. package/src/chat/chatSessionState.ts +3 -2
  18. package/src/chat/chatTurnOrchestrator.ts +1 -7
  19. package/src/chat/commands.ts +28 -27
  20. package/src/chat/display/DiffView.tsx +193 -0
  21. package/src/chat/display/SyntaxText.tsx +192 -0
  22. package/src/chat/display/toolCallDisplay.ts +103 -0
  23. package/src/chat/display/toolResultDisplay.ts +19 -0
  24. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  25. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  26. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  27. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  28. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  29. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  30. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  31. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  32. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  33. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  34. package/src/chat/views/RewindView.tsx +410 -0
  35. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  36. package/src/identity/ens/agentRecords.ts +5 -19
  37. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  38. package/src/identity/ens/ensAutomation/types.ts +0 -1
  39. package/src/identity/hub/OperationalRoutes.tsx +23 -32
  40. package/src/identity/hub/Routes.tsx +13 -13
  41. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  42. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  43. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  44. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  45. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +19 -19
  46. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  47. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  48. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  49. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  50. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  51. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +10 -48
  52. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +11 -9
  53. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  54. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  55. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  56. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  57. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +6 -6
  58. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  59. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  60. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +241 -0
  61. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +27 -82
  62. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +25 -65
  63. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -30
  64. package/src/identity/hub/ens/EnsEditRunners.tsx +62 -0
  65. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +15 -14
  66. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +68 -217
  67. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +18 -11
  68. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -48
  69. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  70. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +4 -4
  71. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  72. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  73. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  74. package/src/identity/hub/{effects/ens → ens}/transactions.ts +232 -232
  75. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +12 -26
  76. package/src/identity/hub/identityHubReducer.ts +3 -3
  77. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +17 -10
  78. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +55 -177
  79. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  80. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -173
  81. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +21 -21
  82. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  83. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  84. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  85. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  86. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  87. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  88. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  89. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  90. package/src/identity/hub/restore/restoreAdmin.ts +34 -0
  91. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  92. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  93. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  94. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  95. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  96. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  97. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +16 -11
  98. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +8 -9
  99. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  100. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  101. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  102. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  103. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +2 -4
  104. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  105. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  106. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +6 -47
  107. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  108. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  109. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  110. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  111. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  112. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -2
  113. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  114. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +7 -40
  115. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -4
  116. package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -7
  117. package/src/identity/hub/shared/reconciliation/walletSetup.ts +27 -0
  118. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  119. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  120. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  121. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  122. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  123. package/src/identity/hub/useIdentityHubController.ts +11 -11
  124. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  125. package/src/identity/wallet/browserWallet/types.ts +0 -5
  126. package/src/identity/wallet/page/copy.ts +1 -31
  127. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  128. package/src/models/ModelPicker.tsx +248 -8
  129. package/src/models/catalog.ts +29 -1
  130. package/src/models/modelPickerOptions.ts +12 -10
  131. package/src/models/providerDisplay.ts +16 -0
  132. package/src/providers/errors.ts +6 -4
  133. package/src/providers/openai-chat.ts +2 -1
  134. package/src/providers/openai-responses-format.ts +156 -0
  135. package/src/providers/openai-responses.ts +276 -0
  136. package/src/providers/registry.ts +85 -8
  137. package/src/runtime/sessionMode.ts +1 -1
  138. package/src/runtime/systemPrompt.ts +4 -2
  139. package/src/runtime/toolExecution.ts +9 -6
  140. package/src/runtime/turn.ts +29 -1
  141. package/src/storage/rewind.ts +20 -0
  142. package/src/storage/secrets.ts +4 -1
  143. package/src/storage/sessions.ts +2 -1
  144. package/src/tools/bashSafety.ts +7 -3
  145. package/src/tools/bashTool.ts +1 -1
  146. package/src/tools/contracts.ts +3 -0
  147. package/src/tools/deleteFileTool.ts +8 -3
  148. package/src/tools/editTool.ts +10 -5
  149. package/src/tools/fileDiff.ts +261 -0
  150. package/src/tools/privateContinuityEditTool.ts +11 -1
  151. package/src/tools/writeFileTool.ts +8 -3
  152. package/src/ui/Spinner.tsx +25 -3
  153. package/src/ui/TextInput.tsx +2 -2
  154. package/src/ui/theme.ts +17 -0
  155. package/src/utils/clipboard.ts +10 -7
  156. package/src/utils/openExternal.ts +20 -10
  157. package/src/chat/RewindView.tsx +0 -386
  158. package/src/chat/toolResultDisplay.ts +0 -8
  159. package/src/identity/ens/ensRegistration.ts +0 -199
  160. package/src/identity/hub/effects/index.ts +0 -74
  161. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  162. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  163. package/src/identity/hub/effects/restoreAdmin.ts +0 -93
  164. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  165. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +0 -336
  166. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -198
  167. package/src/identity/hub/reconciliation/walletSetup.ts +0 -220
  168. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  169. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  170. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  171. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  172. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  173. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  174. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  175. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  176. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  177. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -0,0 +1,156 @@
1
+ import type { Message, MessageContentBlock } from './contracts.js'
2
+ import { messageTextContent } from '../utils/messages.js'
3
+ import type { OpenAIToolDefinition } from './openai-chat.js'
4
+
5
+ export type ResponsesInputContent =
6
+ | { type: 'input_text'; text: string }
7
+ | { type: 'output_text'; text: string }
8
+
9
+ export type ResponsesInputItem =
10
+ | { type: 'message'; role: 'user' | 'assistant'; content: ResponsesInputContent[] }
11
+ | { type: 'function_call'; id?: string; call_id: string; name: string; arguments: string }
12
+ | { type: 'function_call_output'; call_id: string; output: string }
13
+
14
+ export type ResponsesTool = {
15
+ type: 'function'
16
+ name: string
17
+ description: string
18
+ parameters: Record<string, unknown>
19
+ }
20
+
21
+ export type ResponsesRequestBody = {
22
+ model: string
23
+ input: ResponsesInputItem[]
24
+ instructions?: string
25
+ tools?: ResponsesTool[]
26
+ tool_choice?: 'auto' | 'none' | 'required'
27
+ parallel_tool_calls?: boolean
28
+ stream: true
29
+ store: false
30
+ max_output_tokens?: number
31
+ }
32
+
33
+ export function buildResponsesBody(args: {
34
+ model: string
35
+ messages: Message[]
36
+ tools: OpenAIToolDefinition[]
37
+ maxOutputTokens?: number
38
+ }): ResponsesRequestBody {
39
+ const { instructions, items } = splitMessages(args.messages)
40
+ const body: ResponsesRequestBody = {
41
+ model: args.model,
42
+ input: items,
43
+ stream: true,
44
+ store: false,
45
+ }
46
+ if (instructions) body.instructions = instructions
47
+ if (args.tools.length > 0) {
48
+ body.tools = args.tools.map(tool => ({
49
+ type: 'function' as const,
50
+ name: tool.function.name,
51
+ description: tool.function.description,
52
+ parameters: tool.function.parameters as Record<string, unknown>,
53
+ }))
54
+ body.parallel_tool_calls = true
55
+ body.tool_choice = 'auto'
56
+ }
57
+ if (args.maxOutputTokens !== undefined) {
58
+ body.max_output_tokens = args.maxOutputTokens
59
+ }
60
+ return body
61
+ }
62
+
63
+ function splitMessages(messages: Message[]): {
64
+ instructions?: string
65
+ items: ResponsesInputItem[]
66
+ } {
67
+ const instructions: string[] = []
68
+ const items: ResponsesInputItem[] = []
69
+
70
+ for (const message of messages) {
71
+ if (message.role === 'system') {
72
+ const text = typeof message.content === 'string'
73
+ ? message.content
74
+ : messageTextContent(message)
75
+ if (text) instructions.push(text)
76
+ continue
77
+ }
78
+
79
+ if (message.role === 'user') {
80
+ const blocks = normalizeBlocks(message.content)
81
+ const toolResults = blocks.filter(isToolResultBlock)
82
+ if (toolResults.length > 0) {
83
+ for (const block of toolResults) {
84
+ items.push({
85
+ type: 'function_call_output',
86
+ call_id: block.toolUseId,
87
+ output: block.content,
88
+ })
89
+ }
90
+ const remainingText = blocks
91
+ .filter(isTextBlock)
92
+ .map(block => block.text)
93
+ .join('')
94
+ if (remainingText) {
95
+ items.push({
96
+ type: 'message',
97
+ role: 'user',
98
+ content: [{ type: 'input_text', text: remainingText }],
99
+ })
100
+ }
101
+ continue
102
+ }
103
+ const text = blocks.filter(isTextBlock).map(block => block.text).join('')
104
+ if (text) {
105
+ items.push({
106
+ type: 'message',
107
+ role: 'user',
108
+ content: [{ type: 'input_text', text }],
109
+ })
110
+ }
111
+ continue
112
+ }
113
+
114
+ const blocks = normalizeBlocks(message.content)
115
+ const text = blocks.filter(isTextBlock).map(block => block.text).join('')
116
+ if (text) {
117
+ items.push({
118
+ type: 'message',
119
+ role: 'assistant',
120
+ content: [{ type: 'output_text', text }],
121
+ })
122
+ }
123
+ for (const block of blocks.filter(isToolUseBlock)) {
124
+ items.push({
125
+ type: 'function_call',
126
+ call_id: block.id,
127
+ name: block.name,
128
+ arguments: JSON.stringify(block.input),
129
+ })
130
+ }
131
+ }
132
+
133
+ return {
134
+ instructions: instructions.length > 0 ? instructions.join('\n\n') : undefined,
135
+ items,
136
+ }
137
+ }
138
+
139
+ function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
140
+ if (typeof content === 'string') {
141
+ return content ? [{ type: 'text', text: content }] : []
142
+ }
143
+ return content
144
+ }
145
+
146
+ function isTextBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'text' }> {
147
+ return block.type === 'text'
148
+ }
149
+
150
+ function isToolUseBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_use' }> {
151
+ return block.type === 'tool_use'
152
+ }
153
+
154
+ function isToolResultBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_result' }> {
155
+ return block.type === 'tool_result'
156
+ }
@@ -0,0 +1,276 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+ import type { Message, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
3
+ import { ProviderError } from './contracts.js'
4
+ import { providerErrorFromResponse } from './errors.js'
5
+ import { fetchWithRetryStreamEvents } from './retry.js'
6
+ import { iterSseEvents } from './sse.js'
7
+ import { buildResponsesBody } from './openai-responses-format.js'
8
+ import type { OpenAIToolDefinition } from './openai-chat.js'
9
+
10
+ const READ_TIMEOUT_MS = 45_000
11
+
12
+ type DoneStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown'
13
+
14
+ export type OpenAIResponsesProviderOptions = {
15
+ model: string
16
+ baseUrl: string
17
+ accessToken: string
18
+ accountId?: string
19
+ originator?: string
20
+ tools?: OpenAIToolDefinition[]
21
+ maxRetries?: number
22
+ refresh?: () => Promise<string>
23
+ }
24
+
25
+ type StreamingToolCall = {
26
+ callId: string
27
+ name: string
28
+ inputJson: string
29
+ started: boolean
30
+ }
31
+
32
+ export class OpenAIResponsesProvider implements Provider {
33
+ readonly id: ProviderId = 'openai'
34
+ readonly model: string
35
+ readonly supportsTools: boolean
36
+ private accessToken: string
37
+ private readonly baseUrl: string
38
+ private readonly accountId?: string
39
+ private readonly originator: string
40
+ private readonly tools: OpenAIToolDefinition[]
41
+ private readonly maxRetries?: number
42
+ private readonly refresh?: () => Promise<string>
43
+
44
+ constructor(opts: OpenAIResponsesProviderOptions) {
45
+ this.model = opts.model
46
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '')
47
+ this.accessToken = opts.accessToken
48
+ this.accountId = opts.accountId
49
+ this.originator = opts.originator ?? 'codex_cli_rs'
50
+ this.tools = opts.tools ?? []
51
+ this.maxRetries = opts.maxRetries
52
+ this.refresh = opts.refresh
53
+ this.supportsTools = this.tools.length > 0
54
+ }
55
+
56
+ async *complete(
57
+ messages: Message[],
58
+ signal: AbortSignal,
59
+ options: ProviderCompleteOptions = {},
60
+ ): AsyncIterable<StreamEvent> {
61
+ if (!this.accessToken) {
62
+ const error = new ProviderError('missing OAuth access token for openai (sign in again via the model picker)')
63
+ yield { type: 'error', message: error.message }
64
+ return
65
+ }
66
+
67
+ let attempt = 0
68
+ while (true) {
69
+ attempt += 1
70
+ const body = JSON.stringify(buildResponsesBody({
71
+ model: this.model,
72
+ messages,
73
+ tools: this.tools,
74
+ maxOutputTokens: options.maxTokens,
75
+ }))
76
+
77
+ let response: Response
78
+ try {
79
+ response = yield* fetchWithRetryStreamEvents(`${this.baseUrl}/responses`, {
80
+ method: 'POST',
81
+ headers: this.requestHeaders(),
82
+ body,
83
+ }, { signal, maxRetries: this.maxRetries, rateLimitResetProvider: 'openai-compatible' })
84
+ } catch (err: unknown) {
85
+ if (signal.aborted) return
86
+ yield { type: 'error', message: networkErrorMessage(this.baseUrl, err) }
87
+ return
88
+ }
89
+
90
+ if (response.status === 401 && this.refresh && attempt === 1) {
91
+ try {
92
+ this.accessToken = await this.refresh()
93
+ continue
94
+ } catch (refreshErr) {
95
+ const message = refreshErr instanceof Error ? refreshErr.message : String(refreshErr)
96
+ yield { type: 'error', message: `OpenAI sign-in expired and refresh failed: ${message}` }
97
+ return
98
+ }
99
+ }
100
+
101
+ if (!response.ok) {
102
+ const error = await providerErrorFromResponse('openai', response)
103
+ yield { type: 'error', message: error.message }
104
+ return
105
+ }
106
+ if (!response.body) {
107
+ yield { type: 'error', message: 'empty response body' }
108
+ return
109
+ }
110
+
111
+ yield* this.parseStream(response.body, signal)
112
+ return
113
+ }
114
+ }
115
+
116
+ private requestHeaders(): Record<string, string> {
117
+ const headers: Record<string, string> = {
118
+ 'Content-Type': 'application/json',
119
+ Accept: 'text/event-stream',
120
+ Authorization: `Bearer ${this.accessToken}`,
121
+ originator: this.originator,
122
+ }
123
+ if (this.accountId) headers['chatgpt-account-id'] = this.accountId
124
+ return headers
125
+ }
126
+
127
+ private async *parseStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): AsyncIterable<StreamEvent> {
128
+ const toolCalls = new Map<string, StreamingToolCall>()
129
+ let inputTokens: number | undefined
130
+ let outputTokens: number | undefined
131
+ let stopReason: DoneStopReason = 'unknown'
132
+
133
+ try {
134
+ for await (const frame of iterSseEvents(body, signal, READ_TIMEOUT_MS)) {
135
+ const eventName = frame.event ?? ''
136
+ if (!frame.data || frame.data === '[DONE]') continue
137
+ let parsed: Record<string, unknown>
138
+ try {
139
+ parsed = JSON.parse(frame.data) as Record<string, unknown>
140
+ } catch {
141
+ continue
142
+ }
143
+
144
+ switch (eventName) {
145
+ case 'response.output_text.delta': {
146
+ const delta = typeof parsed.delta === 'string' ? parsed.delta : ''
147
+ if (delta) yield { type: 'text', delta }
148
+ break
149
+ }
150
+ case 'response.reasoning_summary_text.delta':
151
+ case 'response.reasoning.delta':
152
+ case 'response.reasoning_text.delta': {
153
+ const delta = typeof parsed.delta === 'string' ? parsed.delta : ''
154
+ if (delta) yield { type: 'thinking', delta }
155
+ break
156
+ }
157
+ case 'response.output_item.added': {
158
+ const item = (parsed.item ?? {}) as Record<string, unknown>
159
+ if (item.type === 'function_call') {
160
+ const callId = pickString(item.call_id) ?? pickString(item.id) ?? `tool-${toolCalls.size}`
161
+ const name = pickString(item.name) ?? ''
162
+ toolCalls.set(callId, { callId, name, inputJson: '', started: false })
163
+ if (name) {
164
+ toolCalls.get(callId)!.started = true
165
+ yield { type: 'tool_use_start', id: callId, name }
166
+ }
167
+ }
168
+ break
169
+ }
170
+ case 'response.function_call_arguments.delta': {
171
+ const callId = resolveCallId(parsed, toolCalls)
172
+ const delta = typeof parsed.delta === 'string' ? parsed.delta : ''
173
+ if (!callId || !delta) break
174
+ const existing = toolCalls.get(callId)
175
+ if (!existing) break
176
+ existing.inputJson += delta
177
+ yield { type: 'tool_use_delta', id: callId, delta }
178
+ break
179
+ }
180
+ case 'response.output_item.done': {
181
+ const item = (parsed.item ?? {}) as Record<string, unknown>
182
+ if (item.type === 'function_call') {
183
+ const callId = pickString(item.call_id) ?? pickString(item.id)
184
+ if (!callId) break
185
+ const existing = toolCalls.get(callId)
186
+ const name = pickString(item.name) ?? existing?.name ?? ''
187
+ const argsJson = pickString(item.arguments) ?? existing?.inputJson ?? ''
188
+ stopReason = 'tool_use'
189
+ yield {
190
+ type: 'tool_use_stop',
191
+ id: callId,
192
+ name,
193
+ input: parseToolArguments(argsJson),
194
+ }
195
+ toolCalls.delete(callId)
196
+ }
197
+ break
198
+ }
199
+ case 'response.completed': {
200
+ const usage = (parsed.response as { usage?: Record<string, unknown> } | undefined)?.usage
201
+ const tokens = readUsage(usage)
202
+ if (tokens.input !== undefined) inputTokens = tokens.input
203
+ if (tokens.output !== undefined) outputTokens = tokens.output
204
+ if (stopReason !== 'tool_use') stopReason = 'end_turn'
205
+ break
206
+ }
207
+ case 'response.failed':
208
+ case 'response.error':
209
+ case 'error': {
210
+ const error = parsed.error as { message?: string } | undefined
211
+ const message = error?.message ?? 'Responses API error'
212
+ yield { type: 'error', message }
213
+ return
214
+ }
215
+ case 'response.incomplete': {
216
+ const reason = pickString((parsed.response as { incomplete_details?: { reason?: string } } | undefined)?.incomplete_details?.reason)
217
+ if (reason === 'max_output_tokens') stopReason = 'max_tokens'
218
+ break
219
+ }
220
+ default:
221
+ break
222
+ }
223
+ }
224
+ } catch (err: unknown) {
225
+ if (signal.aborted) return
226
+ yield { type: 'error', message: networkErrorMessage(this.baseUrl, err, 'stream error') }
227
+ return
228
+ }
229
+
230
+ if (signal.aborted) return
231
+ yield { type: 'done', inputTokens, outputTokens, stopReason }
232
+ }
233
+ }
234
+
235
+ function pickString(value: unknown): string | undefined {
236
+ return typeof value === 'string' && value.length > 0 ? value : undefined
237
+ }
238
+
239
+ function resolveCallId(parsed: Record<string, unknown>, calls: Map<string, StreamingToolCall>): string | undefined {
240
+ return (
241
+ pickString(parsed.call_id)
242
+ ?? pickString(parsed.item_id)
243
+ ?? pickString((parsed.item as Record<string, unknown> | undefined)?.call_id)
244
+ ?? pickString((parsed.item as Record<string, unknown> | undefined)?.id)
245
+ ?? (calls.size === 1 ? Array.from(calls.keys())[0] : undefined)
246
+ )
247
+ }
248
+
249
+ function readUsage(usage: Record<string, unknown> | undefined): { input?: number; output?: number } {
250
+ if (!usage) return {}
251
+ const input = typeof usage.input_tokens === 'number'
252
+ ? usage.input_tokens
253
+ : typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : undefined
254
+ const output = typeof usage.output_tokens === 'number'
255
+ ? usage.output_tokens
256
+ : typeof usage.completion_tokens === 'number' ? usage.completion_tokens : undefined
257
+ return { input, output }
258
+ }
259
+
260
+ function parseToolArguments(input: string): Record<string, unknown> {
261
+ const trimmed = input.trim()
262
+ if (!trimmed) return {}
263
+ try {
264
+ const parsed = JSON.parse(trimmed) as unknown
265
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
266
+ ? (parsed as Record<string, unknown>)
267
+ : {}
268
+ } catch {
269
+ return {}
270
+ }
271
+ }
272
+
273
+ function networkErrorMessage(baseUrl: string, err: unknown, fallback = 'network error'): string {
274
+ const message = (err as Error).message || fallback
275
+ return `openai request failed at ${baseUrl}: ${message}`
276
+ }
@@ -6,10 +6,19 @@ import type { SessionMode } from '../runtime/sessionMode.js'
6
6
  import { AnthropicProvider } from './anthropic.js'
7
7
  import { GeminiProvider } from './gemini.js'
8
8
  import { OpenAIChatProvider } from './openai-chat.js'
9
+ import { OpenAIResponsesProvider } from './openai-responses.js'
9
10
  import { anthropicTools, geminiTools, openAITools } from '../tools/registry.js'
10
- import { openAIBaseUrlFor } from '../models/catalog.js'
11
+ import { openAIBaseUrlFor, OPENAI_OAUTH_DEFAULT_MODEL, isOpenAIOAuthAllowedModel } from '../models/catalog.js'
12
+ import {
13
+ getOpenAIOAuthCredentials,
14
+ setOpenAIOAuthCredentials,
15
+ type OpenAIOAuthCredentials,
16
+ } from '../auth/openaiOAuth/credentials.js'
17
+ import { refreshOpenAIAccessToken, shouldRefresh } from '../auth/openaiOAuth/refresh.js'
11
18
  import type { Tool } from '../tools/contracts.js'
12
19
 
20
+ export const OPENAI_CHATGPT_BACKEND_URL = 'https://chatgpt.com/backend-api/codex'
21
+
13
22
  export function isLocalProvider(provider: string): boolean {
14
23
  return provider === 'llamacpp'
15
24
  }
@@ -27,16 +36,84 @@ export function createProvider(config: EthagentConfig, options: { mode?: Session
27
36
  tools: openAITools(mode, toolContext),
28
37
  })
29
38
  case 'openai':
30
- return new OpenAIChatProvider({
31
- id: 'openai',
32
- model: config.model,
33
- baseUrl: openAIBaseUrlFor(config),
34
- loadApiKey: () => getKey('openai'),
35
- tools: openAITools(mode, toolContext),
36
- })
39
+ return createOpenAIProvider(config, openAITools(mode, toolContext))
37
40
  case 'anthropic':
38
41
  return new AnthropicProvider({ model: config.model, tools: anthropicTools(mode, toolContext) })
39
42
  case 'gemini':
40
43
  return new GeminiProvider({ model: config.model, tools: geminiTools(mode, toolContext) })
41
44
  }
42
45
  }
46
+
47
+ function createOpenAIProvider(config: EthagentConfig, tools: ReturnType<typeof openAITools>): Provider {
48
+ return new OpenAIRoutingProvider(config, tools)
49
+ }
50
+
51
+ class OpenAIRoutingProvider implements Provider {
52
+ readonly id = 'openai' as const
53
+ readonly model: string
54
+ readonly supportsTools: boolean
55
+ private delegate: Provider | null = null
56
+ private readonly config: EthagentConfig
57
+ private readonly tools: ReturnType<typeof openAITools>
58
+
59
+ constructor(config: EthagentConfig, tools: ReturnType<typeof openAITools>) {
60
+ this.config = config
61
+ this.tools = tools
62
+ this.model = config.model
63
+ this.supportsTools = tools.length > 0
64
+ }
65
+
66
+ async *complete(...args: Parameters<Provider['complete']>): ReturnType<Provider['complete']> {
67
+ if (!this.delegate) this.delegate = await this.resolveDelegate()
68
+ yield* this.delegate.complete(...args)
69
+ }
70
+
71
+ private async resolveDelegate(): Promise<Provider> {
72
+ const oauth = await loadFreshOAuthCredentials()
73
+ if (oauth) {
74
+ const oauthModel = isOpenAIOAuthAllowedModel(this.model) ? this.model : OPENAI_OAUTH_DEFAULT_MODEL
75
+ return new OpenAIResponsesProvider({
76
+ model: oauthModel,
77
+ baseUrl: OPENAI_CHATGPT_BACKEND_URL,
78
+ accessToken: oauth.accessToken,
79
+ accountId: oauth.accountId,
80
+ tools: this.tools,
81
+ refresh: async () => {
82
+ const next = await loadFreshOAuthCredentials({ force: true })
83
+ if (!next) throw new Error('No OAuth credentials available')
84
+ return next.accessToken
85
+ },
86
+ })
87
+ }
88
+ return new OpenAIChatProvider({
89
+ id: 'openai',
90
+ model: this.model,
91
+ baseUrl: openAIBaseUrlFor(this.config),
92
+ loadApiKey: () => getKey('openai'),
93
+ tools: this.tools,
94
+ })
95
+ }
96
+ }
97
+
98
+ async function loadFreshOAuthCredentials(options: { force?: boolean } = {}): Promise<OpenAIOAuthCredentials | null> {
99
+ const current = await getOpenAIOAuthCredentials()
100
+ if (!current) return null
101
+ if (!options.force && !shouldRefresh(current)) return current
102
+
103
+ try {
104
+ const refreshed = await refreshOpenAIAccessToken(current.refreshToken)
105
+ const now = Date.now()
106
+ const next: OpenAIOAuthCredentials = {
107
+ accessToken: refreshed.accessToken,
108
+ refreshToken: refreshed.refreshToken,
109
+ idToken: refreshed.idToken ?? current.idToken,
110
+ accountId: refreshed.accountId ?? current.accountId,
111
+ expiresAt: now + refreshed.expiresIn * 1000,
112
+ lastRefreshAt: now,
113
+ }
114
+ await setOpenAIOAuthCredentials(next)
115
+ return next
116
+ } catch {
117
+ return current
118
+ }
119
+ }
@@ -24,7 +24,7 @@ export function nextSessionMode(mode: SessionMode): SessionMode {
24
24
  }
25
25
 
26
26
  export function sessionModeLabel(mode: SessionMode): string {
27
- return mode === 'plan' ? 'Plan mode' : mode === 'accept-edits' ? 'Accept edits on' : ''
27
+ return mode === 'plan' ? 'plan mode' : mode === 'accept-edits' ? 'accept edits on' : ''
28
28
  }
29
29
 
30
30
  export function modePolicy(mode: PolicyMode): ModePolicy {
@@ -76,7 +76,7 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
76
76
  '**DIRECT REQUESTS**: If the user asks to change directory, list files, or read a file, respond with exactly one matching native tool call. Do not substitute prose or claim the action was taken.',
77
77
  '**EVIDENCE REQUIRED**: Do not claim a path is missing, a directory does not exist, or a file is absent unless you have a `list_directory` or `read_file` result from this conversation that confirms it.',
78
78
  '**TOOL TYPING**: Tool names are NOT shell commands. NEVER pass `list_directory`, `read_file`, `edit_file`, or `change_directory` directly to `run_bash`. Call the matching native tool.',
79
- 'Prefer targeted `read_file` and `edit_file` calls over general `run_bash` operations when both solve the task.',
79
+ '**PREFER NATIVE TOOLS**: If a request can be answered by `list_directory`, `read_file`, `edit_file`, `write_file`, `delete_file`, or `change_directory`, use that tool. Treat reaching for `run_bash` as a flag that you may be doing it wrong — only proceed if the action genuinely needs a real shell.',
80
80
  ...(ctx.mode === 'plan'
81
81
  ? [
82
82
  'Only read/list tools and permission-gated private continuity reads are available in plan mode.',
@@ -93,11 +93,14 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
93
93
  'When exact private continuity text is needed for surgical removal or targeted replacement, call `read_private_continuity_file` with `file: "MEMORY.md"` or `file: "SOUL.md"` first.',
94
94
  'When the user wants memory, persona, preferences, or private identity continuity changed, call `propose_private_continuity_edit`; do NOT create, overwrite, or patch SOUL.md/MEMORY.md with `write_file` or `edit_file`.',
95
95
  'For private continuity, edit the existing scaffold and build on top of it: prefer `appendToSection`+`appendText` for new notes or use `oldText`+`newText` for targeted replacement. Never omit the edit anchor, never create a new file, and never replace the whole file.',
96
+ 'Never call `propose_private_continuity_edit` with `{}` or only `file`. Send exactly one edit mode: either `appendToSection` + non-empty `appendText`, or `oldText` + `newText`. Omit the fields you are not using — do not pass empty strings for `oldText`/`newText` alongside an append, or empty `appendToSection`/`appendText` alongside a targeted edit.',
96
97
  'If the user asks to remember preferences or facts, call exactly one private continuity append such as `{"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or durable memory."}`.',
97
98
  'If the user asks to change persona or standing behavior, call exactly one private continuity append such as `{"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior."}`.',
98
99
  ]
99
100
  : ['No agent identity is linked in this session. Do not attempt private identity continuity edits; ask the user to create or load an agent first.']),
100
101
  'Use `run_bash` **only** when true shell execution is necessary.',
102
+ '**NO BASH EXPLORATION**: Never use `run_bash` to inspect the workspace. That means no `node -e`, no heredocs (`<<EOF`, `<<\'NODE\'`), no `find`, `grep`, `cat`, `head`, `tail`, `ls`, `dir`, `type`, `tree`, or shell loops to read or walk files. Use `list_directory` for directories and `read_file` for files. `run_bash` is reserved for actions that require a real shell: running tests, builds, git operations, launching processes. If you catch yourself writing a one-liner to read or list something, stop and call the native tool instead.',
103
+ '**DISCOVERY BUDGET**: For exploratory or "tell me about" questions, target ≤5 tool calls total. If 5 calls of the same kind have not given you enough to answer, the question does not need more depth — answer from what you have. Do not recursively scan, walk every subdirectory, or write deeper scripts to be more thorough than the user asked for. Stop and reply.',
101
104
  'Never use `run_bash` to produce conversational text. Do not call `echo`, `printf`, or similar to emit your reply — write it as your assistant text. Bash is for actions that need a real shell, not for generating words.',
102
105
  '**CWD CONTINUITY**: The working directory below is authoritative. After `change_directory` succeeds, use the new path as the base for subsequent actions.',
103
106
  'Do not lag behind the CWD. Edit/read relative to the *current* working directory.',
@@ -120,7 +123,6 @@ function buildToolEnabledPrompt(ctx: SystemPromptContext): string {
120
123
  ? [
121
124
  'For private SOUL.md or MEMORY.md inspection, do not search project folders. Call `read_private_continuity_file` with `file: "SOUL.md"` or `file: "MEMORY.md"`.',
122
125
  'For private SOUL.md or MEMORY.md changes, call `propose_private_continuity_edit` with `file: "SOUL.md"` or `file: "MEMORY.md"` and an in-place append/replacement payload.',
123
- 'Never call `propose_private_continuity_edit` with `{}` or only `file`. For memory/preferences include `appendToSection: "Durable User Preferences"` and a non-empty `appendText`; for persona include `appendToSection: "Persona"` and a non-empty `appendText`.',
124
126
  ]
125
127
  : []),
126
128
  'For targeted private continuity edits with `oldText`, copy the text verbatim from the most recent `read_private_continuity_file` output. For workspace targeted edits, copy from the most recent `read_file` output.',
@@ -17,10 +17,7 @@ import type {
17
17
  import { setCwd as setRuntimeCwd } from './cwd.js'
18
18
  import type { EthagentConfig } from '../storage/config.js'
19
19
  import type { SessionMessage } from '../storage/sessions.js'
20
- import {
21
- summarizeToolInput,
22
- toolResultContentForRow,
23
- } from '../chat/chatScreenUtils.js'
20
+ import { toolResultContentForRow, toolResultDiffForRow } from '../chat/chatScreenUtils.js'
24
21
  import type { MessageRow } from '../chat/MessageList.js'
25
22
  import { modePolicy, toPermissionMode, type SessionMode } from './sessionMode.js'
26
23
 
@@ -239,7 +236,7 @@ export async function runPendingToolUses(args: {
239
236
  id: rowId,
240
237
  name: toolUse.name,
241
238
  summary: toolUse.name,
242
- input: summarizeToolInput(toolUse.input),
239
+ input: toolUse.input,
243
240
  },
244
241
  ])
245
242
  await args.persistTurnMessage({
@@ -282,9 +279,15 @@ async function recordToolResult(
282
279
  ): Promise<void> {
283
280
  const isError = !result.ok
284
281
  const resultContent = toolResultContentForRow(toolUse.name, result.content, isError)
282
+ const diff = toolResultDiffForRow(result.content, isError)
285
283
  args.updateRows(prev => prev.map(row =>
286
284
  row.role === 'tool_call' && row.id === rowId
287
- ? { ...row, result: { content: resultContent, summary: result.summary, isError } }
285
+ ? {
286
+ ...row,
287
+ result: diff
288
+ ? { content: resultContent, summary: result.summary, isError, diff }
289
+ : { content: resultContent, summary: result.summary, isError },
290
+ }
288
291
  : row,
289
292
  ))
290
293
  await args.persistTurnMessage({