the-campfire 0.1.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.
- package/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-bridge.ts +2322 -0
|
@@ -0,0 +1,1987 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex App-Server Adapter
|
|
3
|
+
*
|
|
4
|
+
* Translates between the Codex app-server JSON-RPC protocol (stdin/stdout)
|
|
5
|
+
* and Campfire's BrowserIncomingMessage/BrowserOutgoingMessage types.
|
|
6
|
+
*
|
|
7
|
+
* This allows the browser to be completely unaware of which backend is running —
|
|
8
|
+
* it sees the same message types regardless of whether Claude Code or Codex is
|
|
9
|
+
* the backend.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import type { Subprocess } from "bun";
|
|
14
|
+
import type {
|
|
15
|
+
BrowserIncomingMessage,
|
|
16
|
+
BrowserOutgoingMessage,
|
|
17
|
+
SessionState,
|
|
18
|
+
PermissionRequest,
|
|
19
|
+
CLIResultMessage,
|
|
20
|
+
McpServerDetail,
|
|
21
|
+
McpServerConfig,
|
|
22
|
+
} from "./session-types.js";
|
|
23
|
+
import type { RecorderManager } from "./recorder.js";
|
|
24
|
+
import type { AgentAdapter } from "./adapter-types.js";
|
|
25
|
+
|
|
26
|
+
// ─── Codex JSON-RPC Types ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface JsonRpcRequest {
|
|
29
|
+
method: string;
|
|
30
|
+
id: number;
|
|
31
|
+
params: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface JsonRpcNotification {
|
|
35
|
+
method: string;
|
|
36
|
+
params: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface JsonRpcResponse {
|
|
40
|
+
id: number;
|
|
41
|
+
result?: unknown;
|
|
42
|
+
error?: { code: number; message: string; data?: unknown };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
46
|
+
|
|
47
|
+
// Codex item types
|
|
48
|
+
interface CodexItem {
|
|
49
|
+
type: string;
|
|
50
|
+
id: string;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Safely extract a string kind from a Codex file change entry.
|
|
55
|
+
* Codex may send kind as a string ("create") or as an object ({ type: "modify" }). */
|
|
56
|
+
function safeKind(kind: unknown): string {
|
|
57
|
+
if (typeof kind === "string") return kind;
|
|
58
|
+
if (kind && typeof kind === "object" && "type" in kind) {
|
|
59
|
+
const t = (kind as Record<string, unknown>).type;
|
|
60
|
+
if (typeof t === "string") return t;
|
|
61
|
+
}
|
|
62
|
+
return "modify";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface CodexAgentMessageItem extends CodexItem {
|
|
66
|
+
type: "agentMessage";
|
|
67
|
+
text?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CodexCommandExecutionItem extends CodexItem {
|
|
71
|
+
type: "commandExecution";
|
|
72
|
+
command: string | string[];
|
|
73
|
+
cwd?: string;
|
|
74
|
+
status: "inProgress" | "completed" | "failed" | "declined";
|
|
75
|
+
exitCode?: number;
|
|
76
|
+
durationMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface CodexFileChangeItem extends CodexItem {
|
|
80
|
+
type: "fileChange";
|
|
81
|
+
changes?: Array<{ path: string; kind: unknown; diff?: string }>;
|
|
82
|
+
status: "inProgress" | "completed" | "failed" | "declined";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface CodexMcpToolCallItem extends CodexItem {
|
|
86
|
+
type: "mcpToolCall";
|
|
87
|
+
server: string;
|
|
88
|
+
tool: string;
|
|
89
|
+
status: "inProgress" | "completed" | "failed";
|
|
90
|
+
arguments?: Record<string, unknown>;
|
|
91
|
+
result?: string;
|
|
92
|
+
error?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface CodexWebSearchItem extends CodexItem {
|
|
96
|
+
type: "webSearch";
|
|
97
|
+
query?: string;
|
|
98
|
+
action?: { type: string; url?: string; pattern?: string };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface CodexReasoningItem extends CodexItem {
|
|
102
|
+
type: "reasoning";
|
|
103
|
+
summary?: string;
|
|
104
|
+
content?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface CodexContextCompactionItem extends CodexItem {
|
|
108
|
+
type: "contextCompaction";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface CodexMcpServerStatus {
|
|
112
|
+
name: string;
|
|
113
|
+
tools?: Record<string, { name?: string; annotations?: unknown }>;
|
|
114
|
+
authStatus?: "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface CodexMcpStatusListResponse {
|
|
118
|
+
data?: CodexMcpServerStatus[];
|
|
119
|
+
nextCursor?: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Adapter Options ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface CodexAdapterOptions {
|
|
125
|
+
model?: string;
|
|
126
|
+
cwd?: string;
|
|
127
|
+
approvalMode?: string;
|
|
128
|
+
sandbox?: "workspace-write" | "danger-full-access";
|
|
129
|
+
/** If provided, resume an existing thread instead of starting a new one. */
|
|
130
|
+
threadId?: string;
|
|
131
|
+
/** Optional recorder for raw message capture. */
|
|
132
|
+
recorder?: RecorderManager;
|
|
133
|
+
/** Reasoning effort for o-series models (low/medium/high). */
|
|
134
|
+
reasoningEffort?: "low" | "medium" | "high";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── JSON-RPC Transport ───────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
class JsonRpcTransport {
|
|
140
|
+
private nextId = 1;
|
|
141
|
+
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
142
|
+
private notificationHandler: ((method: string, params: Record<string, unknown>) => void) | null = null;
|
|
143
|
+
private requestHandler: ((method: string, id: number, params: Record<string, unknown>) => void) | null = null;
|
|
144
|
+
private rawInCb: ((line: string) => void) | null = null;
|
|
145
|
+
private rawOutCb: ((data: string) => void) | null = null;
|
|
146
|
+
private writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
147
|
+
private connected = true;
|
|
148
|
+
private buffer = "";
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
stdin: WritableStream<Uint8Array> | { write(data: Uint8Array): number },
|
|
152
|
+
stdout: ReadableStream<Uint8Array>,
|
|
153
|
+
) {
|
|
154
|
+
// Handle both Bun subprocess stdin types
|
|
155
|
+
let writable: WritableStream<Uint8Array>;
|
|
156
|
+
if ("write" in stdin && typeof stdin.write === "function") {
|
|
157
|
+
// Bun's subprocess stdin has a .write() method directly
|
|
158
|
+
writable = new WritableStream({
|
|
159
|
+
write(chunk) {
|
|
160
|
+
(stdin as { write(data: Uint8Array): number }).write(chunk);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
} else {
|
|
164
|
+
writable = stdin as WritableStream<Uint8Array>;
|
|
165
|
+
}
|
|
166
|
+
// Acquire writer once and hold it — avoids "WritableStream is locked" race
|
|
167
|
+
// when concurrent async calls (e.g. rateLimits + turn/start) overlap.
|
|
168
|
+
this.writer = writable.getWriter();
|
|
169
|
+
|
|
170
|
+
this.readStdout(stdout);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async readStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
|
|
174
|
+
const reader = stdout.getReader();
|
|
175
|
+
const decoder = new TextDecoder();
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const { done, value } = await reader.read();
|
|
179
|
+
if (done) break;
|
|
180
|
+
this.buffer += decoder.decode(value, { stream: true });
|
|
181
|
+
this.processBuffer();
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.error("[codex-adapter] stdout reader error:", err);
|
|
185
|
+
} finally {
|
|
186
|
+
this.connected = false;
|
|
187
|
+
// Reject all pending promises so callers don't hang indefinitely
|
|
188
|
+
// when the Codex process crashes or exits unexpectedly.
|
|
189
|
+
for (const [id, { reject }] of this.pending) {
|
|
190
|
+
reject(new Error("Transport closed"));
|
|
191
|
+
}
|
|
192
|
+
this.pending.clear();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private processBuffer(): void {
|
|
197
|
+
const lines = this.buffer.split("\n");
|
|
198
|
+
// Keep the last incomplete line in the buffer
|
|
199
|
+
this.buffer = lines.pop() || "";
|
|
200
|
+
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
if (!trimmed) continue;
|
|
204
|
+
|
|
205
|
+
// Record raw incoming line before parsing
|
|
206
|
+
this.rawInCb?.(trimmed);
|
|
207
|
+
|
|
208
|
+
let msg: JsonRpcMessage;
|
|
209
|
+
try {
|
|
210
|
+
msg = JSON.parse(trimmed);
|
|
211
|
+
} catch {
|
|
212
|
+
console.warn("[codex-adapter] Failed to parse JSON-RPC:", trimmed.substring(0, 200));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.dispatch(msg);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private dispatch(msg: JsonRpcMessage): void {
|
|
221
|
+
if ("id" in msg && msg.id !== undefined) {
|
|
222
|
+
if ("method" in msg && msg.method) {
|
|
223
|
+
// This is a request FROM the server (e.g., approval request)
|
|
224
|
+
this.requestHandler?.(msg.method, msg.id as number, (msg as JsonRpcRequest).params || {});
|
|
225
|
+
} else {
|
|
226
|
+
// This is a response to one of our requests
|
|
227
|
+
const pending = this.pending.get(msg.id as number);
|
|
228
|
+
if (pending) {
|
|
229
|
+
this.pending.delete(msg.id as number);
|
|
230
|
+
const resp = msg as JsonRpcResponse;
|
|
231
|
+
if (resp.error) {
|
|
232
|
+
pending.reject(new Error(resp.error.message));
|
|
233
|
+
} else {
|
|
234
|
+
pending.resolve(resp.result);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else if ("method" in msg) {
|
|
239
|
+
// Notification (no id)
|
|
240
|
+
this.notificationHandler?.(msg.method, (msg as JsonRpcNotification).params || {});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Send a request and wait for the matching response. */
|
|
245
|
+
async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
246
|
+
const id = this.nextId++;
|
|
247
|
+
return new Promise(async (resolve, reject) => {
|
|
248
|
+
this.pending.set(id, { resolve, reject });
|
|
249
|
+
const request = JSON.stringify({ method, id, params });
|
|
250
|
+
try {
|
|
251
|
+
await this.writeRaw(request + "\n");
|
|
252
|
+
} catch (err) {
|
|
253
|
+
this.pending.delete(id);
|
|
254
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Send a notification (no response expected). */
|
|
260
|
+
async notify(method: string, params: Record<string, unknown> = {}): Promise<void> {
|
|
261
|
+
const notification = JSON.stringify({ method, params });
|
|
262
|
+
await this.writeRaw(notification + "\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Respond to a request from the server (e.g., approval). */
|
|
266
|
+
async respond(id: number, result: unknown): Promise<void> {
|
|
267
|
+
const response = JSON.stringify({ id, result });
|
|
268
|
+
await this.writeRaw(response + "\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Register handler for server-initiated notifications. */
|
|
272
|
+
onNotification(handler: (method: string, params: Record<string, unknown>) => void): void {
|
|
273
|
+
this.notificationHandler = handler;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Register handler for server-initiated requests (need a response). */
|
|
277
|
+
onRequest(handler: (method: string, id: number, params: Record<string, unknown>) => void): void {
|
|
278
|
+
this.requestHandler = handler;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
isConnected(): boolean {
|
|
282
|
+
return this.connected;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Register callback for raw incoming lines (before JSON parse). */
|
|
286
|
+
onRawIncoming(cb: (line: string) => void): void {
|
|
287
|
+
this.rawInCb = cb;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Register callback for raw outgoing data (before write). */
|
|
291
|
+
onRawOutgoing(cb: (data: string) => void): void {
|
|
292
|
+
this.rawOutCb = cb;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async writeRaw(data: string): Promise<void> {
|
|
296
|
+
if (!this.connected) {
|
|
297
|
+
throw new Error("Transport closed");
|
|
298
|
+
}
|
|
299
|
+
// Record raw outgoing data before writing
|
|
300
|
+
this.rawOutCb?.(data);
|
|
301
|
+
await this.writer.write(new TextEncoder().encode(data));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Codex Adapter ────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
// ─── Codex Model Pricing (USD per token) ─────────────────────────────────────
|
|
308
|
+
// Source: https://openai.com/api/pricing/
|
|
309
|
+
// Prices are per-token (not per million). Cached input uses reduced rates.
|
|
310
|
+
const CODEX_MODEL_PRICING: Record<string, { input: number; cachedInput: number; output: number }> = {
|
|
311
|
+
"o3": { input: 2.00 / 1e6, cachedInput: 0.50 / 1e6, output: 8.00 / 1e6 },
|
|
312
|
+
"o3-pro": { input: 20.00 / 1e6, cachedInput: 20.00 / 1e6, output: 80.00 / 1e6 },
|
|
313
|
+
"o4-mini": { input: 1.10 / 1e6, cachedInput: 0.275 / 1e6, output: 4.40 / 1e6 },
|
|
314
|
+
"gpt-5": { input: 1.25 / 1e6, cachedInput: 0.125 / 1e6, output: 10.00 / 1e6 },
|
|
315
|
+
"gpt-5-codex": { input: 1.25 / 1e6, cachedInput: 0.125 / 1e6, output: 10.00 / 1e6 },
|
|
316
|
+
"gpt-5.2-codex": { input: 1.25 / 1e6, cachedInput: 0.125 / 1e6, output: 10.00 / 1e6 },
|
|
317
|
+
"gpt-5-mini": { input: 0.30 / 1e6, cachedInput: 0.03 / 1e6, output: 1.25 / 1e6 },
|
|
318
|
+
"gpt-4.1": { input: 2.00 / 1e6, cachedInput: 0.50 / 1e6, output: 8.00 / 1e6 },
|
|
319
|
+
"gpt-4.1-mini": { input: 0.40 / 1e6, cachedInput: 0.10 / 1e6, output: 1.60 / 1e6 },
|
|
320
|
+
"gpt-4.1-nano": { input: 0.10 / 1e6, cachedInput: 0.025 / 1e6, output: 0.40 / 1e6 },
|
|
321
|
+
"gpt-4o": { input: 2.50 / 1e6, cachedInput: 1.25 / 1e6, output: 10.00 / 1e6 },
|
|
322
|
+
"gpt-4o-mini": { input: 0.15 / 1e6, cachedInput: 0.075 / 1e6, output: 0.60 / 1e6 },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/** Look up pricing for a model, falling back to prefix matching then a default. */
|
|
326
|
+
function getCodexPricing(model: string): { input: number; cachedInput: number; output: number } {
|
|
327
|
+
const lower = model.toLowerCase();
|
|
328
|
+
// Exact match
|
|
329
|
+
if (CODEX_MODEL_PRICING[lower]) return CODEX_MODEL_PRICING[lower];
|
|
330
|
+
// Prefix match (handles dated versions like "gpt-5-codex-2025-09-23")
|
|
331
|
+
for (const [key, pricing] of Object.entries(CODEX_MODEL_PRICING)) {
|
|
332
|
+
if (lower.startsWith(key)) return pricing;
|
|
333
|
+
}
|
|
334
|
+
// Default: gpt-5-codex pricing as a reasonable fallback
|
|
335
|
+
return CODEX_MODEL_PRICING["gpt-5-codex"];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export class CodexAdapter implements AgentAdapter {
|
|
339
|
+
private transport: JsonRpcTransport;
|
|
340
|
+
private proc: Subprocess;
|
|
341
|
+
private sessionId: string;
|
|
342
|
+
private options: CodexAdapterOptions;
|
|
343
|
+
|
|
344
|
+
private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
|
|
345
|
+
private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
|
|
346
|
+
private disconnectCb: (() => void) | null = null;
|
|
347
|
+
private initErrorCb: ((error: string) => void) | null = null;
|
|
348
|
+
|
|
349
|
+
// State
|
|
350
|
+
private threadId: string | null = null;
|
|
351
|
+
private currentTurnId: string | null = null;
|
|
352
|
+
private connected = false;
|
|
353
|
+
private initialized = false;
|
|
354
|
+
private initFailed = false;
|
|
355
|
+
|
|
356
|
+
// Streaming accumulator for agent messages
|
|
357
|
+
private streamingText = "";
|
|
358
|
+
private streamingItemId: string | null = null;
|
|
359
|
+
|
|
360
|
+
// Track command execution start times for progress indicator
|
|
361
|
+
private commandStartTimes = new Map<string, number>();
|
|
362
|
+
|
|
363
|
+
// Accumulate reasoning text by item ID so we can emit final thinking blocks.
|
|
364
|
+
private reasoningTextByItemId = new Map<string, string>();
|
|
365
|
+
|
|
366
|
+
// Track which item IDs we have already emitted a tool_use block for.
|
|
367
|
+
// When Codex auto-approves (approvalPolicy "never"), it may skip item/started
|
|
368
|
+
// and only send item/completed — we need to emit tool_use before tool_result.
|
|
369
|
+
private emittedToolUseIds = new Set<string>();
|
|
370
|
+
|
|
371
|
+
// Queue messages received before initialization completes
|
|
372
|
+
private pendingOutgoing: BrowserOutgoingMessage[] = [];
|
|
373
|
+
|
|
374
|
+
// Pending approval requests (Codex sends these as JSON-RPC requests with an id)
|
|
375
|
+
private pendingApprovals = new Map<string, number>(); // request_id -> JSON-RPC id
|
|
376
|
+
|
|
377
|
+
// Track request types that need different response formats
|
|
378
|
+
private pendingUserInputQuestionIds = new Map<string, string[]>(); // request_id -> ordered Codex question IDs
|
|
379
|
+
private pendingReviewDecisions = new Set<string>(); // request_ids that need ReviewDecision format
|
|
380
|
+
private pendingDynamicToolCalls = new Map<string, {
|
|
381
|
+
jsonRpcId: number;
|
|
382
|
+
callId: string;
|
|
383
|
+
toolName: string;
|
|
384
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
385
|
+
}>(); // request_id -> pending dynamic tool call metadata
|
|
386
|
+
|
|
387
|
+
// Cumulative cost tracking (calculated from token usage + model pricing)
|
|
388
|
+
private cumulativeCostUsd = 0;
|
|
389
|
+
private cumulativeTurns = 0;
|
|
390
|
+
private lastTotalTokens: { input: number; output: number } = { input: 0, output: 0 };
|
|
391
|
+
|
|
392
|
+
// Codex account rate limits (fetched after init, updated via notification)
|
|
393
|
+
private _rateLimits: {
|
|
394
|
+
primary: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null;
|
|
395
|
+
secondary: { usedPercent: number; windowDurationMins: number; resetsAt: number } | null;
|
|
396
|
+
} | null = null;
|
|
397
|
+
private static readonly DYNAMIC_TOOL_CALL_TIMEOUT_MS = 120_000;
|
|
398
|
+
|
|
399
|
+
constructor(proc: Subprocess, sessionId: string, options: CodexAdapterOptions = {}) {
|
|
400
|
+
this.proc = proc;
|
|
401
|
+
this.sessionId = sessionId;
|
|
402
|
+
this.options = options;
|
|
403
|
+
|
|
404
|
+
const stdout = proc.stdout;
|
|
405
|
+
const stdin = proc.stdin;
|
|
406
|
+
if (!stdout || !stdin || typeof stdout === "number" || typeof stdin === "number") {
|
|
407
|
+
throw new Error("Codex process must have stdio pipes");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.transport = new JsonRpcTransport(
|
|
411
|
+
stdin as WritableStream<Uint8Array> | { write(data: Uint8Array): number },
|
|
412
|
+
stdout as ReadableStream<Uint8Array>,
|
|
413
|
+
);
|
|
414
|
+
this.transport.onNotification((method, params) => this.handleNotification(method, params));
|
|
415
|
+
this.transport.onRequest((method, id, params) => this.handleRequest(method, id, params));
|
|
416
|
+
|
|
417
|
+
// Wire raw message recording if a recorder is provided
|
|
418
|
+
if (options.recorder) {
|
|
419
|
+
const recorder = options.recorder;
|
|
420
|
+
const cwd = options.cwd || "";
|
|
421
|
+
this.transport.onRawIncoming((line) => {
|
|
422
|
+
recorder.record(sessionId, "in", line, "cli", "codex", cwd);
|
|
423
|
+
});
|
|
424
|
+
this.transport.onRawOutgoing((data) => {
|
|
425
|
+
recorder.record(sessionId, "out", data.trimEnd(), "cli", "codex", cwd);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Monitor process exit
|
|
430
|
+
proc.exited.then(() => {
|
|
431
|
+
this.connected = false;
|
|
432
|
+
for (const pending of this.pendingDynamicToolCalls.values()) {
|
|
433
|
+
clearTimeout(pending.timeout);
|
|
434
|
+
}
|
|
435
|
+
this.pendingDynamicToolCalls.clear();
|
|
436
|
+
this.disconnectCb?.();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Start initialization
|
|
440
|
+
this.initialize();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
getRateLimits() {
|
|
446
|
+
return this._rateLimits;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
sendBrowserMessage(msg: BrowserOutgoingMessage): boolean {
|
|
450
|
+
// If initialization failed, reject all new messages
|
|
451
|
+
if (this.initFailed) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Queue messages if not yet initialized (init is async)
|
|
456
|
+
if (!this.initialized || !this.threadId) {
|
|
457
|
+
if (
|
|
458
|
+
msg.type === "user_message"
|
|
459
|
+
|| msg.type === "permission_response"
|
|
460
|
+
|| msg.type === "mcp_get_status"
|
|
461
|
+
|| msg.type === "mcp_toggle"
|
|
462
|
+
|| msg.type === "mcp_reconnect"
|
|
463
|
+
|| msg.type === "mcp_set_servers"
|
|
464
|
+
) {
|
|
465
|
+
console.log(`[codex-adapter] Queuing ${msg.type} — adapter not yet initialized`);
|
|
466
|
+
this.pendingOutgoing.push(msg);
|
|
467
|
+
return true; // accepted, will be sent after init
|
|
468
|
+
}
|
|
469
|
+
// Non-queueable messages are dropped if not connected
|
|
470
|
+
if (!this.connected) return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return this.dispatchOutgoing(msg);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private dispatchOutgoing(msg: BrowserOutgoingMessage): boolean {
|
|
477
|
+
switch (msg.type) {
|
|
478
|
+
case "user_message":
|
|
479
|
+
this.handleOutgoingUserMessage(msg);
|
|
480
|
+
return true;
|
|
481
|
+
case "permission_response":
|
|
482
|
+
this.handleOutgoingPermissionResponse(msg);
|
|
483
|
+
return true;
|
|
484
|
+
case "interrupt":
|
|
485
|
+
this.handleOutgoingInterrupt();
|
|
486
|
+
return true;
|
|
487
|
+
case "set_model":
|
|
488
|
+
console.warn("[codex-adapter] Runtime model switching not supported by Codex");
|
|
489
|
+
return false;
|
|
490
|
+
case "set_permission_mode":
|
|
491
|
+
console.warn("[codex-adapter] Runtime permission mode switching not supported by Codex");
|
|
492
|
+
return false;
|
|
493
|
+
case "mcp_get_status":
|
|
494
|
+
this.handleOutgoingMcpGetStatus();
|
|
495
|
+
return true;
|
|
496
|
+
case "mcp_toggle":
|
|
497
|
+
this.handleOutgoingMcpToggle(msg.serverName, msg.enabled);
|
|
498
|
+
return true;
|
|
499
|
+
case "mcp_reconnect":
|
|
500
|
+
this.handleOutgoingMcpReconnect();
|
|
501
|
+
return true;
|
|
502
|
+
case "mcp_set_servers":
|
|
503
|
+
this.handleOutgoingMcpSetServers(msg.servers);
|
|
504
|
+
return true;
|
|
505
|
+
default:
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
|
|
511
|
+
this.browserMessageCb = cb;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
|
|
515
|
+
this.sessionMetaCb = cb;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
onDisconnect(cb: () => void): void {
|
|
519
|
+
this.disconnectCb = cb;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
onInitError(cb: (error: string) => void): void {
|
|
523
|
+
this.initErrorCb = cb;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
isConnected(): boolean {
|
|
527
|
+
return this.connected;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async disconnect(): Promise<void> {
|
|
531
|
+
this.connected = false;
|
|
532
|
+
try {
|
|
533
|
+
this.proc.kill("SIGTERM");
|
|
534
|
+
await Promise.race([
|
|
535
|
+
this.proc.exited,
|
|
536
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
537
|
+
]);
|
|
538
|
+
} catch {}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
getThreadId(): string | null {
|
|
542
|
+
return this.threadId;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
getBackendSessionId(): string | null {
|
|
546
|
+
return this.threadId;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Initialization ──────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
private async initialize(): Promise<void> {
|
|
552
|
+
try {
|
|
553
|
+
// Step 1: Send initialize request
|
|
554
|
+
const result = await this.transport.call("initialize", {
|
|
555
|
+
clientInfo: {
|
|
556
|
+
name: "campfire",
|
|
557
|
+
title: "Campfire",
|
|
558
|
+
version: "1.0.0",
|
|
559
|
+
},
|
|
560
|
+
capabilities: {
|
|
561
|
+
experimentalApi: false,
|
|
562
|
+
},
|
|
563
|
+
}) as Record<string, unknown>;
|
|
564
|
+
|
|
565
|
+
// Step 2: Send initialized notification
|
|
566
|
+
await this.transport.notify("initialized", {});
|
|
567
|
+
|
|
568
|
+
this.connected = true;
|
|
569
|
+
this.initialized = true;
|
|
570
|
+
|
|
571
|
+
// Step 3: Start or resume a thread
|
|
572
|
+
if (this.options.threadId) {
|
|
573
|
+
// Resume an existing thread
|
|
574
|
+
const resumeResult = await this.transport.call("thread/resume", {
|
|
575
|
+
threadId: this.options.threadId,
|
|
576
|
+
model: this.options.model,
|
|
577
|
+
cwd: this.options.cwd,
|
|
578
|
+
approvalPolicy: this.mapApprovalPolicy(this.options.approvalMode),
|
|
579
|
+
sandbox: this.options.sandbox || this.mapSandboxPolicy(this.options.approvalMode),
|
|
580
|
+
...(this.options.reasoningEffort ? { reasoningEffort: this.options.reasoningEffort } : {}),
|
|
581
|
+
}) as { thread: { id: string } };
|
|
582
|
+
this.threadId = resumeResult.thread.id;
|
|
583
|
+
} else {
|
|
584
|
+
// Start a new thread
|
|
585
|
+
const threadResult = await this.transport.call("thread/start", {
|
|
586
|
+
model: this.options.model,
|
|
587
|
+
cwd: this.options.cwd,
|
|
588
|
+
approvalPolicy: this.mapApprovalPolicy(this.options.approvalMode),
|
|
589
|
+
sandbox: this.options.sandbox || this.mapSandboxPolicy(this.options.approvalMode),
|
|
590
|
+
...(this.options.reasoningEffort ? { reasoningEffort: this.options.reasoningEffort } : {}),
|
|
591
|
+
}) as { thread: { id: string } };
|
|
592
|
+
this.threadId = threadResult.thread.id;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Notify session metadata
|
|
596
|
+
this.sessionMetaCb?.({
|
|
597
|
+
cliSessionId: this.threadId,
|
|
598
|
+
model: this.options.model,
|
|
599
|
+
cwd: this.options.cwd,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Send session_init to browser
|
|
603
|
+
const state: SessionState = {
|
|
604
|
+
session_id: this.sessionId,
|
|
605
|
+
backend_type: "codex",
|
|
606
|
+
model: this.options.model || "",
|
|
607
|
+
cwd: this.options.cwd || "",
|
|
608
|
+
tools: [],
|
|
609
|
+
permissionMode: this.options.approvalMode || "suggest",
|
|
610
|
+
claude_code_version: "",
|
|
611
|
+
mcp_servers: [],
|
|
612
|
+
agents: [],
|
|
613
|
+
slash_commands: [],
|
|
614
|
+
skills: [],
|
|
615
|
+
total_cost_usd: 0,
|
|
616
|
+
num_turns: 0,
|
|
617
|
+
context_used_percent: 0,
|
|
618
|
+
is_compacting: false,
|
|
619
|
+
git_branch: "",
|
|
620
|
+
is_worktree: false,
|
|
621
|
+
repo_root: "",
|
|
622
|
+
git_ahead: 0,
|
|
623
|
+
git_behind: 0,
|
|
624
|
+
total_lines_added: 0,
|
|
625
|
+
total_lines_removed: 0,
|
|
626
|
+
total_duration_api_ms: 0,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
this.emit({ type: "session_init", session: state });
|
|
630
|
+
|
|
631
|
+
// Fetch initial rate limits (non-blocking — don't fail init if this errors)
|
|
632
|
+
this.transport.call("account/rateLimits/read", {}).then((result) => {
|
|
633
|
+
this.updateRateLimits(result as Record<string, unknown>);
|
|
634
|
+
}).catch(() => { /* best-effort */ });
|
|
635
|
+
|
|
636
|
+
// Flush any messages that were queued during initialization
|
|
637
|
+
if (this.pendingOutgoing.length > 0) {
|
|
638
|
+
console.log(`[codex-adapter] Flushing ${this.pendingOutgoing.length} queued message(s)`);
|
|
639
|
+
const queued = this.pendingOutgoing.splice(0);
|
|
640
|
+
for (const msg of queued) {
|
|
641
|
+
this.dispatchOutgoing(msg);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch (err) {
|
|
645
|
+
const errorMsg = `Codex initialization failed: ${err}`;
|
|
646
|
+
console.error(`[codex-adapter] ${errorMsg}`);
|
|
647
|
+
this.initFailed = true;
|
|
648
|
+
this.connected = false;
|
|
649
|
+
// Discard any messages queued during the failed init attempt
|
|
650
|
+
this.pendingOutgoing.length = 0;
|
|
651
|
+
this.emit({ type: "error", message: errorMsg });
|
|
652
|
+
this.initErrorCb?.(errorMsg);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── Outgoing message handlers ───────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
private async handleOutgoingUserMessage(
|
|
659
|
+
msg: { type: "user_message"; content: string; images?: { media_type: string; data: string }[] },
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
if (!this.threadId) {
|
|
662
|
+
this.emit({ type: "error", message: "No Codex thread started yet" });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const input: Array<{ type: string; text?: string; url?: string }> = [];
|
|
667
|
+
|
|
668
|
+
// Add images if present
|
|
669
|
+
if (msg.images?.length) {
|
|
670
|
+
for (const img of msg.images) {
|
|
671
|
+
input.push({
|
|
672
|
+
type: "image",
|
|
673
|
+
url: `data:${img.media_type};base64,${img.data}`,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Add text
|
|
679
|
+
input.push({ type: "text", text: msg.content });
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const result = await this.transport.call("turn/start", {
|
|
683
|
+
threadId: this.threadId,
|
|
684
|
+
input,
|
|
685
|
+
cwd: this.options.cwd,
|
|
686
|
+
}) as { turn: { id: string } };
|
|
687
|
+
|
|
688
|
+
this.currentTurnId = result.turn.id;
|
|
689
|
+
} catch (err) {
|
|
690
|
+
this.emit({ type: "error", message: `Failed to start turn: ${err}` });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private async handleOutgoingPermissionResponse(
|
|
695
|
+
msg: { type: "permission_response"; request_id: string; behavior: "allow" | "deny"; updated_input?: Record<string, unknown> },
|
|
696
|
+
): Promise<void> {
|
|
697
|
+
const jsonRpcId = this.pendingApprovals.get(msg.request_id);
|
|
698
|
+
if (jsonRpcId === undefined) {
|
|
699
|
+
console.warn(`[codex-adapter] No pending approval for request_id=${msg.request_id}`);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Dynamic tool calls (item/tool/call) require a DynamicToolCallResponse payload.
|
|
704
|
+
const pendingDynamic = this.pendingDynamicToolCalls.get(msg.request_id);
|
|
705
|
+
if (pendingDynamic) {
|
|
706
|
+
this.pendingDynamicToolCalls.delete(msg.request_id);
|
|
707
|
+
this.pendingApprovals.delete(msg.request_id);
|
|
708
|
+
clearTimeout(pendingDynamic.timeout);
|
|
709
|
+
|
|
710
|
+
const result = this.buildDynamicToolCallResponse(msg, pendingDynamic.toolName);
|
|
711
|
+
await this.transport.respond(jsonRpcId, result);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
this.pendingApprovals.delete(msg.request_id);
|
|
716
|
+
|
|
717
|
+
// User input requests (item/tool/requestUserInput) need ToolRequestUserInputResponse
|
|
718
|
+
const questionIds = this.pendingUserInputQuestionIds.get(msg.request_id);
|
|
719
|
+
if (questionIds) {
|
|
720
|
+
this.pendingUserInputQuestionIds.delete(msg.request_id);
|
|
721
|
+
|
|
722
|
+
if (msg.behavior === "deny") {
|
|
723
|
+
// Respond with empty answers on deny
|
|
724
|
+
await this.transport.respond(jsonRpcId, { answers: {} });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Convert browser answers (keyed by index "0","1",...) to Codex format (keyed by question ID)
|
|
729
|
+
const browserAnswers = msg.updated_input?.answers as Record<string, string> || {};
|
|
730
|
+
const codexAnswers: Record<string, { answers: string[] }> = {};
|
|
731
|
+
for (let i = 0; i < questionIds.length; i++) {
|
|
732
|
+
const answer = browserAnswers[String(i)];
|
|
733
|
+
if (answer !== undefined) {
|
|
734
|
+
codexAnswers[questionIds[i]] = { answers: [answer] };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
await this.transport.respond(jsonRpcId, { answers: codexAnswers });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Review decisions (applyPatchApproval / execCommandApproval) need ReviewDecision
|
|
743
|
+
if (this.pendingReviewDecisions.has(msg.request_id)) {
|
|
744
|
+
this.pendingReviewDecisions.delete(msg.request_id);
|
|
745
|
+
const decision = msg.behavior === "allow" ? "approved" : "denied";
|
|
746
|
+
await this.transport.respond(jsonRpcId, { decision });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Standard item/*/requestApproval — uses accept/decline
|
|
751
|
+
const decision = msg.behavior === "allow" ? "accept" : "decline";
|
|
752
|
+
await this.transport.respond(jsonRpcId, { decision });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private async handleOutgoingInterrupt(): Promise<void> {
|
|
756
|
+
if (!this.threadId || !this.currentTurnId) return;
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
await this.transport.call("turn/interrupt", {
|
|
760
|
+
threadId: this.threadId,
|
|
761
|
+
turnId: this.currentTurnId,
|
|
762
|
+
});
|
|
763
|
+
} catch (err) {
|
|
764
|
+
console.warn("[codex-adapter] Interrupt failed:", err);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async handleOutgoingMcpGetStatus(): Promise<void> {
|
|
769
|
+
try {
|
|
770
|
+
const statusEntries = await this.listAllMcpServerStatuses();
|
|
771
|
+
const configMap = await this.readMcpServersConfig();
|
|
772
|
+
|
|
773
|
+
const names = new Set<string>([
|
|
774
|
+
...statusEntries.map((s) => s.name),
|
|
775
|
+
...Object.keys(configMap),
|
|
776
|
+
]);
|
|
777
|
+
|
|
778
|
+
const statusByName = new Map(statusEntries.map((s) => [s.name, s]));
|
|
779
|
+
const servers: McpServerDetail[] = Array.from(names).sort().map((name) => {
|
|
780
|
+
const status = statusByName.get(name);
|
|
781
|
+
const config = this.toMcpServerConfig(configMap[name]);
|
|
782
|
+
const isEnabled = this.isMcpServerEnabled(configMap[name]);
|
|
783
|
+
const serverStatus: McpServerDetail["status"] =
|
|
784
|
+
!isEnabled
|
|
785
|
+
? "disabled"
|
|
786
|
+
: (status?.authStatus === "notLoggedIn" ? "failed" : "connected");
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
name,
|
|
790
|
+
status: serverStatus,
|
|
791
|
+
error: status?.authStatus === "notLoggedIn" ? "MCP server requires login" : undefined,
|
|
792
|
+
config,
|
|
793
|
+
scope: "user",
|
|
794
|
+
tools: this.mapMcpTools(status?.tools),
|
|
795
|
+
};
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
this.emit({ type: "mcp_status", servers });
|
|
799
|
+
} catch (err) {
|
|
800
|
+
this.emit({ type: "error", message: `Failed to get MCP status: ${err}` });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private async handleOutgoingMcpToggle(serverName: string, enabled: boolean): Promise<void> {
|
|
805
|
+
try {
|
|
806
|
+
if (serverName.includes(".")) {
|
|
807
|
+
throw new Error("Server names containing '.' are not supported for toggle");
|
|
808
|
+
}
|
|
809
|
+
await this.transport.call("config/value/write", {
|
|
810
|
+
keyPath: `mcp_servers.${serverName}.enabled`,
|
|
811
|
+
value: enabled,
|
|
812
|
+
mergeStrategy: "upsert",
|
|
813
|
+
});
|
|
814
|
+
await this.reloadMcpServers();
|
|
815
|
+
await this.handleOutgoingMcpGetStatus();
|
|
816
|
+
} catch (err) {
|
|
817
|
+
// Some existing configs may contain legacy/foreign fields (e.g. `transport`)
|
|
818
|
+
// that fail on reload when touched. If so, remove this server entry entirely.
|
|
819
|
+
const msg = String(err);
|
|
820
|
+
if (msg.includes("invalid transport")) {
|
|
821
|
+
try {
|
|
822
|
+
await this.transport.call("config/value/write", {
|
|
823
|
+
keyPath: `mcp_servers.${serverName}`,
|
|
824
|
+
value: null,
|
|
825
|
+
mergeStrategy: "replace",
|
|
826
|
+
});
|
|
827
|
+
await this.reloadMcpServers();
|
|
828
|
+
await this.handleOutgoingMcpGetStatus();
|
|
829
|
+
return;
|
|
830
|
+
} catch {
|
|
831
|
+
// fall through to user-visible error below
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
this.emit({ type: "error", message: `Failed to toggle MCP server "${serverName}": ${err}` });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private async handleOutgoingMcpReconnect(): Promise<void> {
|
|
839
|
+
try {
|
|
840
|
+
await this.reloadMcpServers();
|
|
841
|
+
await this.handleOutgoingMcpGetStatus();
|
|
842
|
+
} catch (err) {
|
|
843
|
+
this.emit({ type: "error", message: `Failed to reload MCP servers: ${err}` });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private async handleOutgoingMcpSetServers(servers: Record<string, McpServerConfig>): Promise<void> {
|
|
848
|
+
try {
|
|
849
|
+
const edits: Array<{ keyPath: string; value: Record<string, unknown>; mergeStrategy: "upsert" }> = [];
|
|
850
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
851
|
+
if (name.includes(".")) {
|
|
852
|
+
throw new Error(`Server names containing '.' are not supported: ${name}`);
|
|
853
|
+
}
|
|
854
|
+
edits.push({
|
|
855
|
+
keyPath: `mcp_servers.${name}`,
|
|
856
|
+
value: this.fromMcpServerConfig(config),
|
|
857
|
+
mergeStrategy: "upsert",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (edits.length > 0) {
|
|
861
|
+
await this.transport.call("config/batchWrite", {
|
|
862
|
+
edits,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
await this.reloadMcpServers();
|
|
866
|
+
await this.handleOutgoingMcpGetStatus();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
this.emit({ type: "error", message: `Failed to configure MCP servers: ${err}` });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Incoming notification handlers ──────────────────────────────────────
|
|
873
|
+
|
|
874
|
+
private handleNotification(method: string, params: Record<string, unknown>): void {
|
|
875
|
+
// Debug: log all significant notifications to understand Codex event flow
|
|
876
|
+
if (method.startsWith("item/") || method.startsWith("turn/") || method.startsWith("thread/")) {
|
|
877
|
+
const item = params.item as { type?: string; id?: string } | undefined;
|
|
878
|
+
console.log(`[codex-adapter] ← ${method}${item ? ` type=${item.type} id=${item.id}` : ""}${!item && Object.keys(params).length > 0 ? ` keys=[${Object.keys(params).join(",")}]` : ""}`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
switch (method) {
|
|
883
|
+
case "item/started":
|
|
884
|
+
this.handleItemStarted(params);
|
|
885
|
+
break;
|
|
886
|
+
case "item/agentMessage/delta":
|
|
887
|
+
this.handleAgentMessageDelta(params);
|
|
888
|
+
break;
|
|
889
|
+
case "item/commandExecution/outputDelta":
|
|
890
|
+
// Streaming command output — emit as tool_progress so the browser
|
|
891
|
+
// shows a live elapsed-time indicator while the command runs.
|
|
892
|
+
this.emitCommandProgress(params);
|
|
893
|
+
break;
|
|
894
|
+
case "item/fileChange/outputDelta":
|
|
895
|
+
// Streaming file change output. Same as above.
|
|
896
|
+
break;
|
|
897
|
+
case "item/reasoning/textDelta":
|
|
898
|
+
case "item/reasoning/summaryTextDelta":
|
|
899
|
+
case "item/reasoning/summaryPartAdded":
|
|
900
|
+
this.handleReasoningDelta(params);
|
|
901
|
+
break;
|
|
902
|
+
case "item/mcpToolCall/progress": {
|
|
903
|
+
// MCP tool call progress — map to tool_progress
|
|
904
|
+
const itemId = params.itemId as string | undefined;
|
|
905
|
+
const threadId = params.threadId as string | undefined;
|
|
906
|
+
if (itemId) {
|
|
907
|
+
this.emit({
|
|
908
|
+
type: "tool_progress",
|
|
909
|
+
tool_use_id: itemId,
|
|
910
|
+
tool_name: "mcp_tool_call",
|
|
911
|
+
elapsed_time_seconds: 0,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
case "item/plan/delta":
|
|
917
|
+
// Plan updates — could display in future.
|
|
918
|
+
break;
|
|
919
|
+
case "item/updated":
|
|
920
|
+
this.handleItemUpdated(params);
|
|
921
|
+
break;
|
|
922
|
+
case "item/completed":
|
|
923
|
+
this.handleItemCompleted(params);
|
|
924
|
+
break;
|
|
925
|
+
case "rawResponseItem/completed":
|
|
926
|
+
// Raw model response — internal, not needed for UI.
|
|
927
|
+
break;
|
|
928
|
+
case "turn/started":
|
|
929
|
+
// Turn started, nothing to emit
|
|
930
|
+
break;
|
|
931
|
+
case "turn/completed":
|
|
932
|
+
this.handleTurnCompleted(params);
|
|
933
|
+
break;
|
|
934
|
+
case "turn/plan/updated":
|
|
935
|
+
// Could emit as tool_progress or similar
|
|
936
|
+
break;
|
|
937
|
+
case "turn/diff/updated":
|
|
938
|
+
// Could show diff, but not needed for MVP
|
|
939
|
+
break;
|
|
940
|
+
case "thread/started":
|
|
941
|
+
// Thread started after init — nothing to emit.
|
|
942
|
+
break;
|
|
943
|
+
case "thread/tokenUsage/updated":
|
|
944
|
+
this.handleTokenUsageUpdated(params);
|
|
945
|
+
break;
|
|
946
|
+
case "account/updated":
|
|
947
|
+
case "account/login/completed":
|
|
948
|
+
// Auth events
|
|
949
|
+
break;
|
|
950
|
+
case "account/rateLimits/updated":
|
|
951
|
+
this.updateRateLimits(params);
|
|
952
|
+
break;
|
|
953
|
+
case "codex/event/stream_error": {
|
|
954
|
+
const msg = params.msg as { message?: string } | undefined;
|
|
955
|
+
if (msg?.message) {
|
|
956
|
+
console.log(`[codex-adapter] Stream error: ${msg.message}`);
|
|
957
|
+
}
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
case "codex/event/error": {
|
|
961
|
+
const msg = params.msg as { message?: string } | undefined;
|
|
962
|
+
if (msg?.message) {
|
|
963
|
+
console.error(`[codex-adapter] Codex error: ${msg.message}`);
|
|
964
|
+
this.emit({ type: "error", message: msg.message });
|
|
965
|
+
}
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
case "error": {
|
|
969
|
+
const errorMsg = (params as Record<string, unknown>).message || JSON.stringify(params);
|
|
970
|
+
console.warn(`[codex-adapter] Error notification:`, JSON.stringify(params).substring(0, 500));
|
|
971
|
+
this.emit({
|
|
972
|
+
type: "assistant",
|
|
973
|
+
message: {
|
|
974
|
+
id: `codex-error-${Date.now()}`,
|
|
975
|
+
type: "message",
|
|
976
|
+
role: "assistant",
|
|
977
|
+
model: this.options.model || "codex",
|
|
978
|
+
content: [{ type: "text", text: `Error: ${errorMsg}` }],
|
|
979
|
+
stop_reason: "error",
|
|
980
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
981
|
+
},
|
|
982
|
+
parent_tool_use_id: null,
|
|
983
|
+
timestamp: Date.now(),
|
|
984
|
+
});
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
default:
|
|
988
|
+
// Unknown notification, log for debugging
|
|
989
|
+
if (!method.startsWith("account/") && !method.startsWith("codex/event/")) {
|
|
990
|
+
console.log(`[codex-adapter] Unhandled notification: ${method}`, JSON.stringify(params).substring(0, 200));
|
|
991
|
+
}
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
} catch (err) {
|
|
995
|
+
console.error(`[codex-adapter] Error handling notification ${method}:`, err);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ── Incoming request handlers (approval requests) ───────────────────────
|
|
1000
|
+
|
|
1001
|
+
private handleRequest(method: string, id: number, params: Record<string, unknown>): void {
|
|
1002
|
+
try {
|
|
1003
|
+
switch (method) {
|
|
1004
|
+
case "item/commandExecution/requestApproval":
|
|
1005
|
+
this.handleCommandApproval(id, params);
|
|
1006
|
+
break;
|
|
1007
|
+
case "item/fileChange/requestApproval":
|
|
1008
|
+
this.handleFileChangeApproval(id, params);
|
|
1009
|
+
break;
|
|
1010
|
+
case "item/mcpToolCall/requestApproval":
|
|
1011
|
+
this.handleMcpToolCallApproval(id, params);
|
|
1012
|
+
break;
|
|
1013
|
+
case "item/tool/call":
|
|
1014
|
+
this.handleDynamicToolCall(id, params);
|
|
1015
|
+
break;
|
|
1016
|
+
case "item/tool/requestUserInput":
|
|
1017
|
+
this.handleUserInputRequest(id, params);
|
|
1018
|
+
break;
|
|
1019
|
+
case "applyPatchApproval":
|
|
1020
|
+
this.handleApplyPatchApproval(id, params);
|
|
1021
|
+
break;
|
|
1022
|
+
case "execCommandApproval":
|
|
1023
|
+
this.handleExecCommandApproval(id, params);
|
|
1024
|
+
break;
|
|
1025
|
+
case "account/chatgptAuthTokens/refresh":
|
|
1026
|
+
console.warn("[codex-adapter] Auth token refresh not supported");
|
|
1027
|
+
this.transport.respond(id, { error: "not supported" });
|
|
1028
|
+
break;
|
|
1029
|
+
default:
|
|
1030
|
+
console.log(`[codex-adapter] Unhandled request: ${method}`);
|
|
1031
|
+
// Auto-accept unknown requests
|
|
1032
|
+
this.transport.respond(id, { decision: "accept" });
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.error(`[codex-adapter] Error handling request ${method}:`, err);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private handleCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1041
|
+
const requestId = `codex-approval-${randomUUID()}`;
|
|
1042
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1043
|
+
|
|
1044
|
+
const command = params.command as string | string[] | undefined;
|
|
1045
|
+
const commandStr = params.parsedCmd as string || (Array.isArray(command) ? command.join(" ") : command) || "";
|
|
1046
|
+
|
|
1047
|
+
const perm: PermissionRequest = {
|
|
1048
|
+
request_id: requestId,
|
|
1049
|
+
tool_name: "Bash",
|
|
1050
|
+
input: {
|
|
1051
|
+
command: commandStr,
|
|
1052
|
+
cwd: params.cwd as string || this.options.cwd || "",
|
|
1053
|
+
},
|
|
1054
|
+
description: params.reason as string || `Execute: ${commandStr}`,
|
|
1055
|
+
tool_use_id: params.itemId as string || requestId,
|
|
1056
|
+
timestamp: Date.now(),
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private handleFileChangeApproval(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1063
|
+
const requestId = `codex-approval-${randomUUID()}`;
|
|
1064
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1065
|
+
|
|
1066
|
+
// Extract file paths from changes array if available
|
|
1067
|
+
const changes = params.changes as Array<{ path?: string; kind?: string }> | undefined;
|
|
1068
|
+
const filePaths = changes?.map((c) => c.path).filter(Boolean) || [];
|
|
1069
|
+
const fileList = filePaths.length > 0 ? filePaths.join(", ") : undefined;
|
|
1070
|
+
|
|
1071
|
+
const perm: PermissionRequest = {
|
|
1072
|
+
request_id: requestId,
|
|
1073
|
+
tool_name: "Edit",
|
|
1074
|
+
input: {
|
|
1075
|
+
description: params.reason as string || "File changes pending approval",
|
|
1076
|
+
...(filePaths.length > 0 && { file_paths: filePaths }),
|
|
1077
|
+
...(changes && { changes }),
|
|
1078
|
+
},
|
|
1079
|
+
description: params.reason as string || (fileList ? `Codex wants to modify: ${fileList}` : "Codex wants to modify files"),
|
|
1080
|
+
tool_use_id: params.itemId as string || requestId,
|
|
1081
|
+
timestamp: Date.now(),
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private handleMcpToolCallApproval(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1088
|
+
const requestId = `codex-approval-${randomUUID()}`;
|
|
1089
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1090
|
+
|
|
1091
|
+
const server = params.server as string || "unknown";
|
|
1092
|
+
const tool = params.tool as string || "unknown";
|
|
1093
|
+
const args = params.arguments as Record<string, unknown> || {};
|
|
1094
|
+
|
|
1095
|
+
const perm: PermissionRequest = {
|
|
1096
|
+
request_id: requestId,
|
|
1097
|
+
tool_name: `mcp:${server}:${tool}`,
|
|
1098
|
+
input: args,
|
|
1099
|
+
description: params.reason as string || `MCP tool call: ${server}/${tool}`,
|
|
1100
|
+
tool_use_id: params.itemId as string || requestId,
|
|
1101
|
+
timestamp: Date.now(),
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private handleDynamicToolCall(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1108
|
+
const callId = params.callId as string || `dynamic-${randomUUID()}`;
|
|
1109
|
+
const toolName = params.tool as string || "unknown_dynamic_tool";
|
|
1110
|
+
const toolArgs = params.arguments as Record<string, unknown> || {};
|
|
1111
|
+
const requestId = `codex-dynamic-${randomUUID()}`;
|
|
1112
|
+
|
|
1113
|
+
console.log(`[codex-adapter] Dynamic tool call received: ${toolName} (callId=${callId})`);
|
|
1114
|
+
|
|
1115
|
+
// Emit tool_use so the browser sees this custom tool invocation.
|
|
1116
|
+
this.emitToolUseTracked(callId, `dynamic:${toolName}`, toolArgs);
|
|
1117
|
+
|
|
1118
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1119
|
+
const timeout = setTimeout(() => {
|
|
1120
|
+
this.resolveDynamicToolCallTimeout(requestId);
|
|
1121
|
+
}, CodexAdapter.DYNAMIC_TOOL_CALL_TIMEOUT_MS);
|
|
1122
|
+
|
|
1123
|
+
this.pendingDynamicToolCalls.set(requestId, {
|
|
1124
|
+
jsonRpcId,
|
|
1125
|
+
callId,
|
|
1126
|
+
toolName,
|
|
1127
|
+
timeout,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
const perm: PermissionRequest = {
|
|
1131
|
+
request_id: requestId,
|
|
1132
|
+
tool_name: `dynamic:${toolName}`,
|
|
1133
|
+
input: {
|
|
1134
|
+
...toolArgs,
|
|
1135
|
+
call_id: callId,
|
|
1136
|
+
},
|
|
1137
|
+
description: `Custom tool call: ${toolName}`,
|
|
1138
|
+
tool_use_id: callId,
|
|
1139
|
+
timestamp: Date.now(),
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private async resolveDynamicToolCallTimeout(requestId: string): Promise<void> {
|
|
1146
|
+
const pending = this.pendingDynamicToolCalls.get(requestId);
|
|
1147
|
+
if (!pending) return;
|
|
1148
|
+
|
|
1149
|
+
this.pendingDynamicToolCalls.delete(requestId);
|
|
1150
|
+
this.pendingApprovals.delete(requestId);
|
|
1151
|
+
|
|
1152
|
+
this.emitToolResult(
|
|
1153
|
+
pending.callId,
|
|
1154
|
+
`Dynamic tool "${pending.toolName}" timed out waiting for output.`,
|
|
1155
|
+
true,
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
try {
|
|
1159
|
+
await this.transport.respond(pending.jsonRpcId, {
|
|
1160
|
+
contentItems: [{ type: "inputText", text: `Timed out waiting for dynamic tool output: ${pending.toolName}` }],
|
|
1161
|
+
success: false,
|
|
1162
|
+
});
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.warn(`[codex-adapter] Failed to send dynamic tool timeout response: ${err}`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private buildDynamicToolCallResponse(
|
|
1169
|
+
msg: { behavior: "allow" | "deny"; updated_input?: Record<string, unknown> },
|
|
1170
|
+
toolName: string,
|
|
1171
|
+
): { contentItems: unknown[]; success: boolean; structuredContent?: unknown } {
|
|
1172
|
+
if (msg.behavior === "deny") {
|
|
1173
|
+
return {
|
|
1174
|
+
contentItems: [{ type: "inputText", text: `Dynamic tool "${toolName}" was denied by user` }],
|
|
1175
|
+
success: false,
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const rawContentItems = msg.updated_input?.contentItems;
|
|
1180
|
+
const contentItems = Array.isArray(rawContentItems) && rawContentItems.length > 0
|
|
1181
|
+
? rawContentItems
|
|
1182
|
+
: [{ type: "inputText", text: String(msg.updated_input?.text || "Dynamic tool call completed") }];
|
|
1183
|
+
|
|
1184
|
+
const success = typeof msg.updated_input?.success === "boolean"
|
|
1185
|
+
? msg.updated_input.success
|
|
1186
|
+
: true;
|
|
1187
|
+
|
|
1188
|
+
const structuredContent = msg.updated_input?.structuredContent;
|
|
1189
|
+
|
|
1190
|
+
return {
|
|
1191
|
+
contentItems,
|
|
1192
|
+
success,
|
|
1193
|
+
...(structuredContent !== undefined ? { structuredContent } : {}),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private handleUserInputRequest(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1198
|
+
const requestId = `codex-userinput-${randomUUID()}`;
|
|
1199
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1200
|
+
|
|
1201
|
+
const questions = params.questions as Array<{
|
|
1202
|
+
id: string; header: string; question: string;
|
|
1203
|
+
isOther: boolean; isSecret: boolean;
|
|
1204
|
+
options: Array<{ label: string; description: string }> | null;
|
|
1205
|
+
}> || [];
|
|
1206
|
+
|
|
1207
|
+
// Store question IDs so we can map browser indices back to Codex IDs in the response
|
|
1208
|
+
this.pendingUserInputQuestionIds.set(requestId, questions.map((q) => q.id));
|
|
1209
|
+
|
|
1210
|
+
// Convert to our AskUserQuestion format (matches AskUserQuestionDisplay component)
|
|
1211
|
+
const perm: PermissionRequest = {
|
|
1212
|
+
request_id: requestId,
|
|
1213
|
+
tool_name: "AskUserQuestion",
|
|
1214
|
+
input: {
|
|
1215
|
+
questions: questions.map((q) => ({
|
|
1216
|
+
header: q.header,
|
|
1217
|
+
question: q.question,
|
|
1218
|
+
options: q.options?.map((o) => ({ label: o.label, description: o.description })) || [],
|
|
1219
|
+
})),
|
|
1220
|
+
},
|
|
1221
|
+
description: questions[0]?.question || "User input requested",
|
|
1222
|
+
tool_use_id: params.itemId as string || requestId,
|
|
1223
|
+
timestamp: Date.now(),
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private handleApplyPatchApproval(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1230
|
+
const requestId = `codex-patch-${randomUUID()}`;
|
|
1231
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1232
|
+
this.pendingReviewDecisions.add(requestId);
|
|
1233
|
+
|
|
1234
|
+
const fileChanges = params.fileChanges as Record<string, unknown> || {};
|
|
1235
|
+
const filePaths = Object.keys(fileChanges);
|
|
1236
|
+
const reason = params.reason as string | null;
|
|
1237
|
+
|
|
1238
|
+
const perm: PermissionRequest = {
|
|
1239
|
+
request_id: requestId,
|
|
1240
|
+
tool_name: "Edit",
|
|
1241
|
+
input: {
|
|
1242
|
+
file_paths: filePaths,
|
|
1243
|
+
...(reason && { reason }),
|
|
1244
|
+
},
|
|
1245
|
+
description: reason || (filePaths.length > 0
|
|
1246
|
+
? `Codex wants to modify: ${filePaths.join(", ")}`
|
|
1247
|
+
: "Codex wants to modify files"),
|
|
1248
|
+
tool_use_id: params.callId as string || requestId,
|
|
1249
|
+
timestamp: Date.now(),
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private handleExecCommandApproval(jsonRpcId: number, params: Record<string, unknown>): void {
|
|
1256
|
+
const requestId = `codex-exec-${randomUUID()}`;
|
|
1257
|
+
this.pendingApprovals.set(requestId, jsonRpcId);
|
|
1258
|
+
this.pendingReviewDecisions.add(requestId);
|
|
1259
|
+
|
|
1260
|
+
const command = params.command as string[] || [];
|
|
1261
|
+
const commandStr = command.join(" ");
|
|
1262
|
+
const cwd = params.cwd as string || this.options.cwd || "";
|
|
1263
|
+
const reason = params.reason as string | null;
|
|
1264
|
+
|
|
1265
|
+
const perm: PermissionRequest = {
|
|
1266
|
+
request_id: requestId,
|
|
1267
|
+
tool_name: "Bash",
|
|
1268
|
+
input: {
|
|
1269
|
+
command: commandStr,
|
|
1270
|
+
cwd,
|
|
1271
|
+
},
|
|
1272
|
+
description: reason || `Execute: ${commandStr}`,
|
|
1273
|
+
tool_use_id: params.callId as string || requestId,
|
|
1274
|
+
timestamp: Date.now(),
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
this.emit({ type: "permission_request", request: perm });
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ── Item event handlers ─────────────────────────────────────────────────
|
|
1281
|
+
|
|
1282
|
+
private handleItemStarted(params: Record<string, unknown>): void {
|
|
1283
|
+
const item = params.item as CodexItem;
|
|
1284
|
+
if (!item) return;
|
|
1285
|
+
|
|
1286
|
+
switch (item.type) {
|
|
1287
|
+
case "agentMessage":
|
|
1288
|
+
// Start streaming accumulation
|
|
1289
|
+
this.streamingItemId = item.id;
|
|
1290
|
+
this.streamingText = "";
|
|
1291
|
+
// Emit message_start stream event so the browser knows streaming began
|
|
1292
|
+
this.emit({
|
|
1293
|
+
type: "stream_event",
|
|
1294
|
+
event: {
|
|
1295
|
+
type: "message_start",
|
|
1296
|
+
message: {
|
|
1297
|
+
id: this.makeMessageId("agent", item.id),
|
|
1298
|
+
type: "message",
|
|
1299
|
+
role: "assistant",
|
|
1300
|
+
model: this.options.model || "",
|
|
1301
|
+
content: [],
|
|
1302
|
+
stop_reason: null,
|
|
1303
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1304
|
+
},
|
|
1305
|
+
},
|
|
1306
|
+
parent_tool_use_id: null,
|
|
1307
|
+
});
|
|
1308
|
+
// Also emit content_block_start
|
|
1309
|
+
this.emit({
|
|
1310
|
+
type: "stream_event",
|
|
1311
|
+
event: {
|
|
1312
|
+
type: "content_block_start",
|
|
1313
|
+
index: 0,
|
|
1314
|
+
content_block: { type: "text", text: "" },
|
|
1315
|
+
},
|
|
1316
|
+
parent_tool_use_id: null,
|
|
1317
|
+
});
|
|
1318
|
+
break;
|
|
1319
|
+
|
|
1320
|
+
case "commandExecution": {
|
|
1321
|
+
const cmd = item as CodexCommandExecutionItem;
|
|
1322
|
+
const commandStr = Array.isArray(cmd.command) ? cmd.command.join(" ") : (cmd.command || "");
|
|
1323
|
+
this.commandStartTimes.set(item.id, Date.now());
|
|
1324
|
+
this.emitToolUseStart(item.id, "Bash", { command: commandStr });
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
case "fileChange": {
|
|
1329
|
+
const fc = item as CodexFileChangeItem;
|
|
1330
|
+
const changes = fc.changes || [];
|
|
1331
|
+
const firstChange = changes[0];
|
|
1332
|
+
const toolName = safeKind(firstChange?.kind) === "create" ? "Write" : "Edit";
|
|
1333
|
+
const toolInput = {
|
|
1334
|
+
file_path: firstChange?.path || "",
|
|
1335
|
+
changes: changes.map((c) => ({ path: c.path, kind: safeKind(c.kind) })),
|
|
1336
|
+
};
|
|
1337
|
+
this.emitToolUseStart(item.id, toolName, toolInput);
|
|
1338
|
+
break;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
case "mcpToolCall": {
|
|
1342
|
+
const mcp = item as CodexMcpToolCallItem;
|
|
1343
|
+
this.emitToolUseStart(item.id, `mcp:${mcp.server}:${mcp.tool}`, mcp.arguments || {});
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
case "webSearch": {
|
|
1348
|
+
const ws = item as CodexWebSearchItem;
|
|
1349
|
+
this.emitToolUseStart(item.id, "WebSearch", { query: ws.query || "" });
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
case "reasoning": {
|
|
1354
|
+
const r = item as CodexReasoningItem;
|
|
1355
|
+
this.reasoningTextByItemId.set(item.id, r.summary || r.content || "");
|
|
1356
|
+
// Emit as thinking content block
|
|
1357
|
+
if (r.summary || r.content) {
|
|
1358
|
+
this.emit({
|
|
1359
|
+
type: "stream_event",
|
|
1360
|
+
event: {
|
|
1361
|
+
type: "content_block_start",
|
|
1362
|
+
index: 0,
|
|
1363
|
+
content_block: { type: "thinking", thinking: r.summary || r.content || "" },
|
|
1364
|
+
},
|
|
1365
|
+
parent_tool_use_id: null,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
case "contextCompaction":
|
|
1372
|
+
this.emit({ type: "status_change", status: "compacting" });
|
|
1373
|
+
break;
|
|
1374
|
+
|
|
1375
|
+
default:
|
|
1376
|
+
// userMessage is an echo of browser input and not needed in UI.
|
|
1377
|
+
if (item.type !== "userMessage") {
|
|
1378
|
+
console.log(`[codex-adapter] Unhandled item/started type: ${item.type}`, JSON.stringify(item).substring(0, 300));
|
|
1379
|
+
}
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
private handleReasoningDelta(params: Record<string, unknown>): void {
|
|
1385
|
+
const itemId = params.itemId as string | undefined;
|
|
1386
|
+
if (!itemId) return;
|
|
1387
|
+
|
|
1388
|
+
if (!this.reasoningTextByItemId.has(itemId)) {
|
|
1389
|
+
this.reasoningTextByItemId.set(itemId, "");
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const delta = params.delta as string | undefined;
|
|
1393
|
+
if (delta) {
|
|
1394
|
+
const current = this.reasoningTextByItemId.get(itemId) || "";
|
|
1395
|
+
this.reasoningTextByItemId.set(itemId, current + delta);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
private handleAgentMessageDelta(params: Record<string, unknown>): void {
|
|
1400
|
+
const delta = params.delta as string;
|
|
1401
|
+
if (!delta) return;
|
|
1402
|
+
|
|
1403
|
+
this.streamingText += delta;
|
|
1404
|
+
|
|
1405
|
+
// Emit as content_block_delta (matches Claude's streaming format)
|
|
1406
|
+
this.emit({
|
|
1407
|
+
type: "stream_event",
|
|
1408
|
+
event: {
|
|
1409
|
+
type: "content_block_delta",
|
|
1410
|
+
index: 0,
|
|
1411
|
+
delta: { type: "text_delta", text: delta },
|
|
1412
|
+
},
|
|
1413
|
+
parent_tool_use_id: null,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private handleItemUpdated(params: Record<string, unknown>): void {
|
|
1418
|
+
// item/updated is a general update — currently we handle streaming via the specific delta events
|
|
1419
|
+
// Could handle status updates for command_execution / file_change items here
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private handleItemCompleted(params: Record<string, unknown>): void {
|
|
1423
|
+
const item = params.item as CodexItem;
|
|
1424
|
+
if (!item) return;
|
|
1425
|
+
|
|
1426
|
+
switch (item.type) {
|
|
1427
|
+
case "agentMessage": {
|
|
1428
|
+
const agentMsg = item as CodexAgentMessageItem;
|
|
1429
|
+
const text = agentMsg.text || this.streamingText;
|
|
1430
|
+
|
|
1431
|
+
// Emit message_stop for streaming
|
|
1432
|
+
this.emit({
|
|
1433
|
+
type: "stream_event",
|
|
1434
|
+
event: {
|
|
1435
|
+
type: "content_block_stop",
|
|
1436
|
+
index: 0,
|
|
1437
|
+
},
|
|
1438
|
+
parent_tool_use_id: null,
|
|
1439
|
+
});
|
|
1440
|
+
this.emit({
|
|
1441
|
+
type: "stream_event",
|
|
1442
|
+
event: {
|
|
1443
|
+
type: "message_delta",
|
|
1444
|
+
delta: { stop_reason: null }, // null, not "end_turn" — the turn may continue with tool calls
|
|
1445
|
+
usage: { output_tokens: 0 },
|
|
1446
|
+
},
|
|
1447
|
+
parent_tool_use_id: null,
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
// Emit the full assistant message
|
|
1451
|
+
this.emit({
|
|
1452
|
+
type: "assistant",
|
|
1453
|
+
message: {
|
|
1454
|
+
id: this.makeMessageId("agent", item.id),
|
|
1455
|
+
type: "message",
|
|
1456
|
+
role: "assistant",
|
|
1457
|
+
model: this.options.model || "",
|
|
1458
|
+
content: [{ type: "text", text }],
|
|
1459
|
+
stop_reason: "end_turn",
|
|
1460
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1461
|
+
},
|
|
1462
|
+
parent_tool_use_id: null,
|
|
1463
|
+
timestamp: Date.now(),
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
// Reset streaming state
|
|
1467
|
+
this.streamingText = "";
|
|
1468
|
+
this.streamingItemId = null;
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
case "commandExecution": {
|
|
1473
|
+
const cmd = item as CodexCommandExecutionItem;
|
|
1474
|
+
const commandStr = Array.isArray(cmd.command) ? cmd.command.join(" ") : (cmd.command || "");
|
|
1475
|
+
// Ensure tool_use was emitted (may be skipped when auto-approved)
|
|
1476
|
+
this.ensureToolUseEmitted(item.id, "Bash", { command: commandStr });
|
|
1477
|
+
// Clean up progress tracking
|
|
1478
|
+
this.commandStartTimes.delete(item.id);
|
|
1479
|
+
// Emit tool result
|
|
1480
|
+
const output = (item as Record<string, unknown>).stdout as string || "";
|
|
1481
|
+
const stderr = (item as Record<string, unknown>).stderr as string || "";
|
|
1482
|
+
const combinedOutput = [output, stderr].filter(Boolean).join("\n").trim();
|
|
1483
|
+
const exitCode = typeof cmd.exitCode === "number" ? cmd.exitCode : 0;
|
|
1484
|
+
const durationMs = typeof cmd.durationMs === "number" ? cmd.durationMs : undefined;
|
|
1485
|
+
const failed = cmd.status === "failed" || cmd.status === "declined" || exitCode !== 0;
|
|
1486
|
+
|
|
1487
|
+
// Keep successful no-output commands silent in the chat feed.
|
|
1488
|
+
if (!combinedOutput && !failed) {
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
let resultText = combinedOutput;
|
|
1493
|
+
if (!resultText) {
|
|
1494
|
+
resultText = `Exit code: ${exitCode}`;
|
|
1495
|
+
} else if (exitCode !== 0) {
|
|
1496
|
+
resultText = `${resultText}\nExit code: ${exitCode}`;
|
|
1497
|
+
}
|
|
1498
|
+
// Append duration if available and significant (>100ms)
|
|
1499
|
+
if (durationMs !== undefined && durationMs >= 100) {
|
|
1500
|
+
const durationStr = durationMs >= 1000
|
|
1501
|
+
? `${(durationMs / 1000).toFixed(1)}s`
|
|
1502
|
+
: `${durationMs}ms`;
|
|
1503
|
+
resultText = `${resultText}\n(${durationStr})`;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
this.emitToolResult(item.id, resultText, failed);
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
case "fileChange": {
|
|
1511
|
+
const fc = item as CodexFileChangeItem;
|
|
1512
|
+
const changes = fc.changes || [];
|
|
1513
|
+
const firstChange = changes[0];
|
|
1514
|
+
const toolName = safeKind(firstChange?.kind) === "create" ? "Write" : "Edit";
|
|
1515
|
+
// Ensure tool_use was emitted
|
|
1516
|
+
this.ensureToolUseEmitted(item.id, toolName, {
|
|
1517
|
+
file_path: firstChange?.path || "",
|
|
1518
|
+
changes: changes.map((c) => ({ path: c.path, kind: safeKind(c.kind) })),
|
|
1519
|
+
});
|
|
1520
|
+
const summary = changes.map((c) => `${safeKind(c.kind)}: ${c.path}`).join("\n");
|
|
1521
|
+
this.emitToolResult(item.id, summary || "File changes applied", fc.status === "failed");
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
case "mcpToolCall": {
|
|
1526
|
+
const mcp = item as CodexMcpToolCallItem;
|
|
1527
|
+
// Ensure tool_use was emitted
|
|
1528
|
+
this.ensureToolUseEmitted(item.id, `mcp:${mcp.server}:${mcp.tool}`, mcp.arguments || {});
|
|
1529
|
+
this.emitToolResult(item.id, mcp.result || mcp.error || "MCP tool call completed", mcp.status === "failed");
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
case "webSearch": {
|
|
1534
|
+
const ws = item as CodexWebSearchItem;
|
|
1535
|
+
// Ensure tool_use was emitted
|
|
1536
|
+
this.ensureToolUseEmitted(item.id, "WebSearch", { query: ws.query || "" });
|
|
1537
|
+
this.emitToolResult(item.id, ws.action?.url || ws.query || "Web search completed", false);
|
|
1538
|
+
break;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
case "reasoning": {
|
|
1542
|
+
const r = item as CodexReasoningItem;
|
|
1543
|
+
const thinkingText = (
|
|
1544
|
+
this.reasoningTextByItemId.get(item.id)
|
|
1545
|
+
|| r.summary
|
|
1546
|
+
|| r.content
|
|
1547
|
+
|| ""
|
|
1548
|
+
).trim();
|
|
1549
|
+
|
|
1550
|
+
if (thinkingText) {
|
|
1551
|
+
this.emit({
|
|
1552
|
+
type: "assistant",
|
|
1553
|
+
message: {
|
|
1554
|
+
id: this.makeMessageId("reasoning", item.id),
|
|
1555
|
+
type: "message",
|
|
1556
|
+
role: "assistant",
|
|
1557
|
+
model: this.options.model || "",
|
|
1558
|
+
content: [{ type: "thinking", thinking: thinkingText }],
|
|
1559
|
+
stop_reason: null,
|
|
1560
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1561
|
+
},
|
|
1562
|
+
parent_tool_use_id: null,
|
|
1563
|
+
timestamp: Date.now(),
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
this.reasoningTextByItemId.delete(item.id);
|
|
1568
|
+
|
|
1569
|
+
// Close the thinking content block that was opened in handleItemStarted
|
|
1570
|
+
this.emit({
|
|
1571
|
+
type: "stream_event",
|
|
1572
|
+
event: {
|
|
1573
|
+
type: "content_block_stop",
|
|
1574
|
+
index: 0,
|
|
1575
|
+
},
|
|
1576
|
+
parent_tool_use_id: null,
|
|
1577
|
+
});
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
case "contextCompaction":
|
|
1582
|
+
this.emit({ type: "status_change", status: null });
|
|
1583
|
+
break;
|
|
1584
|
+
|
|
1585
|
+
default:
|
|
1586
|
+
if (item.type !== "userMessage") {
|
|
1587
|
+
console.log(`[codex-adapter] Unhandled item/completed type: ${item.type}`, JSON.stringify(item).substring(0, 300));
|
|
1588
|
+
}
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
private handleTurnCompleted(params: Record<string, unknown>): void {
|
|
1594
|
+
const turn = params.turn as { id: string; status: string; error?: { message: string } } | undefined;
|
|
1595
|
+
this.cumulativeTurns++;
|
|
1596
|
+
|
|
1597
|
+
// Synthesize a CLIResultMessage-like structure
|
|
1598
|
+
const result: CLIResultMessage = {
|
|
1599
|
+
type: "result",
|
|
1600
|
+
subtype: turn?.status === "completed" ? "success" : "error_during_execution",
|
|
1601
|
+
is_error: turn?.status !== "completed",
|
|
1602
|
+
result: turn?.error?.message,
|
|
1603
|
+
duration_ms: 0,
|
|
1604
|
+
duration_api_ms: 0,
|
|
1605
|
+
num_turns: this.cumulativeTurns,
|
|
1606
|
+
total_cost_usd: this.cumulativeCostUsd,
|
|
1607
|
+
stop_reason: turn?.status || "end_turn",
|
|
1608
|
+
usage: {
|
|
1609
|
+
input_tokens: this.lastTotalTokens.input,
|
|
1610
|
+
output_tokens: this.lastTotalTokens.output,
|
|
1611
|
+
cache_creation_input_tokens: 0,
|
|
1612
|
+
cache_read_input_tokens: 0,
|
|
1613
|
+
},
|
|
1614
|
+
uuid: randomUUID(),
|
|
1615
|
+
session_id: this.sessionId,
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
this.emit({ type: "result", data: result });
|
|
1619
|
+
this.currentTurnId = null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private updateRateLimits(data: Record<string, unknown>): void {
|
|
1623
|
+
const rl = data?.rateLimits as Record<string, unknown> | undefined;
|
|
1624
|
+
if (!rl) return;
|
|
1625
|
+
this._rateLimits = {
|
|
1626
|
+
primary: rl.primary as { usedPercent: number; windowDurationMins: number; resetsAt: number } | null,
|
|
1627
|
+
secondary: rl.secondary as { usedPercent: number; windowDurationMins: number; resetsAt: number } | null,
|
|
1628
|
+
};
|
|
1629
|
+
// Forward rate limits to browser for UI display
|
|
1630
|
+
this.emit({
|
|
1631
|
+
type: "session_update",
|
|
1632
|
+
session: {
|
|
1633
|
+
codex_rate_limits: {
|
|
1634
|
+
primary: this._rateLimits.primary,
|
|
1635
|
+
secondary: this._rateLimits.secondary,
|
|
1636
|
+
},
|
|
1637
|
+
},
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
private handleTokenUsageUpdated(params: Record<string, unknown>): void {
|
|
1642
|
+
// Codex sends: { threadId, turnId, tokenUsage: {
|
|
1643
|
+
// total: { totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens },
|
|
1644
|
+
// last: { totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens },
|
|
1645
|
+
// modelContextWindow: 258400
|
|
1646
|
+
// }}
|
|
1647
|
+
// IMPORTANT: `total` is cumulative across all turns and can far exceed the context window.
|
|
1648
|
+
// `last` is the most recent turn — its inputTokens reflects what's actually in context.
|
|
1649
|
+
const tokenUsage = params.tokenUsage as Record<string, unknown> | undefined;
|
|
1650
|
+
if (!tokenUsage) return;
|
|
1651
|
+
|
|
1652
|
+
const total = tokenUsage.total as Record<string, number> | undefined;
|
|
1653
|
+
const last = tokenUsage.last as Record<string, number> | undefined;
|
|
1654
|
+
const contextWindow = tokenUsage.modelContextWindow as number | undefined;
|
|
1655
|
+
|
|
1656
|
+
const updates: Partial<SessionState> = {};
|
|
1657
|
+
|
|
1658
|
+
// Use last turn's input tokens for context usage — that's what's actually in the window
|
|
1659
|
+
if (last && contextWindow && contextWindow > 0) {
|
|
1660
|
+
const usedInContext = (last.inputTokens || 0) + (last.outputTokens || 0);
|
|
1661
|
+
const pct = Math.round((usedInContext / contextWindow) * 100);
|
|
1662
|
+
updates.context_used_percent = Math.max(0, Math.min(pct, 100));
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Forward cumulative token breakdown for display in the UI
|
|
1666
|
+
if (total) {
|
|
1667
|
+
updates.codex_token_details = {
|
|
1668
|
+
inputTokens: total.inputTokens || 0,
|
|
1669
|
+
outputTokens: total.outputTokens || 0,
|
|
1670
|
+
cachedInputTokens: total.cachedInputTokens || 0,
|
|
1671
|
+
reasoningOutputTokens: total.reasoningOutputTokens || 0,
|
|
1672
|
+
modelContextWindow: contextWindow || 0,
|
|
1673
|
+
};
|
|
1674
|
+
|
|
1675
|
+
// Calculate cost from cumulative token delta
|
|
1676
|
+
const pricing = getCodexPricing(this.options.model || "");
|
|
1677
|
+
const totalInput = total.inputTokens || 0;
|
|
1678
|
+
const totalOutput = total.outputTokens || 0;
|
|
1679
|
+
const totalCached = total.cachedInputTokens || 0;
|
|
1680
|
+
|
|
1681
|
+
// Compute delta tokens since last update (Codex sends cumulative totals)
|
|
1682
|
+
const deltaInput = Math.max(0, totalInput - this.lastTotalTokens.input);
|
|
1683
|
+
const deltaOutput = Math.max(0, totalOutput - this.lastTotalTokens.output);
|
|
1684
|
+
this.lastTotalTokens = { input: totalInput, output: totalOutput };
|
|
1685
|
+
|
|
1686
|
+
// For the delta: split input into cached vs non-cached proportionally
|
|
1687
|
+
const cachedRatio = totalInput > 0 ? totalCached / totalInput : 0;
|
|
1688
|
+
const deltaCachedInput = Math.round(deltaInput * cachedRatio);
|
|
1689
|
+
const deltaFreshInput = deltaInput - deltaCachedInput;
|
|
1690
|
+
|
|
1691
|
+
const deltaCost =
|
|
1692
|
+
deltaFreshInput * pricing.input +
|
|
1693
|
+
deltaCachedInput * pricing.cachedInput +
|
|
1694
|
+
deltaOutput * pricing.output;
|
|
1695
|
+
|
|
1696
|
+
this.cumulativeCostUsd += deltaCost;
|
|
1697
|
+
updates.total_cost_usd = this.cumulativeCostUsd;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (Object.keys(updates).length > 0) {
|
|
1701
|
+
this.emit({
|
|
1702
|
+
type: "session_update",
|
|
1703
|
+
session: updates,
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// ── Command progress tracking ─────────────────────────────────────────
|
|
1709
|
+
|
|
1710
|
+
private emitCommandProgress(params: Record<string, unknown>): void {
|
|
1711
|
+
const itemId = params.itemId as string | undefined;
|
|
1712
|
+
if (!itemId) return;
|
|
1713
|
+
const startTime = this.commandStartTimes.get(itemId);
|
|
1714
|
+
const elapsed = startTime ? Math.round((Date.now() - startTime) / 1000) : 0;
|
|
1715
|
+
this.emit({
|
|
1716
|
+
type: "tool_progress",
|
|
1717
|
+
tool_use_id: itemId,
|
|
1718
|
+
tool_name: "Bash",
|
|
1719
|
+
elapsed_time_seconds: elapsed,
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
1724
|
+
|
|
1725
|
+
private emit(msg: BrowserIncomingMessage): void {
|
|
1726
|
+
this.browserMessageCb?.(msg);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** Emit an assistant message with a tool_use content block (no tracking). */
|
|
1730
|
+
private emitToolUse(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
|
|
1731
|
+
console.log(`[codex-adapter] Emitting tool_use: ${toolName} id=${toolUseId}`);
|
|
1732
|
+
this.emit({
|
|
1733
|
+
type: "assistant",
|
|
1734
|
+
message: {
|
|
1735
|
+
id: this.makeMessageId("tool_use", toolUseId),
|
|
1736
|
+
type: "message",
|
|
1737
|
+
role: "assistant",
|
|
1738
|
+
model: this.options.model || "",
|
|
1739
|
+
content: [
|
|
1740
|
+
{
|
|
1741
|
+
type: "tool_use",
|
|
1742
|
+
id: toolUseId,
|
|
1743
|
+
name: toolName,
|
|
1744
|
+
input,
|
|
1745
|
+
},
|
|
1746
|
+
],
|
|
1747
|
+
stop_reason: null,
|
|
1748
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1749
|
+
},
|
|
1750
|
+
parent_tool_use_id: null,
|
|
1751
|
+
timestamp: Date.now(),
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/** Emit tool_use and track the ID so we don't double-emit. */
|
|
1756
|
+
private emitToolUseTracked(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
|
|
1757
|
+
this.emittedToolUseIds.add(toolUseId);
|
|
1758
|
+
this.emitToolUse(toolUseId, toolName, input);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Emit a tool_use start sequence: stream_event content_block_start + assistant message.
|
|
1763
|
+
* This matches Claude Code's streaming pattern and ensures the frontend sees the tool block
|
|
1764
|
+
* even during active streaming.
|
|
1765
|
+
*/
|
|
1766
|
+
private emitToolUseStart(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
|
|
1767
|
+
// Emit stream event for tool_use start (matches Claude Code pattern)
|
|
1768
|
+
this.emit({
|
|
1769
|
+
type: "stream_event",
|
|
1770
|
+
event: {
|
|
1771
|
+
type: "content_block_start",
|
|
1772
|
+
index: 0,
|
|
1773
|
+
content_block: { type: "tool_use", id: toolUseId, name: toolName, input: {} },
|
|
1774
|
+
},
|
|
1775
|
+
parent_tool_use_id: null,
|
|
1776
|
+
});
|
|
1777
|
+
this.emitToolUseTracked(toolUseId, toolName, input);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/** Emit tool_use only if item/started was never received for this ID. */
|
|
1781
|
+
private ensureToolUseEmitted(toolUseId: string, toolName: string, input: Record<string, unknown>): void {
|
|
1782
|
+
if (!this.emittedToolUseIds.has(toolUseId)) {
|
|
1783
|
+
console.log(`[codex-adapter] Backfilling tool_use for ${toolName} (id=${toolUseId}) — item/started was missing`);
|
|
1784
|
+
this.emitToolUseTracked(toolUseId, toolName, input);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Emit an assistant message with a tool_result content block. */
|
|
1789
|
+
private emitToolResult(toolUseId: string, content: unknown, isError: boolean): void {
|
|
1790
|
+
const safeContent = typeof content === "string" ? content : JSON.stringify(content);
|
|
1791
|
+
this.emit({
|
|
1792
|
+
type: "assistant",
|
|
1793
|
+
message: {
|
|
1794
|
+
id: this.makeMessageId("tool_result", toolUseId),
|
|
1795
|
+
type: "message",
|
|
1796
|
+
role: "assistant",
|
|
1797
|
+
model: this.options.model || "",
|
|
1798
|
+
content: [
|
|
1799
|
+
{
|
|
1800
|
+
type: "tool_result",
|
|
1801
|
+
tool_use_id: toolUseId,
|
|
1802
|
+
content: safeContent,
|
|
1803
|
+
is_error: isError,
|
|
1804
|
+
},
|
|
1805
|
+
],
|
|
1806
|
+
stop_reason: null,
|
|
1807
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
1808
|
+
},
|
|
1809
|
+
parent_tool_use_id: null,
|
|
1810
|
+
timestamp: Date.now(),
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
private makeMessageId(kind: string, sourceId?: string): string {
|
|
1815
|
+
if (sourceId) return `codex-${kind}-${sourceId}`;
|
|
1816
|
+
return `codex-${kind}-${randomUUID()}`;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
private mapApprovalPolicy(mode?: string): string {
|
|
1820
|
+
switch (mode) {
|
|
1821
|
+
case "bypassPermissions":
|
|
1822
|
+
return "never";
|
|
1823
|
+
case "plan":
|
|
1824
|
+
case "acceptEdits":
|
|
1825
|
+
case "default":
|
|
1826
|
+
default:
|
|
1827
|
+
return "untrusted";
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
private mapSandboxPolicy(mode?: string): string {
|
|
1832
|
+
switch (mode) {
|
|
1833
|
+
case "bypassPermissions":
|
|
1834
|
+
return "danger-full-access";
|
|
1835
|
+
default:
|
|
1836
|
+
return "workspace-write";
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
private async listAllMcpServerStatuses(): Promise<CodexMcpServerStatus[]> {
|
|
1841
|
+
const out: CodexMcpServerStatus[] = [];
|
|
1842
|
+
let cursor: string | null = null;
|
|
1843
|
+
let page = 0;
|
|
1844
|
+
|
|
1845
|
+
while (page < 50) {
|
|
1846
|
+
const response = await this.transport.call("mcpServerStatus/list", {
|
|
1847
|
+
cursor,
|
|
1848
|
+
limit: 100,
|
|
1849
|
+
}) as CodexMcpStatusListResponse;
|
|
1850
|
+
if (Array.isArray(response.data)) {
|
|
1851
|
+
out.push(...response.data);
|
|
1852
|
+
}
|
|
1853
|
+
cursor = typeof response.nextCursor === "string" ? response.nextCursor : null;
|
|
1854
|
+
if (!cursor) break;
|
|
1855
|
+
page++;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
return out;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
private async readMcpServersConfig(): Promise<Record<string, unknown>> {
|
|
1862
|
+
const response = await this.transport.call("config/read", {}) as {
|
|
1863
|
+
config?: Record<string, unknown>;
|
|
1864
|
+
};
|
|
1865
|
+
const config = this.asRecord(response?.config) || {};
|
|
1866
|
+
return this.asRecord(config.mcp_servers) || {};
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
private async reloadMcpServers(): Promise<void> {
|
|
1870
|
+
await this.transport.call("config/mcpServer/reload", {});
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
private isMcpServerEnabled(value: unknown): boolean {
|
|
1874
|
+
const cfg = this.asRecord(value);
|
|
1875
|
+
if (!cfg) return true;
|
|
1876
|
+
return cfg.enabled !== false;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
private asRecord(value: unknown): Record<string, unknown> | null {
|
|
1880
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1881
|
+
? value as Record<string, unknown>
|
|
1882
|
+
: null;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
private toMcpServerConfig(value: unknown): McpServerConfig {
|
|
1886
|
+
const cfg = this.asRecord(value) || {};
|
|
1887
|
+
const args = Array.isArray(cfg.args)
|
|
1888
|
+
? cfg.args.filter((a): a is string => typeof a === "string")
|
|
1889
|
+
: undefined;
|
|
1890
|
+
const env = this.asRecord(cfg.env) as Record<string, string> | null;
|
|
1891
|
+
|
|
1892
|
+
let type: McpServerConfig["type"] = "sdk";
|
|
1893
|
+
if (cfg.type === "stdio" || cfg.type === "sse" || cfg.type === "http" || cfg.type === "sdk") {
|
|
1894
|
+
type = cfg.type;
|
|
1895
|
+
} else if (typeof cfg.command === "string") {
|
|
1896
|
+
type = "stdio";
|
|
1897
|
+
} else if (typeof cfg.url === "string") {
|
|
1898
|
+
type = "http";
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
return {
|
|
1902
|
+
type,
|
|
1903
|
+
command: typeof cfg.command === "string" ? cfg.command : undefined,
|
|
1904
|
+
args,
|
|
1905
|
+
env: env || undefined,
|
|
1906
|
+
url: typeof cfg.url === "string" ? cfg.url : undefined,
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
private fromMcpServerConfig(config: McpServerConfig): Record<string, unknown> {
|
|
1911
|
+
const out: Record<string, unknown> = {};
|
|
1912
|
+
if (typeof config.command === "string") out.command = config.command;
|
|
1913
|
+
if (Array.isArray(config.args)) out.args = config.args;
|
|
1914
|
+
if (config.env) out.env = config.env;
|
|
1915
|
+
if (typeof config.url === "string") out.url = config.url;
|
|
1916
|
+
return out;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
private normalizeRawMcpServerConfig(value: unknown): Record<string, unknown> {
|
|
1920
|
+
const cfg = this.asRecord(value) || {};
|
|
1921
|
+
const out: Record<string, unknown> = {};
|
|
1922
|
+
|
|
1923
|
+
// Keep only fields supported by Codex raw MCP config schema
|
|
1924
|
+
if (typeof cfg.command === "string") out.command = cfg.command;
|
|
1925
|
+
if (Array.isArray(cfg.args)) out.args = cfg.args.filter((a) => typeof a === "string");
|
|
1926
|
+
if (typeof cfg.cwd === "string") out.cwd = cfg.cwd;
|
|
1927
|
+
if (typeof cfg.url === "string") out.url = cfg.url;
|
|
1928
|
+
if (typeof cfg.enabled === "boolean") out.enabled = cfg.enabled;
|
|
1929
|
+
if (typeof cfg.required === "boolean") out.required = cfg.required;
|
|
1930
|
+
|
|
1931
|
+
const env = this.asRecord(cfg.env);
|
|
1932
|
+
if (env) out.env = Object.fromEntries(
|
|
1933
|
+
Object.entries(env).filter(([, v]) => typeof v === "string"),
|
|
1934
|
+
);
|
|
1935
|
+
|
|
1936
|
+
const envHttpHeaders = this.asRecord(cfg.env_http_headers);
|
|
1937
|
+
if (envHttpHeaders) out.env_http_headers = Object.fromEntries(
|
|
1938
|
+
Object.entries(envHttpHeaders).filter(([, v]) => typeof v === "string"),
|
|
1939
|
+
);
|
|
1940
|
+
|
|
1941
|
+
const httpHeaders = this.asRecord(cfg.http_headers);
|
|
1942
|
+
if (httpHeaders) out.http_headers = Object.fromEntries(
|
|
1943
|
+
Object.entries(httpHeaders).filter(([, v]) => typeof v === "string"),
|
|
1944
|
+
);
|
|
1945
|
+
|
|
1946
|
+
const asStringArray = (arr: unknown): string[] | undefined =>
|
|
1947
|
+
Array.isArray(arr)
|
|
1948
|
+
? arr.filter((x): x is string => typeof x === "string")
|
|
1949
|
+
: undefined;
|
|
1950
|
+
|
|
1951
|
+
const disabledTools = asStringArray(cfg.disabled_tools);
|
|
1952
|
+
if (disabledTools) out.disabled_tools = disabledTools;
|
|
1953
|
+
const enabledTools = asStringArray(cfg.enabled_tools);
|
|
1954
|
+
if (enabledTools) out.enabled_tools = enabledTools;
|
|
1955
|
+
const envVars = asStringArray(cfg.env_vars);
|
|
1956
|
+
if (envVars) out.env_vars = envVars;
|
|
1957
|
+
const scopes = asStringArray(cfg.scopes);
|
|
1958
|
+
if (scopes) out.scopes = scopes;
|
|
1959
|
+
|
|
1960
|
+
if (typeof cfg.startup_timeout_ms === "number") out.startup_timeout_ms = cfg.startup_timeout_ms;
|
|
1961
|
+
if (typeof cfg.startup_timeout_sec === "number") out.startup_timeout_sec = cfg.startup_timeout_sec;
|
|
1962
|
+
if (typeof cfg.tool_timeout_sec === "number") out.tool_timeout_sec = cfg.tool_timeout_sec;
|
|
1963
|
+
if (typeof cfg.bearer_token === "string") out.bearer_token = cfg.bearer_token;
|
|
1964
|
+
if (typeof cfg.bearer_token_env_var === "string") out.bearer_token_env_var = cfg.bearer_token_env_var;
|
|
1965
|
+
|
|
1966
|
+
return out;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
private mapMcpTools(
|
|
1970
|
+
tools: Record<string, { name?: string; annotations?: unknown }> | undefined,
|
|
1971
|
+
): McpServerDetail["tools"] {
|
|
1972
|
+
if (!tools) return [];
|
|
1973
|
+
return Object.entries(tools).map(([key, tool]) => {
|
|
1974
|
+
const ann = this.asRecord(tool.annotations);
|
|
1975
|
+
const annotations = ann ? {
|
|
1976
|
+
readOnly: (ann.readOnly ?? ann.readOnlyHint) === true,
|
|
1977
|
+
destructive: (ann.destructive ?? ann.destructiveHint) === true,
|
|
1978
|
+
openWorld: (ann.openWorld ?? ann.openWorldHint) === true,
|
|
1979
|
+
} : undefined;
|
|
1980
|
+
|
|
1981
|
+
return {
|
|
1982
|
+
name: typeof tool.name === "string" ? tool.name : key,
|
|
1983
|
+
annotations,
|
|
1984
|
+
};
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
}
|