@rynx-ai/runtime 0.1.0 → 0.1.9

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 (53) 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 +291 -39
  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 +78 -5
  9. package/dist/claude/native-integration.js +417 -26
  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 +3 -3
  14. package/dist/codex/rollout-synth.js +1 -1
  15. package/dist/codex-app-server/client.d.ts +26 -40
  16. package/dist/codex-app-server/client.js +1128 -99
  17. package/dist/codex-app-server/forwarder.d.ts +7 -7
  18. package/dist/codex-app-server/forwarder.js +11 -5
  19. package/dist/codex-app-server/mapping.d.ts +1 -1
  20. package/dist/codex-app-server/mapping.js +27 -2
  21. package/dist/codex-app-server/protocol.d.ts +238 -4
  22. package/dist/codex-app-server/transport.d.ts +20 -5
  23. package/dist/codex-app-server/transport.js +93 -40
  24. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  25. package/dist/codex-app-server/ws-channel.js +23 -7
  26. package/dist/codex-child-env.js +33 -0
  27. package/dist/codex-home.d.ts +6 -6
  28. package/dist/codex-home.js +8 -9
  29. package/dist/codex-session-store.d.ts +2 -1
  30. package/dist/host.d.ts +34 -33
  31. package/dist/host.js +531 -91
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.js +1 -1
  34. package/dist/interactions.d.ts +61 -0
  35. package/dist/interactions.js +236 -0
  36. package/dist/models-catalog.d.ts +1 -1
  37. package/dist/models-catalog.js +1 -1
  38. package/dist/runner/child.d.ts +9 -1
  39. package/dist/runner/child.js +93 -15
  40. package/dist/runner/manager.d.ts +59 -10
  41. package/dist/runner/manager.js +385 -41
  42. package/dist/runner/protocol.d.ts +18 -7
  43. package/dist/runner-main.js +9 -6
  44. package/dist/runtime-status.js +1 -1
  45. package/dist/terminal/claude-tui.d.ts +8 -3
  46. package/dist/terminal/claude-tui.js +6 -2
  47. package/dist/terminal/codex-tui.d.ts +3 -3
  48. package/dist/terminal/codex-tui.js +1 -1
  49. package/dist/terminal/registry.d.ts +1 -1
  50. package/dist/terminal/registry.js +1 -1
  51. package/dist/terminal/tmux.d.ts +6 -6
  52. package/dist/terminal/tmux.js +10 -10
  53. package/package.json +3 -3
@@ -1,38 +1,976 @@
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
+ allowOther: question.isOther,
553
+ options: options.map((option) => ({
554
+ value: option.label,
555
+ label: option.label,
556
+ ...(option.description ? { description: option.description } : {}),
557
+ })),
558
+ };
559
+ }
560
+ return {
561
+ id: question.id,
562
+ type: "text",
563
+ label: question.question,
564
+ description: question.header || undefined,
565
+ required: true,
566
+ ...(question.isSecret ? { secret: true } : {}),
567
+ };
568
+ });
569
+ request = {
570
+ ...base,
571
+ kind: "question",
572
+ title: questions.length === 1 && questions[0]?.header
573
+ ? questions[0].header
574
+ : "Input required",
575
+ fields,
576
+ actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
577
+ };
578
+ break;
579
+ }
580
+ case "mcpServer/elicitation/request": {
581
+ const input = params;
582
+ const urlDescription = input.mode === "url" ? `${input.message}\n${input.url}` : input.message;
583
+ request = {
584
+ ...base,
585
+ kind: "form",
586
+ title: `${input.serverName || "MCP server"} requests input`,
587
+ description: urlDescription,
588
+ fields: input.mode === "url" ? [] : mcpFields(input.requestedSchema),
589
+ actions: [
590
+ {
591
+ id: "accept",
592
+ label: "Continue",
593
+ style: "primary",
594
+ requiresAnswers: input.mode !== "url",
595
+ },
596
+ { id: "decline", label: "Decline", style: "danger", requiresAnswers: false },
597
+ { id: "cancel", label: "Cancel", requiresAnswers: false },
598
+ ],
599
+ };
600
+ break;
601
+ }
602
+ case "item/permissions/requestApproval": {
603
+ const input = params;
604
+ const permissionSummary = exactJsonSummary("Requested permissions", input.permissions);
605
+ request = {
606
+ ...base,
607
+ kind: "permission",
608
+ title: "Additional permissions required",
609
+ description: input.reason ?? undefined,
610
+ fields: [],
611
+ actions: [
612
+ { id: "grant_turn", label: "Allow all for turn", style: "primary", requiresAnswers: false },
613
+ {
614
+ id: "grant_session",
615
+ label: "Allow, and don’t ask again for this session",
616
+ requiresAnswers: false,
617
+ },
618
+ { id: "decline", label: "Deny", style: "danger", requiresAnswers: false },
619
+ { id: "cancel", label: "Cancel", requiresAnswers: false },
620
+ ],
621
+ context: {
622
+ toolName: "permissions",
623
+ ...(input.cwd ? { cwd: input.cwd } : {}),
624
+ ...(permissionSummary ? { summary: permissionSummary } : {}),
625
+ },
626
+ };
627
+ break;
628
+ }
629
+ case "item/fileChange/requestApproval": {
630
+ const input = params;
631
+ request = {
632
+ ...base,
633
+ kind: "permission",
634
+ title: input.grantRoot ? `Write access under ${input.grantRoot}` : "Approve file changes",
635
+ description: boundedText(input.reason),
636
+ fields: [],
637
+ actions: permissionActions(),
638
+ context: {
639
+ toolName: "file_change",
640
+ ...(input.grantRoot ? { summary: `Write access under ${input.grantRoot}` } : {}),
641
+ },
642
+ };
643
+ break;
644
+ }
645
+ case "applyPatchApproval": {
646
+ const input = params;
647
+ const diff = legacyPatchPreview(input.fileChanges ?? {});
648
+ request = {
649
+ ...base,
650
+ kind: "permission",
651
+ title: input.grantRoot ? `Write access under ${input.grantRoot}` : "Approve file changes",
652
+ description: boundedText(input.reason),
653
+ fields: [],
654
+ actions: permissionActions(),
655
+ context: {
656
+ toolName: "file_change",
657
+ ...(input.grantRoot ? { summary: `Write access under ${input.grantRoot}` } : {}),
658
+ ...(diff ? { diff } : {}),
659
+ },
660
+ };
661
+ break;
662
+ }
663
+ case "item/commandExecution/requestApproval": {
664
+ const input = params;
665
+ const command = commandText(input.command);
666
+ const reason = boundedText(input.reason);
667
+ const summary = commandApprovalSummary(input);
668
+ request = {
669
+ ...base,
670
+ kind: "permission",
671
+ title: input.networkApprovalContext && !command
672
+ ? `Approve network access to ${input.networkApprovalContext.host}`
673
+ : "Approve command",
674
+ description: reason,
675
+ fields: [],
676
+ actions: commandDecisionActions(input).map((entry) => entry.action),
677
+ context: {
678
+ toolName: "command_execution",
679
+ ...(command ? { command } : {}),
680
+ ...(input.cwd ? { cwd: input.cwd } : {}),
681
+ ...(summary ? { summary } : {}),
682
+ },
683
+ };
684
+ break;
685
+ }
686
+ case "execCommandApproval": {
687
+ const input = params;
688
+ const command = commandText(input.command);
689
+ const reason = boundedText(input.reason);
690
+ request = {
691
+ ...base,
692
+ kind: "permission",
693
+ title: "Approve command",
694
+ description: reason,
695
+ fields: [],
696
+ actions: permissionActions(),
697
+ context: {
698
+ toolName: "command_execution",
699
+ ...(command ? { command } : {}),
700
+ ...(input.cwd ? { cwd: input.cwd } : {}),
701
+ ...(reason ? { summary: reason } : {}),
702
+ },
703
+ };
704
+ break;
705
+ }
706
+ }
707
+ const bounded = boundInteractionRequest(request);
708
+ if (!bounded.ok)
709
+ throw new InteractionRequestBoundsError(bounded.reason);
710
+ return { request: bounded.request, ...(turnId ? { turnId } : {}) };
711
+ }
712
+ function mcpContent(params, answers) {
713
+ if (params.mode === "url")
714
+ return {};
715
+ // Re-validate the native schema at resolution time. It remains untrusted
716
+ // provider input even though the same object was validated for rendering.
717
+ mcpFields(params.requestedSchema);
718
+ const schema = asRecord(params.requestedSchema);
719
+ const properties = asRecord(schema.properties);
720
+ const content = Object.create(null);
721
+ for (const [id, rawProperty] of Object.entries(properties)) {
722
+ const property = asRecord(rawProperty);
723
+ const answer = answers[id];
724
+ const value = answer === undefined && hasOwn(property, "default")
725
+ ? property.default
726
+ : answer;
727
+ if (value === undefined)
728
+ continue;
729
+ if (property.type === "boolean") {
730
+ if (value !== "true" && value !== "false" && typeof value !== "boolean") {
731
+ throw new Error(`MCP field ${id} must be true or false`);
732
+ }
733
+ content[id] = typeof value === "boolean" ? value : value === "true";
734
+ continue;
735
+ }
736
+ if (property.type === "number" || property.type === "integer") {
737
+ const number = typeof value === "number"
738
+ ? value
739
+ : typeof value === "string" && value.trim().length > 0
740
+ ? Number(value)
741
+ : Number.NaN;
742
+ if (!Number.isFinite(number))
743
+ throw new Error(`MCP field ${id} must be a finite number`);
744
+ if (property.type === "integer" && !Number.isInteger(number)) {
745
+ throw new Error(`MCP field ${id} must be an integer`);
746
+ }
747
+ const minimum = optionalFiniteNumber(property.minimum, `MCP field ${id}.minimum`);
748
+ const maximum = optionalFiniteNumber(property.maximum, `MCP field ${id}.maximum`);
749
+ if (minimum !== undefined && number < minimum) {
750
+ throw new Error(`MCP field ${id} must be at least ${minimum}`);
751
+ }
752
+ if (maximum !== undefined && number > maximum) {
753
+ throw new Error(`MCP field ${id} must be at most ${maximum}`);
754
+ }
755
+ content[id] = number;
756
+ continue;
757
+ }
758
+ if (property.type === "array") {
759
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
760
+ throw new Error(`MCP field ${id} must be a string array`);
761
+ }
762
+ if (new Set(value).size !== value.length) {
763
+ throw new Error(`MCP field ${id} must not contain duplicate selections`);
764
+ }
765
+ const items = asRecord(property.items);
766
+ const options = hasOwn(items, "anyOf")
767
+ ? titledOptions(items.anyOf, `MCP field ${id}.items.anyOf`)
768
+ : enumOptions(items.enum, undefined, `MCP field ${id}.items.enum`);
769
+ if (value.some((entry) => !options.some((option) => option.value === entry))) {
770
+ throw new Error(`MCP field ${id} contains an unsupported selection`);
771
+ }
772
+ const minItems = optionalNonNegativeInteger(property.minItems, `MCP field ${id}.minItems`);
773
+ const maxItems = optionalNonNegativeInteger(property.maxItems, `MCP field ${id}.maxItems`);
774
+ if (minItems !== undefined && value.length < minItems) {
775
+ throw new Error(`MCP field ${id} requires at least ${minItems} selections`);
776
+ }
777
+ if (maxItems !== undefined && value.length > maxItems) {
778
+ throw new Error(`MCP field ${id} allows at most ${maxItems} selections`);
779
+ }
780
+ content[id] = [...value];
781
+ continue;
782
+ }
783
+ if (property.type === "string") {
784
+ if (typeof value !== "string")
785
+ throw new Error(`MCP field ${id} must be a string`);
786
+ if (hasOwn(property, "oneOf")) {
787
+ const options = titledOptions(property.oneOf, `MCP field ${id}.oneOf`);
788
+ if (!options.some((option) => option.value === value)) {
789
+ throw new Error(`MCP field ${id} contains an unsupported selection`);
790
+ }
791
+ }
792
+ else if (hasOwn(property, "enum")) {
793
+ const options = enumOptions(property.enum, property.enumNames, `MCP field ${id}.enum`);
794
+ if (!options.some((option) => option.value === value)) {
795
+ throw new Error(`MCP field ${id} contains an unsupported selection`);
796
+ }
797
+ }
798
+ else {
799
+ validateStringValue(value, property, `MCP field ${id}`);
800
+ }
801
+ content[id] = value;
802
+ continue;
803
+ }
804
+ throw new Error(`MCP field ${id} uses an unsupported schema`);
805
+ }
806
+ return content;
807
+ }
808
+ function buildNativeInteractionResponse(pending, resolution) {
809
+ const invalid = validateInteractionResolution(pending.request, resolution);
810
+ if (invalid)
811
+ throw new Error(invalid);
812
+ const answers = resolution.answers ?? {};
813
+ switch (pending.method) {
814
+ case "item/tool/requestUserInput": {
815
+ const response = { answers: {} };
816
+ for (const [id, value] of Object.entries(answers)) {
817
+ response.answers[id] = { answers: Array.isArray(value) ? value : [value] };
818
+ }
819
+ return response;
820
+ }
821
+ case "mcpServer/elicitation/request": {
822
+ const action = resolution.actionId;
823
+ if (action !== "accept" && action !== "decline" && action !== "cancel") {
824
+ throw new Error(`unsupported MCP action: ${action}`);
825
+ }
826
+ const response = {
827
+ action,
828
+ content: action === "accept"
829
+ ? mcpContent(pending.params, answers)
830
+ : null,
831
+ _meta: null,
832
+ };
833
+ return response;
834
+ }
835
+ case "item/permissions/requestApproval": {
836
+ const input = pending.params;
837
+ const granted = resolution.actionId === "grant_turn" || resolution.actionId === "grant_session";
838
+ const permissions = {};
839
+ if (granted && input.permissions.network)
840
+ permissions.network = input.permissions.network;
841
+ if (granted && input.permissions.fileSystem)
842
+ permissions.fileSystem = input.permissions.fileSystem;
843
+ const response = {
844
+ permissions,
845
+ scope: resolution.actionId === "grant_session" ? "session" : "turn",
846
+ };
847
+ return response;
848
+ }
849
+ case "execCommandApproval":
850
+ case "applyPatchApproval": {
851
+ const response = {
852
+ decision: {
853
+ accept: "approved",
854
+ accept_session: "approved_for_session",
855
+ decline: "denied",
856
+ cancel: "abort",
857
+ }[resolution.actionId],
858
+ };
859
+ return response;
860
+ }
861
+ case "item/commandExecution/requestApproval": {
862
+ const input = pending.params;
863
+ const selected = commandDecisionActions(input).find((entry) => entry.action.id === resolution.actionId);
864
+ if (!selected)
865
+ throw new Error(`unsupported approval action: ${resolution.actionId}`);
866
+ const response = { decision: selected.decision };
867
+ return response;
868
+ }
869
+ case "item/fileChange/requestApproval": {
870
+ const decision = resolution.actionId === "accept_session"
871
+ ? "acceptForSession"
872
+ : resolution.actionId;
873
+ if (decision !== "accept" && decision !== "acceptForSession" && decision !== "decline" && decision !== "cancel") {
874
+ throw new Error(`unsupported approval action: ${resolution.actionId}`);
875
+ }
876
+ return { decision };
877
+ }
878
+ }
879
+ }
880
+ function automaticCommandDecision(params, desired) {
881
+ const entries = commandDecisionActions(params);
882
+ const simple = new Map(entries
883
+ .filter((entry) => typeof entry.decision === "string")
884
+ .map((entry) => [entry.decision, entry.decision]));
885
+ const preferences = desired === "acceptForSession"
886
+ ? ["acceptForSession", "accept", "cancel", "decline"]
887
+ : desired === "accept"
888
+ ? ["accept", "acceptForSession", "cancel", "decline"]
889
+ : desired === "decline"
890
+ ? ["decline", "cancel"]
891
+ : ["cancel", "decline"];
892
+ for (const preference of preferences) {
893
+ const decision = simple.get(preference);
894
+ if (decision)
895
+ return decision;
896
+ }
897
+ throw new UnsupportedInteractionSchemaError("command approval does not advertise a compatible automatic decision");
898
+ }
899
+ function buildAutomaticResponse(method, params, decision) {
900
+ switch (method) {
901
+ case "item/tool/requestUserInput":
902
+ return { answers: {} };
903
+ case "mcpServer/elicitation/request":
904
+ return {
905
+ action: "cancel",
906
+ content: null,
907
+ _meta: null,
908
+ };
909
+ case "item/permissions/requestApproval": {
910
+ const input = params;
911
+ const permissions = {};
912
+ if (decision === "acceptForSession") {
913
+ if (input.permissions.network)
914
+ permissions.network = input.permissions.network;
915
+ if (input.permissions.fileSystem)
916
+ permissions.fileSystem = input.permissions.fileSystem;
917
+ }
918
+ return {
919
+ permissions,
920
+ scope: decision === "acceptForSession" ? "session" : "turn",
921
+ };
922
+ }
923
+ case "execCommandApproval":
924
+ case "applyPatchApproval": {
925
+ const response = {
926
+ decision: decision === "acceptForSession"
927
+ ? "approved_for_session"
928
+ : decision === "accept"
929
+ ? "approved"
930
+ : decision === "decline"
931
+ ? "denied"
932
+ : "abort",
933
+ };
934
+ return response;
935
+ }
936
+ case "item/commandExecution/requestApproval":
937
+ return {
938
+ decision: automaticCommandDecision(params, decision),
939
+ };
940
+ case "item/fileChange/requestApproval":
941
+ return { decision };
942
+ }
943
+ }
944
+ const MAX_SETTLED_INTERACTIONS = 512;
11
945
  export class CodexAppServerClient {
12
946
  transport;
13
947
  logger;
14
948
  clientInfo;
15
949
  approvalDecisionPolicy;
16
- interactiveApprovals;
17
950
  channel;
18
951
  notificationSubscribers = new Set();
19
- approvalListener = null;
20
- pendingApprovals = new Map();
952
+ interactionListener = null;
953
+ connectionListener = null;
954
+ connectionState = "disconnected";
955
+ pendingInteractions = new Map();
956
+ settledInteractions = new Set();
21
957
  initializeResponse = null;
22
- constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", interactiveApprovals = false, }) {
958
+ constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", }) {
23
959
  this.logger = logger;
24
960
  this.clientInfo = clientInfo;
25
961
  this.approvalDecisionPolicy = approvalDecisionPolicy;
26
- this.interactiveApprovals = interactiveApprovals;
27
962
  this.channel = channel ?? null;
28
963
  this.transport = new CodexAppServerTransport({
29
964
  ...(channel ? { channel } : { spawner }),
30
965
  logger,
31
966
  onNotification: (method, params) => this.dispatchNotification(method, params),
32
- onServerRequest: (method, params) => this.handleServerRequest(method, params),
967
+ onServerRequest: (method, params, requestId) => this.handleServerRequest(method, params, requestId),
968
+ onServerRequestResponseDelivery: (requestId, result) => this.handleServerRequestResponseDelivery(requestId, result),
33
969
  });
34
970
  this.transport.on("exit", () => {
35
971
  this.initializeResponse = null;
972
+ this.cancelPendingInteractions("app_server_disconnected");
973
+ this.setConnectionState("disconnected");
36
974
  });
37
975
  }
38
976
  /**
@@ -55,6 +993,7 @@ export class CodexAppServerClient {
55
993
  capabilities: { experimentalApi: true },
56
994
  });
57
995
  this.initializeResponse = response;
996
+ this.setConnectionState("connected");
58
997
  return response;
59
998
  }
60
999
  async getAuthStatus(params = {}) {
@@ -148,6 +1087,20 @@ export class CodexAppServerClient {
148
1087
  await this.transport.stop();
149
1088
  }
150
1089
  dispatchNotification(method, params) {
1090
+ if (method === "serverRequest/resolved") {
1091
+ const p = asRecord(params);
1092
+ const nativeRequestId = p?.requestId;
1093
+ if (typeof nativeRequestId === "string" || typeof nativeRequestId === "number") {
1094
+ this.resolveByProvider(nativeRequestId, stringValue(p?.threadId));
1095
+ }
1096
+ }
1097
+ else if (method === "turn/completed") {
1098
+ const p = asRecord(params);
1099
+ const turn = asRecord(p?.turn);
1100
+ const turnId = stringValue(turn?.id) ?? stringValue(p?.turnId);
1101
+ if (turnId)
1102
+ this.cancelPendingInteractions("turn_completed", turnId);
1103
+ }
151
1104
  for (const listener of this.notificationSubscribers) {
152
1105
  try {
153
1106
  listener(method, params);
@@ -161,19 +1114,16 @@ export class CodexAppServerClient {
161
1114
  }
162
1115
  }
163
1116
  }
164
- async handleServerRequest(method, params) {
1117
+ async handleServerRequest(method, params, requestId) {
165
1118
  switch (method) {
166
1119
  case "item/commandExecution/requestApproval":
167
1120
  case "execCommandApproval":
168
- return this.handleApproval("exec", params);
169
1121
  case "item/fileChange/requestApproval":
170
1122
  case "applyPatchApproval":
171
- return this.handleApproval("patch", params);
172
1123
  case "item/tool/requestUserInput":
173
1124
  case "mcpServer/elicitation/request":
174
- return this.respondNotSupported(method);
175
1125
  case "item/permissions/requestApproval":
176
- return { decision: this.declineDecisionForApprovals() };
1126
+ return this.handleInteraction(method, params, requestId);
177
1127
  case "account/chatgptAuthTokens/refresh":
178
1128
  // Codex asks the host to refresh tokens; without an attached UI the
179
1129
  // best we can do is decline, surfacing the auth error to the caller
@@ -188,66 +1138,96 @@ export class CodexAppServerClient {
188
1138
  }
189
1139
  }
190
1140
  /**
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).
1141
+ * Register the provider-neutral interaction listener. A request is inserted
1142
+ * into the pending map before the listener is invoked, so a synchronous
1143
+ * resolver still wins correctly.
194
1144
  */
195
- setApprovalRequestListener(listener) {
196
- this.approvalListener = listener;
1145
+ setInteractionListener(listener) {
1146
+ this.interactionListener = listener;
197
1147
  }
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);
1148
+ /** Observe the underlying app-server connection independently from any one
1149
+ * interaction. A disconnected client that never received the duplicate
1150
+ * native request still has to count as unavailable during host failover. */
1151
+ setConnectionListener(listener) {
1152
+ this.connectionListener = listener;
1153
+ listener?.(this.connectionState);
1154
+ }
1155
+ setConnectionState(state) {
1156
+ if (this.connectionState === state)
1157
+ return;
1158
+ this.connectionState = state;
1159
+ this.connectionListener?.(state);
1160
+ }
1161
+ resolveInteraction(interactionId, resolution) {
1162
+ const pending = this.pendingInteractions.get(interactionId);
204
1163
  if (!pending) {
205
- return false;
1164
+ return this.settledInteractions.has(interactionId)
1165
+ ? { disposition: "already_resolved" }
1166
+ : { disposition: "not_found" };
1167
+ }
1168
+ if (pending.submission)
1169
+ return { disposition: "already_resolved" };
1170
+ let nativeResponse;
1171
+ try {
1172
+ nativeResponse = buildNativeInteractionResponse(pending, resolution);
1173
+ }
1174
+ catch (error) {
1175
+ return {
1176
+ disposition: "invalid",
1177
+ message: error instanceof Error ? error.message : String(error),
1178
+ };
206
1179
  }
207
- clearTimeout(pending.timer);
208
- this.pendingApprovals.delete(approvalId);
209
- pending.resolve(decision);
210
- return true;
1180
+ pending.submission = {
1181
+ resolution: redactInteractionResolution(pending.request, resolution),
1182
+ };
1183
+ pending.resolve(nativeResponse);
1184
+ return { disposition: "applied" };
211
1185
  }
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;
1186
+ /** Cancel every pending request owned by this connection without replying. */
1187
+ cancelInteractions(reason = "client_stopped") {
1188
+ this.cancelPendingInteractions(reason);
1189
+ }
1190
+ async handleInteraction(method, params, nativeRequestId) {
1191
+ let adapted;
233
1192
  try {
234
- listener(request);
1193
+ adapted = buildCodexInteraction(method, params, nativeRequestId);
235
1194
  }
236
1195
  catch (error) {
237
- this.logger.log({
238
- event: "client.approval_listener_failed",
239
- error: error instanceof Error ? error.message : String(error),
240
- });
1196
+ if (error instanceof InteractionRequestBoundsError ||
1197
+ error instanceof UnsupportedInteractionSchemaError) {
1198
+ this.logger.log({
1199
+ event: "client.interaction_request_rejected",
1200
+ method,
1201
+ reason: error.message,
1202
+ });
1203
+ return buildAutomaticResponse(method, params, "cancel");
1204
+ }
1205
+ throw error;
241
1206
  }
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 });
1207
+ const listener = this.interactionListener;
1208
+ // Provider policy decides whether an approval request exists. Once it does,
1209
+ // an installed generic listener must see it; only standalone/no-listener
1210
+ // clients use the automatic fallback.
1211
+ if (!listener) {
1212
+ return buildAutomaticResponse(method, params, this.autoApprovalDecision());
1213
+ }
1214
+ return new Promise((resolve) => {
1215
+ const pending = {
1216
+ method,
1217
+ nativeRequestId,
1218
+ params,
1219
+ request: adapted.request,
1220
+ ...(adapted.turnId ? { turnId: adapted.turnId } : {}),
1221
+ resolve,
1222
+ };
1223
+ // Register before notifying: the listener is allowed to answer inline.
1224
+ this.pendingInteractions.set(adapted.request.interactionId, pending);
1225
+ this.emitInteraction({
1226
+ type: "requested",
1227
+ request: adapted.request,
1228
+ ...(adapted.turnId ? { turnId: adapted.turnId } : {}),
1229
+ });
249
1230
  });
250
- return { decision };
251
1231
  }
252
1232
  autoApprovalDecision() {
253
1233
  switch (this.approvalDecisionPolicy) {
@@ -260,45 +1240,94 @@ export class CodexAppServerClient {
260
1240
  return "cancel";
261
1241
  }
262
1242
  }
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" };
1243
+ resolveByProvider(nativeRequestId, threadId) {
1244
+ for (const [interactionId, pending] of this.pendingInteractions) {
1245
+ const pendingParams = asRecord(pending.params);
1246
+ const pendingThreadId = stringValue(pendingParams?.threadId) ??
1247
+ stringValue(pendingParams?.conversationId);
1248
+ if (requestIdKey(pending.nativeRequestId) !== requestIdKey(nativeRequestId) ||
1249
+ (threadId && pendingThreadId && threadId !== pendingThreadId)) {
1250
+ continue;
1251
+ }
1252
+ this.pendingInteractions.delete(interactionId);
1253
+ this.rememberSettled(interactionId);
1254
+ if (pending.submission) {
1255
+ this.emitInteraction({
1256
+ type: "resolved",
1257
+ interactionId,
1258
+ resolution: pending.submission.resolution,
1259
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1260
+ });
1261
+ }
1262
+ else {
1263
+ // This connection never submitted a response, so another app-server
1264
+ // client resolved the request. Do not reply to the stale native request.
1265
+ pending.resolve(NO_SERVER_RESPONSE);
1266
+ this.emitInteraction({
1267
+ type: "cancelled",
1268
+ interactionId,
1269
+ reason: "resolved_by_another_client",
1270
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1271
+ });
1272
+ }
1273
+ return;
272
1274
  }
273
1275
  }
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" };
1276
+ handleServerRequestResponseDelivery(nativeRequestId, result) {
1277
+ for (const [interactionId, pending] of this.pendingInteractions) {
1278
+ if (requestIdKey(pending.nativeRequestId) !== requestIdKey(nativeRequestId))
1279
+ continue;
1280
+ if (!pending.submission)
1281
+ return;
1282
+ if (result.delivered)
1283
+ return;
1284
+ this.pendingInteractions.delete(interactionId);
1285
+ this.rememberSettled(interactionId);
1286
+ this.emitInteraction({
1287
+ type: "cancelled",
1288
+ interactionId,
1289
+ reason: "response_write_failed",
1290
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1291
+ });
1292
+ return;
283
1293
  }
284
1294
  }
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";
1295
+ cancelPendingInteractions(reason, turnId) {
1296
+ for (const [interactionId, pending] of [...this.pendingInteractions]) {
1297
+ // Some provider requests omit turnId (notably MCP URL/form variants).
1298
+ // Codex has one active Turn per thread, so an unknown correlation must be
1299
+ // cancelled by its completion instead of leaking forever.
1300
+ if (turnId && pending.turnId && pending.turnId !== turnId)
1301
+ continue;
1302
+ this.pendingInteractions.delete(interactionId);
1303
+ this.rememberSettled(interactionId);
1304
+ pending.resolve(NO_SERVER_RESPONSE);
1305
+ this.emitInteraction({
1306
+ type: "cancelled",
1307
+ interactionId,
1308
+ reason,
1309
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
1310
+ });
294
1311
  }
295
1312
  }
296
- respondNotSupported(method) {
297
- this.logger.log({
298
- event: "client.server_request_unsupported",
299
- method,
300
- });
301
- return null;
1313
+ emitInteraction(event) {
1314
+ try {
1315
+ this.interactionListener?.(event);
1316
+ }
1317
+ catch (error) {
1318
+ this.logger.log({
1319
+ event: "client.interaction_listener_failed",
1320
+ error: error instanceof Error ? error.message : String(error),
1321
+ });
1322
+ }
1323
+ }
1324
+ rememberSettled(interactionId) {
1325
+ this.settledInteractions.add(interactionId);
1326
+ if (this.settledInteractions.size <= MAX_SETTLED_INTERACTIONS)
1327
+ return;
1328
+ const oldest = this.settledInteractions.values().next().value;
1329
+ if (oldest)
1330
+ this.settledInteractions.delete(oldest);
302
1331
  }
303
1332
  }
304
1333
  /**