@zvada/agent-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/package.json +87 -0
  4. package/src/client/client.ts +589 -0
  5. package/src/client/index.ts +18 -0
  6. package/src/client/transports.ts +84 -0
  7. package/src/core/agents/acp/acp-agent.ts +322 -0
  8. package/src/core/agents/acp/adapter.ts +260 -0
  9. package/src/core/agents/acp/client.ts +212 -0
  10. package/src/core/agents/acp/known-agents.ts +29 -0
  11. package/src/core/agents/acp/mappings.ts +136 -0
  12. package/src/core/agents/base.ts +145 -0
  13. package/src/core/agents/claude-code/adapter.ts +451 -0
  14. package/src/core/agents/claude-code/claude-agent.ts +235 -0
  15. package/src/core/agents/claude-code/generator-session.ts +344 -0
  16. package/src/core/agents/claude-code/options.ts +161 -0
  17. package/src/core/agents/claude-code/session-manager.ts +159 -0
  18. package/src/core/agents/codex-app-server/adapter.ts +214 -0
  19. package/src/core/agents/codex-app-server/client.ts +221 -0
  20. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
  21. package/src/core/agents/codex-items.ts +122 -0
  22. package/src/core/agents/codex-sdk/adapter.ts +204 -0
  23. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
  24. package/src/core/agents/config-fingerprint.ts +19 -0
  25. package/src/core/agents/error-classifier.ts +68 -0
  26. package/src/core/agents/registry.ts +40 -0
  27. package/src/core/agents/session-store.ts +72 -0
  28. package/src/core/agents/tool-meta.ts +68 -0
  29. package/src/core/agents/types.ts +54 -0
  30. package/src/core/index.ts +114 -0
  31. package/src/core/presets.ts +78 -0
  32. package/src/core/provision/extract.ts +31 -0
  33. package/src/core/provision/index.ts +10 -0
  34. package/src/core/provision/npm.ts +114 -0
  35. package/src/core/provision/pins.ts +51 -0
  36. package/src/core/provision/platform.ts +73 -0
  37. package/src/core/provision/provisioner.ts +478 -0
  38. package/src/core/proxy/anthropic-proxy.ts +69 -0
  39. package/src/core/proxy/api-key-store.ts +34 -0
  40. package/src/core/proxy/index.ts +7 -0
  41. package/src/core/runtime/agent-runtime.ts +363 -0
  42. package/src/core/runtime/event-processor.ts +218 -0
  43. package/src/core/runtime/event-sink.ts +37 -0
  44. package/src/core/utils/errors.ts +41 -0
  45. package/src/index.ts +4 -0
  46. package/src/protocol/async-queue.ts +68 -0
  47. package/src/protocol/config.ts +100 -0
  48. package/src/protocol/factories.ts +125 -0
  49. package/src/protocol/harness.ts +50 -0
  50. package/src/protocol/ids.ts +53 -0
  51. package/src/protocol/index.ts +16 -0
  52. package/src/protocol/lifecycle.ts +309 -0
  53. package/src/protocol/models.ts +45 -0
  54. package/src/protocol/part-input.ts +58 -0
  55. package/src/protocol/parts.ts +60 -0
  56. package/src/protocol/thinking.ts +32 -0
  57. package/src/protocol/tokens.ts +39 -0
  58. package/src/protocol/tool-state.ts +89 -0
  59. package/src/protocol/wire.ts +313 -0
  60. package/src/server/acp/binding.ts +163 -0
  61. package/src/server/acp/translate.ts +160 -0
  62. package/src/server/agent-server.ts +357 -0
  63. package/src/server/bin.ts +174 -0
  64. package/src/server/index.ts +24 -0
  65. package/src/server/install.ts +51 -0
  66. package/src/server/session-log.ts +66 -0
  67. package/src/server/transports.ts +149 -0
@@ -0,0 +1,385 @@
1
+ import type {
2
+ AgentCapabilities,
3
+ AgentInput,
4
+ PermissionMode,
5
+ PermissionToolCall,
6
+ } from "../../../protocol/index.ts";
7
+ import { AsyncQueue, codexReasoningEffort } from "../../../protocol/index.ts";
8
+ import type { AgentExecuteOptions, PermissionRequestHandler, RawAgentEvent } from "../base.ts";
9
+ import { BaseAgent } from "../base.ts";
10
+ import { configFingerprint } from "../config-fingerprint.ts";
11
+ import { SessionStore } from "../session-store.ts";
12
+ import {
13
+ CodexAppServerClient,
14
+ type CodexAppServerClientOptions,
15
+ type CodexNotification,
16
+ } from "./client.ts";
17
+
18
+ const CAPABILITIES: AgentCapabilities = {
19
+ multiTurn: true,
20
+ sessionResume: true,
21
+ // Model/effort are overridable per turn/start, so no restart is needed.
22
+ modelSwitch: "in-session",
23
+ thinkingLevels: true,
24
+ images: true,
25
+ mcpServers: false,
26
+ permissionRequests: true,
27
+ };
28
+
29
+ export interface AppServerSession {
30
+ client: CodexAppServerClient;
31
+ threadId: string;
32
+ cwd: string;
33
+ approvalPolicy: string;
34
+ sandbox: string;
35
+ systemPromptAppend?: string;
36
+ envFingerprint: string;
37
+ /** Engine permission broker for the CURRENT turn (set per execute). */
38
+ permissionHandler?: PermissionRequestHandler;
39
+ idleTimer?: ReturnType<typeof setTimeout>;
40
+ }
41
+
42
+ export function appServerSessionCompatible(
43
+ existing: AppServerSession | undefined,
44
+ options: AgentExecuteOptions,
45
+ ): boolean {
46
+ return Boolean(
47
+ existing &&
48
+ !existing.client.closed &&
49
+ existing.cwd === options.cwd &&
50
+ existing.approvalPolicy === approvalPolicyFor(options) &&
51
+ existing.sandbox === mapSandbox(options.permissionMode) &&
52
+ (existing.systemPromptAppend ?? "") === (options.systemPromptAppend ?? "") &&
53
+ existing.envFingerprint === configFingerprint(options.env) &&
54
+ (!options.resumeSessionId || options.resumeSessionId === existing.threadId),
55
+ );
56
+ }
57
+
58
+ type UserInput = { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string };
59
+
60
+ function mapSandbox(mode: PermissionMode | undefined): string {
61
+ switch (mode) {
62
+ case "bypassPermissions":
63
+ return "danger-full-access";
64
+ case "acceptEdits":
65
+ case "dontAsk":
66
+ // Never-ask posture, but inside the normal sandbox — danger-full is
67
+ // reserved for the explicitly dangerous mode.
68
+ return "workspace-write";
69
+ default:
70
+ return "read-only";
71
+ }
72
+ }
73
+
74
+ function toUserInput(input: AgentInput): UserInput[] {
75
+ if (typeof input === "string") return [{ type: "text", text: input, text_elements: [] }];
76
+ const out: UserInput[] = [];
77
+ for (const part of input) {
78
+ if (part.type === "text") out.push({ type: "text", text: part.text, text_elements: [] });
79
+ else if (part.url) out.push({ type: "image", url: part.url });
80
+ }
81
+ return out.length ? out : [{ type: "text", text: "", text_elements: [] }];
82
+ }
83
+
84
+ /**
85
+ * Ask-the-user only makes sense in `default` mode with an engine broker
86
+ * attached; every other posture auto-decides (sandbox enforces safety).
87
+ */
88
+ function approvalPolicyFor(options: AgentExecuteOptions): string {
89
+ const asks = (options.permissionMode ?? "default") === "default";
90
+ return asks && options.onPermissionRequest ? "on-request" : "never";
91
+ }
92
+
93
+ /** Normalize a codex approval request into the engine's PermissionToolCall. */
94
+ function approvalToolCall(method: string, params: Record<string, unknown>): PermissionToolCall {
95
+ const id =
96
+ (typeof params.itemId === "string" && params.itemId) ||
97
+ (typeof params.callId === "string" && params.callId) ||
98
+ "approval";
99
+ const reason = typeof params.reason === "string" ? params.reason : undefined;
100
+ if (method.includes("commandExecution") || method === "execCommandApproval") {
101
+ const command = Array.isArray(params.command)
102
+ ? (params.command as string[]).join(" ")
103
+ : typeof params.command === "string"
104
+ ? params.command
105
+ : "";
106
+ return {
107
+ toolCallId: id,
108
+ toolName: "shell",
109
+ kind: "execute",
110
+ ...(reason && { title: reason }),
111
+ rawInput: { command, ...(params.cwd ? { cwd: params.cwd } : {}) },
112
+ };
113
+ }
114
+ // v1 ApplyPatchApprovalParams carries `fileChanges`; v2
115
+ // FileChangeRequestApprovalParams does not (only itemId/reason/grantRoot),
116
+ // so `locations` is best-effort and may be empty for v2 edits.
117
+ const changes = (params.fileChanges ?? params.changes ?? {}) as Record<string, unknown>;
118
+ const paths = Object.keys(changes);
119
+ return {
120
+ toolCallId: id,
121
+ toolName: "apply_patch",
122
+ kind: "edit",
123
+ ...(reason && { title: reason }),
124
+ ...(paths.length && { locations: paths.map((path) => ({ path })) }),
125
+ rawInput: { changes },
126
+ };
127
+ }
128
+
129
+ /** Idle time before an unused app-server subprocess is shut down. */
130
+ const IDLE_TIMEOUT_MS = 5 * 60_000;
131
+
132
+ /** How long a retryable error may go unanswered before the turn is failed. */
133
+ const RETRY_STALL_TIMEOUT_MS = 120_000;
134
+
135
+ export interface CodexAppServerAgentOptions {
136
+ createClient?: (options: CodexAppServerClientOptions) => CodexAppServerClient;
137
+ idleTimeoutMs?: number;
138
+ /** Fail a turn if the app-server goes silent this long after a retryable error. */
139
+ retryStallTimeoutMs?: number;
140
+ /**
141
+ * Resolve the `codex` binary to spawn (typically the provisioner's,
142
+ * memoized). Falls back to `$CODEX_CLI_PATH`, then PATH. Operator-level
143
+ * only — never sourced from the wire.
144
+ */
145
+ resolveCliPath?: () => Promise<string | undefined>;
146
+ }
147
+
148
+ /** Retryable v2 `error` notifications do not end the turn; Codex keeps going. */
149
+ export function appServerNotificationEndsTurn(notification: CodexNotification): boolean {
150
+ return (
151
+ notification.method === "turn/completed" ||
152
+ (notification.method === "error" && notification.params.willRetry !== true)
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Codex harness backed by a long-lived `codex app-server` subprocess speaking
158
+ * JSON-RPC. Unlike the SDK harness this streams token-level deltas. A client +
159
+ * thread is created per logical session and reused for multi-turn; model/effort
160
+ * are passed per `turn/start`. Resume uses `thread/resume` with the native id.
161
+ */
162
+ export class CodexAppServerAgent extends BaseAgent {
163
+ readonly harness = "codex-app-server" as const;
164
+ readonly capabilities = CAPABILITIES;
165
+ private readonly sessions = new SessionStore<AppServerSession>();
166
+
167
+ constructor(private readonly agentOptions: CodexAppServerAgentOptions = {}) {
168
+ super();
169
+ }
170
+
171
+ private async resolveSession(options: AgentExecuteOptions): Promise<AppServerSession> {
172
+ const approvalPolicy = approvalPolicyFor(options);
173
+ const sandbox = mapSandbox(options.permissionMode);
174
+ const existing = this.sessions.get(options.sessionId);
175
+ // Reuse a warm subprocess only when its (immutable) approval posture
176
+ // matches; otherwise recreate it, resuming the same thread so context
177
+ // survives. `resumeThreadId` carries that over to the spawn below.
178
+ let resumeThreadId = options.resumeSessionId;
179
+ if (existing && !existing.client.closed) {
180
+ if (appServerSessionCompatible(existing, options)) {
181
+ this.sessions.clearIdle(existing);
182
+ return existing;
183
+ }
184
+ // Preserve context within the same cwd/env boundary (mirrors the
185
+ // claude-code restart policy); an explicit resumeSessionId always wins.
186
+ if (
187
+ resumeThreadId === undefined &&
188
+ existing.cwd === options.cwd &&
189
+ existing.envFingerprint === configFingerprint(options.env)
190
+ ) {
191
+ resumeThreadId = existing.threadId;
192
+ }
193
+ this.sessions.close(options.sessionId);
194
+ }
195
+
196
+ const clientOptions: CodexAppServerClientOptions = {
197
+ codexPath:
198
+ (await this.agentOptions.resolveCliPath?.()) ?? process.env.CODEX_CLI_PATH ?? "codex",
199
+ cwd: options.cwd,
200
+ env: options.env ? ({ ...process.env, ...options.env } as NodeJS.ProcessEnv) : process.env,
201
+ };
202
+ const client =
203
+ this.agentOptions.createClient?.(clientOptions) ?? new CodexAppServerClient(clientOptions);
204
+ // Self-heal: if the subprocess ever exits, drop it so the next turn respawns.
205
+ client.onClose(() => this.sessions.dropIfCurrent(options.sessionId, client));
206
+ await client.initialize();
207
+
208
+ const startParams: Record<string, unknown> = {
209
+ cwd: options.cwd,
210
+ sandbox,
211
+ approvalPolicy,
212
+ experimentalRawEvents: false,
213
+ persistExtendedHistory: false,
214
+ };
215
+ if (options.model) startParams.model = options.model;
216
+ if (options.systemPromptAppend) startParams.developerInstructions = options.systemPromptAppend;
217
+
218
+ // A dead/unknown resume target ("no rollout found ...") must not kill the
219
+ // turn: ANY resume failure falls back to a fresh thread, and the fresh id
220
+ // ≠ requested id makes execute() report resumed:false — the structured
221
+ // fallback signal (claude/acp parity). Systemic failures (auth, dead
222
+ // subprocess) fail thread/start identically, so nothing is masked.
223
+ const startThread = async (): Promise<string> => {
224
+ const r = (await client.request("thread/start", startParams)) as {
225
+ thread?: { id?: string };
226
+ };
227
+ if (!r?.thread?.id) throw new Error("codex app-server thread/start returned no thread id");
228
+ return r.thread.id;
229
+ };
230
+ let threadId: string | undefined;
231
+ if (resumeThreadId) {
232
+ threadId = await client
233
+ .request("thread/resume", { threadId: resumeThreadId, ...startParams })
234
+ .then((r) => (r as { thread?: { id?: string } })?.thread?.id ?? resumeThreadId)
235
+ .catch(() => undefined);
236
+ }
237
+ threadId ??= await startThread();
238
+
239
+ const session: AppServerSession = {
240
+ client,
241
+ threadId,
242
+ cwd: options.cwd,
243
+ approvalPolicy,
244
+ sandbox,
245
+ systemPromptAppend: options.systemPromptAppend,
246
+ envFingerprint: configFingerprint(options.env),
247
+ };
248
+ // Approval requests (server→client) resolve against the engine broker of
249
+ // whichever turn is current; thread mismatches and broker-less turns decline.
250
+ client.setRequestHandler(async (method, params) => {
251
+ const requestThread = params.threadId ?? params.conversationId;
252
+ const v2 = method.startsWith("item/");
253
+ const decline = v2 ? { decision: "decline" } : { decision: "denied" };
254
+ if (requestThread && requestThread !== session.threadId) return decline;
255
+ const handler = session.permissionHandler;
256
+ if (!handler) return decline;
257
+ const decision = await handler(approvalToolCall(method, params));
258
+ switch (decision.decision) {
259
+ case "allow":
260
+ return v2 ? { decision: "accept" } : { decision: "approved" };
261
+ case "cancel":
262
+ return v2 ? { decision: "cancel" } : { decision: "abort" };
263
+ default:
264
+ return decline;
265
+ }
266
+ });
267
+ this.sessions.set(options.sessionId, session);
268
+ return session;
269
+ }
270
+
271
+ async *execute(
272
+ input: AgentInput,
273
+ options: AgentExecuteOptions,
274
+ ): AsyncIterableIterator<RawAgentEvent> {
275
+ const controller = this.trackTurn(options.sessionId, options.signal);
276
+ const queue = new AsyncQueue<CodexNotification>();
277
+ // A retryable error promises more notifications. If none arrive, the
278
+ // subprocess is wedged-but-alive and the turn would hang forever — bound it.
279
+ let stallTimer: ReturnType<typeof setTimeout> | undefined;
280
+ const clearStall = () => {
281
+ if (stallTimer) clearTimeout(stallTimer);
282
+ stallTimer = undefined;
283
+ };
284
+ const unsubscribe: Array<() => void> = [];
285
+ let session: AppServerSession | undefined;
286
+ try {
287
+ session = await this.resolveSession(options);
288
+ session.permissionHandler = options.onPermissionRequest;
289
+ options.onNativeSession?.(session.threadId, {
290
+ resumed: session.threadId === options.resumeSessionId,
291
+ });
292
+ const threadId = session.threadId;
293
+ const client = session.client;
294
+
295
+ let turnId: string | undefined;
296
+ let aborted = false;
297
+ const interrupt = () => {
298
+ if (turnId) {
299
+ void client.request("turn/interrupt", { threadId, turnId }).catch(() => {});
300
+ }
301
+ };
302
+
303
+ unsubscribe.push(
304
+ client.onNotification((n) => {
305
+ if (n.params.threadId && n.params.threadId !== threadId) return;
306
+ if (n.method === "turn/started") {
307
+ const turn = n.params.turn as { id?: string } | undefined;
308
+ if (turn?.id) {
309
+ turnId = turn.id;
310
+ if (aborted) interrupt(); // abort arrived before the turn id was known
311
+ }
312
+ }
313
+ queue.push(n);
314
+ if (appServerNotificationEndsTurn(n)) queue.end();
315
+ if (n.method === "error" && n.params.willRetry === true) {
316
+ clearStall();
317
+ stallTimer = setTimeout(
318
+ () => queue.fail(new Error("codex app-server went silent after a retryable error")),
319
+ this.agentOptions.retryStallTimeoutMs ?? RETRY_STALL_TIMEOUT_MS,
320
+ );
321
+ stallTimer.unref?.();
322
+ } else {
323
+ clearStall();
324
+ }
325
+ }),
326
+ );
327
+ // If the subprocess dies mid-turn, fail the turn instead of hanging.
328
+ unsubscribe.push(
329
+ client.onClose((err) => queue.fail(err ?? new Error("codex app-server exited mid-turn"))),
330
+ );
331
+
332
+ controller.signal.addEventListener(
333
+ "abort",
334
+ () => {
335
+ aborted = true;
336
+ interrupt();
337
+ // Surface as a cancellation: the runtime classifies this as aborted.
338
+ queue.fail(new Error("turn aborted"));
339
+ },
340
+ { once: true },
341
+ );
342
+
343
+ const turnParams: Record<string, unknown> = {
344
+ threadId,
345
+ input: toUserInput(input),
346
+ };
347
+ if (options.model) turnParams.model = options.model;
348
+ if (options.thinkingLevel) turnParams.effort = codexReasoningEffort(options.thinkingLevel);
349
+
350
+ client.request("turn/start", turnParams).catch((err: unknown) => {
351
+ queue.push({ method: "error", params: { message: String(err) } });
352
+ queue.end();
353
+ });
354
+
355
+ for await (const notification of queue) {
356
+ yield notification as RawAgentEvent;
357
+ }
358
+ } finally {
359
+ clearStall();
360
+ for (const off of unsubscribe) off();
361
+ this.endTurn(options.sessionId, controller);
362
+ if (session && !session.client.closed) {
363
+ this.sessions.armIdle(
364
+ options.sessionId,
365
+ session,
366
+ this.agentOptions.idleTimeoutMs ?? IDLE_TIMEOUT_MS,
367
+ );
368
+ }
369
+ }
370
+ }
371
+
372
+ override async cancel(sessionId: string): Promise<void> {
373
+ await super.cancel(sessionId);
374
+ }
375
+
376
+ override async release(sessionId: string): Promise<void> {
377
+ await super.release(sessionId);
378
+ this.sessions.close(sessionId);
379
+ }
380
+
381
+ override async terminateAll(): Promise<void> {
382
+ await super.terminateAll();
383
+ this.sessions.closeAll();
384
+ }
385
+ }
@@ -0,0 +1,122 @@
1
+ import type { ToolMeta } from "../../protocol/index.ts";
2
+ import { CODEX_TOOL_KINDS, codexFileChangeLocations } from "./tool-meta.ts";
3
+
4
+ /**
5
+ * The codex tool-item vocabulary, shared by the Codex SDK adapter
6
+ * (snake_case fields) and the app-server adapter (camelCase fields, with
7
+ * snake_case fallbacks for older payloads). Descriptors are pure: adapters
8
+ * keep their own streaming mechanics and call these for naming, input/meta
9
+ * shaping, and completion-result extraction.
10
+ */
11
+ export interface CodexToolItem {
12
+ id: string;
13
+ command?: string | string[];
14
+ aggregatedOutput?: string;
15
+ aggregated_output?: string;
16
+ exitCode?: number;
17
+ exit_code?: number;
18
+ status?: string;
19
+ changes?: Array<{ path: string; kind: string }>;
20
+ server?: string;
21
+ tool?: string;
22
+ arguments?: unknown;
23
+ result?: unknown;
24
+ error?: { message?: string } | string;
25
+ query?: string;
26
+ }
27
+
28
+ export interface CodexToolDescriptor {
29
+ toolName(item: CodexToolItem): string;
30
+ input(item: CodexToolItem): Record<string, unknown>;
31
+ meta(item: CodexToolItem): ToolMeta;
32
+ result(item: CodexToolItem): { output: string; isError: boolean };
33
+ }
34
+
35
+ export function pickCodexError(err: CodexToolItem["error"]): string {
36
+ if (!err) return "";
37
+ return typeof err === "string" ? err : (err.message ?? "");
38
+ }
39
+
40
+ /** Join `{text}` blocks of an MCP content array; JSON-stringify anything else. */
41
+ function stringifyMcpResult(result: unknown): string {
42
+ const content =
43
+ result && typeof result === "object" && "content" in result
44
+ ? (result as { content?: unknown }).content
45
+ : result;
46
+ if (content == null) return "";
47
+ if (typeof content === "string") return content;
48
+ if (Array.isArray(content)) {
49
+ return content
50
+ .map((b) =>
51
+ b && typeof b === "object" && "text" in b
52
+ ? String((b as { text: unknown }).text)
53
+ : JSON.stringify(b),
54
+ )
55
+ .join("");
56
+ }
57
+ return JSON.stringify(content);
58
+ }
59
+
60
+ const failed = (item: CodexToolItem) => item.status === "failed";
61
+
62
+ export const CODEX_TOOL_ITEMS: Record<string, CodexToolDescriptor> = {
63
+ commandExecution: {
64
+ toolName: () => "shell",
65
+ input: (item) => ({ command: item.command ?? "" }),
66
+ meta: () => ({ kind: CODEX_TOOL_KINDS.shell }),
67
+ result: (item) => ({
68
+ output: item.aggregatedOutput ?? item.aggregated_output ?? "",
69
+ isError: failed(item) || (item.exitCode ?? item.exit_code ?? 0) !== 0,
70
+ }),
71
+ },
72
+ fileChange: {
73
+ toolName: () => "apply_patch",
74
+ input: (item) => ({ changes: item.changes ?? [] }),
75
+ meta: (item) => ({
76
+ kind: CODEX_TOOL_KINDS.apply_patch,
77
+ locations: codexFileChangeLocations(item.changes),
78
+ }),
79
+ result: (item) => ({
80
+ output: (item.changes ?? []).map((c) => `${c.kind} ${c.path}`).join("\n"),
81
+ isError: failed(item),
82
+ }),
83
+ },
84
+ mcpToolCall: {
85
+ toolName: (item) => `${item.server ?? "mcp"}.${item.tool ?? "tool"}`,
86
+ input: (item) => ({ arguments: item.arguments }),
87
+ meta: () => ({ kind: CODEX_TOOL_KINDS.mcp }),
88
+ result: (item) => {
89
+ const err = pickCodexError(item.error);
90
+ return {
91
+ output: err || stringifyMcpResult(item.result),
92
+ isError: failed(item) || Boolean(err),
93
+ };
94
+ },
95
+ },
96
+ webSearch: {
97
+ toolName: () => "web_search",
98
+ input: (item) => ({ query: item.query ?? "" }),
99
+ meta: () => ({ kind: CODEX_TOOL_KINDS.web_search }),
100
+ result: (item) => ({ output: item.query ?? "", isError: failed(item) }),
101
+ },
102
+ };
103
+
104
+ /** Map either protocol's item-type spelling onto a descriptor. */
105
+ export function codexToolDescriptor(itemType: string): CodexToolDescriptor | undefined {
106
+ switch (itemType) {
107
+ case "command_execution":
108
+ case "commandExecution":
109
+ return CODEX_TOOL_ITEMS.commandExecution;
110
+ case "file_change":
111
+ case "fileChange":
112
+ return CODEX_TOOL_ITEMS.fileChange;
113
+ case "mcp_tool_call":
114
+ case "mcpToolCall":
115
+ return CODEX_TOOL_ITEMS.mcpToolCall;
116
+ case "web_search":
117
+ case "webSearch":
118
+ return CODEX_TOOL_ITEMS.webSearch;
119
+ default:
120
+ return undefined;
121
+ }
122
+ }