@parall/codex-agent 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import * as os from 'node:os';
4
4
  import {
5
5
  ParallAgentGateway,
6
6
  capabilityBinDir,
7
+ configureHttpKeepAlive,
7
8
  createPlatformConfigManager,
8
9
  createLogger,
9
10
  createOtelLogger,
@@ -68,6 +69,8 @@ function resolveProviderEnv(): void {
68
69
  }
69
70
 
70
71
  async function main() {
72
+ // Before any fetch: long-lived HTTP connections for every bridge→api call.
73
+ configureHttpKeepAlive();
71
74
  const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex');
72
75
  activeLog = createOtelLogger('agent', 'codex-agent');
73
76
  try {
@@ -205,13 +208,13 @@ async function main() {
205
208
  //
206
209
  // What each half of the refresh actually reaches: the shim dir on PATH
207
210
  // makes the granted TOOL work on the agent's next shell command, live,
208
- // no restart needed. The refreshed PROMPT reaches the next thread that
209
- // gets STARTED codex bakes developerInstructions into a thread at
210
- // thread/start and neither resume nor fork replaces them (live-probed on
211
- // 0.144.1; the retired workspace-config channel had the same limitation).
212
- // So an agent with a long-lived persisted thread keeps the older fragment
213
- // text in its prompt until that thread is replaced
214
- // docs/tech-debt/codex-persisted-thread-prompt-refresh.md.
211
+ // no restart needed. The refreshed PROMPT reaches (a) every thread
212
+ // started from here on (thread/start bakes it in), and (b) the
213
+ // persisted main thread on its next safe dispatch: the restart below
214
+ // makes the next open a fresh-process thread/resume, which applies the
215
+ // new value to the thread's canonical configuration, and the adapter
216
+ // then compacts the thread so the model-visible context is rebuilt from
217
+ // it (two-plane semantics: src/instructions-refresh.ts).
215
218
  const fragments = applyChannelCapabilities();
216
219
  const joinedFragments = fragments.join('\n\n');
217
220
  let promptWritten = true;
@@ -0,0 +1,367 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { GatewayLogger } from '@parall/agent-core';
3
+ import { extractThreadIdFromNotification } from './app-server-protocol.js';
4
+ import {
5
+ JSON_RPC_METHOD_NOT_FOUND,
6
+ JsonRpcError,
7
+ type JsonRpcStdioClient,
8
+ } from './jsonrpc-client.js';
9
+ import type { CodexSessionManager } from './session-manager.js';
10
+
11
+ /**
12
+ * Convergence of platform `developerInstructions` onto the persisted main
13
+ * thread. codex 0.144.1 keeps the instructions on two planes (verified by
14
+ * source reading of rust-v0.144.1 plus a raw-request probe against the
15
+ * pinned CLI with a mock Responses API):
16
+ *
17
+ * - CANONICAL — the session configuration. A `thread/resume` in a process
18
+ * where the thread is not already running DOES apply the
19
+ * `developerInstructions` param to the reconstructed session (a resume of
20
+ * a still-running thread ignores every override and logs a mismatch).
21
+ * - EFFECTIVE — what the model actually sees: the initial-context developer
22
+ * message living in conversation history. An ordinary post-resume turn
23
+ * does NOT re-emit it (`TurnContextItem` does not carry instructions and
24
+ * the settings-update diff does not cover them), so after a resume with
25
+ * new instructions the model keeps seeing the old text.
26
+ *
27
+ * Compaction (`thread/compact/start`, also the auto-compaction path) is the
28
+ * official convergence point: it rebuilds the initial context from the
29
+ * CANONICAL configuration into the replacement history — after which every
30
+ * turn, restart, resume, and further compaction carries the new instructions,
31
+ * and exactly one instruction block exists (rebuild, not append).
32
+ *
33
+ * So the refresh recipe is: get canonical right (the existing lazy-restart →
34
+ * fresh-process `thread/resume` chain already sends the current instructions),
35
+ * then trigger one explicit compaction when the thread's last-known EFFECTIVE
36
+ * instructions differ. The session state file remembers the sha256 of the
37
+ * effective instructions: recorded when a thread is STARTED (baking makes
38
+ * them effective immediately) and after a compaction completes — never on
39
+ * resume alone, which is precisely the plane it does not touch.
40
+ *
41
+ * Failure posture (at-least-once, never exactly-once): a refresh that did not
42
+ * complete is never recorded, so the next dispatch retries. A cleanly FAILED
43
+ * compaction (turn closed: error, interrupt honored, subprocess died) does
44
+ * not block the current turn — canonical is already current after the
45
+ * resume, so it degrades to "old text until the next retry or organic
46
+ * compaction". The ONE exception is 'stalled': the compaction turn ignored
47
+ * the interrupt past the grace and may still be running, so dispatch() must
48
+ * not race turn/start into the busy thread (a rejection there would look
49
+ * like a stale thread and rotate it) — it keeps the persisted thread,
50
+ * bounces the subprocess, and errors THIS dispatch for ledger redrive onto a
51
+ * clean process. Continuity outranks single-dispatch availability. A CLI
52
+ * without `thread/compact/start` (JSON-RPC -32601) disables further attempts
53
+ * for the subprocess lifetime (a CLI upgrade implies a respawn) and keeps
54
+ * the sha unrecorded so a capable CLI converges later.
55
+ */
56
+
57
+ export type NotificationTap = (method: string, params: unknown) => void;
58
+
59
+ export interface NotificationTapSource {
60
+ /** Register a listener for every server notification; returns unregister. */
61
+ addNotificationTap(tap: NotificationTap): () => void;
62
+ }
63
+
64
+ export function sha256Hex(text: string): string {
65
+ return createHash('sha256').update(text, 'utf8').digest('hex');
66
+ }
67
+
68
+ const DEFAULT_COMPACT_TIMEOUT_MS = 120_000;
69
+ /** After an over-budget compaction is interrupted, how long to wait for its turn to close. */
70
+ const COMPACT_INTERRUPT_GRACE_MS = 10_000;
71
+
72
+ export type RefreshOutcome =
73
+ | 'noop'
74
+ | 'adopted-baseline'
75
+ | 'refreshed'
76
+ | 'unsupported'
77
+ | 'failed'
78
+ // The compaction turn never closed inside budget + interrupt grace — it
79
+ // may STILL be running on the thread. dispatch() must not let a busy-thread
80
+ // turn/start rejection rotate the persisted main thread in this state.
81
+ | 'stalled';
82
+
83
+ class CompactionStalledError extends Error {}
84
+
85
+ export class MainThreadInstructionsRefresher {
86
+ /**
87
+ * Set when this subprocess rejected thread/compact/start with
88
+ * method-not-found (an older CLI). Reset on every spawn via
89
+ * resetForNewSubprocess() — a CLI upgrade implies a respawn, so each
90
+ * subprocess gets exactly one probe.
91
+ */
92
+ private compactUnsupported = false;
93
+
94
+ constructor(
95
+ private readonly opts: {
96
+ sessionManager: Pick<
97
+ CodexSessionManager,
98
+ 'getEffectiveInstructionsSha' | 'recordEffectiveInstructionsSha' | 'recordStartedThread'
99
+ >;
100
+ log?: GatewayLogger;
101
+ compactTimeoutMs?: number;
102
+ /** Test knob for the post-interrupt grace (default 10s). */
103
+ interruptGraceMs?: number;
104
+ },
105
+ ) {}
106
+
107
+ /**
108
+ * Instructions each thread was opened WITH in this process — its live
109
+ * CANONICAL configuration (same-tick capture of the thread/start /
110
+ * thread/resume param). Process-local: the adapter clears it whenever the
111
+ * subprocess goes away (clearThreadState), because a canonical fact only
112
+ * describes a thread loaded in the CURRENT app-server.
113
+ */
114
+ private readonly canonicalByThread = new Map<string, string | undefined>();
115
+
116
+ resetForNewSubprocess(): void {
117
+ this.compactUnsupported = false;
118
+ }
119
+
120
+ /** Canonical facts die with the subprocess that held the threads. */
121
+ clearThreadState(): void {
122
+ this.canonicalByThread.clear();
123
+ }
124
+
125
+ /**
126
+ * Thread opened via thread/start: instructions are baked into the initial
127
+ * context, so canonical AND effective converge the moment the start
128
+ * succeeds — no compaction involved. Thread id and effective sha persist in
129
+ * ONE state-file write: a crash between two separate writes would be
130
+ * indistinguishable from a legacy file, and legacy state is deliberately
131
+ * adopted without compaction.
132
+ */
133
+ recordBaked(sessionKey: string, threadId: string, instructions: string | undefined): void {
134
+ this.canonicalByThread.set(threadId, instructions);
135
+ this.opts.sessionManager.recordStartedThread(
136
+ sessionKey,
137
+ threadId,
138
+ instructions ? sha256Hex(instructions) : undefined,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Thread opened via thread/resume: a fresh-process resume applies the
144
+ * param to the CANONICAL configuration only — the effective plane is
145
+ * reconciled separately, never recorded here.
146
+ */
147
+ recordResumed(threadId: string, instructions: string | undefined): void {
148
+ this.canonicalByThread.set(threadId, instructions);
149
+ }
150
+
151
+ /** What the thread's live canonical configuration carries, if opened here. */
152
+ canonicalFor(threadId: string): string | undefined {
153
+ return this.canonicalByThread.get(threadId);
154
+ }
155
+
156
+ /**
157
+ * Converge the EFFECTIVE plane after the thread is open in this process.
158
+ * Compares against the thread's recorded canonical value — the dispatch
159
+ * guard has already bounced the subprocess if the desired instructions
160
+ * changed after the open, so canonical is current by the time this runs.
161
+ */
162
+ async reconcileAfterOpen(args: {
163
+ client: JsonRpcStdioClient;
164
+ taps: NotificationTapSource;
165
+ sessionKey: string;
166
+ threadId: string;
167
+ log?: GatewayLogger;
168
+ }): Promise<RefreshOutcome> {
169
+ const { client, taps, sessionKey, threadId } = args;
170
+ const log = args.log ?? this.opts.log;
171
+ const sentInstructions = this.canonicalByThread.get(threadId);
172
+ if (!sentInstructions) return 'noop';
173
+ const canonicalSha = sha256Hex(sentInstructions);
174
+ const effectiveSha = this.opts.sessionManager.getEffectiveInstructionsSha(sessionKey);
175
+ if (effectiveSha === canonicalSha) return 'noop';
176
+ if (effectiveSha === undefined) {
177
+ // State file predates effective-plane tracking (recordBaked persists
178
+ // thread id + sha in one atomic write, so a crash cannot manufacture
179
+ // this state for a tracked thread). Adopt the current value as the
180
+ // baseline WITHOUT forcing a compaction: rolling this feature out
181
+ // must not compress every existing thread. A staleness inherited from
182
+ // the pre-tracking era converges at the next instructions change or at
183
+ // the next organic compaction (canonical is already current by then).
184
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
185
+ log?.info?.(
186
+ `adopted current platform instructions as effective baseline for thread ${threadId} (no prior record)`,
187
+ );
188
+ return 'adopted-baseline';
189
+ }
190
+ if (this.compactUnsupported) return 'unsupported';
191
+ // The waiter registers its notification tap BEFORE the request goes out:
192
+ // the response and the compaction-turn notifications can arrive in one
193
+ // stdout chunk, and the client's line loop dispatches notifications
194
+ // synchronously — a tap registered only after `await sendRequest` resolves
195
+ // (a queued microtask) would miss every one of them and hang on the
196
+ // timeout.
197
+ const compaction = this.watchForCompaction(client, taps, threadId);
198
+ try {
199
+ await client.sendRequest('thread/compact/start', { threadId });
200
+ await compaction.done;
201
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
202
+ log?.info?.(
203
+ `platform instructions refreshed on persisted thread ${threadId} (compaction rebuilt initial context from the resumed configuration)`,
204
+ );
205
+ return 'refreshed';
206
+ } catch (err) {
207
+ compaction.cancel();
208
+ if (err instanceof CompactionStalledError) {
209
+ log?.warn?.(
210
+ `platform instructions refresh stalled (${errToString(err)}); bouncing the subprocess before the next turn`,
211
+ );
212
+ return 'stalled';
213
+ }
214
+ if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
215
+ this.compactUnsupported = true;
216
+ log?.warn?.(
217
+ 'thread/compact/start not supported by this codex CLI; the persisted thread keeps its previous platform instructions until it is replaced or the CLI is upgraded (tools still refresh live via the capability shim dir)',
218
+ );
219
+ return 'unsupported';
220
+ }
221
+ log?.warn?.(
222
+ `platform instructions refresh did not complete (will retry next dispatch; the resumed configuration already carries the new value): ${errToString(err)}`,
223
+ );
224
+ return 'failed';
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Compaction runs as its own turn on the thread:
230
+ * turn/started → item/started{contextCompaction} →
231
+ * item/completed{contextCompaction} → turn/completed{status}
232
+ * (wire sequence captured against the pinned 0.144.1 CLI). Success = the
233
+ * contextCompaction item completed AND the turn closed; a turn that closes
234
+ * without the item having completed is a failure. Waiting for turn close —
235
+ * not just the item — keeps the next turn/start from racing into a thread
236
+ * that is still finishing the compaction turn.
237
+ *
238
+ * Timeout does NOT simply return: past the budget the compaction turn may
239
+ * still be RUNNING, and handing control back would let dispatch() issue
240
+ * turn/start against a busy thread — a rejection there is treated as a
241
+ * stale thread and would ROTATE the persisted main thread (continuity
242
+ * loss). Instead the watcher interrupts the compaction turn best-effort
243
+ * and holds a short grace for it to close. A compaction that completes
244
+ * during the grace still counts as success (late, but the effective plane
245
+ * DID converge). Residual: a server that has not even emitted
246
+ * turn/started by the deadline leaves nothing to interrupt — pathological,
247
+ * and the grace still absorbs a late-materializing close.
248
+ */
249
+ private watchForCompaction(
250
+ client: JsonRpcStdioClient,
251
+ taps: NotificationTapSource,
252
+ threadId: string,
253
+ ): { done: Promise<void>; cancel: () => void } {
254
+ const timeoutMs = this.opts.compactTimeoutMs ?? DEFAULT_COMPACT_TIMEOUT_MS;
255
+ const graceMs = this.opts.interruptGraceMs ?? COMPACT_INTERRUPT_GRACE_MS;
256
+ let cancel = () => {};
257
+ const done = new Promise<void>((resolve, reject) => {
258
+ let itemCompleted = false;
259
+ let compactionTurnId: string | undefined;
260
+ let interrupted = false;
261
+ let settled = false;
262
+ let unregister = () => {};
263
+ let graceTimer: NodeJS.Timeout | undefined;
264
+ const finish = (err?: Error) => {
265
+ if (settled) return;
266
+ settled = true;
267
+ clearTimeout(timer);
268
+ if (graceTimer) clearTimeout(graceTimer);
269
+ unregister();
270
+ if (err) reject(err);
271
+ else resolve();
272
+ };
273
+ const timer = setTimeout(() => {
274
+ interrupted = true;
275
+ if (compactionTurnId) {
276
+ // Best-effort and non-lethal: an unanswered interrupt must expire
277
+ // with the grace window, not arm the client's default assume-hung
278
+ // timeout into killing the subprocess minutes later mid-something.
279
+ client
280
+ .sendRequest(
281
+ 'turn/interrupt',
282
+ { threadId, turnId: compactionTurnId },
283
+ { timeoutMs: graceMs, lethalTimeout: false },
284
+ )
285
+ .catch(() => {});
286
+ }
287
+ graceTimer = setTimeout(
288
+ () =>
289
+ finish(
290
+ new CompactionStalledError(
291
+ `compaction did not complete within ${timeoutMs}ms (interrupt grace elapsed; the compaction turn may still be running)`,
292
+ ),
293
+ ),
294
+ graceMs,
295
+ );
296
+ }, timeoutMs);
297
+ // Cancellation resolves (never rejects): the caller cancels only when
298
+ // the request itself already failed, and that error is what it reports.
299
+ cancel = () => finish();
300
+ unregister = taps.addNotificationTap((method, params) => {
301
+ const notificationThreadId = extractThreadIdFromNotification(params);
302
+ if (method === 'error') {
303
+ // A thread-less error is a global one — including the adapter's
304
+ // synthetic subprocess-disposal broadcast. No further compaction
305
+ // notifications can arrive after that; waiting out the timeout
306
+ // would stall the dispatch for the full budget on a dead client.
307
+ if (notificationThreadId === undefined || notificationThreadId === threadId) {
308
+ const msg = (params as { message?: unknown })?.message;
309
+ finish(new Error(`app-server error during compaction: ${String(msg ?? 'unknown')}`));
310
+ }
311
+ return;
312
+ }
313
+ if (notificationThreadId !== threadId) return;
314
+ if (method === 'turn/started') {
315
+ compactionTurnId = turnIdOf(params) ?? compactionTurnId;
316
+ return;
317
+ }
318
+ if (method === 'item/completed' && itemType(params) === 'contextCompaction') {
319
+ itemCompleted = true;
320
+ return;
321
+ }
322
+ if (method === 'turn/completed') {
323
+ const status = turnStatus(params);
324
+ if (itemCompleted && status !== 'failed') finish();
325
+ else
326
+ finish(
327
+ new Error(
328
+ interrupted
329
+ ? `compaction did not complete within ${timeoutMs}ms (turn closed after interrupt)`
330
+ : `compaction turn ended without completing (status=${status ?? 'unknown'})`,
331
+ ),
332
+ );
333
+ }
334
+ });
335
+ });
336
+ // Detached-consumer guard: if the subprocess dies while the
337
+ // thread/compact/start REQUEST is still in flight, the disposal
338
+ // broadcast rejects this waiter before reconcileAfterOpen ever awaits
339
+ // it — and its catch path only cancels, it never consumes `done`. An
340
+ // unconsumed rejection would crash the bridge (Node's default
341
+ // unhandled-rejection behavior) on the exact path that is supposed to
342
+ // degrade and retry. The extra consumer does not affect the success
343
+ // path's own await.
344
+ done.catch(() => {});
345
+ return { done, cancel };
346
+ }
347
+ }
348
+
349
+ function itemType(params: unknown): string | undefined {
350
+ const item = (params as { item?: { type?: unknown } })?.item;
351
+ return typeof item?.type === 'string' ? item.type : undefined;
352
+ }
353
+
354
+ function turnStatus(params: unknown): string | undefined {
355
+ const turn = (params as { turn?: { status?: unknown } })?.turn;
356
+ return typeof turn?.status === 'string' ? turn.status : undefined;
357
+ }
358
+
359
+ function turnIdOf(params: unknown): string | undefined {
360
+ const turn = (params as { turn?: { id?: unknown } })?.turn;
361
+ return typeof turn?.id === 'string' ? turn.id : undefined;
362
+ }
363
+
364
+ function errToString(err: unknown): string {
365
+ if (err instanceof Error) return err.message;
366
+ return String(err);
367
+ }
@@ -24,6 +24,47 @@ export type JsonRpcResponse = {
24
24
 
25
25
  export type NotificationHandler = (method: string, params: unknown) => void;
26
26
 
27
+ /**
28
+ * A handled server request's response payload. The wrapper (rather than a
29
+ * bare `unknown`) makes the "undefined = unhandled" sentinel explicit in the
30
+ * type — `unknown | undefined` would collapse and hide the contract.
31
+ *
32
+ * A `result` of `undefined` is normalized to `null` on the wire: JSON.stringify
33
+ * DROPS an undefined member, which would emit a frame carrying neither
34
+ * `result` nor `error` — the app-server may ignore such a frame and keep
35
+ * waiting, parking the turn (the exact failure this handler exists to
36
+ * prevent). `null` is a valid JSON-RPC success result.
37
+ */
38
+ export type ServerRequestAnswer = { result: unknown } | undefined;
39
+
40
+ /**
41
+ * Answers a server→client request. Return `{ result }` to respond, or
42
+ * `undefined` to have the client reply with a method-not-found error. A
43
+ * request must never go unanswered — the app-server blocks its turn until a
44
+ * response arrives, so a dropped request parks that turn forever.
45
+ */
46
+ export type ServerRequestHandler = (method: string, params: unknown) => ServerRequestAnswer;
47
+
48
+ /** JSON-RPC 2.0 spec code for "Method not found". */
49
+ export const JSON_RPC_METHOD_NOT_FOUND = -32601;
50
+
51
+ /**
52
+ * Rejection error that preserves the JSON-RPC error object's code (and data),
53
+ * so callers can branch on protocol-level conditions — e.g. method-not-found
54
+ * on an older CLI — without matching on server message text, whose wording
55
+ * shifts between codex versions.
56
+ */
57
+ export class JsonRpcError extends Error {
58
+ constructor(
59
+ readonly code: number,
60
+ message: string,
61
+ readonly data?: unknown,
62
+ ) {
63
+ super(message || 'JSON-RPC error');
64
+ this.name = 'JsonRpcError';
65
+ }
66
+ }
67
+
27
68
  type Pending = {
28
69
  resolve: (result: unknown) => void;
29
70
  reject: (err: Error) => void;
@@ -46,6 +87,7 @@ export class JsonRpcStdioClient {
46
87
  private readonly pending = new Map<JsonRpcId, Pending>();
47
88
  private buffer = '';
48
89
  private onNotification: NotificationHandler | null = null;
90
+ private onServerRequest: ServerRequestHandler | null = null;
49
91
  private disposed = false;
50
92
 
51
93
  constructor(
@@ -64,10 +106,28 @@ export class JsonRpcStdioClient {
64
106
  this.onNotification = handler;
65
107
  }
66
108
 
67
- sendRequest(method: string, params?: unknown): Promise<unknown> {
109
+ setServerRequestHandler(handler: ServerRequestHandler) {
110
+ this.onServerRequest = handler;
111
+ }
112
+
113
+ /**
114
+ * `options.timeoutMs` overrides the client-wide budget for this request.
115
+ * `options.lethalTimeout: false` makes a timeout reject WITHOUT killing the
116
+ * subprocess — for best-effort side requests (e.g. the compaction watcher's
117
+ * turn/interrupt) where "no answer" must not translate into a delayed
118
+ * subprocess kill landing on an unrelated later dispatch. Default (true)
119
+ * keeps the existing assume-hung semantics.
120
+ */
121
+ sendRequest(
122
+ method: string,
123
+ params?: unknown,
124
+ options?: { timeoutMs?: number; lethalTimeout?: boolean },
125
+ ): Promise<unknown> {
68
126
  if (this.disposed) {
69
127
  return Promise.reject(new Error('JSON-RPC client disposed'));
70
128
  }
129
+ const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
130
+ const lethalTimeout = options?.lethalTimeout ?? true;
71
131
  const id = this.nextId++;
72
132
  const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params };
73
133
  const promise = new Promise<unknown>((resolve, reject) => {
@@ -75,11 +135,11 @@ export class JsonRpcStdioClient {
75
135
  const pending = this.pending.get(id);
76
136
  if (!pending) return;
77
137
  this.pending.delete(id);
78
- reject(
79
- new Error(`JSON-RPC request "${method}" timed out after ${this.requestTimeoutMs}ms`),
80
- );
81
- this.killUnhealthy(new Error(`request "${method}" timed out; subprocess assumed hung`));
82
- }, this.requestTimeoutMs);
138
+ reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
139
+ if (lethalTimeout) {
140
+ this.killUnhealthy(new Error(`request "${method}" timed out; subprocess assumed hung`));
141
+ }
142
+ }, timeoutMs);
83
143
  this.pending.set(id, { resolve, reject, timer });
84
144
  });
85
145
  this.writeFrame(request);
@@ -150,23 +210,65 @@ export class JsonRpcStdioClient {
150
210
  this.pending.delete(message.id);
151
211
  clearTimeout(pending.timer);
152
212
  if (message.error) {
153
- pending.reject(new Error(message.error.message || 'JSON-RPC error'));
213
+ pending.reject(
214
+ new JsonRpcError(message.error.code, message.error.message, message.error.data),
215
+ );
154
216
  } else {
155
217
  pending.resolve(message.result);
156
218
  }
157
219
  return;
158
220
  }
159
221
 
222
+ if (isServerRequest(message)) {
223
+ this.answerServerRequest(message);
224
+ return;
225
+ }
226
+
160
227
  if (isNotification(message)) {
161
228
  this.onNotification?.(message.method, message.params);
162
229
  }
163
230
  }
231
+
232
+ private answerServerRequest(request: JsonRpcRequest) {
233
+ let answer: ServerRequestAnswer;
234
+ try {
235
+ answer = this.onServerRequest?.(request.method, request.params);
236
+ } catch (err) {
237
+ this.writeFrame({
238
+ jsonrpc: '2.0',
239
+ id: request.id,
240
+ error: { code: -32603, message: `server request handler failed: ${String(err)}` },
241
+ });
242
+ return;
243
+ }
244
+ if (answer !== undefined) {
245
+ // undefined → null: a dropped `result` member would produce a frame
246
+ // that is neither a success nor an error response.
247
+ this.writeFrame({
248
+ jsonrpc: '2.0',
249
+ id: request.id,
250
+ result: answer.result === undefined ? null : answer.result,
251
+ });
252
+ return;
253
+ }
254
+ this.writeFrame({
255
+ jsonrpc: '2.0',
256
+ id: request.id,
257
+ error: { code: -32601, message: `unsupported server request: ${request.method}` },
258
+ });
259
+ }
164
260
  }
165
261
 
166
262
  function isResponse(m: unknown): m is JsonRpcResponse {
167
263
  return !!m && typeof m === 'object' && 'id' in m && ('result' in m || 'error' in m);
168
264
  }
169
265
 
266
+ // A server→client request carries BOTH `method` and `id` (and no
267
+ // result/error). It must be answered — see ServerRequestHandler.
268
+ function isServerRequest(m: unknown): m is JsonRpcRequest {
269
+ return !!m && typeof m === 'object' && 'method' in m && 'id' in m;
270
+ }
271
+
170
272
  function isNotification(m: unknown): m is JsonRpcNotification {
171
273
  return !!m && typeof m === 'object' && 'method' in m && !('id' in m);
172
274
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Answers for codex app-server server→client requests.
3
+ *
4
+ * The bridge runs headless with `approvalPolicy: "never"`, so approval-shaped
5
+ * requests should not occur — but the protocol allows the app-server to send
6
+ * them (e.g. a thread that fell back to `on-request`), and an unanswered
7
+ * request parks its turn until the dispatch deadline. Every known
8
+ * approval-shaped method is answered with an explicit denial; unknown methods
9
+ * get a method-not-found error from the JSON-RPC layer (`undefined` here).
10
+ *
11
+ * Wire shapes verified against codex-rs 0.144.1
12
+ * (`app-server-protocol/src/protocol/common.rs` server_request_definitions):
13
+ * - v2 `item/commandExecution/requestApproval` / `item/fileChange/requestApproval`
14
+ * respond `{ decision }` with camelCase variants — `decline` denies but lets
15
+ * the turn continue (vs `cancel`, which also interrupts the turn).
16
+ * - legacy v1 `execCommandApproval` / `applyPatchApproval` (SendUserTurn-era,
17
+ * unused by this bridge) respond `{ decision }` with snake_case
18
+ * ReviewDecision values.
19
+ */
20
+ const APPROVAL_DENIALS: Record<string, unknown> = {
21
+ 'item/commandExecution/requestApproval': { decision: 'decline' },
22
+ 'item/fileChange/requestApproval': { decision: 'decline' },
23
+ // NOT listed: `item/permissions/requestApproval` — its response shape is a
24
+ // permission GRANT (no deny variant), so denial is correctly expressed by
25
+ // the -32601 error fallback.
26
+ execCommandApproval: { decision: 'denied' },
27
+ applyPatchApproval: { decision: 'denied' },
28
+ };
29
+
30
+ /**
31
+ * Returns the response payload for a server request, or `undefined` when the
32
+ * method is not one we can meaningfully answer (the JSON-RPC client then
33
+ * replies -32601, which also unblocks the app-server). Own-property lookup
34
+ * only — a method named like an Object.prototype member (`toString`,
35
+ * `constructor`) must fall through to -32601, not return an inherited
36
+ * function that would serialize into a result-less frame.
37
+ */
38
+ export function answerServerRequest(method: string): unknown | undefined {
39
+ return Object.hasOwn(APPROVAL_DENIALS, method) ? APPROVAL_DENIALS[method] : undefined;
40
+ }