@parall/codex-agent 1.45.0 → 1.46.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.
@@ -0,0 +1,136 @@
1
+ import type { GatewayLogger } from '@parall/agent-core';
2
+ import { type JsonRpcStdioClient } from './jsonrpc-client.js';
3
+ import type { CodexSessionManager } from './session-manager.js';
4
+ /**
5
+ * Convergence of platform `developerInstructions` onto the persisted main
6
+ * thread. codex 0.144.1 keeps the instructions on two planes (verified by
7
+ * source reading of rust-v0.144.1 plus a raw-request probe against the
8
+ * pinned CLI with a mock Responses API):
9
+ *
10
+ * - CANONICAL — the session configuration. A `thread/resume` in a process
11
+ * where the thread is not already running DOES apply the
12
+ * `developerInstructions` param to the reconstructed session (a resume of
13
+ * a still-running thread ignores every override and logs a mismatch).
14
+ * - EFFECTIVE — what the model actually sees: the initial-context developer
15
+ * message living in conversation history. An ordinary post-resume turn
16
+ * does NOT re-emit it (`TurnContextItem` does not carry instructions and
17
+ * the settings-update diff does not cover them), so after a resume with
18
+ * new instructions the model keeps seeing the old text.
19
+ *
20
+ * Compaction (`thread/compact/start`, also the auto-compaction path) is the
21
+ * official convergence point: it rebuilds the initial context from the
22
+ * CANONICAL configuration into the replacement history — after which every
23
+ * turn, restart, resume, and further compaction carries the new instructions,
24
+ * and exactly one instruction block exists (rebuild, not append).
25
+ *
26
+ * So the refresh recipe is: get canonical right (the existing lazy-restart →
27
+ * fresh-process `thread/resume` chain already sends the current instructions),
28
+ * then trigger one explicit compaction when the thread's last-known EFFECTIVE
29
+ * instructions differ. The session state file remembers the sha256 of the
30
+ * effective instructions: recorded when a thread is STARTED (baking makes
31
+ * them effective immediately) and after a compaction completes — never on
32
+ * resume alone, which is precisely the plane it does not touch.
33
+ *
34
+ * Failure posture (at-least-once, never exactly-once): a refresh that did not
35
+ * complete is never recorded, so the next dispatch retries. A cleanly FAILED
36
+ * compaction (turn closed: error, interrupt honored, subprocess died) does
37
+ * not block the current turn — canonical is already current after the
38
+ * resume, so it degrades to "old text until the next retry or organic
39
+ * compaction". The ONE exception is 'stalled': the compaction turn ignored
40
+ * the interrupt past the grace and may still be running, so dispatch() must
41
+ * not race turn/start into the busy thread (a rejection there would look
42
+ * like a stale thread and rotate it) — it keeps the persisted thread,
43
+ * bounces the subprocess, and errors THIS dispatch for ledger redrive onto a
44
+ * clean process. Continuity outranks single-dispatch availability. A CLI
45
+ * without `thread/compact/start` (JSON-RPC -32601) disables further attempts
46
+ * for the subprocess lifetime (a CLI upgrade implies a respawn) and keeps
47
+ * the sha unrecorded so a capable CLI converges later.
48
+ */
49
+ export type NotificationTap = (method: string, params: unknown) => void;
50
+ export interface NotificationTapSource {
51
+ /** Register a listener for every server notification; returns unregister. */
52
+ addNotificationTap(tap: NotificationTap): () => void;
53
+ }
54
+ export declare function sha256Hex(text: string): string;
55
+ export type RefreshOutcome = 'noop' | 'adopted-baseline' | 'refreshed' | 'unsupported' | 'failed' | 'stalled';
56
+ export declare class MainThreadInstructionsRefresher {
57
+ private readonly opts;
58
+ /**
59
+ * Set when this subprocess rejected thread/compact/start with
60
+ * method-not-found (an older CLI). Reset on every spawn via
61
+ * resetForNewSubprocess() — a CLI upgrade implies a respawn, so each
62
+ * subprocess gets exactly one probe.
63
+ */
64
+ private compactUnsupported;
65
+ constructor(opts: {
66
+ sessionManager: Pick<CodexSessionManager, 'getEffectiveInstructionsSha' | 'recordEffectiveInstructionsSha' | 'recordStartedThread'>;
67
+ log?: GatewayLogger;
68
+ compactTimeoutMs?: number;
69
+ /** Test knob for the post-interrupt grace (default 10s). */
70
+ interruptGraceMs?: number;
71
+ });
72
+ /**
73
+ * Instructions each thread was opened WITH in this process — its live
74
+ * CANONICAL configuration (same-tick capture of the thread/start /
75
+ * thread/resume param). Process-local: the adapter clears it whenever the
76
+ * subprocess goes away (clearThreadState), because a canonical fact only
77
+ * describes a thread loaded in the CURRENT app-server.
78
+ */
79
+ private readonly canonicalByThread;
80
+ resetForNewSubprocess(): void;
81
+ /** Canonical facts die with the subprocess that held the threads. */
82
+ clearThreadState(): void;
83
+ /**
84
+ * Thread opened via thread/start: instructions are baked into the initial
85
+ * context, so canonical AND effective converge the moment the start
86
+ * succeeds — no compaction involved. Thread id and effective sha persist in
87
+ * ONE state-file write: a crash between two separate writes would be
88
+ * indistinguishable from a legacy file, and legacy state is deliberately
89
+ * adopted without compaction.
90
+ */
91
+ recordBaked(sessionKey: string, threadId: string, instructions: string | undefined): void;
92
+ /**
93
+ * Thread opened via thread/resume: a fresh-process resume applies the
94
+ * param to the CANONICAL configuration only — the effective plane is
95
+ * reconciled separately, never recorded here.
96
+ */
97
+ recordResumed(threadId: string, instructions: string | undefined): void;
98
+ /** What the thread's live canonical configuration carries, if opened here. */
99
+ canonicalFor(threadId: string): string | undefined;
100
+ /**
101
+ * Converge the EFFECTIVE plane after the thread is open in this process.
102
+ * Compares against the thread's recorded canonical value — the dispatch
103
+ * guard has already bounced the subprocess if the desired instructions
104
+ * changed after the open, so canonical is current by the time this runs.
105
+ */
106
+ reconcileAfterOpen(args: {
107
+ client: JsonRpcStdioClient;
108
+ taps: NotificationTapSource;
109
+ sessionKey: string;
110
+ threadId: string;
111
+ log?: GatewayLogger;
112
+ }): Promise<RefreshOutcome>;
113
+ /**
114
+ * Compaction runs as its own turn on the thread:
115
+ * turn/started → item/started{contextCompaction} →
116
+ * item/completed{contextCompaction} → turn/completed{status}
117
+ * (wire sequence captured against the pinned 0.144.1 CLI). Success = the
118
+ * contextCompaction item completed AND the turn closed; a turn that closes
119
+ * without the item having completed is a failure. Waiting for turn close —
120
+ * not just the item — keeps the next turn/start from racing into a thread
121
+ * that is still finishing the compaction turn.
122
+ *
123
+ * Timeout does NOT simply return: past the budget the compaction turn may
124
+ * still be RUNNING, and handing control back would let dispatch() issue
125
+ * turn/start against a busy thread — a rejection there is treated as a
126
+ * stale thread and would ROTATE the persisted main thread (continuity
127
+ * loss). Instead the watcher interrupts the compaction turn best-effort
128
+ * and holds a short grace for it to close. A compaction that completes
129
+ * during the grace still counts as success (late, but the effective plane
130
+ * DID converge). Residual: a server that has not even emitted
131
+ * turn/started by the deadline leaves nothing to interrupt — pathological,
132
+ * and the grace still absorbs a late-materializing close.
133
+ */
134
+ private watchForCompaction;
135
+ }
136
+ //# sourceMappingURL=instructions-refresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instructions-refresh.d.ts","sourceRoot":"","sources":["../src/instructions-refresh.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAExE,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,IAAI,CAAC;CACtD;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAMD,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,kBAAkB,GAClB,WAAW,GACX,aAAa,GACb,QAAQ,GAIR,SAAS,CAAC;AAId,qBAAa,+BAA+B;IAUxC,OAAO,CAAC,QAAQ,CAAC,IAAI;IATvB;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB,CAAS;gBAGhB,IAAI,EAAE;QACrB,cAAc,EAAE,IAAI,CAClB,mBAAmB,EACnB,6BAA6B,GAAG,gCAAgC,GAAG,qBAAqB,CACzF,CAAC;QACF,GAAG,CAAC,EAAE,aAAa,CAAC;QACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,4DAA4D;QAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B;IAGH;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAyC;IAE3E,qBAAqB,IAAI,IAAI;IAI7B,qEAAqE;IACrE,gBAAgB,IAAI,IAAI;IAIxB;;;;;;;OAOG;IACH,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IASzF;;;;OAIG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAIvE,8EAA8E;IAC9E,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIlD;;;;;OAKG;IACG,kBAAkB,CAAC,IAAI,EAAE;QAC7B,MAAM,EAAE,kBAAkB,CAAC;QAC3B,IAAI,EAAE,qBAAqB,CAAC;QAC5B,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB,GAAG,OAAO,CAAC,cAAc,CAAC;IA4D3B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,kBAAkB;CAkG3B"}
@@ -0,0 +1,244 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { extractThreadIdFromNotification } from './app-server-protocol.js';
3
+ import { JSON_RPC_METHOD_NOT_FOUND, JsonRpcError, } from './jsonrpc-client.js';
4
+ export function sha256Hex(text) {
5
+ return createHash('sha256').update(text, 'utf8').digest('hex');
6
+ }
7
+ const DEFAULT_COMPACT_TIMEOUT_MS = 120_000;
8
+ /** After an over-budget compaction is interrupted, how long to wait for its turn to close. */
9
+ const COMPACT_INTERRUPT_GRACE_MS = 10_000;
10
+ class CompactionStalledError extends Error {
11
+ }
12
+ export class MainThreadInstructionsRefresher {
13
+ opts;
14
+ /**
15
+ * Set when this subprocess rejected thread/compact/start with
16
+ * method-not-found (an older CLI). Reset on every spawn via
17
+ * resetForNewSubprocess() — a CLI upgrade implies a respawn, so each
18
+ * subprocess gets exactly one probe.
19
+ */
20
+ compactUnsupported = false;
21
+ constructor(opts) {
22
+ this.opts = opts;
23
+ }
24
+ /**
25
+ * Instructions each thread was opened WITH in this process — its live
26
+ * CANONICAL configuration (same-tick capture of the thread/start /
27
+ * thread/resume param). Process-local: the adapter clears it whenever the
28
+ * subprocess goes away (clearThreadState), because a canonical fact only
29
+ * describes a thread loaded in the CURRENT app-server.
30
+ */
31
+ canonicalByThread = new Map();
32
+ resetForNewSubprocess() {
33
+ this.compactUnsupported = false;
34
+ }
35
+ /** Canonical facts die with the subprocess that held the threads. */
36
+ clearThreadState() {
37
+ this.canonicalByThread.clear();
38
+ }
39
+ /**
40
+ * Thread opened via thread/start: instructions are baked into the initial
41
+ * context, so canonical AND effective converge the moment the start
42
+ * succeeds — no compaction involved. Thread id and effective sha persist in
43
+ * ONE state-file write: a crash between two separate writes would be
44
+ * indistinguishable from a legacy file, and legacy state is deliberately
45
+ * adopted without compaction.
46
+ */
47
+ recordBaked(sessionKey, threadId, instructions) {
48
+ this.canonicalByThread.set(threadId, instructions);
49
+ this.opts.sessionManager.recordStartedThread(sessionKey, threadId, instructions ? sha256Hex(instructions) : undefined);
50
+ }
51
+ /**
52
+ * Thread opened via thread/resume: a fresh-process resume applies the
53
+ * param to the CANONICAL configuration only — the effective plane is
54
+ * reconciled separately, never recorded here.
55
+ */
56
+ recordResumed(threadId, instructions) {
57
+ this.canonicalByThread.set(threadId, instructions);
58
+ }
59
+ /** What the thread's live canonical configuration carries, if opened here. */
60
+ canonicalFor(threadId) {
61
+ return this.canonicalByThread.get(threadId);
62
+ }
63
+ /**
64
+ * Converge the EFFECTIVE plane after the thread is open in this process.
65
+ * Compares against the thread's recorded canonical value — the dispatch
66
+ * guard has already bounced the subprocess if the desired instructions
67
+ * changed after the open, so canonical is current by the time this runs.
68
+ */
69
+ async reconcileAfterOpen(args) {
70
+ const { client, taps, sessionKey, threadId } = args;
71
+ const log = args.log ?? this.opts.log;
72
+ const sentInstructions = this.canonicalByThread.get(threadId);
73
+ if (!sentInstructions)
74
+ return 'noop';
75
+ const canonicalSha = sha256Hex(sentInstructions);
76
+ const effectiveSha = this.opts.sessionManager.getEffectiveInstructionsSha(sessionKey);
77
+ if (effectiveSha === canonicalSha)
78
+ return 'noop';
79
+ if (effectiveSha === undefined) {
80
+ // State file predates effective-plane tracking (recordBaked persists
81
+ // thread id + sha in one atomic write, so a crash cannot manufacture
82
+ // this state for a tracked thread). Adopt the current value as the
83
+ // baseline WITHOUT forcing a compaction: rolling this feature out
84
+ // must not compress every existing thread. A staleness inherited from
85
+ // the pre-tracking era converges at the next instructions change or at
86
+ // the next organic compaction (canonical is already current by then).
87
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
88
+ log?.info?.(`adopted current platform instructions as effective baseline for thread ${threadId} (no prior record)`);
89
+ return 'adopted-baseline';
90
+ }
91
+ if (this.compactUnsupported)
92
+ return 'unsupported';
93
+ // The waiter registers its notification tap BEFORE the request goes out:
94
+ // the response and the compaction-turn notifications can arrive in one
95
+ // stdout chunk, and the client's line loop dispatches notifications
96
+ // synchronously — a tap registered only after `await sendRequest` resolves
97
+ // (a queued microtask) would miss every one of them and hang on the
98
+ // timeout.
99
+ const compaction = this.watchForCompaction(client, taps, threadId);
100
+ try {
101
+ await client.sendRequest('thread/compact/start', { threadId });
102
+ await compaction.done;
103
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
104
+ log?.info?.(`platform instructions refreshed on persisted thread ${threadId} (compaction rebuilt initial context from the resumed configuration)`);
105
+ return 'refreshed';
106
+ }
107
+ catch (err) {
108
+ compaction.cancel();
109
+ if (err instanceof CompactionStalledError) {
110
+ log?.warn?.(`platform instructions refresh stalled (${errToString(err)}); bouncing the subprocess before the next turn`);
111
+ return 'stalled';
112
+ }
113
+ if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
114
+ this.compactUnsupported = true;
115
+ log?.warn?.('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)');
116
+ return 'unsupported';
117
+ }
118
+ log?.warn?.(`platform instructions refresh did not complete (will retry next dispatch; the resumed configuration already carries the new value): ${errToString(err)}`);
119
+ return 'failed';
120
+ }
121
+ }
122
+ /**
123
+ * Compaction runs as its own turn on the thread:
124
+ * turn/started → item/started{contextCompaction} →
125
+ * item/completed{contextCompaction} → turn/completed{status}
126
+ * (wire sequence captured against the pinned 0.144.1 CLI). Success = the
127
+ * contextCompaction item completed AND the turn closed; a turn that closes
128
+ * without the item having completed is a failure. Waiting for turn close —
129
+ * not just the item — keeps the next turn/start from racing into a thread
130
+ * that is still finishing the compaction turn.
131
+ *
132
+ * Timeout does NOT simply return: past the budget the compaction turn may
133
+ * still be RUNNING, and handing control back would let dispatch() issue
134
+ * turn/start against a busy thread — a rejection there is treated as a
135
+ * stale thread and would ROTATE the persisted main thread (continuity
136
+ * loss). Instead the watcher interrupts the compaction turn best-effort
137
+ * and holds a short grace for it to close. A compaction that completes
138
+ * during the grace still counts as success (late, but the effective plane
139
+ * DID converge). Residual: a server that has not even emitted
140
+ * turn/started by the deadline leaves nothing to interrupt — pathological,
141
+ * and the grace still absorbs a late-materializing close.
142
+ */
143
+ watchForCompaction(client, taps, threadId) {
144
+ const timeoutMs = this.opts.compactTimeoutMs ?? DEFAULT_COMPACT_TIMEOUT_MS;
145
+ const graceMs = this.opts.interruptGraceMs ?? COMPACT_INTERRUPT_GRACE_MS;
146
+ let cancel = () => { };
147
+ const done = new Promise((resolve, reject) => {
148
+ let itemCompleted = false;
149
+ let compactionTurnId;
150
+ let interrupted = false;
151
+ let settled = false;
152
+ let unregister = () => { };
153
+ let graceTimer;
154
+ const finish = (err) => {
155
+ if (settled)
156
+ return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ if (graceTimer)
160
+ clearTimeout(graceTimer);
161
+ unregister();
162
+ if (err)
163
+ reject(err);
164
+ else
165
+ resolve();
166
+ };
167
+ const timer = setTimeout(() => {
168
+ interrupted = true;
169
+ if (compactionTurnId) {
170
+ // Best-effort and non-lethal: an unanswered interrupt must expire
171
+ // with the grace window, not arm the client's default assume-hung
172
+ // timeout into killing the subprocess minutes later mid-something.
173
+ client
174
+ .sendRequest('turn/interrupt', { threadId, turnId: compactionTurnId }, { timeoutMs: graceMs, lethalTimeout: false })
175
+ .catch(() => { });
176
+ }
177
+ graceTimer = setTimeout(() => finish(new CompactionStalledError(`compaction did not complete within ${timeoutMs}ms (interrupt grace elapsed; the compaction turn may still be running)`)), graceMs);
178
+ }, timeoutMs);
179
+ // Cancellation resolves (never rejects): the caller cancels only when
180
+ // the request itself already failed, and that error is what it reports.
181
+ cancel = () => finish();
182
+ unregister = taps.addNotificationTap((method, params) => {
183
+ const notificationThreadId = extractThreadIdFromNotification(params);
184
+ if (method === 'error') {
185
+ // A thread-less error is a global one — including the adapter's
186
+ // synthetic subprocess-disposal broadcast. No further compaction
187
+ // notifications can arrive after that; waiting out the timeout
188
+ // would stall the dispatch for the full budget on a dead client.
189
+ if (notificationThreadId === undefined || notificationThreadId === threadId) {
190
+ const msg = params?.message;
191
+ finish(new Error(`app-server error during compaction: ${String(msg ?? 'unknown')}`));
192
+ }
193
+ return;
194
+ }
195
+ if (notificationThreadId !== threadId)
196
+ return;
197
+ if (method === 'turn/started') {
198
+ compactionTurnId = turnIdOf(params) ?? compactionTurnId;
199
+ return;
200
+ }
201
+ if (method === 'item/completed' && itemType(params) === 'contextCompaction') {
202
+ itemCompleted = true;
203
+ return;
204
+ }
205
+ if (method === 'turn/completed') {
206
+ const status = turnStatus(params);
207
+ if (itemCompleted && status !== 'failed')
208
+ finish();
209
+ else
210
+ finish(new Error(interrupted
211
+ ? `compaction did not complete within ${timeoutMs}ms (turn closed after interrupt)`
212
+ : `compaction turn ended without completing (status=${status ?? 'unknown'})`));
213
+ }
214
+ });
215
+ });
216
+ // Detached-consumer guard: if the subprocess dies while the
217
+ // thread/compact/start REQUEST is still in flight, the disposal
218
+ // broadcast rejects this waiter before reconcileAfterOpen ever awaits
219
+ // it — and its catch path only cancels, it never consumes `done`. An
220
+ // unconsumed rejection would crash the bridge (Node's default
221
+ // unhandled-rejection behavior) on the exact path that is supposed to
222
+ // degrade and retry. The extra consumer does not affect the success
223
+ // path's own await.
224
+ done.catch(() => { });
225
+ return { done, cancel };
226
+ }
227
+ }
228
+ function itemType(params) {
229
+ const item = params?.item;
230
+ return typeof item?.type === 'string' ? item.type : undefined;
231
+ }
232
+ function turnStatus(params) {
233
+ const turn = params?.turn;
234
+ return typeof turn?.status === 'string' ? turn.status : undefined;
235
+ }
236
+ function turnIdOf(params) {
237
+ const turn = params?.turn;
238
+ return typeof turn?.id === 'string' ? turn.id : undefined;
239
+ }
240
+ function errToString(err) {
241
+ if (err instanceof Error)
242
+ return err.message;
243
+ return String(err);
244
+ }
@@ -22,6 +22,40 @@ export type JsonRpcResponse = {
22
22
  };
23
23
  };
24
24
  export type NotificationHandler = (method: string, params: unknown) => void;
25
+ /**
26
+ * A handled server request's response payload. The wrapper (rather than a
27
+ * bare `unknown`) makes the "undefined = unhandled" sentinel explicit in the
28
+ * type — `unknown | undefined` would collapse and hide the contract.
29
+ *
30
+ * A `result` of `undefined` is normalized to `null` on the wire: JSON.stringify
31
+ * DROPS an undefined member, which would emit a frame carrying neither
32
+ * `result` nor `error` — the app-server may ignore such a frame and keep
33
+ * waiting, parking the turn (the exact failure this handler exists to
34
+ * prevent). `null` is a valid JSON-RPC success result.
35
+ */
36
+ export type ServerRequestAnswer = {
37
+ result: unknown;
38
+ } | undefined;
39
+ /**
40
+ * Answers a server→client request. Return `{ result }` to respond, or
41
+ * `undefined` to have the client reply with a method-not-found error. A
42
+ * request must never go unanswered — the app-server blocks its turn until a
43
+ * response arrives, so a dropped request parks that turn forever.
44
+ */
45
+ export type ServerRequestHandler = (method: string, params: unknown) => ServerRequestAnswer;
46
+ /** JSON-RPC 2.0 spec code for "Method not found". */
47
+ export declare const JSON_RPC_METHOD_NOT_FOUND = -32601;
48
+ /**
49
+ * Rejection error that preserves the JSON-RPC error object's code (and data),
50
+ * so callers can branch on protocol-level conditions — e.g. method-not-found
51
+ * on an older CLI — without matching on server message text, whose wording
52
+ * shifts between codex versions.
53
+ */
54
+ export declare class JsonRpcError extends Error {
55
+ readonly code: number;
56
+ readonly data?: unknown | undefined;
57
+ constructor(code: number, message: string, data?: unknown | undefined);
58
+ }
25
59
  /**
26
60
  * Minimal JSON-RPC 2.0 stdio client used to drive a long-running
27
61
  * `codex app-server --listen stdio://` subprocess. Frames are newline
@@ -38,10 +72,23 @@ export declare class JsonRpcStdioClient {
38
72
  private readonly pending;
39
73
  private buffer;
40
74
  private onNotification;
75
+ private onServerRequest;
41
76
  private disposed;
42
77
  constructor(proc: ChildProcessWithoutNullStreams, requestTimeoutMs?: number, killProcess?: ((proc: ChildProcessWithoutNullStreams) => void) | undefined);
43
78
  setNotificationHandler(handler: NotificationHandler): void;
44
- sendRequest(method: string, params?: unknown): Promise<unknown>;
79
+ setServerRequestHandler(handler: ServerRequestHandler): void;
80
+ /**
81
+ * `options.timeoutMs` overrides the client-wide budget for this request.
82
+ * `options.lethalTimeout: false` makes a timeout reject WITHOUT killing the
83
+ * subprocess — for best-effort side requests (e.g. the compaction watcher's
84
+ * turn/interrupt) where "no answer" must not translate into a delayed
85
+ * subprocess kill landing on an unrelated later dispatch. Default (true)
86
+ * keeps the existing assume-hung semantics.
87
+ */
88
+ sendRequest(method: string, params?: unknown, options?: {
89
+ timeoutMs?: number;
90
+ lethalTimeout?: boolean;
91
+ }): Promise<unknown>;
45
92
  sendNotification(method: string, params?: unknown): void;
46
93
  isDisposed(): boolean;
47
94
  dispose(err: Error): void;
@@ -49,6 +96,7 @@ export declare class JsonRpcStdioClient {
49
96
  private killUnhealthy;
50
97
  private ingest;
51
98
  private handleLine;
99
+ private answerServerRequest;
52
100
  }
53
101
  export {};
54
102
  //# sourceMappingURL=jsonrpc-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"jsonrpc-client.d.ts","sourceRoot":"","sources":["../src/jsonrpc-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AAEzE,KAAK,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjC,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC3D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAW5E;;;;;;;GAOG;AACH,qBAAa,kBAAkB;IAQ3B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAT/B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,QAAQ,CAAS;gBAGN,IAAI,EAAE,8BAA8B,EACpC,gBAAgB,GAAE,MAAmC,EACrD,WAAW,CAAC,GAAE,CAAC,IAAI,EAAE,8BAA8B,KAAK,IAAI,aAAA;IAS/E,sBAAsB,CAAC,OAAO,EAAE,mBAAmB;IAInD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAsB/D,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO;IAMjD,UAAU,IAAI,OAAO;IAIrB,OAAO,CAAC,GAAG,EAAE,KAAK;IAUlB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,aAAa;IAWrB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,UAAU;CAyBnB"}
1
+ {"version":3,"file":"jsonrpc-client.d.ts","sourceRoot":"","sources":["../src/jsonrpc-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AAEzE,KAAK,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjC,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC3D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAE5E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAAC;AAElE;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,mBAAmB,CAAC;AAE5F,qDAAqD;AACrD,eAAO,MAAM,yBAAyB,SAAS,CAAC;AAEhD;;;;;GAKG;AACH,qBAAa,YAAa,SAAQ,KAAK;IAEnC,QAAQ,CAAC,IAAI,EAAE,MAAM;IAErB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO;gBAFd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACN,IAAI,CAAC,EAAE,OAAO,YAAA;CAK1B;AAWD;;;;;;;GAOG;AACH,qBAAa,kBAAkB;IAS3B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAV/B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,eAAe,CAAqC;IAC5D,OAAO,CAAC,QAAQ,CAAS;gBAGN,IAAI,EAAE,8BAA8B,EACpC,gBAAgB,GAAE,MAAmC,EACrD,WAAW,CAAC,GAAE,CAAC,IAAI,EAAE,8BAA8B,KAAK,IAAI,aAAA;IAS/E,sBAAsB,CAAC,OAAO,EAAE,mBAAmB;IAInD,uBAAuB,CAAC,OAAO,EAAE,oBAAoB;IAIrD;;;;;;;OAOG;IACH,WAAW,CACT,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GACxD,OAAO,CAAC,OAAO,CAAC;IAwBnB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO;IAMjD,UAAU,IAAI,OAAO;IAIrB,OAAO,CAAC,GAAG,EAAE,KAAK;IAUlB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,aAAa;IAWrB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,UAAU;IAiClB,OAAO,CAAC,mBAAmB;CA4B5B"}
@@ -1,3 +1,21 @@
1
+ /** JSON-RPC 2.0 spec code for "Method not found". */
2
+ export const JSON_RPC_METHOD_NOT_FOUND = -32601;
3
+ /**
4
+ * Rejection error that preserves the JSON-RPC error object's code (and data),
5
+ * so callers can branch on protocol-level conditions — e.g. method-not-found
6
+ * on an older CLI — without matching on server message text, whose wording
7
+ * shifts between codex versions.
8
+ */
9
+ export class JsonRpcError extends Error {
10
+ code;
11
+ data;
12
+ constructor(code, message, data) {
13
+ super(message || 'JSON-RPC error');
14
+ this.code = code;
15
+ this.data = data;
16
+ this.name = 'JsonRpcError';
17
+ }
18
+ }
1
19
  /** Maximum time to wait for a JSON-RPC response before rejecting. */
2
20
  const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
3
21
  /**
@@ -16,6 +34,7 @@ export class JsonRpcStdioClient {
16
34
  pending = new Map();
17
35
  buffer = '';
18
36
  onNotification = null;
37
+ onServerRequest = null;
19
38
  disposed = false;
20
39
  constructor(proc, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, killProcess) {
21
40
  this.proc = proc;
@@ -30,10 +49,23 @@ export class JsonRpcStdioClient {
30
49
  setNotificationHandler(handler) {
31
50
  this.onNotification = handler;
32
51
  }
33
- sendRequest(method, params) {
52
+ setServerRequestHandler(handler) {
53
+ this.onServerRequest = handler;
54
+ }
55
+ /**
56
+ * `options.timeoutMs` overrides the client-wide budget for this request.
57
+ * `options.lethalTimeout: false` makes a timeout reject WITHOUT killing the
58
+ * subprocess — for best-effort side requests (e.g. the compaction watcher's
59
+ * turn/interrupt) where "no answer" must not translate into a delayed
60
+ * subprocess kill landing on an unrelated later dispatch. Default (true)
61
+ * keeps the existing assume-hung semantics.
62
+ */
63
+ sendRequest(method, params, options) {
34
64
  if (this.disposed) {
35
65
  return Promise.reject(new Error('JSON-RPC client disposed'));
36
66
  }
67
+ const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs;
68
+ const lethalTimeout = options?.lethalTimeout ?? true;
37
69
  const id = this.nextId++;
38
70
  const request = { jsonrpc: '2.0', id, method, params };
39
71
  const promise = new Promise((resolve, reject) => {
@@ -42,9 +74,11 @@ export class JsonRpcStdioClient {
42
74
  if (!pending)
43
75
  return;
44
76
  this.pending.delete(id);
45
- reject(new Error(`JSON-RPC request "${method}" timed out after ${this.requestTimeoutMs}ms`));
46
- this.killUnhealthy(new Error(`request "${method}" timed out; subprocess assumed hung`));
47
- }, this.requestTimeoutMs);
77
+ reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
78
+ if (lethalTimeout) {
79
+ this.killUnhealthy(new Error(`request "${method}" timed out; subprocess assumed hung`));
80
+ }
81
+ }, timeoutMs);
48
82
  this.pending.set(id, { resolve, reject, timer });
49
83
  });
50
84
  this.writeFrame(request);
@@ -114,21 +148,59 @@ export class JsonRpcStdioClient {
114
148
  this.pending.delete(message.id);
115
149
  clearTimeout(pending.timer);
116
150
  if (message.error) {
117
- pending.reject(new Error(message.error.message || 'JSON-RPC error'));
151
+ pending.reject(new JsonRpcError(message.error.code, message.error.message, message.error.data));
118
152
  }
119
153
  else {
120
154
  pending.resolve(message.result);
121
155
  }
122
156
  return;
123
157
  }
158
+ if (isServerRequest(message)) {
159
+ this.answerServerRequest(message);
160
+ return;
161
+ }
124
162
  if (isNotification(message)) {
125
163
  this.onNotification?.(message.method, message.params);
126
164
  }
127
165
  }
166
+ answerServerRequest(request) {
167
+ let answer;
168
+ try {
169
+ answer = this.onServerRequest?.(request.method, request.params);
170
+ }
171
+ catch (err) {
172
+ this.writeFrame({
173
+ jsonrpc: '2.0',
174
+ id: request.id,
175
+ error: { code: -32603, message: `server request handler failed: ${String(err)}` },
176
+ });
177
+ return;
178
+ }
179
+ if (answer !== undefined) {
180
+ // undefined → null: a dropped `result` member would produce a frame
181
+ // that is neither a success nor an error response.
182
+ this.writeFrame({
183
+ jsonrpc: '2.0',
184
+ id: request.id,
185
+ result: answer.result === undefined ? null : answer.result,
186
+ });
187
+ return;
188
+ }
189
+ this.writeFrame({
190
+ jsonrpc: '2.0',
191
+ id: request.id,
192
+ error: { code: -32601, message: `unsupported server request: ${request.method}` },
193
+ });
194
+ }
128
195
  }
129
196
  function isResponse(m) {
130
197
  return !!m && typeof m === 'object' && 'id' in m && ('result' in m || 'error' in m);
131
198
  }
199
+ // A server→client request carries BOTH `method` and `id` (and no
200
+ // result/error). It must be answered — see ServerRequestHandler.
201
+ function isServerRequest(m) {
202
+ return !!m && typeof m === 'object' && 'method' in m && 'id' in m;
203
+ }
132
204
  function isNotification(m) {
133
205
  return !!m && typeof m === 'object' && 'method' in m && !('id' in m);
134
206
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Returns the response payload for a server request, or `undefined` when the
3
+ * method is not one we can meaningfully answer (the JSON-RPC client then
4
+ * replies -32601, which also unblocks the app-server). Own-property lookup
5
+ * only — a method named like an Object.prototype member (`toString`,
6
+ * `constructor`) must fall through to -32601, not return an inherited
7
+ * function that would serialize into a result-less frame.
8
+ */
9
+ export declare function answerServerRequest(method: string): unknown | undefined;
10
+ //# sourceMappingURL=server-requests.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-requests.d.ts","sourceRoot":"","sources":["../src/server-requests.ts"],"names":[],"mappings":"AA6BA;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAEvE"}
@@ -0,0 +1,39 @@
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 = {
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
+ * Returns the response payload for a server request, or `undefined` when the
31
+ * method is not one we can meaningfully answer (the JSON-RPC client then
32
+ * replies -32601, which also unblocks the app-server). Own-property lookup
33
+ * only — a method named like an Object.prototype member (`toString`,
34
+ * `constructor`) must fall through to -32601, not return an inherited
35
+ * function that would serialize into a result-less frame.
36
+ */
37
+ export function answerServerRequest(method) {
38
+ return Object.hasOwn(APPROVAL_DENIALS, method) ? APPROVAL_DENIALS[method] : undefined;
39
+ }