@rynx-ai/runtime 0.1.0 → 0.1.10-beta.2

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 (56) hide show
  1. package/dist/claude/executor.d.ts +3 -5
  2. package/dist/claude/executor.js +3 -5
  3. package/dist/claude/native-bridge.d.ts +74 -17
  4. package/dist/claude/native-bridge.js +225 -30
  5. package/dist/claude/native-hook-main.js +327 -38
  6. package/dist/claude/native-hooks.d.ts +3 -2
  7. package/dist/claude/native-hooks.js +15 -6
  8. package/dist/claude/native-integration.d.ts +123 -16
  9. package/dist/claude/native-integration.js +624 -81
  10. package/dist/claude/settings.d.ts +8 -0
  11. package/dist/claude/settings.js +50 -0
  12. package/dist/claude/transcript.d.ts +2 -2
  13. package/dist/claude/transcript.js +14 -3
  14. package/dist/codex/rollout-synth.d.ts +8 -3
  15. package/dist/codex/rollout-synth.js +65 -32
  16. package/dist/codex-app-server/client.d.ts +27 -40
  17. package/dist/codex-app-server/client.js +1134 -99
  18. package/dist/codex-app-server/forwarder.d.ts +36 -10
  19. package/dist/codex-app-server/forwarder.js +146 -28
  20. package/dist/codex-app-server/mapping.d.ts +1 -1
  21. package/dist/codex-app-server/mapping.js +64 -5
  22. package/dist/codex-app-server/protocol.d.ts +269 -4
  23. package/dist/codex-app-server/transport.d.ts +20 -5
  24. package/dist/codex-app-server/transport.js +93 -40
  25. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  26. package/dist/codex-app-server/ws-channel.js +23 -7
  27. package/dist/codex-child-env.js +33 -0
  28. package/dist/codex-home.d.ts +16 -6
  29. package/dist/codex-home.js +46 -15
  30. package/dist/codex-session-store.d.ts +2 -1
  31. package/dist/host.d.ts +38 -38
  32. package/dist/host.js +626 -121
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.js +1 -1
  35. package/dist/input-resources.d.ts +13 -0
  36. package/dist/input-resources.js +67 -0
  37. package/dist/interactions.d.ts +61 -0
  38. package/dist/interactions.js +236 -0
  39. package/dist/models-catalog.d.ts +5 -13
  40. package/dist/models-catalog.js +60 -9
  41. package/dist/runner/child.d.ts +9 -1
  42. package/dist/runner/child.js +100 -19
  43. package/dist/runner/manager.d.ts +79 -11
  44. package/dist/runner/manager.js +423 -43
  45. package/dist/runner/protocol.d.ts +30 -11
  46. package/dist/runner-main.js +9 -6
  47. package/dist/runtime-status.js +1 -1
  48. package/dist/terminal/claude-tui.d.ts +8 -3
  49. package/dist/terminal/claude-tui.js +6 -2
  50. package/dist/terminal/codex-tui.d.ts +3 -3
  51. package/dist/terminal/codex-tui.js +1 -1
  52. package/dist/terminal/registry.d.ts +1 -1
  53. package/dist/terminal/registry.js +1 -1
  54. package/dist/terminal/tmux.d.ts +6 -6
  55. package/dist/terminal/tmux.js +10 -10
  56. package/package.json +8 -3
@@ -1,38 +1,977 @@
1
- import { randomUUID } from "node:crypto";
2
- import { CodexAppServerTransport, } from "./transport.js";
1
+ import { createHash } from "node:crypto";
2
+ import { CodexAppServerTransport, NO_SERVER_RESPONSE, } from "./transport.js";
3
+ import { boundInteractionRequest, INTERACTION_LIMITS, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
3
4
  const DEFAULT_CLIENT_INFO = {
4
5
  name: "lark-agent-bridge",
5
6
  title: "lark-agent-bridge",
6
7
  version: "0.1.0",
7
8
  };
8
- /** Falls back to the auto policy this long after an interactive approval is
9
- * surfaced with no user answer (5 min). */
10
- const APPROVAL_TIMEOUT_MS = 300_000;
9
+ class InteractionRequestBoundsError extends Error {
10
+ }
11
+ class UnsupportedInteractionSchemaError extends Error {
12
+ }
13
+ const PERMISSION_SUMMARY_BYTES = INTERACTION_LIMITS.descriptionBytes;
14
+ function asRecord(value) {
15
+ return value && typeof value === "object" && !Array.isArray(value)
16
+ ? value
17
+ : undefined;
18
+ }
19
+ function stringValue(value) {
20
+ return typeof value === "string" && value ? value : undefined;
21
+ }
22
+ function boundedText(value, max = 8_000) {
23
+ const text = stringValue(value);
24
+ if (!text)
25
+ return undefined;
26
+ return text.length <= max ? text : `${text.slice(0, max)}\n…`;
27
+ }
28
+ function commandText(value) {
29
+ if (Array.isArray(value)) {
30
+ const parts = value.filter((part) => typeof part === "string");
31
+ return boundedText(parts.join(" "));
32
+ }
33
+ return boundedText(value);
34
+ }
35
+ function legacyPatchPreview(fileChanges) {
36
+ const chunks = [];
37
+ for (const [path, change] of Object.entries(fileChanges)) {
38
+ const detail = change.type === "update" ? change.unified_diff : change.content;
39
+ chunks.push(`${path} (${change.type})\n${detail}`);
40
+ }
41
+ return boundedText(chunks.join("\n\n"));
42
+ }
43
+ function requestIdKey(value) {
44
+ return `${typeof value}:${String(value)}`;
45
+ }
46
+ function codexInteractionId(method, requestId, params) {
47
+ const record = asRecord(params);
48
+ const identity = JSON.stringify([
49
+ method,
50
+ requestIdKey(requestId),
51
+ stringValue(record?.threadId) ?? stringValue(record?.conversationId) ?? "",
52
+ stringValue(record?.turnId) ?? "",
53
+ stringValue(record?.itemId) ??
54
+ stringValue(record?.callId) ??
55
+ stringValue(record?.approvalId) ??
56
+ stringValue(record?.elicitationId) ??
57
+ "",
58
+ ]);
59
+ return `codex_${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
60
+ }
61
+ function permissionActions() {
62
+ return [
63
+ { id: "accept", label: "Allow once", style: "primary", requiresAnswers: false },
64
+ {
65
+ id: "accept_session",
66
+ label: "Allow, and don’t ask again for this session",
67
+ requiresAnswers: false,
68
+ },
69
+ { id: "decline", label: "Deny", style: "danger", requiresAnswers: false },
70
+ { id: "cancel", label: "Cancel", requiresAnswers: false },
71
+ ];
72
+ }
73
+ function exactJsonSummary(label, value) {
74
+ if (value === undefined || value === null)
75
+ return undefined;
76
+ const serialized = JSON.stringify(value);
77
+ if (serialized === undefined)
78
+ return undefined;
79
+ const result = `${label}: ${serialized}`;
80
+ if (Buffer.byteLength(result, "utf8") > PERMISSION_SUMMARY_BYTES) {
81
+ throw new InteractionRequestBoundsError(`${label} exceeds the presentation limit`);
82
+ }
83
+ return result;
84
+ }
85
+ function joinedSummary(parts) {
86
+ const present = parts.filter((part) => Boolean(part));
87
+ if (present.length === 0)
88
+ return undefined;
89
+ const result = present.join(" · ");
90
+ if (Buffer.byteLength(result, "utf8") > PERMISSION_SUMMARY_BYTES) {
91
+ throw new InteractionRequestBoundsError("permission summary exceeds the presentation limit");
92
+ }
93
+ return result;
94
+ }
95
+ function commandApprovalSummary(input) {
96
+ return joinedSummary([
97
+ exactJsonSummary("Network target", input.networkApprovalContext),
98
+ exactJsonSummary("Command actions", input.commandActions),
99
+ exactJsonSummary("Additional permissions", input.additionalPermissions),
100
+ exactJsonSummary("Exec policy amendment", input.proposedExecpolicyAmendment),
101
+ exactJsonSummary("Network policy amendments", input.proposedNetworkPolicyAmendments),
102
+ exactJsonSummary("Available decisions", input.availableDecisions),
103
+ ]);
104
+ }
105
+ function commandDecisionActions(input) {
106
+ const advertised = input.availableDecisions;
107
+ const decisions = advertised == null
108
+ ? ["accept", "acceptForSession", "decline", "cancel"]
109
+ : advertised;
110
+ if (!Array.isArray(decisions) || decisions.length === 0) {
111
+ throw new UnsupportedInteractionSchemaError("command approval has no available decisions");
112
+ }
113
+ const seen = new Set();
114
+ return decisions.map((rawDecision, index) => {
115
+ let action;
116
+ let decision;
117
+ if (typeof rawDecision === "string") {
118
+ decision = rawDecision;
119
+ action = {
120
+ accept: {
121
+ id: "accept",
122
+ label: "Allow once",
123
+ style: "primary",
124
+ requiresAnswers: false,
125
+ },
126
+ acceptForSession: {
127
+ id: "accept_session",
128
+ label: "Allow, and don’t ask again for this session",
129
+ requiresAnswers: false,
130
+ },
131
+ decline: {
132
+ id: "decline",
133
+ label: "Deny",
134
+ style: "danger",
135
+ requiresAnswers: false,
136
+ },
137
+ cancel: { id: "cancel", label: "Cancel", requiresAnswers: false },
138
+ }[rawDecision];
139
+ if (!action) {
140
+ throw new UnsupportedInteractionSchemaError(`unsupported command approval decision: ${String(rawDecision)}`);
141
+ }
142
+ }
143
+ else {
144
+ const record = asRecord(rawDecision);
145
+ const execPolicy = asRecord(record?.acceptWithExecpolicyAmendment);
146
+ const execAmendment = execPolicy?.execpolicy_amendment;
147
+ const networkPolicy = asRecord(record?.applyNetworkPolicyAmendment);
148
+ const networkAmendment = asRecord(networkPolicy?.network_policy_amendment);
149
+ if (record && Object.keys(record).length === 1 &&
150
+ execPolicy && Object.keys(execPolicy).length === 1 &&
151
+ Array.isArray(execAmendment) &&
152
+ execAmendment.every((value) => typeof value === "string")) {
153
+ decision = rawDecision;
154
+ action = {
155
+ id: `accept_execpolicy_amendment_${index}`,
156
+ label: "Allow, and don’t ask again for this command rule",
157
+ requiresAnswers: false,
158
+ };
159
+ }
160
+ else if (record && Object.keys(record).length === 1 &&
161
+ networkPolicy && Object.keys(networkPolicy).length === 1 &&
162
+ networkAmendment && Object.keys(networkAmendment).length === 2 &&
163
+ typeof networkAmendment.host === "string" && networkAmendment.host.length > 0 &&
164
+ (networkAmendment.action === "allow" || networkAmendment.action === "deny")) {
165
+ decision = rawDecision;
166
+ action = {
167
+ id: `apply_network_policy_amendment_${index}`,
168
+ label: networkAmendment.action === "allow"
169
+ ? `Allow, and don’t ask again for ${networkAmendment.host}`
170
+ : `Deny ${networkAmendment.host}`,
171
+ requiresAnswers: false,
172
+ ...(networkAmendment.action === "allow" ? { style: "primary" } : {}),
173
+ };
174
+ }
175
+ else {
176
+ throw new UnsupportedInteractionSchemaError(`unsupported command approval decision at index ${index}`);
177
+ }
178
+ }
179
+ if (seen.has(action.id)) {
180
+ throw new UnsupportedInteractionSchemaError(`duplicate command approval decision: ${action.id}`);
181
+ }
182
+ seen.add(action.id);
183
+ return { action, decision };
184
+ });
185
+ }
186
+ function assertOnlyKeys(value, allowed, scope) {
187
+ const allowedSet = new Set(allowed);
188
+ const unsupported = Object.keys(value).find((key) => !allowedSet.has(key));
189
+ if (unsupported) {
190
+ throw new UnsupportedInteractionSchemaError(`${scope} uses unsupported keyword: ${unsupported}`);
191
+ }
192
+ }
193
+ function optionalFiniteNumber(value, name) {
194
+ if (value === undefined)
195
+ return undefined;
196
+ if (typeof value !== "number" || !Number.isFinite(value)) {
197
+ throw new UnsupportedInteractionSchemaError(`${name} must be a finite number`);
198
+ }
199
+ return value;
200
+ }
201
+ function optionalNonNegativeInteger(value, name) {
202
+ const parsed = optionalFiniteNumber(value, name);
203
+ if (parsed !== undefined && (!Number.isInteger(parsed) || parsed < 0)) {
204
+ throw new UnsupportedInteractionSchemaError(`${name} must be a non-negative integer`);
205
+ }
206
+ return parsed;
207
+ }
208
+ function schemaDescription(description, constraints) {
209
+ const values = [description, ...constraints].filter((value) => Boolean(value));
210
+ return values.length > 0 ? values.join(" ") : undefined;
211
+ }
212
+ function titledOptions(value, scope) {
213
+ if (!Array.isArray(value) || value.length === 0) {
214
+ throw new UnsupportedInteractionSchemaError(`${scope} must contain at least one option`);
215
+ }
216
+ if (value.length > INTERACTION_LIMITS.optionsPerField) {
217
+ throw new InteractionRequestBoundsError(`${scope} has too many options`);
218
+ }
219
+ const seen = new Set();
220
+ return value.map((raw, index) => {
221
+ const option = asRecord(raw);
222
+ const optionValue = option?.const;
223
+ const title = option?.title;
224
+ if (!option || Object.keys(option).some((key) => key !== "const" && key !== "title") ||
225
+ typeof optionValue !== "string" || !optionValue || typeof title !== "string" || !title) {
226
+ throw new UnsupportedInteractionSchemaError(`${scope}[${index}] is not a titled string option`);
227
+ }
228
+ if (seen.has(optionValue)) {
229
+ throw new UnsupportedInteractionSchemaError(`${scope} contains duplicate option values`);
230
+ }
231
+ seen.add(optionValue);
232
+ return { value: optionValue, label: title };
233
+ });
234
+ }
235
+ function enumOptions(values, labels, scope) {
236
+ if (!Array.isArray(values) || values.length === 0 ||
237
+ !values.every((value) => typeof value === "string" && value.length > 0)) {
238
+ throw new UnsupportedInteractionSchemaError(`${scope} must be a non-empty string enum`);
239
+ }
240
+ if (values.length > INTERACTION_LIMITS.optionsPerField) {
241
+ throw new InteractionRequestBoundsError(`${scope} has too many options`);
242
+ }
243
+ if (labels !== undefined &&
244
+ (!Array.isArray(labels) || labels.length !== values.length ||
245
+ !labels.every((label) => typeof label === "string" && label.length > 0))) {
246
+ throw new UnsupportedInteractionSchemaError(`${scope} enumNames must match enum`);
247
+ }
248
+ const seen = new Set();
249
+ return values.map((value, index) => {
250
+ if (seen.has(value)) {
251
+ throw new UnsupportedInteractionSchemaError(`${scope} contains duplicate option values`);
252
+ }
253
+ seen.add(value);
254
+ return { value, label: Array.isArray(labels) ? labels[index] : value };
255
+ });
256
+ }
257
+ function hasOwn(value, key) {
258
+ return Object.prototype.hasOwnProperty.call(value, key);
259
+ }
260
+ function stringConstraints(property, scope, ErrorType = Error) {
261
+ const minLength = optionalNonNegativeInteger(property.minLength, `${scope}.minLength`);
262
+ const maxLength = optionalNonNegativeInteger(property.maxLength, `${scope}.maxLength`);
263
+ if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
264
+ throw new ErrorType(`${scope} has minLength greater than maxLength`);
265
+ }
266
+ const format = property.format;
267
+ if (format !== undefined && format !== "email" && format !== "uri" &&
268
+ format !== "date" && format !== "date-time") {
269
+ throw new ErrorType(`${scope} uses unsupported string format: ${String(format)}`);
270
+ }
271
+ return {
272
+ ...(minLength !== undefined ? { minLength } : {}),
273
+ ...(maxLength !== undefined ? { maxLength } : {}),
274
+ ...(typeof format === "string" ? { format } : {}),
275
+ };
276
+ }
277
+ function validateStringValue(value, property, scope, ErrorType = Error) {
278
+ const { minLength, maxLength, format } = stringConstraints(property, scope, ErrorType);
279
+ const length = [...value].length;
280
+ if (minLength !== undefined && length < minLength) {
281
+ throw new ErrorType(`${scope} must contain at least ${minLength} characters`);
282
+ }
283
+ if (maxLength !== undefined && length > maxLength) {
284
+ throw new ErrorType(`${scope} must contain at most ${maxLength} characters`);
285
+ }
286
+ if (format === undefined)
287
+ return;
288
+ let valid = false;
289
+ if (format === "email") {
290
+ valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
291
+ }
292
+ else if (format === "uri") {
293
+ try {
294
+ valid = Boolean(new URL(value).protocol);
295
+ }
296
+ catch {
297
+ valid = false;
298
+ }
299
+ }
300
+ else if (format === "date") {
301
+ const timestamp = Date.parse(`${value}T00:00:00.000Z`);
302
+ valid = /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(timestamp) &&
303
+ new Date(timestamp).toISOString().slice(0, 10) === value;
304
+ }
305
+ else if (format === "date-time") {
306
+ valid = value.includes("T") && Number.isFinite(Date.parse(value));
307
+ }
308
+ if (!valid)
309
+ throw new ErrorType(`${scope} is not a valid ${format}`);
310
+ }
311
+ function mcpFields(schemaValue) {
312
+ const schema = asRecord(schemaValue);
313
+ if (!schema || schema.type !== "object") {
314
+ throw new UnsupportedInteractionSchemaError("MCP form schema must be an object schema");
315
+ }
316
+ assertOnlyKeys(schema, ["$schema", "type", "properties", "required"], "MCP form schema");
317
+ if (schema.$schema !== undefined && typeof schema.$schema !== "string") {
318
+ throw new UnsupportedInteractionSchemaError("MCP form $schema must be a string");
319
+ }
320
+ const properties = asRecord(schema?.properties);
321
+ if (!properties) {
322
+ throw new UnsupportedInteractionSchemaError("MCP form properties must be an object");
323
+ }
324
+ if (Object.keys(properties).length > INTERACTION_LIMITS.fields) {
325
+ throw new InteractionRequestBoundsError("MCP form has too many fields");
326
+ }
327
+ const requiredValues = schema.required ?? [];
328
+ if (!Array.isArray(requiredValues) || !requiredValues.every((value) => typeof value === "string")) {
329
+ throw new UnsupportedInteractionSchemaError("MCP form required must be a string array");
330
+ }
331
+ const required = new Set();
332
+ for (const id of requiredValues) {
333
+ if (!id || required.has(id) || !hasOwn(properties, id)) {
334
+ throw new UnsupportedInteractionSchemaError(`MCP form has invalid required field: ${id}`);
335
+ }
336
+ required.add(id);
337
+ }
338
+ const fields = [];
339
+ for (const [id, raw] of Object.entries(properties)) {
340
+ const property = asRecord(raw);
341
+ if (!property) {
342
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} must be an object schema`);
343
+ }
344
+ const label = stringValue(property.title) ?? id;
345
+ if (property.title !== undefined && !stringValue(property.title)) {
346
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid title`);
347
+ }
348
+ const rawDescription = property.description;
349
+ if (rawDescription !== undefined && typeof rawDescription !== "string") {
350
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid description`);
351
+ }
352
+ const description = rawDescription;
353
+ const requiredWithoutDefault = required.has(id) && !hasOwn(property, "default");
354
+ if (property.type === "string" && hasOwn(property, "oneOf")) {
355
+ assertOnlyKeys(property, ["type", "title", "description", "oneOf", "default"], `MCP field ${id}`);
356
+ const options = titledOptions(property.oneOf, `MCP field ${id}.oneOf`);
357
+ if (hasOwn(property, "default") &&
358
+ (typeof property.default !== "string" || !options.some((option) => option.value === property.default))) {
359
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid default`);
360
+ }
361
+ fields.push({
362
+ id,
363
+ type: "select",
364
+ label,
365
+ options,
366
+ ...(description ? { description } : {}),
367
+ ...(requiredWithoutDefault ? { required: true } : {}),
368
+ });
369
+ continue;
370
+ }
371
+ if (property.type === "string" && hasOwn(property, "enum")) {
372
+ assertOnlyKeys(property, ["type", "title", "description", "enum", "enumNames", "default"], `MCP field ${id}`);
373
+ const options = enumOptions(property.enum, property.enumNames, `MCP field ${id}.enum`);
374
+ if (hasOwn(property, "default") &&
375
+ (typeof property.default !== "string" || !options.some((option) => option.value === property.default))) {
376
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid default`);
377
+ }
378
+ fields.push({
379
+ id,
380
+ type: "select",
381
+ label,
382
+ options,
383
+ ...(description ? { description } : {}),
384
+ ...(requiredWithoutDefault ? { required: true } : {}),
385
+ });
386
+ continue;
387
+ }
388
+ if (property.type === "array") {
389
+ assertOnlyKeys(property, ["type", "title", "description", "minItems", "maxItems", "items", "default"], `MCP field ${id}`);
390
+ const items = asRecord(property.items);
391
+ if (!items) {
392
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id}.items must be an enum schema`);
393
+ }
394
+ let options;
395
+ if (hasOwn(items, "anyOf")) {
396
+ assertOnlyKeys(items, ["anyOf"], `MCP field ${id}.items`);
397
+ options = titledOptions(items.anyOf, `MCP field ${id}.items.anyOf`);
398
+ }
399
+ else {
400
+ assertOnlyKeys(items, ["type", "enum"], `MCP field ${id}.items`);
401
+ if (items.type !== "string") {
402
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id}.items must contain strings`);
403
+ }
404
+ options = enumOptions(items.enum, undefined, `MCP field ${id}.items.enum`);
405
+ }
406
+ const minItems = optionalNonNegativeInteger(property.minItems, `MCP field ${id}.minItems`);
407
+ const maxItems = optionalNonNegativeInteger(property.maxItems, `MCP field ${id}.maxItems`);
408
+ if (minItems !== undefined && maxItems !== undefined && minItems > maxItems) {
409
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has minItems greater than maxItems`);
410
+ }
411
+ if (minItems !== undefined && minItems > options.length) {
412
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} cannot satisfy minItems`);
413
+ }
414
+ if (hasOwn(property, "default")) {
415
+ if (!Array.isArray(property.default) ||
416
+ !property.default.every((value) => typeof value === "string" && options.some((option) => option.value === value)) ||
417
+ new Set(property.default).size !== property.default.length ||
418
+ (minItems !== undefined && property.default.length < minItems) ||
419
+ (maxItems !== undefined && property.default.length > maxItems)) {
420
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid default`);
421
+ }
422
+ }
423
+ const constraints = [
424
+ minItems !== undefined ? `Select at least ${minItems}.` : undefined,
425
+ maxItems !== undefined ? `Select at most ${maxItems}.` : undefined,
426
+ hasOwn(property, "default") ? `Default: ${property.default.join(", ")}.` : undefined,
427
+ ].filter((value) => Boolean(value));
428
+ fields.push({
429
+ id,
430
+ type: "select",
431
+ label,
432
+ options,
433
+ multiple: true,
434
+ ...(schemaDescription(description, constraints)
435
+ ? { description: schemaDescription(description, constraints) }
436
+ : {}),
437
+ ...(requiredWithoutDefault ? { required: true } : {}),
438
+ });
439
+ continue;
440
+ }
441
+ if (property.type === "boolean") {
442
+ assertOnlyKeys(property, ["type", "title", "description", "default"], `MCP field ${id}`);
443
+ if (hasOwn(property, "default") && typeof property.default !== "boolean") {
444
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid default`);
445
+ }
446
+ const constraints = hasOwn(property, "default") ? [`Default: ${String(property.default)}.`] : [];
447
+ fields.push({
448
+ id,
449
+ type: "select",
450
+ label,
451
+ options: [
452
+ { value: "true", label: "True" },
453
+ { value: "false", label: "False" },
454
+ ],
455
+ ...(schemaDescription(description, constraints)
456
+ ? { description: schemaDescription(description, constraints) }
457
+ : {}),
458
+ ...(requiredWithoutDefault ? { required: true } : {}),
459
+ });
460
+ continue;
461
+ }
462
+ if (property.type === "number" || property.type === "integer") {
463
+ assertOnlyKeys(property, ["type", "title", "description", "minimum", "maximum", "default"], `MCP field ${id}`);
464
+ const minimum = optionalFiniteNumber(property.minimum, `MCP field ${id}.minimum`);
465
+ const maximum = optionalFiniteNumber(property.maximum, `MCP field ${id}.maximum`);
466
+ if (minimum !== undefined && maximum !== undefined && minimum > maximum) {
467
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has minimum greater than maximum`);
468
+ }
469
+ if (hasOwn(property, "default")) {
470
+ if (typeof property.default !== "number" || !Number.isFinite(property.default) ||
471
+ (property.type === "integer" && !Number.isInteger(property.default)) ||
472
+ (minimum !== undefined && property.default < minimum) ||
473
+ (maximum !== undefined && property.default > maximum)) {
474
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid default`);
475
+ }
476
+ }
477
+ const constraints = [
478
+ property.type === "integer" ? "Enter an integer." : "Enter a number.",
479
+ minimum !== undefined ? `Minimum: ${minimum}.` : undefined,
480
+ maximum !== undefined ? `Maximum: ${maximum}.` : undefined,
481
+ hasOwn(property, "default") ? `Default: ${String(property.default)}.` : undefined,
482
+ ].filter((value) => Boolean(value));
483
+ fields.push({
484
+ id,
485
+ type: "text",
486
+ label,
487
+ ...(schemaDescription(description, constraints)
488
+ ? { description: schemaDescription(description, constraints) }
489
+ : {}),
490
+ ...(requiredWithoutDefault ? { required: true } : {}),
491
+ ...(hasOwn(property, "default") ? { placeholder: String(property.default) } : {}),
492
+ });
493
+ continue;
494
+ }
495
+ if (property.type === "string") {
496
+ assertOnlyKeys(property, ["type", "title", "description", "minLength", "maxLength", "format", "default"], `MCP field ${id}`);
497
+ if (hasOwn(property, "default") && typeof property.default !== "string") {
498
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} has an invalid default`);
499
+ }
500
+ if (hasOwn(property, "default")) {
501
+ validateStringValue(property.default, property, `MCP field ${id}`, UnsupportedInteractionSchemaError);
502
+ }
503
+ else {
504
+ stringConstraints(property, `MCP field ${id}`, UnsupportedInteractionSchemaError);
505
+ }
506
+ const minLength = optionalNonNegativeInteger(property.minLength, `MCP field ${id}.minLength`);
507
+ const maxLength = optionalNonNegativeInteger(property.maxLength, `MCP field ${id}.maxLength`);
508
+ const constraints = [
509
+ minLength !== undefined ? `Minimum length: ${minLength}.` : undefined,
510
+ maxLength !== undefined ? `Maximum length: ${maxLength}.` : undefined,
511
+ property.format !== undefined ? `Format: ${String(property.format)}.` : undefined,
512
+ hasOwn(property, "default") ? `Default: ${String(property.default)}.` : undefined,
513
+ ].filter((value) => Boolean(value));
514
+ fields.push({
515
+ id,
516
+ type: "text",
517
+ label,
518
+ ...(schemaDescription(description, constraints)
519
+ ? { description: schemaDescription(description, constraints) }
520
+ : {}),
521
+ ...(requiredWithoutDefault ? { required: true } : {}),
522
+ ...(hasOwn(property, "default") ? { placeholder: property.default } : {}),
523
+ });
524
+ continue;
525
+ }
526
+ throw new UnsupportedInteractionSchemaError(`MCP field ${id} uses unsupported type: ${String(property.type)}`);
527
+ }
528
+ return fields;
529
+ }
530
+ function buildCodexInteraction(method, params, nativeRequestId) {
531
+ const p = asRecord(params) ?? {};
532
+ const interactionId = codexInteractionId(method, nativeRequestId, params);
533
+ const turnId = stringValue(p.turnId);
534
+ const base = {
535
+ interactionId,
536
+ createdAt: Date.now(),
537
+ };
538
+ let request;
539
+ switch (method) {
540
+ case "item/tool/requestUserInput": {
541
+ const input = params;
542
+ const questions = Array.isArray(input.questions) ? input.questions : [];
543
+ const fields = questions.map((question) => {
544
+ const options = Array.isArray(question.options) ? question.options : [];
545
+ if (options.length > 0) {
546
+ return {
547
+ id: question.id,
548
+ type: "select",
549
+ label: question.question,
550
+ description: question.header || undefined,
551
+ required: true,
552
+ ...(question.multiSelect ? { multiple: true } : {}),
553
+ allowOther: question.isOther,
554
+ options: options.map((option) => ({
555
+ value: option.label,
556
+ label: option.label,
557
+ ...(option.description ? { description: option.description } : {}),
558
+ })),
559
+ };
560
+ }
561
+ return {
562
+ id: question.id,
563
+ type: "text",
564
+ label: question.question,
565
+ description: question.header || undefined,
566
+ required: true,
567
+ ...(question.isSecret ? { secret: true } : {}),
568
+ };
569
+ });
570
+ request = {
571
+ ...base,
572
+ kind: "question",
573
+ title: questions.length === 1 && questions[0]?.header
574
+ ? questions[0].header
575
+ : "Input required",
576
+ fields,
577
+ actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
578
+ };
579
+ break;
580
+ }
581
+ case "mcpServer/elicitation/request": {
582
+ const input = params;
583
+ const urlDescription = input.mode === "url" ? `${input.message}\n${input.url}` : input.message;
584
+ request = {
585
+ ...base,
586
+ kind: "form",
587
+ title: `${input.serverName || "MCP server"} requests input`,
588
+ description: urlDescription,
589
+ fields: input.mode === "url" ? [] : mcpFields(input.requestedSchema),
590
+ actions: [
591
+ {
592
+ id: "accept",
593
+ label: "Continue",
594
+ style: "primary",
595
+ requiresAnswers: input.mode !== "url",
596
+ },
597
+ { id: "decline", label: "Decline", style: "danger", requiresAnswers: false },
598
+ { id: "cancel", label: "Cancel", requiresAnswers: false },
599
+ ],
600
+ };
601
+ break;
602
+ }
603
+ case "item/permissions/requestApproval": {
604
+ const input = params;
605
+ const permissionSummary = exactJsonSummary("Requested permissions", input.permissions);
606
+ request = {
607
+ ...base,
608
+ kind: "permission",
609
+ title: "Additional permissions required",
610
+ description: input.reason ?? undefined,
611
+ fields: [],
612
+ actions: [
613
+ { id: "grant_turn", label: "Allow all for turn", style: "primary", requiresAnswers: false },
614
+ {
615
+ id: "grant_session",
616
+ label: "Allow, and don’t ask again for this session",
617
+ requiresAnswers: false,
618
+ },
619
+ { id: "decline", label: "Deny", style: "danger", requiresAnswers: false },
620
+ { id: "cancel", label: "Cancel", requiresAnswers: false },
621
+ ],
622
+ context: {
623
+ toolName: "permissions",
624
+ ...(input.cwd ? { cwd: input.cwd } : {}),
625
+ ...(permissionSummary ? { summary: permissionSummary } : {}),
626
+ },
627
+ };
628
+ break;
629
+ }
630
+ case "item/fileChange/requestApproval": {
631
+ const input = params;
632
+ request = {
633
+ ...base,
634
+ kind: "permission",
635
+ title: input.grantRoot ? `Write access under ${input.grantRoot}` : "Approve file changes",
636
+ description: boundedText(input.reason),
637
+ fields: [],
638
+ actions: permissionActions(),
639
+ context: {
640
+ toolName: "file_change",
641
+ ...(input.grantRoot ? { summary: `Write access under ${input.grantRoot}` } : {}),
642
+ },
643
+ };
644
+ break;
645
+ }
646
+ case "applyPatchApproval": {
647
+ const input = params;
648
+ const diff = legacyPatchPreview(input.fileChanges ?? {});
649
+ request = {
650
+ ...base,
651
+ kind: "permission",
652
+ title: input.grantRoot ? `Write access under ${input.grantRoot}` : "Approve file changes",
653
+ description: boundedText(input.reason),
654
+ fields: [],
655
+ actions: permissionActions(),
656
+ context: {
657
+ toolName: "file_change",
658
+ ...(input.grantRoot ? { summary: `Write access under ${input.grantRoot}` } : {}),
659
+ ...(diff ? { diff } : {}),
660
+ },
661
+ };
662
+ break;
663
+ }
664
+ case "item/commandExecution/requestApproval": {
665
+ const input = params;
666
+ const command = commandText(input.command);
667
+ const reason = boundedText(input.reason);
668
+ const summary = commandApprovalSummary(input);
669
+ request = {
670
+ ...base,
671
+ kind: "permission",
672
+ title: input.networkApprovalContext && !command
673
+ ? `Approve network access to ${input.networkApprovalContext.host}`
674
+ : "Approve command",
675
+ description: reason,
676
+ fields: [],
677
+ actions: commandDecisionActions(input).map((entry) => entry.action),
678
+ context: {
679
+ toolName: "command_execution",
680
+ ...(command ? { command } : {}),
681
+ ...(input.cwd ? { cwd: input.cwd } : {}),
682
+ ...(summary ? { summary } : {}),
683
+ },
684
+ };
685
+ break;
686
+ }
687
+ case "execCommandApproval": {
688
+ const input = params;
689
+ const command = commandText(input.command);
690
+ const reason = boundedText(input.reason);
691
+ request = {
692
+ ...base,
693
+ kind: "permission",
694
+ title: "Approve command",
695
+ description: reason,
696
+ fields: [],
697
+ actions: permissionActions(),
698
+ context: {
699
+ toolName: "command_execution",
700
+ ...(command ? { command } : {}),
701
+ ...(input.cwd ? { cwd: input.cwd } : {}),
702
+ ...(reason ? { summary: reason } : {}),
703
+ },
704
+ };
705
+ break;
706
+ }
707
+ }
708
+ const bounded = boundInteractionRequest(request);
709
+ if (!bounded.ok)
710
+ throw new InteractionRequestBoundsError(bounded.reason);
711
+ return { request: bounded.request, ...(turnId ? { turnId } : {}) };
712
+ }
713
+ function mcpContent(params, answers) {
714
+ if (params.mode === "url")
715
+ return {};
716
+ // Re-validate the native schema at resolution time. It remains untrusted
717
+ // provider input even though the same object was validated for rendering.
718
+ mcpFields(params.requestedSchema);
719
+ const schema = asRecord(params.requestedSchema);
720
+ const properties = asRecord(schema.properties);
721
+ const content = Object.create(null);
722
+ for (const [id, rawProperty] of Object.entries(properties)) {
723
+ const property = asRecord(rawProperty);
724
+ const answer = answers[id];
725
+ const value = answer === undefined && hasOwn(property, "default")
726
+ ? property.default
727
+ : answer;
728
+ if (value === undefined)
729
+ continue;
730
+ if (property.type === "boolean") {
731
+ if (value !== "true" && value !== "false" && typeof value !== "boolean") {
732
+ throw new Error(`MCP field ${id} must be true or false`);
733
+ }
734
+ content[id] = typeof value === "boolean" ? value : value === "true";
735
+ continue;
736
+ }
737
+ if (property.type === "number" || property.type === "integer") {
738
+ const number = typeof value === "number"
739
+ ? value
740
+ : typeof value === "string" && value.trim().length > 0
741
+ ? Number(value)
742
+ : Number.NaN;
743
+ if (!Number.isFinite(number))
744
+ throw new Error(`MCP field ${id} must be a finite number`);
745
+ if (property.type === "integer" && !Number.isInteger(number)) {
746
+ throw new Error(`MCP field ${id} must be an integer`);
747
+ }
748
+ const minimum = optionalFiniteNumber(property.minimum, `MCP field ${id}.minimum`);
749
+ const maximum = optionalFiniteNumber(property.maximum, `MCP field ${id}.maximum`);
750
+ if (minimum !== undefined && number < minimum) {
751
+ throw new Error(`MCP field ${id} must be at least ${minimum}`);
752
+ }
753
+ if (maximum !== undefined && number > maximum) {
754
+ throw new Error(`MCP field ${id} must be at most ${maximum}`);
755
+ }
756
+ content[id] = number;
757
+ continue;
758
+ }
759
+ if (property.type === "array") {
760
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
761
+ throw new Error(`MCP field ${id} must be a string array`);
762
+ }
763
+ if (new Set(value).size !== value.length) {
764
+ throw new Error(`MCP field ${id} must not contain duplicate selections`);
765
+ }
766
+ const items = asRecord(property.items);
767
+ const options = hasOwn(items, "anyOf")
768
+ ? titledOptions(items.anyOf, `MCP field ${id}.items.anyOf`)
769
+ : enumOptions(items.enum, undefined, `MCP field ${id}.items.enum`);
770
+ if (value.some((entry) => !options.some((option) => option.value === entry))) {
771
+ throw new Error(`MCP field ${id} contains an unsupported selection`);
772
+ }
773
+ const minItems = optionalNonNegativeInteger(property.minItems, `MCP field ${id}.minItems`);
774
+ const maxItems = optionalNonNegativeInteger(property.maxItems, `MCP field ${id}.maxItems`);
775
+ if (minItems !== undefined && value.length < minItems) {
776
+ throw new Error(`MCP field ${id} requires at least ${minItems} selections`);
777
+ }
778
+ if (maxItems !== undefined && value.length > maxItems) {
779
+ throw new Error(`MCP field ${id} allows at most ${maxItems} selections`);
780
+ }
781
+ content[id] = [...value];
782
+ continue;
783
+ }
784
+ if (property.type === "string") {
785
+ if (typeof value !== "string")
786
+ throw new Error(`MCP field ${id} must be a string`);
787
+ if (hasOwn(property, "oneOf")) {
788
+ const options = titledOptions(property.oneOf, `MCP field ${id}.oneOf`);
789
+ if (!options.some((option) => option.value === value)) {
790
+ throw new Error(`MCP field ${id} contains an unsupported selection`);
791
+ }
792
+ }
793
+ else if (hasOwn(property, "enum")) {
794
+ const options = enumOptions(property.enum, property.enumNames, `MCP field ${id}.enum`);
795
+ if (!options.some((option) => option.value === value)) {
796
+ throw new Error(`MCP field ${id} contains an unsupported selection`);
797
+ }
798
+ }
799
+ else {
800
+ validateStringValue(value, property, `MCP field ${id}`);
801
+ }
802
+ content[id] = value;
803
+ continue;
804
+ }
805
+ throw new Error(`MCP field ${id} uses an unsupported schema`);
806
+ }
807
+ return content;
808
+ }
809
+ function buildNativeInteractionResponse(pending, resolution) {
810
+ const invalid = validateInteractionResolution(pending.request, resolution);
811
+ if (invalid)
812
+ throw new Error(invalid);
813
+ const answers = resolution.answers ?? {};
814
+ switch (pending.method) {
815
+ case "item/tool/requestUserInput": {
816
+ const response = { answers: {} };
817
+ for (const [id, value] of Object.entries(answers)) {
818
+ response.answers[id] = { answers: Array.isArray(value) ? value : [value] };
819
+ }
820
+ return response;
821
+ }
822
+ case "mcpServer/elicitation/request": {
823
+ const action = resolution.actionId;
824
+ if (action !== "accept" && action !== "decline" && action !== "cancel") {
825
+ throw new Error(`unsupported MCP action: ${action}`);
826
+ }
827
+ const response = {
828
+ action,
829
+ content: action === "accept"
830
+ ? mcpContent(pending.params, answers)
831
+ : null,
832
+ _meta: null,
833
+ };
834
+ return response;
835
+ }
836
+ case "item/permissions/requestApproval": {
837
+ const input = pending.params;
838
+ const granted = resolution.actionId === "grant_turn" || resolution.actionId === "grant_session";
839
+ const permissions = {};
840
+ if (granted && input.permissions.network)
841
+ permissions.network = input.permissions.network;
842
+ if (granted && input.permissions.fileSystem)
843
+ permissions.fileSystem = input.permissions.fileSystem;
844
+ const response = {
845
+ permissions,
846
+ scope: resolution.actionId === "grant_session" ? "session" : "turn",
847
+ };
848
+ return response;
849
+ }
850
+ case "execCommandApproval":
851
+ case "applyPatchApproval": {
852
+ const response = {
853
+ decision: {
854
+ accept: "approved",
855
+ accept_session: "approved_for_session",
856
+ decline: "denied",
857
+ cancel: "abort",
858
+ }[resolution.actionId],
859
+ };
860
+ return response;
861
+ }
862
+ case "item/commandExecution/requestApproval": {
863
+ const input = pending.params;
864
+ const selected = commandDecisionActions(input).find((entry) => entry.action.id === resolution.actionId);
865
+ if (!selected)
866
+ throw new Error(`unsupported approval action: ${resolution.actionId}`);
867
+ const response = { decision: selected.decision };
868
+ return response;
869
+ }
870
+ case "item/fileChange/requestApproval": {
871
+ const decision = resolution.actionId === "accept_session"
872
+ ? "acceptForSession"
873
+ : resolution.actionId;
874
+ if (decision !== "accept" && decision !== "acceptForSession" && decision !== "decline" && decision !== "cancel") {
875
+ throw new Error(`unsupported approval action: ${resolution.actionId}`);
876
+ }
877
+ return { decision };
878
+ }
879
+ }
880
+ }
881
+ function automaticCommandDecision(params, desired) {
882
+ const entries = commandDecisionActions(params);
883
+ const simple = new Map(entries
884
+ .filter((entry) => typeof entry.decision === "string")
885
+ .map((entry) => [entry.decision, entry.decision]));
886
+ const preferences = desired === "acceptForSession"
887
+ ? ["acceptForSession", "accept", "cancel", "decline"]
888
+ : desired === "accept"
889
+ ? ["accept", "acceptForSession", "cancel", "decline"]
890
+ : desired === "decline"
891
+ ? ["decline", "cancel"]
892
+ : ["cancel", "decline"];
893
+ for (const preference of preferences) {
894
+ const decision = simple.get(preference);
895
+ if (decision)
896
+ return decision;
897
+ }
898
+ throw new UnsupportedInteractionSchemaError("command approval does not advertise a compatible automatic decision");
899
+ }
900
+ function buildAutomaticResponse(method, params, decision) {
901
+ switch (method) {
902
+ case "item/tool/requestUserInput":
903
+ return { answers: {} };
904
+ case "mcpServer/elicitation/request":
905
+ return {
906
+ action: "cancel",
907
+ content: null,
908
+ _meta: null,
909
+ };
910
+ case "item/permissions/requestApproval": {
911
+ const input = params;
912
+ const permissions = {};
913
+ if (decision === "acceptForSession") {
914
+ if (input.permissions.network)
915
+ permissions.network = input.permissions.network;
916
+ if (input.permissions.fileSystem)
917
+ permissions.fileSystem = input.permissions.fileSystem;
918
+ }
919
+ return {
920
+ permissions,
921
+ scope: decision === "acceptForSession" ? "session" : "turn",
922
+ };
923
+ }
924
+ case "execCommandApproval":
925
+ case "applyPatchApproval": {
926
+ const response = {
927
+ decision: decision === "acceptForSession"
928
+ ? "approved_for_session"
929
+ : decision === "accept"
930
+ ? "approved"
931
+ : decision === "decline"
932
+ ? "denied"
933
+ : "abort",
934
+ };
935
+ return response;
936
+ }
937
+ case "item/commandExecution/requestApproval":
938
+ return {
939
+ decision: automaticCommandDecision(params, decision),
940
+ };
941
+ case "item/fileChange/requestApproval":
942
+ return { decision };
943
+ }
944
+ }
945
+ const MAX_SETTLED_INTERACTIONS = 512;
11
946
  export class CodexAppServerClient {
12
947
  transport;
13
948
  logger;
14
949
  clientInfo;
15
950
  approvalDecisionPolicy;
16
- interactiveApprovals;
17
951
  channel;
18
952
  notificationSubscribers = new Set();
19
- approvalListener = null;
20
- pendingApprovals = new Map();
953
+ interactionListener = null;
954
+ connectionListener = null;
955
+ connectionState = "disconnected";
956
+ pendingInteractions = new Map();
957
+ settledInteractions = new Set();
21
958
  initializeResponse = null;
22
- constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", interactiveApprovals = false, }) {
959
+ constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", }) {
23
960
  this.logger = logger;
24
961
  this.clientInfo = clientInfo;
25
962
  this.approvalDecisionPolicy = approvalDecisionPolicy;
26
- this.interactiveApprovals = interactiveApprovals;
27
963
  this.channel = channel ?? null;
28
964
  this.transport = new CodexAppServerTransport({
29
965
  ...(channel ? { channel } : { spawner }),
30
966
  logger,
31
967
  onNotification: (method, params) => this.dispatchNotification(method, params),
32
- onServerRequest: (method, params) => this.handleServerRequest(method, params),
968
+ onServerRequest: (method, params, requestId) => this.handleServerRequest(method, params, requestId),
969
+ onServerRequestResponseDelivery: (requestId, result) => this.handleServerRequestResponseDelivery(requestId, result),
33
970
  });
34
971
  this.transport.on("exit", () => {
35
972
  this.initializeResponse = null;
973
+ this.cancelPendingInteractions("app_server_disconnected");
974
+ this.setConnectionState("disconnected");
36
975
  });
37
976
  }
38
977
  /**
@@ -55,6 +994,7 @@ export class CodexAppServerClient {
55
994
  capabilities: { experimentalApi: true },
56
995
  });
57
996
  this.initializeResponse = response;
997
+ this.setConnectionState("connected");
58
998
  return response;
59
999
  }
60
1000
  async getAuthStatus(params = {}) {
@@ -148,6 +1088,20 @@ export class CodexAppServerClient {
148
1088
  await this.transport.stop();
149
1089
  }
150
1090
  dispatchNotification(method, params) {
1091
+ if (method === "serverRequest/resolved") {
1092
+ const p = asRecord(params);
1093
+ const nativeRequestId = p?.requestId;
1094
+ if (typeof nativeRequestId === "string" || typeof nativeRequestId === "number") {
1095
+ this.resolveByProvider(nativeRequestId, stringValue(p?.threadId));
1096
+ }
1097
+ }
1098
+ else if (method === "turn/completed") {
1099
+ const p = asRecord(params);
1100
+ const turn = asRecord(p?.turn);
1101
+ const turnId = stringValue(turn?.id) ?? stringValue(p?.turnId);
1102
+ if (turnId)
1103
+ this.cancelPendingInteractions("turn_completed", turnId);
1104
+ }
151
1105
  for (const listener of this.notificationSubscribers) {
152
1106
  try {
153
1107
  listener(method, params);
@@ -161,19 +1115,16 @@ export class CodexAppServerClient {
161
1115
  }
162
1116
  }
163
1117
  }
164
- async handleServerRequest(method, params) {
1118
+ async handleServerRequest(method, params, requestId) {
165
1119
  switch (method) {
166
1120
  case "item/commandExecution/requestApproval":
167
1121
  case "execCommandApproval":
168
- return this.handleApproval("exec", params);
169
1122
  case "item/fileChange/requestApproval":
170
1123
  case "applyPatchApproval":
171
- return this.handleApproval("patch", params);
172
1124
  case "item/tool/requestUserInput":
173
1125
  case "mcpServer/elicitation/request":
174
- return this.respondNotSupported(method);
175
1126
  case "item/permissions/requestApproval":
176
- return { decision: this.declineDecisionForApprovals() };
1127
+ return this.handleInteraction(method, params, requestId);
177
1128
  case "account/chatgptAuthTokens/refresh":
178
1129
  // Codex asks the host to refresh tokens; without an attached UI the
179
1130
  // best we can do is decline, surfacing the auth error to the caller
@@ -188,66 +1139,96 @@ export class CodexAppServerClient {
188
1139
  }
189
1140
  }
190
1141
  /**
191
- * Register the listener that surfaces interactive approval requests. Set by
192
- * the executor for the duration of a run so requests reach its event stream
193
- * (and thus the web / Lark approval card).
1142
+ * Register the provider-neutral interaction listener. A request is inserted
1143
+ * into the pending map before the listener is invoked, so a synchronous
1144
+ * resolver still wins correctly.
194
1145
  */
195
- setApprovalRequestListener(listener) {
196
- this.approvalListener = listener;
1146
+ setInteractionListener(listener) {
1147
+ this.interactionListener = listener;
197
1148
  }
198
- /**
199
- * Deliver a user's decision for a pending interactive approval. Returns false
200
- * if the approval id is unknown (already resolved, timed out, or auto-decided).
201
- */
202
- resolveApproval(approvalId, decision) {
203
- const pending = this.pendingApprovals.get(approvalId);
1149
+ /** Observe the underlying app-server connection independently from any one
1150
+ * interaction. A disconnected client that never received the duplicate
1151
+ * native request still has to count as unavailable during host failover. */
1152
+ setConnectionListener(listener) {
1153
+ this.connectionListener = listener;
1154
+ listener?.(this.connectionState);
1155
+ }
1156
+ setConnectionState(state) {
1157
+ if (this.connectionState === state)
1158
+ return;
1159
+ this.connectionState = state;
1160
+ this.connectionListener?.(state);
1161
+ }
1162
+ resolveInteraction(interactionId, resolution) {
1163
+ const pending = this.pendingInteractions.get(interactionId);
204
1164
  if (!pending) {
205
- return false;
1165
+ return this.settledInteractions.has(interactionId)
1166
+ ? { disposition: "already_resolved" }
1167
+ : { disposition: "not_found" };
206
1168
  }
207
- clearTimeout(pending.timer);
208
- this.pendingApprovals.delete(approvalId);
209
- pending.resolve(decision);
210
- return true;
1169
+ if (pending.submission)
1170
+ return { disposition: "already_resolved" };
1171
+ let nativeResponse;
1172
+ try {
1173
+ nativeResponse = buildNativeInteractionResponse(pending, resolution);
1174
+ }
1175
+ catch (error) {
1176
+ return {
1177
+ disposition: "invalid",
1178
+ message: error instanceof Error ? error.message : String(error),
1179
+ };
1180
+ }
1181
+ pending.submission = {
1182
+ resolution: redactInteractionResolution(pending.request, resolution),
1183
+ };
1184
+ pending.resolve(nativeResponse);
1185
+ return { disposition: "applied" };
211
1186
  }
212
- /**
213
- * Interactive approval path: surface the request and block the codex
214
- * server-request until the user decides (or the timeout falls back to the
215
- * auto policy). When interactive approvals are off, decide immediately.
216
- */
217
- async handleApproval(kind, params) {
218
- const listener = this.approvalListener;
219
- if (!this.interactiveApprovals || !listener) {
220
- return kind === "exec"
221
- ? this.respondCommandApproval(params)
222
- : this.respondFileChangeApproval(params);
223
- }
224
- const approvalId = randomUUID();
225
- const request = { approvalId, kind };
226
- const p = params;
227
- if (typeof p.command === "string")
228
- request.command = p.command;
229
- if (typeof p.cwd === "string")
230
- request.cwd = p.cwd;
231
- if (typeof p.diff === "string")
232
- request.diff = p.diff;
1187
+ /** Cancel every pending request owned by this connection without replying. */
1188
+ cancelInteractions(reason = "client_stopped") {
1189
+ this.cancelPendingInteractions(reason);
1190
+ }
1191
+ async handleInteraction(method, params, nativeRequestId) {
1192
+ let adapted;
233
1193
  try {
234
- listener(request);
1194
+ adapted = buildCodexInteraction(method, params, nativeRequestId);
235
1195
  }
236
1196
  catch (error) {
237
- this.logger.log({
238
- event: "client.approval_listener_failed",
239
- error: error instanceof Error ? error.message : String(error),
240
- });
1197
+ if (error instanceof InteractionRequestBoundsError ||
1198
+ error instanceof UnsupportedInteractionSchemaError) {
1199
+ this.logger.log({
1200
+ event: "client.interaction_request_rejected",
1201
+ method,
1202
+ reason: error.message,
1203
+ });
1204
+ return buildAutomaticResponse(method, params, "cancel");
1205
+ }
1206
+ throw error;
1207
+ }
1208
+ const listener = this.interactionListener;
1209
+ // Provider policy decides whether an approval request exists. Once it does,
1210
+ // an installed generic listener must see it; only standalone/no-listener
1211
+ // clients use the automatic fallback.
1212
+ if (!listener) {
1213
+ return buildAutomaticResponse(method, params, this.autoApprovalDecision());
241
1214
  }
242
- const decision = await new Promise((resolve) => {
243
- const timer = setTimeout(() => {
244
- this.pendingApprovals.delete(approvalId);
245
- resolve(this.autoApprovalDecision());
246
- }, APPROVAL_TIMEOUT_MS);
247
- timer.unref?.();
248
- this.pendingApprovals.set(approvalId, { resolve, timer });
1215
+ return new Promise((resolve) => {
1216
+ const pending = {
1217
+ method,
1218
+ nativeRequestId,
1219
+ params,
1220
+ request: adapted.request,
1221
+ ...(adapted.turnId ? { turnId: adapted.turnId } : {}),
1222
+ resolve,
1223
+ };
1224
+ // Register before notifying: the listener is allowed to answer inline.
1225
+ this.pendingInteractions.set(adapted.request.interactionId, pending);
1226
+ this.emitInteraction({
1227
+ type: "requested",
1228
+ request: adapted.request,
1229
+ ...(adapted.turnId ? { turnId: adapted.turnId } : {}),
1230
+ });
249
1231
  });
250
- return { decision };
251
1232
  }
252
1233
  autoApprovalDecision() {
253
1234
  switch (this.approvalDecisionPolicy) {
@@ -260,45 +1241,94 @@ export class CodexAppServerClient {
260
1241
  return "cancel";
261
1242
  }
262
1243
  }
263
- respondCommandApproval(_params) {
264
- switch (this.approvalDecisionPolicy) {
265
- case "auto-approve-session":
266
- return { decision: "acceptForSession" };
267
- case "auto-decline":
268
- return { decision: "decline" };
269
- case "auto-cancel":
270
- default:
271
- return { decision: "cancel" };
1244
+ resolveByProvider(nativeRequestId, threadId) {
1245
+ for (const [interactionId, pending] of this.pendingInteractions) {
1246
+ const pendingParams = asRecord(pending.params);
1247
+ const pendingThreadId = stringValue(pendingParams?.threadId) ??
1248
+ stringValue(pendingParams?.conversationId);
1249
+ if (requestIdKey(pending.nativeRequestId) !== requestIdKey(nativeRequestId) ||
1250
+ (threadId && pendingThreadId && threadId !== pendingThreadId)) {
1251
+ continue;
1252
+ }
1253
+ this.pendingInteractions.delete(interactionId);
1254
+ this.rememberSettled(interactionId);
1255
+ if (pending.submission) {
1256
+ this.emitInteraction({
1257
+ type: "resolved",
1258
+ interactionId,
1259
+ resolution: pending.submission.resolution,
1260
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1261
+ });
1262
+ }
1263
+ else {
1264
+ // This connection never submitted a response, so another app-server
1265
+ // client resolved the request. Do not reply to the stale native request.
1266
+ pending.resolve(NO_SERVER_RESPONSE);
1267
+ this.emitInteraction({
1268
+ type: "cancelled",
1269
+ interactionId,
1270
+ reason: "resolved_by_another_client",
1271
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1272
+ });
1273
+ }
1274
+ return;
272
1275
  }
273
1276
  }
274
- respondFileChangeApproval(_params) {
275
- switch (this.approvalDecisionPolicy) {
276
- case "auto-approve-session":
277
- return { decision: "acceptForSession" };
278
- case "auto-decline":
279
- return { decision: "decline" };
280
- case "auto-cancel":
281
- default:
282
- return { decision: "cancel" };
1277
+ handleServerRequestResponseDelivery(nativeRequestId, result) {
1278
+ for (const [interactionId, pending] of this.pendingInteractions) {
1279
+ if (requestIdKey(pending.nativeRequestId) !== requestIdKey(nativeRequestId))
1280
+ continue;
1281
+ if (!pending.submission)
1282
+ return;
1283
+ if (result.delivered)
1284
+ return;
1285
+ this.pendingInteractions.delete(interactionId);
1286
+ this.rememberSettled(interactionId);
1287
+ this.emitInteraction({
1288
+ type: "cancelled",
1289
+ interactionId,
1290
+ reason: "response_write_failed",
1291
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1292
+ });
1293
+ return;
283
1294
  }
284
1295
  }
285
- declineDecisionForApprovals() {
286
- switch (this.approvalDecisionPolicy) {
287
- case "auto-approve-session":
288
- return "accept";
289
- case "auto-decline":
290
- return "decline";
291
- case "auto-cancel":
292
- default:
293
- return "cancel";
1296
+ cancelPendingInteractions(reason, turnId) {
1297
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
1298
+ // Some provider requests omit turnId (notably MCP URL/form variants).
1299
+ // Codex has one active Turn per thread, so an unknown correlation must be
1300
+ // cancelled by its completion instead of leaking forever.
1301
+ if (turnId && pending.turnId && pending.turnId !== turnId)
1302
+ continue;
1303
+ this.pendingInteractions.delete(interactionId);
1304
+ this.rememberSettled(interactionId);
1305
+ pending.resolve(NO_SERVER_RESPONSE);
1306
+ this.emitInteraction({
1307
+ type: "cancelled",
1308
+ interactionId,
1309
+ reason,
1310
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1311
+ });
294
1312
  }
295
1313
  }
296
- respondNotSupported(method) {
297
- this.logger.log({
298
- event: "client.server_request_unsupported",
299
- method,
300
- });
301
- return null;
1314
+ emitInteraction(event) {
1315
+ try {
1316
+ this.interactionListener?.(event);
1317
+ }
1318
+ catch (error) {
1319
+ this.logger.log({
1320
+ event: "client.interaction_listener_failed",
1321
+ error: error instanceof Error ? error.message : String(error),
1322
+ });
1323
+ }
1324
+ }
1325
+ rememberSettled(interactionId) {
1326
+ this.settledInteractions.add(interactionId);
1327
+ if (this.settledInteractions.size <= MAX_SETTLED_INTERACTIONS)
1328
+ return;
1329
+ const oldest = this.settledInteractions.values().next().value;
1330
+ if (oldest)
1331
+ this.settledInteractions.delete(oldest);
302
1332
  }
303
1333
  }
304
1334
  /**
@@ -330,6 +1360,11 @@ export function buildSandboxPolicy(options) {
330
1360
  export function buildTextUserInput(message) {
331
1361
  return [{ type: "text", text: message, text_elements: [] }];
332
1362
  }
1363
+ export function buildRuntimeUserInput(input) {
1364
+ return input.content.map((part) => part.type === "text"
1365
+ ? { type: "text", text: part.text, text_elements: [] }
1366
+ : { type: "localImage", path: part.path });
1367
+ }
333
1368
  const defaultLogger = {
334
1369
  log(entry) {
335
1370
  console.log(JSON.stringify({