@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/dispatch.ts CHANGED
@@ -1,11 +1,6 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
1
+ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import * as path from 'node:path';
4
- import {
5
- appendPreparedLocalAttachmentRefs,
6
- pinLocalAttachmentPaths,
7
- } from '@parall/agent-core/internal/attachment-input';
8
- import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
9
4
  import type {
10
5
  CleanupForkOpts,
11
6
  DispatchAdapter,
@@ -15,7 +10,12 @@ import type {
15
10
  GatewayLogger,
16
11
  RuntimeEvent,
17
12
  } from '@parall/agent-core';
18
- import { IS_WIN32, ensureGitRepo, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
13
+ import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
14
+ import {
15
+ appendPreparedLocalAttachmentRefs,
16
+ pinLocalAttachmentPaths,
17
+ } from '@parall/agent-core/internal/attachment-input';
18
+ import { ensureGitRepo, IS_WIN32, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
19
19
  import {
20
20
  buildTurnInput,
21
21
  extractThreadId,
@@ -24,7 +24,13 @@ import {
24
24
  } from './app-server-protocol.js';
25
25
  import type { CodexAgentConfig } from './config.js';
26
26
  import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
27
+ import {
28
+ MainThreadInstructionsRefresher,
29
+ type NotificationTap,
30
+ type RefreshOutcome,
31
+ } from './instructions-refresh.js';
27
32
  import { JsonRpcStdioClient } from './jsonrpc-client.js';
33
+ import { answerServerRequest } from './server-requests.js';
28
34
  import type { CodexSessionManager } from './session-manager.js';
29
35
  import { TurnSink } from './turn-sink.js';
30
36
 
@@ -58,19 +64,22 @@ type CodexAppServerAdapterOptions = Pick<
58
64
  * which coupled the prompt to codex's interactive workspace-trust concept
59
65
  * and made the bridge write into the operator's own config on shared homes.
60
66
  *
61
- * Where it lands, live-probed on 0.144.1 the CLI accepts the param on all
62
- * three entrypoints but only `thread/start` APPLIES it (instructions are
63
- * baked into the thread there). `thread/resume` keeps the thread's own copy;
64
- * `thread/fork` inherits the parent's. The bridge sends it on all three
65
- * anyway: a failed resume falls back to thread/start on the same params, and
66
- * a future CLI that honors it then needs no bridge change.
67
+ * Where it lands on 0.144.1: `thread/start` bakes it in (canonical AND
68
+ * model-visible at once); a fresh-process `thread/resume` updates only the
69
+ * thread's CANONICAL configuration (a resume of a still-running thread
70
+ * ignores every override); `thread/fork` inherits the parent's; compaction
71
+ * rebuilds the model-visible context from the canonical configuration.
67
72
  *
68
- * Consequence: updateConfig() changes what the NEXT thread/start sends; it
69
- * cannot re-instruct an already-persisted thread. Inherited from the retired
70
- * channel, not introduced
71
- * docs/tech-debt/codex-persisted-thread-prompt-refresh.md.
73
+ * Consequence: updateConfig() changes what the NEXT thread open sends; for
74
+ * the persisted main thread, the lazy restart's fresh-process resume plus
75
+ * one explicit compaction converge both planes before the next turn the
76
+ * full two-plane semantics and evidence live in instructions-refresh.ts.
72
77
  */
73
78
  developerInstructions?: string;
79
+ /** Override the compaction wait budget for the instructions refresh (tests). */
80
+ instructionsCompactTimeoutMs?: number;
81
+ /** Override the post-interrupt grace for the instructions refresh (tests). */
82
+ instructionsInterruptGraceMs?: number;
74
83
  };
75
84
 
76
85
  /**
@@ -96,7 +105,22 @@ export class CodexAppServerAdapter implements DispatchAdapter {
96
105
  private readonly activeTurnIds = new Map<string, string>();
97
106
  private readonly pendingInjections = new Map<string, number>();
98
107
  private readonly resumedThreadIds = new Set<string>();
108
+ /** Threads whose reconcile compaction is in flight — tap-only traffic. */
109
+ private readonly reconcilingThreadIds = new Set<string>();
110
+ /**
111
+ * Set when a stalled compaction left (possibly) a zombie turn on the main
112
+ * thread. Until the subprocess ACTUALLY restarts, main-lane redrives must
113
+ * not open/compact/turn-start the thread: re-entering the zombie process
114
+ * lets a second compaction's watcher misattribute the zombie's close as a
115
+ * plain failure, dropping the stalled marker and re-arming the
116
+ * fresh-thread rotation this state exists to prevent. Cleared where the
117
+ * zombie dies: subprocess teardown.
118
+ */
119
+ private mainLaneQuarantined = false;
120
+ private readonly notificationTaps = new Set<NotificationTap>();
121
+ private readonly instructionsRefresher: MainThreadInstructionsRefresher;
99
122
  private stopping = false;
123
+ private lastUnroutedNotificationWarnAt = 0;
100
124
 
101
125
  /**
102
126
  * Store an active turn sink keyed by threadId. If a sink already exists for
@@ -118,7 +142,36 @@ export class CodexAppServerAdapter implements DispatchAdapter {
118
142
  this.activeTurns.set(threadId, sink);
119
143
  }
120
144
 
121
- constructor(private readonly opts: CodexAppServerAdapterOptions) {}
145
+ constructor(private readonly opts: CodexAppServerAdapterOptions) {
146
+ this.instructionsRefresher = new MainThreadInstructionsRefresher({
147
+ sessionManager: opts.sessionManager,
148
+ log: opts.log,
149
+ compactTimeoutMs: opts.instructionsCompactTimeoutMs,
150
+ interruptGraceMs: opts.instructionsInterruptGraceMs,
151
+ });
152
+ }
153
+
154
+ /** Register a listener for every server notification; returns unregister. */
155
+ addNotificationTap(tap: NotificationTap): () => void {
156
+ this.notificationTaps.add(tap);
157
+ return () => this.notificationTaps.delete(tap);
158
+ }
159
+
160
+ /**
161
+ * Tell tap waiters the subprocess is gone so they abort immediately —
162
+ * no further notifications will ever arrive, and a waiter left to its
163
+ * own timeout would stall its dispatch for the full compaction budget.
164
+ * Mirrors the global-error broadcast routeNotification does for sinks.
165
+ */
166
+ private notifyTapsDisposed(reason: string): void {
167
+ for (const tap of this.notificationTaps) {
168
+ try {
169
+ tap('error', { message: reason });
170
+ } catch {
171
+ // Taps must never break teardown.
172
+ }
173
+ }
174
+ }
122
175
 
123
176
  updateConfig(config: {
124
177
  model?: string | null;
@@ -158,7 +211,22 @@ export class CodexAppServerAdapter implements DispatchAdapter {
158
211
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
159
212
  if (!threadId) return;
160
213
  const sink = this.activeTurns.get(threadId);
161
- if (!sink) return;
214
+ // Idempotence guard (DispatchAdapter contract): the deadline timer and
215
+ // the lane-flow error path can both abort the same dispatch before the
216
+ // generator's cleanup empties activeTurns — keying on the sink's own
217
+ // terminal state makes the repeat a full no-op (one turn/interrupt on
218
+ // the wire, not two) and also dedupes against dispose/process-exit
219
+ // having already closed the sink.
220
+ if (!sink || sink.isClosed) return;
221
+ // Best-effort runtime-side stop: closing only the local sink leaves the
222
+ // app-server turn running (or parked on a server request) after the
223
+ // gateway has abandoned the dispatch.
224
+ const turnId = this.activeTurnIds.get(threadId);
225
+ if (turnId && this.client && !this.client.isDisposed()) {
226
+ this.client
227
+ .sendRequest('turn/interrupt', { threadId, turnId })
228
+ .catch((err) => this.opts.log?.warn?.(`turn/interrupt failed: ${errToString(err)}`));
229
+ }
162
230
  sink.push({ kind: 'error', message: 'dispatch deadline exceeded' });
163
231
  sink.close();
164
232
  }
@@ -193,68 +261,92 @@ export class CodexAppServerAdapter implements DispatchAdapter {
193
261
  );
194
262
  }
195
263
 
196
- await this.applyPendingRestart(context.log);
197
- await this.ensureStarted(context.log);
198
- // After ensureStarted resolves the subprocess could still die before we
199
- // capture the client (handleSubprocessClose nulls this.client). Yield a
200
- // clean error event instead of relying on a non-null assertion that would
201
- // throw a TypeError on the next sendRequest call.
202
- const client = this.client;
203
- if (!client) {
204
- yield {
205
- type: 'error',
206
- message: 'Codex app-server not available (subprocess died during dispatch start)',
207
- };
208
- return;
209
- }
210
- const log = this.opts.log ?? context.log;
211
264
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
265
+ if (isMainSession && this.mainLaneQuarantined) {
266
+ // Try the pending restart now — it may have been deferred behind an
267
+ // active fork turn when the quarantine was raised. If it still cannot
268
+ // run, fail this redrive WITHOUT touching the thread.
269
+ await this.applyPendingRestart(context.log);
270
+ if (this.mainLaneQuarantined) {
271
+ const quarantinedThreadId = this.opts.sessionManager.getThreadId(sessionKey);
272
+ if (quarantinedThreadId) {
273
+ yield {
274
+ type: 'runtime_session',
275
+ runtimeSessionId: quarantinedThreadId,
276
+ runtimeLaneKey: sessionKey,
277
+ };
278
+ }
279
+ yield {
280
+ type: 'error',
281
+ message:
282
+ 'main lane quarantined after a stalled compaction; the subprocess restart is still deferred behind an active turn — the thread is retained and this dispatch will be redriven',
283
+ };
284
+ return;
285
+ }
286
+ }
287
+ const liveThreadId = isMainSession
288
+ ? this.opts.sessionManager.getThreadId(sessionKey)
289
+ : undefined;
290
+ if (
291
+ liveThreadId &&
292
+ this.opts.developerInstructions &&
293
+ this.resumedThreadIds.has(liveThreadId) &&
294
+ this.instructionsRefresher.canonicalFor(liveThreadId) !== this.opts.developerInstructions
295
+ ) {
296
+ // Live thread opened with an older prompt: compacting now would
297
+ // rebuild that STALE text, so bounce the subprocess — only a
298
+ // fresh-process resume updates the canonical configuration. The
299
+ // platform-config refresh normally schedules this restart itself;
300
+ // this guard makes convergence independent of that ordering.
301
+ this.requestProcessRestart();
302
+ }
212
303
 
213
- let threadId = this.opts.sessionManager.getThreadId(sessionKey);
214
- if (!threadId) {
304
+ const log = this.opts.log ?? context.log;
305
+ let client!: JsonRpcStdioClient;
306
+ let threadId!: string;
307
+ let reconcileStalled = false;
308
+ for (let attempt = 0; ; attempt++) {
309
+ await this.applyPendingRestart(context.log);
310
+ // Opening counts as active work for restart gating (see
311
+ // applyPendingRestart): the main-session reconcile can run a real
312
+ // compaction turn BEFORE any TurnSink exists, and a concurrent fork
313
+ // dispatch hitting a pending restart in that window would otherwise
314
+ // stop() the subprocess out from under it.
315
+ this.openingDispatches += 1;
316
+ let opened: OpenDispatchOutcome;
215
317
  try {
216
- threadId = await this.openThread(client, { resumeId: undefined });
217
- this.opts.sessionManager.recordThreadId(sessionKey, threadId);
218
- // Mark this freshly-started thread as already live in the current
219
- // app-server process. Without this, the second dispatch after a cold
220
- // start would enter the thread/resume branch below for a thread this
221
- // process just created and if the server treats resume-of-just-
222
- // created-thread as an attach-to-detached-thread operation, it could
223
- // fail the resume path and replace the thread, losing the first turn.
224
- this.resumedThreadIds.add(threadId);
225
- } catch (err) {
226
- yield { type: 'error', message: `Codex thread/start failed: ${errToString(err)}` };
318
+ opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
319
+ } finally {
320
+ this.openingDispatches -= 1;
321
+ }
322
+ if (!opened.ok) {
323
+ yield { type: 'error', message: opened.message };
227
324
  return;
228
325
  }
229
- } else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
230
- // We have a persisted threadId from a previous bridge run — resume it.
231
- // Mirror the deferred-clear pattern from the turn/start retry below: only
232
- // discard the persisted id once a fresh-thread start has actually
233
- // succeeded, so a transient resume failure (network/timeout/upstream
234
- // hiccup) doesn't permanently throw away the prior conversation context.
235
- try {
236
- threadId = await this.openThread(client, { resumeId: threadId });
237
- this.opts.sessionManager.recordThreadId(sessionKey, threadId);
238
- this.resumedThreadIds.add(threadId);
239
- } catch (err) {
240
- log?.warn?.(
241
- `thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`,
326
+ client = opened.client;
327
+ threadId = opened.threadId;
328
+ reconcileStalled = opened.reconcile === 'stalled';
329
+ // Re-check convergence AFTER the async open/reconcile work: on the cold
330
+ // path (spawn + resume + compaction) that window is seconds, and a
331
+ // platform-config update landing inside it would otherwise run this
332
+ // turn on the prompt captured at open time. One bounded retry loops
333
+ // back through the restart path with the newer prompt. If the restart
334
+ // stays deferred (a fork turn is active) or a second update lands
335
+ // during the retry, the turn proceeds and the next dispatch converges —
336
+ // an update after this point is equivalent to one arriving mid-turn.
337
+ if (
338
+ isMainSession &&
339
+ attempt === 0 &&
340
+ this.opts.developerInstructions &&
341
+ this.instructionsRefresher.canonicalFor(threadId) !== this.opts.developerInstructions
342
+ ) {
343
+ log?.info?.(
344
+ 'platform instructions changed while the dispatch was opening the thread; bouncing the subprocess to converge before this turn',
242
345
  );
243
- let freshThreadId: string;
244
- try {
245
- freshThreadId = await this.openThread(client, { resumeId: undefined });
246
- } catch (innerErr) {
247
- yield {
248
- type: 'error',
249
- message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
250
- };
251
- return;
252
- }
253
- this.opts.sessionManager.clearMainThread();
254
- this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
255
- this.resumedThreadIds.add(freshThreadId);
256
- threadId = freshThreadId;
346
+ this.requestProcessRestart();
347
+ continue;
257
348
  }
349
+ break;
258
350
  }
259
351
 
260
352
  const sink = new TurnSink();
@@ -332,10 +424,35 @@ export class CodexAppServerAdapter implements DispatchAdapter {
332
424
  yield { type: 'error', message: `Codex turn/start failed: ${message}` };
333
425
  return;
334
426
  }
427
+ if (reconcileStalled) {
428
+ // A stalled compaction may STILL be running on this thread — a
429
+ // busy-thread rejection here is expected, not evidence of a stale
430
+ // thread, and the fresh-thread retry below would ROTATE the
431
+ // persisted main thread (continuity loss). Quarantine the main
432
+ // lane (redrives must not touch the thread until the subprocess
433
+ // actually restarts — a deferred restart would otherwise send the
434
+ // NEXT dispatch back into the zombie, where a second compaction's
435
+ // watcher can misattribute the zombie's close as a plain failure
436
+ // and lose the stalled marker) and bounce the subprocess.
437
+ this.mainLaneQuarantined = true;
438
+ this.requestProcessRestart();
439
+ this.activeTurns.delete(threadId);
440
+ yield {
441
+ type: 'runtime_session',
442
+ runtimeSessionId: threadId,
443
+ runtimeLaneKey: sessionKey,
444
+ };
445
+ yield {
446
+ type: 'error',
447
+ message: `Codex turn/start failed after a stalled compaction (thread retained; subprocess will restart): ${message}`,
448
+ };
449
+ return;
450
+ }
335
451
  log?.warn?.(
336
452
  `turn/start on thread ${threadId} failed (${message}); attempting one-shot fresh-thread retry`,
337
453
  );
338
454
  this.activeTurns.delete(threadId);
455
+ const sentInstructions = this.opts.developerInstructions;
339
456
  let freshThreadId: string;
340
457
  try {
341
458
  freshThreadId = await this.openThread(client, { resumeId: undefined });
@@ -373,7 +490,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
373
490
  // Retry accepted — the original thread really was unusable. Now safe
374
491
  // to discard the old persisted id and persist the fresh one.
375
492
  this.opts.sessionManager.clearMainThread();
376
- this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
493
+ this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
377
494
  this.resumedThreadIds.add(freshThreadId);
378
495
  threadId = freshThreadId;
379
496
  }
@@ -426,6 +543,113 @@ export class CodexAppServerAdapter implements DispatchAdapter {
426
543
  }
427
544
  }
428
545
 
546
+ /**
547
+ * Bring the app-server up and open (start or resume) the thread for this
548
+ * dispatch, then reconcile the main thread's EFFECTIVE instructions plane.
549
+ * Extracted from dispatch() so the convergence re-check there can loop back
550
+ * through the restart path without duplicating the open logic. Errors are
551
+ * returned, not thrown — the generator yields them as runtime events.
552
+ */
553
+ private async openDispatchTarget(
554
+ sessionKey: string,
555
+ isMainSession: boolean,
556
+ log?: GatewayLogger,
557
+ ): Promise<OpenDispatchOutcome> {
558
+ try {
559
+ await this.ensureStarted(log);
560
+ } catch (err) {
561
+ // Spawn/handshake failures surface as a clean error event like every
562
+ // other open failure, instead of rejecting the dispatch generator.
563
+ return { ok: false, message: `Codex app-server failed to start: ${errToString(err)}` };
564
+ }
565
+ // After ensureStarted resolves the subprocess could still die before we
566
+ // capture the client (handleSubprocessClose nulls this.client). Return a
567
+ // clean error instead of relying on a non-null assertion that would
568
+ // throw a TypeError on the next sendRequest call.
569
+ const client = this.client;
570
+ if (!client) {
571
+ return {
572
+ ok: false,
573
+ message: 'Codex app-server not available (subprocess died during dispatch start)',
574
+ };
575
+ }
576
+
577
+ let threadId = this.opts.sessionManager.getThreadId(sessionKey);
578
+ if (!threadId) {
579
+ try {
580
+ // Capture what openThread will send in the same tick as the call, so
581
+ // the recorded canonical/effective values can never reflect a NEWER
582
+ // instructions text swapped in by updateConfig() mid-flight.
583
+ const sentInstructions = this.opts.developerInstructions;
584
+ threadId = await this.openThread(client, { resumeId: undefined });
585
+ // recordBaked persists thread id + effective sha in ONE write.
586
+ this.instructionsRefresher.recordBaked(sessionKey, threadId, sentInstructions);
587
+ // Mark this freshly-started thread as already live in the current
588
+ // app-server process. Without this, the second dispatch after a cold
589
+ // start would enter the thread/resume branch below for a thread this
590
+ // process just created — and if the server treats resume-of-just-
591
+ // created-thread as an attach-to-detached-thread operation, it could
592
+ // fail the resume path and replace the thread, losing the first turn.
593
+ this.resumedThreadIds.add(threadId);
594
+ } catch (err) {
595
+ return { ok: false, message: `Codex thread/start failed: ${errToString(err)}` };
596
+ }
597
+ } else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
598
+ // We have a persisted threadId from a previous bridge run — resume it.
599
+ // Mirror the deferred-clear pattern from the turn/start retry in
600
+ // dispatch(): only discard the persisted id once a fresh-thread start
601
+ // has actually succeeded, so a transient resume failure (network/
602
+ // timeout/upstream hiccup) doesn't permanently throw away the prior
603
+ // conversation context.
604
+ const sentInstructions = this.opts.developerInstructions;
605
+ try {
606
+ threadId = await this.openThread(client, { resumeId: threadId });
607
+ this.opts.sessionManager.recordThreadId(sessionKey, threadId);
608
+ this.instructionsRefresher.recordResumed(threadId, sentInstructions);
609
+ this.resumedThreadIds.add(threadId);
610
+ } catch (err) {
611
+ log?.warn?.(
612
+ `thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`,
613
+ );
614
+ let freshThreadId: string;
615
+ try {
616
+ freshThreadId = await this.openThread(client, { resumeId: undefined });
617
+ } catch (innerErr) {
618
+ return {
619
+ ok: false,
620
+ message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
621
+ };
622
+ }
623
+ this.opts.sessionManager.clearMainThread();
624
+ this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
625
+ this.resumedThreadIds.add(freshThreadId);
626
+ threadId = freshThreadId;
627
+ }
628
+ }
629
+
630
+ let reconcile: RefreshOutcome | undefined;
631
+ if (isMainSession) {
632
+ // Converge the EFFECTIVE plane before the turn starts (posture incl.
633
+ // the stalled exception: instructions-refresh.ts). Cheap compare when
634
+ // nothing changed; one thread/compact/start when the last-known
635
+ // effective instructions differ from the thread's canonical config.
636
+ this.reconcilingThreadIds.add(threadId);
637
+ try {
638
+ reconcile = await this.instructionsRefresher.reconcileAfterOpen({
639
+ client,
640
+ taps: this,
641
+ sessionKey,
642
+ threadId,
643
+ log,
644
+ });
645
+ } finally {
646
+ this.reconcilingThreadIds.delete(threadId);
647
+ }
648
+ }
649
+
650
+ return { ok: true, client, threadId, reconcile };
651
+ }
652
+
429
653
  getBranchPoint(_sessionKey: string): string | undefined {
430
654
  // Codex app-server does not expose a branch-point API; fork scope prefix
431
655
  // provides the behavioral fallback for this runtime.
@@ -442,6 +666,14 @@ export class CodexAppServerAdapter implements DispatchAdapter {
442
666
  const forkParams: Record<string, unknown> = {
443
667
  threadId: parentThreadId,
444
668
  ephemeral: true,
669
+ // Forks must inherit the bridge's approval/sandbox contract. thread/fork
670
+ // does NOT inherit them from the parent thread — omitting them falls
671
+ // back to codex defaults (on-request approval, workspace-write), and an
672
+ // escalated command then emits an approval request the headless bridge
673
+ // can only deny, killing the fork's ability to run privileged commands
674
+ // the main session runs freely.
675
+ approvalPolicy: normalizeApprovalPolicy(this.opts.approvalPolicy),
676
+ sandbox: normalizeSandbox(this.opts.sandbox),
445
677
  };
446
678
  if (this.opts.useParallProvider) {
447
679
  forkParams.modelProvider = 'parall';
@@ -484,9 +716,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
484
716
  * to the next dispatch with no active turns — never kills an in-flight turn;
485
717
  * thread state survives via thread/resume.
486
718
  *
487
- * A restart does NOT re-instruct an already-persisted thread see the
488
- * `developerInstructions` option doc. The capability shim dir on PATH is what
489
- * makes a grant/revocation effective immediately.
719
+ * For the persisted main thread this restart is also the CANONICAL half of
720
+ * the instructions refresh: only a fresh-process thread/resume applies the
721
+ * updated developerInstructions to the thread's configuration (a resume of
722
+ * a still-running thread ignores it). The EFFECTIVE half is the explicit
723
+ * compaction in instructions-refresh.ts. The capability shim dir on PATH is
724
+ * what makes a grant/revocation's TOOLS effective immediately.
490
725
  */
491
726
  requestProcessRestart(): void {
492
727
  this.restartRequested = true;
@@ -494,11 +729,18 @@ export class CodexAppServerAdapter implements DispatchAdapter {
494
729
 
495
730
  private restartRequested = false;
496
731
 
732
+ /**
733
+ * Dispatches currently inside their open/reconcile phase — real work on
734
+ * the subprocess (thread open, possibly a compaction turn) that predates
735
+ * any TurnSink registration, so activeTurns alone cannot gate the restart.
736
+ */
737
+ private openingDispatches = 0;
738
+
497
739
  private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
498
- if (!this.restartRequested || this.activeTurns.size > 0) return;
740
+ if (!this.restartRequested || this.activeTurns.size > 0 || this.openingDispatches > 0) return;
499
741
  this.restartRequested = false;
500
742
  (log ?? this.opts.log)?.info?.(
501
- 'restarting codex app-server after a capability change (new threads pick up the refreshed developerInstructions; an already-persisted thread keeps its own)',
743
+ 'restarting codex app-server after a capability change (the fresh-process thread/resume applies the refreshed developerInstructions to the persisted thread configuration; the model-visible context is reconciled before the next turn)',
502
744
  );
503
745
  await this.stop();
504
746
  }
@@ -518,6 +760,10 @@ export class CodexAppServerAdapter implements DispatchAdapter {
518
760
  // dispatch treat the persisted thread as already resumed in the NEW
519
761
  // process and lose conversation continuity when turn/start rejects.
520
762
  this.resumedThreadIds.clear();
763
+ this.instructionsRefresher.clearThreadState();
764
+ // The (possible) zombie compaction turn dies with the subprocess.
765
+ this.mainLaneQuarantined = false;
766
+ this.notifyTapsDisposed('Codex app-server stopped');
521
767
  if (client) client.dispose(new Error('adapter stopped'));
522
768
  if (proc && proc.exitCode === null && proc.signalCode === null) {
523
769
  if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
@@ -539,6 +785,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
539
785
  this.proc = null;
540
786
  this.initialized = false;
541
787
  this.resumedThreadIds.clear();
788
+ this.instructionsRefresher.clearThreadState();
542
789
  }
543
790
  if (this.initialized && this.client) return;
544
791
  if (this.startPromise) return this.startPromise;
@@ -558,6 +805,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
558
805
  // previous stop() — otherwise handleSubprocessClose would mislabel the next
559
806
  // unexpected exit as "during graceful stop".
560
807
  this.stopping = false;
808
+ this.instructionsRefresher.resetForNewSubprocess();
561
809
  ensureGitRepo(this.opts.workspaceDir);
562
810
 
563
811
  // Only steer Codex's own state via CODEX_HOME. Leaving HOME untouched
@@ -631,6 +879,28 @@ export class CodexAppServerAdapter implements DispatchAdapter {
631
879
  }
632
880
  });
633
881
  client.setNotificationHandler((method, params) => this.routeNotification(method, params));
882
+ // Server→client requests (approvals, elicitations) can never be answered
883
+ // interactively by a headless bridge, but each one MUST get a response —
884
+ // the app-server parks the requesting turn until one arrives. Known
885
+ // approval methods get an explicit denial; anything else falls through to
886
+ // the client's method-not-found error. With approvalPolicy "never" these
887
+ // should not occur at all, so surface every occurrence at warn.
888
+ client.setServerRequestHandler((method, params) => {
889
+ const denial = answerServerRequest(method);
890
+ // Log stable identifiers only — approval params carry command lines /
891
+ // file changes / patch content that must not reach stderr or OTLP.
892
+ const p = (params ?? {}) as Record<string, unknown>;
893
+ const ids = ['threadId', 'turnId', 'itemId', 'callId']
894
+ .filter((k) => typeof p[k] === 'string')
895
+ .map((k) => `${k}=${p[k]}`)
896
+ .join(' ');
897
+ (log ?? this.opts.log)?.warn?.(
898
+ `answering app-server request "${method}" with ${
899
+ denial === undefined ? 'method-not-found error' : 'denial'
900
+ } (headless bridge cannot approve)${ids ? ` [${ids}]` : ''}`,
901
+ );
902
+ return denial === undefined ? undefined : { result: denial };
903
+ });
634
904
 
635
905
  proc.once('close', (code, signal) => this.handleSubprocessClose(proc, code, signal, log));
636
906
  proc.once('error', (err) => this.handleSubprocessClose(proc, null, null, log, err));
@@ -686,6 +956,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
686
956
  this.activeTurnIds.clear();
687
957
  this.pendingInjections.clear();
688
958
  this.resumedThreadIds.clear();
959
+ this.instructionsRefresher.clearThreadState();
960
+ this.mainLaneQuarantined = false;
961
+ this.notifyTapsDisposed(`Codex app-server ${reason}`);
689
962
  this.client = null;
690
963
  this.proc = null;
691
964
  this.initialized = false;
@@ -714,8 +987,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
714
987
  if (this.opts.developerInstructions) {
715
988
  // Typed top-level param (camelCase), NOT a raw config.toml override —
716
989
  // trust-independent, so the platform prompt loads regardless of any codex
717
- // workspace-trust state. thread/start applies it; thread/resume keeps the
718
- // thread's own copy. Sent on both — see the option doc.
990
+ // workspace-trust state. thread/start bakes it in; a fresh-process
991
+ // thread/resume applies it to the canonical configuration — see the
992
+ // option doc for the two-plane semantics.
719
993
  commonParams.developerInstructions = this.opts.developerInstructions;
720
994
  }
721
995
  if (this.opts.model) commonParams.model = this.opts.model;
@@ -744,6 +1018,16 @@ export class CodexAppServerAdapter implements DispatchAdapter {
744
1018
  }
745
1019
 
746
1020
  private routeNotification(method: string, params: unknown) {
1021
+ // Taps observe the raw stream (instructions refresh awaits its compaction
1022
+ // turn through one); the sink routing below is unaffected — including by
1023
+ // a tap that throws, which must never break turn delivery.
1024
+ for (const tap of this.notificationTaps) {
1025
+ try {
1026
+ tap(method, params);
1027
+ } catch (err) {
1028
+ this.opts.log?.warn?.(`notification tap threw: ${errToString(err)}`);
1029
+ }
1030
+ }
747
1031
  const threadId = extractThreadIdFromNotification(params);
748
1032
  if (!threadId) {
749
1033
  // Surface server-initiated generic errors to every active turn.
@@ -757,7 +1041,24 @@ export class CodexAppServerAdapter implements DispatchAdapter {
757
1041
  }
758
1042
 
759
1043
  const sink = this.activeTurns.get(threadId);
760
- if (!sink) return;
1044
+ if (!sink) {
1045
+ // A reconciling thread's compaction turn is INTENTIONALLY tap-only —
1046
+ // it runs before any TurnSink exists. Warning here would pollute every
1047
+ // successful instructions refresh and burn the shared rate-limit
1048
+ // window that exists to surface REAL orphaned turns.
1049
+ if (this.reconcilingThreadIds.has(threadId)) return;
1050
+ // Notifications for a thread with no registered sink are dropped. A
1051
+ // sustained stream of these means a turn is running that nothing is
1052
+ // consuming (threadId mismatch, sink torn down early) — the dispatch
1053
+ // side would see indefinite silence, so keep the drop visible
1054
+ // (rate-limited: these arrive per-delta during an orphaned turn).
1055
+ const now = Date.now();
1056
+ if (now - this.lastUnroutedNotificationWarnAt > 10_000) {
1057
+ this.lastUnroutedNotificationWarnAt = now;
1058
+ this.opts.log?.warn?.(`dropping notification "${method}" for unknown thread ${threadId}`);
1059
+ }
1060
+ return;
1061
+ }
761
1062
 
762
1063
  // For turn/completed we still run the mapper first — it emits a
763
1064
  // RuntimeEvent error if `turn.status === "failed"`. Enqueue those events
@@ -772,6 +1073,10 @@ export class CodexAppServerAdapter implements DispatchAdapter {
772
1073
  }
773
1074
  }
774
1075
 
1076
+ type OpenDispatchOutcome =
1077
+ | { ok: true; client: JsonRpcStdioClient; threadId: string; reconcile?: RefreshOutcome }
1078
+ | { ok: false; message: string };
1079
+
775
1080
  function errToString(err: unknown): string {
776
1081
  if (err instanceof Error) return err.message;
777
1082
  return String(err);