@parall/codex-agent 1.58.2 → 1.59.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.
@@ -1,4 +1,4 @@
1
- import type { CleanupForkOpts, DispatchAdapter, DispatchOpts, ForkOpts, ForkSessionHandle, GatewayLogger, RuntimeEvent } from '@parall/agent-core';
1
+ import type { CleanupForkOpts, DispatchAdapter, DispatchInputLifecycle, DispatchOpts, ForkOpts, ForkSessionHandle, GatewayLogger, RuntimeEvent } from '@parall/agent-core';
2
2
  import type { CodexAgentConfig } from './config.js';
3
3
  import { type NotificationTap } from './instructions-refresh.js';
4
4
  import type { CodexSessionManager } from './session-manager.js';
@@ -62,7 +62,7 @@ export declare class CodexAppServerAdapter implements DispatchAdapter {
62
62
  private startPromise;
63
63
  private readonly activeTurns;
64
64
  private readonly activeTurnIds;
65
- private readonly pendingInjections;
65
+ private readonly injections;
66
66
  private readonly resumedThreadIds;
67
67
  /** Threads whose reconcile compaction is in flight — tap-only traffic. */
68
68
  private readonly reconcilingThreadIds;
@@ -105,10 +105,12 @@ export declare class CodexAppServerAdapter implements DispatchAdapter {
105
105
  developerInstructions?: string | null;
106
106
  }): void;
107
107
  private threadConfigOverrides;
108
- enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean>;
108
+ enqueueDuringDispatch(sessionKey: string, body: string, inputLifecycle?: DispatchInputLifecycle): Promise<boolean>;
109
109
  abortDispatch(sessionKey: string): void;
110
110
  hasPendingInjections(sessionKey: string): boolean;
111
- dispatch({ event, bodyForAgent, sessionKey, context, noteActivity, }: DispatchOpts): AsyncIterable<RuntimeEvent>;
111
+ hasUnsettledInjections(sessionKey: string): boolean;
112
+ acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void;
113
+ dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, noteActivity, }: DispatchOpts): AsyncIterable<RuntimeEvent>;
112
114
  /**
113
115
  * Bring the app-server up and open (start or resume) the thread for this
114
116
  * dispatch, then reconcile the main thread's EFFECTIVE instructions plane.
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
package/dist/dispatch.js CHANGED
@@ -5,6 +5,7 @@ import { appendPreparedLocalAttachmentRefs, pinLocalAttachmentPaths, } from '@pa
5
5
  import { ensureGitRepo, IS_WIN32, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
6
6
  import { buildTurnInput, extractThreadId, extractThreadIdFromNotification, extractTurnId, } from './app-server-protocol.js';
7
7
  import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
8
+ import { CodexInjectionRegistry } from './injection-registry.js';
8
9
  import { MainThreadInstructionsRefresher, } from './instructions-refresh.js';
9
10
  import { JsonRpcStdioClient } from './jsonrpc-client.js';
10
11
  import { answerServerRequest } from './server-requests.js';
@@ -32,7 +33,7 @@ export class CodexAppServerAdapter {
32
33
  startPromise = null;
33
34
  activeTurns = new Map();
34
35
  activeTurnIds = new Map();
35
- pendingInjections = new Map();
36
+ injections = new CodexInjectionRegistry();
36
37
  resumedThreadIds = new Set();
37
38
  /** Threads whose reconcile compaction is in flight — tap-only traffic. */
38
39
  reconcilingThreadIds = new Set();
@@ -112,7 +113,10 @@ export class CodexAppServerAdapter {
112
113
  inheritedPath: process.env.PATH,
113
114
  });
114
115
  }
115
- async enqueueDuringDispatch(sessionKey, body) {
116
+ async enqueueDuringDispatch(sessionKey, body, inputLifecycle) {
117
+ // Identity is mandatory — the drain releases the injection by this key (injection-registry.ts).
118
+ if (!inputLifecycle)
119
+ return false;
116
120
  const client = this.client;
117
121
  if (!client || client.isDisposed())
118
122
  return false;
@@ -128,7 +132,7 @@ export class CodexAppServerAdapter {
128
132
  expectedTurnId: turnId,
129
133
  input: buildTurnInput(body, []),
130
134
  });
131
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
135
+ this.injections.register(sessionKey, inputLifecycle.deliveryKey);
132
136
  return true;
133
137
  }
134
138
  catch (err) {
@@ -137,7 +141,8 @@ export class CodexAppServerAdapter {
137
141
  }
138
142
  }
139
143
  abortDispatch(sessionKey) {
140
- this.pendingInjections.delete(sessionKey);
144
+ // A torn-down turn owes nothing: the error complete releases its members for fresh turns.
145
+ this.injections.clear(sessionKey);
141
146
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
142
147
  if (!threadId)
143
148
  return;
@@ -163,24 +168,19 @@ export class CodexAppServerAdapter {
163
168
  sink.close();
164
169
  }
165
170
  hasPendingInjections(sessionKey) {
166
- return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
171
+ return this.injections.hasPending(sessionKey);
167
172
  }
168
- async *dispatch({ event, bodyForAgent, sessionKey, context, noteActivity, }) {
169
- const pending = this.pendingInjections.get(sessionKey) ?? 0;
170
- if (pending > 0) {
171
- this.pendingInjections.delete(sessionKey);
172
- const threadId = this.opts.sessionManager.getThreadId(sessionKey);
173
- if (threadId && this.client && !this.client.isDisposed()) {
174
- (this.opts.log ?? context.log)?.info?.(`${pending} steer injection(s) already sent; skipping turn/start`);
175
- yield {
176
- type: 'runtime_session',
177
- runtimeSessionId: threadId,
178
- runtimeLaneKey: sessionKey,
179
- };
180
- return;
181
- }
182
- (this.opts.log ?? context.log)?.warn?.(`pending steer invalidated (subprocess died); falling through to normal dispatch`);
183
- }
173
+ hasUnsettledInjections(sessionKey) {
174
+ return this.injections.hasUnsettled(sessionKey);
175
+ }
176
+ acknowledgeDiscardedInjection(sessionKey, deliveryKey) {
177
+ this.injections.markDrained(sessionKey, deliveryKey);
178
+ }
179
+ async *dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, noteActivity, }) {
180
+ // This dispatch drains the injected copies it lists; it ALWAYS runs its own
181
+ // turn — never skipped on outstanding injections (PFB-84, injection-registry.ts).
182
+ if (inputLifecycle)
183
+ this.injections.markDrained(sessionKey, inputLifecycle.deliveryKey);
184
184
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
185
185
  if (isMainSession && this.mainLaneQuarantined) {
186
186
  // Try the pending restart now — it may have been deferred behind an
@@ -456,6 +456,7 @@ export class CodexAppServerAdapter {
456
456
  releasePreparedAttachments();
457
457
  this.activeTurns.delete(threadId);
458
458
  this.activeTurnIds.delete(threadId);
459
+ this.injections.settleAll(sessionKey); // the turn consumed (or lost) everything steered into it
459
460
  if (!sawTurnEnd) {
460
461
  sink.close();
461
462
  }
@@ -665,7 +666,7 @@ export class CodexAppServerAdapter {
665
666
  this.client = null;
666
667
  this.initialized = false;
667
668
  this.activeTurnIds.clear();
668
- this.pendingInjections.clear();
669
+ this.injections.clear();
669
670
  // Resume markers are process-local facts. Clear them HERE, synchronously:
670
671
  // the old child's close callback sees the replaced refs and skips its
671
672
  // cleanup as stale, so a marker surviving stop() would make the next
@@ -692,7 +693,7 @@ export class CodexAppServerAdapter {
692
693
  }
693
694
  this.activeTurns.clear();
694
695
  this.activeTurnIds.clear();
695
- this.pendingInjections.clear();
696
+ this.injections.clear();
696
697
  this.client = null;
697
698
  this.proc = null;
698
699
  this.initialized = false;
@@ -856,7 +857,7 @@ export class CodexAppServerAdapter {
856
857
  }
857
858
  this.activeTurns.clear();
858
859
  this.activeTurnIds.clear();
859
- this.pendingInjections.clear();
860
+ this.injections.clear();
860
861
  this.resumedThreadIds.clear();
861
862
  this.instructionsRefresher.clearThreadState();
862
863
  this.mainLaneQuarantined = false;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Bookkeeping for mid-turn steer injections (`turn/steer`), per session key.
3
+ *
4
+ * Codex gives no per-input runtime receipt: a successful `turn/steer` only
5
+ * says the app-server accepted the text into the running turn. So each
6
+ * injection carries two independent facts with two different owners:
7
+ *
8
+ * - settled — the turn it was steered into has ended (the adapter's own
9
+ * dispatch() generator closing on turn/completed, an interrupt, or the
10
+ * subprocess dying). Only an UNSETTLED injection may hold a lane's
11
+ * complete open (DispatchAdapter.hasUnsettledInjections).
12
+ * - drained — the gateway consumed the buffered copy that backs it: either
13
+ * the already-rendered drain branch (acknowledgeDiscardedInjection) or a
14
+ * group dispatch() whose inputLifecycle lists the WorkItem. Until then the
15
+ * copy still owes frame-boundary bookkeeping (hasPendingInjections).
16
+ *
17
+ * Entries are keyed by WorkItem id — the deliveryKey the gateway uses on both
18
+ * sides — never counted. The pre-PFB-84 design kept a per-session COUNT and
19
+ * made the next dispatch() skip turn/start while it was non-zero: a copy the
20
+ * gateway discarded without a dispatch() never decremented it, so the count
21
+ * leaked onto the NEXT unrelated message, whose dispatch then ran no turn and
22
+ * was complete(ok)-swept as no_action — the model never saw it.
23
+ */
24
+ export declare class CodexInjectionRegistry {
25
+ private readonly sessions;
26
+ /** Record a successful steer. `deliveryKey` may join several WorkItem ids with ','. */
27
+ register(sessionKey: string, deliveryKey: string): void;
28
+ /** The buffered copies behind these WorkItems drained (group dispatch or discard). */
29
+ markDrained(sessionKey: string, deliveryKey: string): void;
30
+ /** The turn every injection of this session was steered into has ended. */
31
+ settleAll(sessionKey: string): void;
32
+ /** An injection whose buffered copy has not drained yet (bookkeeping owed). */
33
+ hasPending(sessionKey: string): boolean;
34
+ /** An injection whose turn is still running (the only state that may defer a complete). */
35
+ hasUnsettled(sessionKey: string): boolean;
36
+ /** Drop every entry (turn aborted / subprocess gone): nothing is owed anymore. */
37
+ clear(sessionKey?: string): void;
38
+ }
39
+ //# sourceMappingURL=injection-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"injection-registry.d.ts","sourceRoot":"","sources":["../src/injection-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAsB;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkD;IAE3E,uFAAuF;IACvF,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI;IAWvD,sFAAsF;IACtF,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI;IAY1D,2EAA2E;IAC3E,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAUnC,+EAA+E;IAC/E,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IASvC,2FAA2F;IAC3F,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IASzC,kFAAkF;IAClF,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;CAOjC"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Bookkeeping for mid-turn steer injections (`turn/steer`), per session key.
3
+ *
4
+ * Codex gives no per-input runtime receipt: a successful `turn/steer` only
5
+ * says the app-server accepted the text into the running turn. So each
6
+ * injection carries two independent facts with two different owners:
7
+ *
8
+ * - settled — the turn it was steered into has ended (the adapter's own
9
+ * dispatch() generator closing on turn/completed, an interrupt, or the
10
+ * subprocess dying). Only an UNSETTLED injection may hold a lane's
11
+ * complete open (DispatchAdapter.hasUnsettledInjections).
12
+ * - drained — the gateway consumed the buffered copy that backs it: either
13
+ * the already-rendered drain branch (acknowledgeDiscardedInjection) or a
14
+ * group dispatch() whose inputLifecycle lists the WorkItem. Until then the
15
+ * copy still owes frame-boundary bookkeeping (hasPendingInjections).
16
+ *
17
+ * Entries are keyed by WorkItem id — the deliveryKey the gateway uses on both
18
+ * sides — never counted. The pre-PFB-84 design kept a per-session COUNT and
19
+ * made the next dispatch() skip turn/start while it was non-zero: a copy the
20
+ * gateway discarded without a dispatch() never decremented it, so the count
21
+ * leaked onto the NEXT unrelated message, whose dispatch then ran no turn and
22
+ * was complete(ok)-swept as no_action — the model never saw it.
23
+ */
24
+ export class CodexInjectionRegistry {
25
+ sessions = new Map();
26
+ /** Record a successful steer. `deliveryKey` may join several WorkItem ids with ','. */
27
+ register(sessionKey, deliveryKey) {
28
+ let entries = this.sessions.get(sessionKey);
29
+ if (!entries) {
30
+ entries = new Map();
31
+ this.sessions.set(sessionKey, entries);
32
+ }
33
+ for (const id of splitDeliveryKey(deliveryKey)) {
34
+ if (!entries.has(id))
35
+ entries.set(id, { settled: false, drained: false });
36
+ }
37
+ }
38
+ /** The buffered copies behind these WorkItems drained (group dispatch or discard). */
39
+ markDrained(sessionKey, deliveryKey) {
40
+ const entries = this.sessions.get(sessionKey);
41
+ if (!entries)
42
+ return;
43
+ for (const id of splitDeliveryKey(deliveryKey)) {
44
+ const entry = entries.get(id);
45
+ if (!entry)
46
+ continue;
47
+ entry.drained = true;
48
+ if (entry.settled)
49
+ entries.delete(id);
50
+ }
51
+ if (entries.size === 0)
52
+ this.sessions.delete(sessionKey);
53
+ }
54
+ /** The turn every injection of this session was steered into has ended. */
55
+ settleAll(sessionKey) {
56
+ const entries = this.sessions.get(sessionKey);
57
+ if (!entries)
58
+ return;
59
+ for (const [id, entry] of entries) {
60
+ entry.settled = true;
61
+ if (entry.drained)
62
+ entries.delete(id);
63
+ }
64
+ if (entries.size === 0)
65
+ this.sessions.delete(sessionKey);
66
+ }
67
+ /** An injection whose buffered copy has not drained yet (bookkeeping owed). */
68
+ hasPending(sessionKey) {
69
+ const entries = this.sessions.get(sessionKey);
70
+ if (!entries)
71
+ return false;
72
+ for (const entry of entries.values()) {
73
+ if (!entry.drained)
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /** An injection whose turn is still running (the only state that may defer a complete). */
79
+ hasUnsettled(sessionKey) {
80
+ const entries = this.sessions.get(sessionKey);
81
+ if (!entries)
82
+ return false;
83
+ for (const entry of entries.values()) {
84
+ if (!entry.settled)
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+ /** Drop every entry (turn aborted / subprocess gone): nothing is owed anymore. */
90
+ clear(sessionKey) {
91
+ if (sessionKey === undefined) {
92
+ this.sessions.clear();
93
+ return;
94
+ }
95
+ this.sessions.delete(sessionKey);
96
+ }
97
+ }
98
+ function splitDeliveryKey(deliveryKey) {
99
+ return deliveryKey
100
+ .split(',')
101
+ .map((id) => id.trim())
102
+ .filter((id) => id.length > 0);
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.58.2",
3
+ "version": "1.59.0",
4
4
  "description": "Codex CLI bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,9 +25,9 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "@parall/agent-core": "1.58.2",
29
- "@parall/cli": "1.58.2",
30
- "@parall/sdk": "1.58.2"
28
+ "@parall/agent-core": "1.59.0",
29
+ "@parall/cli": "1.59.0",
30
+ "@parall/sdk": "1.59.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",
package/src/dispatch.ts CHANGED
@@ -4,6 +4,7 @@ import * as path from 'node:path';
4
4
  import type {
5
5
  CleanupForkOpts,
6
6
  DispatchAdapter,
7
+ DispatchInputLifecycle,
7
8
  DispatchOpts,
8
9
  ForkOpts,
9
10
  ForkSessionHandle,
@@ -24,6 +25,7 @@ import {
24
25
  } from './app-server-protocol.js';
25
26
  import type { CodexAgentConfig } from './config.js';
26
27
  import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
28
+ import { CodexInjectionRegistry } from './injection-registry.js';
27
29
  import {
28
30
  MainThreadInstructionsRefresher,
29
31
  type NotificationTap,
@@ -104,7 +106,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
104
106
  private startPromise: Promise<void> | null = null;
105
107
  private readonly activeTurns = new Map<string, TurnSink>();
106
108
  private readonly activeTurnIds = new Map<string, string>();
107
- private readonly pendingInjections = new Map<string, number>();
109
+ private readonly injections = new CodexInjectionRegistry();
108
110
  private readonly resumedThreadIds = new Set<string>();
109
111
  /** Threads whose reconcile compaction is in flight — tap-only traffic. */
110
112
  private readonly reconcilingThreadIds = new Set<string>();
@@ -194,7 +196,13 @@ export class CodexAppServerAdapter implements DispatchAdapter {
194
196
  });
195
197
  }
196
198
 
197
- async enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean> {
199
+ async enqueueDuringDispatch(
200
+ sessionKey: string,
201
+ body: string,
202
+ inputLifecycle?: DispatchInputLifecycle,
203
+ ): Promise<boolean> {
204
+ // Identity is mandatory — the drain releases the injection by this key (injection-registry.ts).
205
+ if (!inputLifecycle) return false;
198
206
  const client = this.client;
199
207
  if (!client || client.isDisposed()) return false;
200
208
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
@@ -207,7 +215,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
207
215
  expectedTurnId: turnId,
208
216
  input: buildTurnInput(body, []),
209
217
  });
210
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
218
+ this.injections.register(sessionKey, inputLifecycle.deliveryKey);
211
219
  return true;
212
220
  } catch (err) {
213
221
  this.opts.log?.warn?.(`turn/steer failed: ${errToString(err)}`);
@@ -216,7 +224,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
216
224
  }
217
225
 
218
226
  abortDispatch(sessionKey: string): void {
219
- this.pendingInjections.delete(sessionKey);
227
+ // A torn-down turn owes nothing: the error complete releases its members for fresh turns.
228
+ this.injections.clear(sessionKey);
220
229
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
221
230
  if (!threadId) return;
222
231
  const sink = this.activeTurns.get(threadId);
@@ -241,7 +250,15 @@ export class CodexAppServerAdapter implements DispatchAdapter {
241
250
  }
242
251
 
243
252
  hasPendingInjections(sessionKey: string): boolean {
244
- return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
253
+ return this.injections.hasPending(sessionKey);
254
+ }
255
+
256
+ hasUnsettledInjections(sessionKey: string): boolean {
257
+ return this.injections.hasUnsettled(sessionKey);
258
+ }
259
+
260
+ acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void {
261
+ this.injections.markDrained(sessionKey, deliveryKey);
245
262
  }
246
263
 
247
264
  async *dispatch({
@@ -249,27 +266,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
249
266
  bodyForAgent,
250
267
  sessionKey,
251
268
  context,
269
+ inputLifecycle,
252
270
  noteActivity,
253
271
  }: DispatchOpts): AsyncIterable<RuntimeEvent> {
254
- const pending = this.pendingInjections.get(sessionKey) ?? 0;
255
- if (pending > 0) {
256
- this.pendingInjections.delete(sessionKey);
257
- const threadId = this.opts.sessionManager.getThreadId(sessionKey);
258
- if (threadId && this.client && !this.client.isDisposed()) {
259
- (this.opts.log ?? context.log)?.info?.(
260
- `${pending} steer injection(s) already sent; skipping turn/start`,
261
- );
262
- yield {
263
- type: 'runtime_session',
264
- runtimeSessionId: threadId,
265
- runtimeLaneKey: sessionKey,
266
- };
267
- return;
268
- }
269
- (this.opts.log ?? context.log)?.warn?.(
270
- `pending steer invalidated (subprocess died); falling through to normal dispatch`,
271
- );
272
- }
272
+ // This dispatch drains the injected copies it lists; it ALWAYS runs its own
273
+ // turn — never skipped on outstanding injections (PFB-84, injection-registry.ts).
274
+ if (inputLifecycle) this.injections.markDrained(sessionKey, inputLifecycle.deliveryKey);
273
275
 
274
276
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
275
277
  if (isMainSession && this.mainLaneQuarantined) {
@@ -552,6 +554,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
552
554
  releasePreparedAttachments();
553
555
  this.activeTurns.delete(threadId!);
554
556
  this.activeTurnIds.delete(threadId!);
557
+ this.injections.settleAll(sessionKey); // the turn consumed (or lost) everything steered into it
555
558
  if (!sawTurnEnd) {
556
559
  sink.close();
557
560
  }
@@ -770,7 +773,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
770
773
  this.client = null;
771
774
  this.initialized = false;
772
775
  this.activeTurnIds.clear();
773
- this.pendingInjections.clear();
776
+ this.injections.clear();
774
777
  // Resume markers are process-local facts. Clear them HERE, synchronously:
775
778
  // the old child's close callback sees the replaced refs and skips its
776
779
  // cleanup as stale, so a marker surviving stop() would make the next
@@ -797,7 +800,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
797
800
  }
798
801
  this.activeTurns.clear();
799
802
  this.activeTurnIds.clear();
800
- this.pendingInjections.clear();
803
+ this.injections.clear();
801
804
  this.client = null;
802
805
  this.proc = null;
803
806
  this.initialized = false;
@@ -977,7 +980,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
977
980
  }
978
981
  this.activeTurns.clear();
979
982
  this.activeTurnIds.clear();
980
- this.pendingInjections.clear();
983
+ this.injections.clear();
981
984
  this.resumedThreadIds.clear();
982
985
  this.instructionsRefresher.clearThreadState();
983
986
  this.mainLaneQuarantined = false;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Bookkeeping for mid-turn steer injections (`turn/steer`), per session key.
3
+ *
4
+ * Codex gives no per-input runtime receipt: a successful `turn/steer` only
5
+ * says the app-server accepted the text into the running turn. So each
6
+ * injection carries two independent facts with two different owners:
7
+ *
8
+ * - settled — the turn it was steered into has ended (the adapter's own
9
+ * dispatch() generator closing on turn/completed, an interrupt, or the
10
+ * subprocess dying). Only an UNSETTLED injection may hold a lane's
11
+ * complete open (DispatchAdapter.hasUnsettledInjections).
12
+ * - drained — the gateway consumed the buffered copy that backs it: either
13
+ * the already-rendered drain branch (acknowledgeDiscardedInjection) or a
14
+ * group dispatch() whose inputLifecycle lists the WorkItem. Until then the
15
+ * copy still owes frame-boundary bookkeeping (hasPendingInjections).
16
+ *
17
+ * Entries are keyed by WorkItem id — the deliveryKey the gateway uses on both
18
+ * sides — never counted. The pre-PFB-84 design kept a per-session COUNT and
19
+ * made the next dispatch() skip turn/start while it was non-zero: a copy the
20
+ * gateway discarded without a dispatch() never decremented it, so the count
21
+ * leaked onto the NEXT unrelated message, whose dispatch then ran no turn and
22
+ * was complete(ok)-swept as no_action — the model never saw it.
23
+ */
24
+ export class CodexInjectionRegistry {
25
+ private readonly sessions = new Map<string, Map<string, InjectionState>>();
26
+
27
+ /** Record a successful steer. `deliveryKey` may join several WorkItem ids with ','. */
28
+ register(sessionKey: string, deliveryKey: string): void {
29
+ let entries = this.sessions.get(sessionKey);
30
+ if (!entries) {
31
+ entries = new Map();
32
+ this.sessions.set(sessionKey, entries);
33
+ }
34
+ for (const id of splitDeliveryKey(deliveryKey)) {
35
+ if (!entries.has(id)) entries.set(id, { settled: false, drained: false });
36
+ }
37
+ }
38
+
39
+ /** The buffered copies behind these WorkItems drained (group dispatch or discard). */
40
+ markDrained(sessionKey: string, deliveryKey: string): void {
41
+ const entries = this.sessions.get(sessionKey);
42
+ if (!entries) return;
43
+ for (const id of splitDeliveryKey(deliveryKey)) {
44
+ const entry = entries.get(id);
45
+ if (!entry) continue;
46
+ entry.drained = true;
47
+ if (entry.settled) entries.delete(id);
48
+ }
49
+ if (entries.size === 0) this.sessions.delete(sessionKey);
50
+ }
51
+
52
+ /** The turn every injection of this session was steered into has ended. */
53
+ settleAll(sessionKey: string): void {
54
+ const entries = this.sessions.get(sessionKey);
55
+ if (!entries) return;
56
+ for (const [id, entry] of entries) {
57
+ entry.settled = true;
58
+ if (entry.drained) entries.delete(id);
59
+ }
60
+ if (entries.size === 0) this.sessions.delete(sessionKey);
61
+ }
62
+
63
+ /** An injection whose buffered copy has not drained yet (bookkeeping owed). */
64
+ hasPending(sessionKey: string): boolean {
65
+ const entries = this.sessions.get(sessionKey);
66
+ if (!entries) return false;
67
+ for (const entry of entries.values()) {
68
+ if (!entry.drained) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** An injection whose turn is still running (the only state that may defer a complete). */
74
+ hasUnsettled(sessionKey: string): boolean {
75
+ const entries = this.sessions.get(sessionKey);
76
+ if (!entries) return false;
77
+ for (const entry of entries.values()) {
78
+ if (!entry.settled) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /** Drop every entry (turn aborted / subprocess gone): nothing is owed anymore. */
84
+ clear(sessionKey?: string): void {
85
+ if (sessionKey === undefined) {
86
+ this.sessions.clear();
87
+ return;
88
+ }
89
+ this.sessions.delete(sessionKey);
90
+ }
91
+ }
92
+
93
+ type InjectionState = { settled: boolean; drained: boolean };
94
+
95
+ function splitDeliveryKey(deliveryKey: string): string[] {
96
+ return deliveryKey
97
+ .split(',')
98
+ .map((id) => id.trim())
99
+ .filter((id) => id.length > 0);
100
+ }