@parall/codex-agent 1.58.1 → 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;
@@ -104,10 +104,13 @@ export declare class CodexAppServerAdapter implements DispatchAdapter {
104
104
  reasoningEffort?: string | null;
105
105
  developerInstructions?: string | null;
106
106
  }): void;
107
- enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean>;
107
+ private threadConfigOverrides;
108
+ enqueueDuringDispatch(sessionKey: string, body: string, inputLifecycle?: DispatchInputLifecycle): Promise<boolean>;
108
109
  abortDispatch(sessionKey: string): void;
109
110
  hasPendingInjections(sessionKey: string): boolean;
110
- 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>;
111
114
  /**
112
115
  * Bring the app-server up and open (start or resume) the thread for this
113
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,9 +5,11 @@ 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';
12
+ import { buildThreadConfigOverrides } from './thread-config.js';
11
13
  import { TurnSink } from './turn-sink.js';
12
14
  /**
13
15
  * Bridge driver backed by `codex app-server --listen stdio://`.
@@ -31,7 +33,7 @@ export class CodexAppServerAdapter {
31
33
  startPromise = null;
32
34
  activeTurns = new Map();
33
35
  activeTurnIds = new Map();
34
- pendingInjections = new Map();
36
+ injections = new CodexInjectionRegistry();
35
37
  resumedThreadIds = new Set();
36
38
  /** Threads whose reconcile compaction is in flight — tap-only traffic. */
37
39
  reconcilingThreadIds = new Set();
@@ -104,7 +106,17 @@ export class CodexAppServerAdapter {
104
106
  if (config.developerInstructions !== undefined)
105
107
  this.opts.developerInstructions = config.developerInstructions ?? undefined;
106
108
  }
107
- async enqueueDuringDispatch(sessionKey, body) {
109
+ threadConfigOverrides() {
110
+ return buildThreadConfigOverrides({
111
+ reasoningEffort: this.opts.reasoningEffort,
112
+ capabilityBinDir: this.opts.capabilityBinDir,
113
+ inheritedPath: process.env.PATH,
114
+ });
115
+ }
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;
108
120
  const client = this.client;
109
121
  if (!client || client.isDisposed())
110
122
  return false;
@@ -120,7 +132,7 @@ export class CodexAppServerAdapter {
120
132
  expectedTurnId: turnId,
121
133
  input: buildTurnInput(body, []),
122
134
  });
123
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
135
+ this.injections.register(sessionKey, inputLifecycle.deliveryKey);
124
136
  return true;
125
137
  }
126
138
  catch (err) {
@@ -129,7 +141,8 @@ export class CodexAppServerAdapter {
129
141
  }
130
142
  }
131
143
  abortDispatch(sessionKey) {
132
- 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);
133
146
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
134
147
  if (!threadId)
135
148
  return;
@@ -155,24 +168,19 @@ export class CodexAppServerAdapter {
155
168
  sink.close();
156
169
  }
157
170
  hasPendingInjections(sessionKey) {
158
- return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
171
+ return this.injections.hasPending(sessionKey);
159
172
  }
160
- async *dispatch({ event, bodyForAgent, sessionKey, context, noteActivity, }) {
161
- const pending = this.pendingInjections.get(sessionKey) ?? 0;
162
- if (pending > 0) {
163
- this.pendingInjections.delete(sessionKey);
164
- const threadId = this.opts.sessionManager.getThreadId(sessionKey);
165
- if (threadId && this.client && !this.client.isDisposed()) {
166
- (this.opts.log ?? context.log)?.info?.(`${pending} steer injection(s) already sent; skipping turn/start`);
167
- yield {
168
- type: 'runtime_session',
169
- runtimeSessionId: threadId,
170
- runtimeLaneKey: sessionKey,
171
- };
172
- return;
173
- }
174
- (this.opts.log ?? context.log)?.warn?.(`pending steer invalidated (subprocess died); falling through to normal dispatch`);
175
- }
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);
176
184
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
177
185
  if (isMainSession && this.mainLaneQuarantined) {
178
186
  // Try the pending restart now — it may have been deferred behind an
@@ -448,6 +456,7 @@ export class CodexAppServerAdapter {
448
456
  releasePreparedAttachments();
449
457
  this.activeTurns.delete(threadId);
450
458
  this.activeTurnIds.delete(threadId);
459
+ this.injections.settleAll(sessionKey); // the turn consumed (or lost) everything steered into it
451
460
  if (!sawTurnEnd) {
452
461
  sink.close();
453
462
  }
@@ -596,10 +605,9 @@ export class CodexAppServerAdapter {
596
605
  }
597
606
  if (this.opts.model)
598
607
  forkParams.model = this.opts.model;
599
- if (this.opts.reasoningEffort) {
600
- // Raw config.toml key — see openThread for the snake_case rationale.
601
- forkParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
602
- }
608
+ const threadConfig = this.threadConfigOverrides();
609
+ if (threadConfig)
610
+ forkParams.config = threadConfig;
603
611
  const result = await client.sendRequest('thread/fork', forkParams);
604
612
  const forkedThreadId = extractThreadId(result);
605
613
  if (!forkedThreadId) {
@@ -658,7 +666,7 @@ export class CodexAppServerAdapter {
658
666
  this.client = null;
659
667
  this.initialized = false;
660
668
  this.activeTurnIds.clear();
661
- this.pendingInjections.clear();
669
+ this.injections.clear();
662
670
  // Resume markers are process-local facts. Clear them HERE, synchronously:
663
671
  // the old child's close callback sees the replaced refs and skips its
664
672
  // cleanup as stale, so a marker surviving stop() would make the next
@@ -685,7 +693,7 @@ export class CodexAppServerAdapter {
685
693
  }
686
694
  this.activeTurns.clear();
687
695
  this.activeTurnIds.clear();
688
- this.pendingInjections.clear();
696
+ this.injections.clear();
689
697
  this.client = null;
690
698
  this.proc = null;
691
699
  this.initialized = false;
@@ -849,7 +857,7 @@ export class CodexAppServerAdapter {
849
857
  }
850
858
  this.activeTurns.clear();
851
859
  this.activeTurnIds.clear();
852
- this.pendingInjections.clear();
860
+ this.injections.clear();
853
861
  this.resumedThreadIds.clear();
854
862
  this.instructionsRefresher.clearThreadState();
855
863
  this.mainLaneQuarantined = false;
@@ -885,16 +893,14 @@ export class CodexAppServerAdapter {
885
893
  }
886
894
  if (this.opts.model)
887
895
  commonParams.model = this.opts.model;
888
- if (this.opts.reasoningEffort) {
889
- // The nested `config` object is raw config.toml overrides and keeps the
890
- // TOML key names `model_reasoning_effort` (snake_case) — unlike the
891
- // typed top-level params (`approvalPolicy` / `sandbox`), which are
892
- // camelCase. Empirically verified against both pinned CLI generations
893
- // (0.133.0 and 0.144.1): the camelCase key is silently ignored
894
- // (thread/start echoes reasoningEffort: null), the snake_case key is
895
- // applied and echoed back.
896
- commonParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
897
- }
896
+ // The nested `config` object is raw config.toml overrides and keeps TOML
897
+ // key names (for example model_reasoning_effort, allow_login_shell, and
898
+ // shell_environment_policy), unlike the typed camelCase top-level params.
899
+ // The reasoning key's casing is verified against pinned CLI generations;
900
+ // the shell policy is the official Codex config surface.
901
+ const threadConfig = this.threadConfigOverrides();
902
+ if (threadConfig)
903
+ commonParams.config = threadConfig;
898
904
  const method = opts.resumeId ? 'thread/resume' : 'thread/start';
899
905
  const params = opts.resumeId
900
906
  ? {
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ export type ThreadConfigOverrideOptions = {
2
+ reasoningEffort?: string;
3
+ capabilityBinDir?: string;
4
+ inheritedPath?: string;
5
+ platform?: NodeJS.Platform;
6
+ };
7
+ /**
8
+ * Build the raw config.toml overrides shared by thread start/resume/fork.
9
+ *
10
+ * Codex normally allows POSIX tools to use a login shell. On macOS,
11
+ * /etc/zprofile's path_helper (and an operator's own profile) can reorder PATH
12
+ * after the app-server inherited it, moving a globally-installed vendor CLI
13
+ * ahead of Parall's credential-broker pointer. Force non-login execution,
14
+ * disable profile-based command translation, and pin the already-resolved
15
+ * daemon PATH with the capability directory first. The daemon resolves the
16
+ * operator's runtime/tool PATH before spawning this bridge, so commands keep
17
+ * the same executable search path without a per-command profile reload.
18
+ */
19
+ export declare function buildThreadConfigOverrides({ reasoningEffort, capabilityBinDir, inheritedPath, platform, }: ThreadConfigOverrideOptions): Record<string, unknown> | undefined;
20
+ //# sourceMappingURL=thread-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thread-config.d.ts","sourceRoot":"","sources":["../src/thread-config.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,2BAA2B,GAAG;IACxC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CAC5B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,QAA2B,GAC5B,EAAE,2BAA2B,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAoBnE"}
@@ -0,0 +1,33 @@
1
+ import * as path from 'node:path';
2
+ /**
3
+ * Build the raw config.toml overrides shared by thread start/resume/fork.
4
+ *
5
+ * Codex normally allows POSIX tools to use a login shell. On macOS,
6
+ * /etc/zprofile's path_helper (and an operator's own profile) can reorder PATH
7
+ * after the app-server inherited it, moving a globally-installed vendor CLI
8
+ * ahead of Parall's credential-broker pointer. Force non-login execution,
9
+ * disable profile-based command translation, and pin the already-resolved
10
+ * daemon PATH with the capability directory first. The daemon resolves the
11
+ * operator's runtime/tool PATH before spawning this bridge, so commands keep
12
+ * the same executable search path without a per-command profile reload.
13
+ */
14
+ export function buildThreadConfigOverrides({ reasoningEffort, capabilityBinDir, inheritedPath, platform = process.platform, }) {
15
+ const config = {};
16
+ if (reasoningEffort)
17
+ config.model_reasoning_effort = reasoningEffort;
18
+ // Windows shells do not have the POSIX login-profile reorder that this
19
+ // override addresses; the app-server process PATH already carries the .cmd
20
+ // pointer directory there.
21
+ if (capabilityBinDir && platform !== 'win32') {
22
+ const pathEntries = (inheritedPath ?? '')
23
+ .split(path.delimiter)
24
+ .filter((entry) => entry && entry !== capabilityBinDir);
25
+ const effectivePath = [capabilityBinDir, ...pathEntries].join(path.delimiter);
26
+ config.allow_login_shell = false;
27
+ config.shell_environment_policy = {
28
+ experimental_use_profile: false,
29
+ set: { PATH: effectivePath },
30
+ };
31
+ }
32
+ return Object.keys(config).length > 0 ? config : undefined;
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.58.1",
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.1",
29
- "@parall/sdk": "1.58.1",
30
- "@parall/cli": "1.58.1"
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,
@@ -32,6 +34,7 @@ import {
32
34
  import { JsonRpcStdioClient } from './jsonrpc-client.js';
33
35
  import { answerServerRequest } from './server-requests.js';
34
36
  import type { CodexSessionManager } from './session-manager.js';
37
+ import { buildThreadConfigOverrides } from './thread-config.js';
35
38
  import { TurnSink } from './turn-sink.js';
36
39
 
37
40
  type CodexAppServerAdapterOptions = Pick<
@@ -103,7 +106,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
103
106
  private startPromise: Promise<void> | null = null;
104
107
  private readonly activeTurns = new Map<string, TurnSink>();
105
108
  private readonly activeTurnIds = new Map<string, string>();
106
- private readonly pendingInjections = new Map<string, number>();
109
+ private readonly injections = new CodexInjectionRegistry();
107
110
  private readonly resumedThreadIds = new Set<string>();
108
111
  /** Threads whose reconcile compaction is in flight — tap-only traffic. */
109
112
  private readonly reconcilingThreadIds = new Set<string>();
@@ -185,7 +188,21 @@ export class CodexAppServerAdapter implements DispatchAdapter {
185
188
  this.opts.developerInstructions = config.developerInstructions ?? undefined;
186
189
  }
187
190
 
188
- async enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean> {
191
+ private threadConfigOverrides(): Record<string, unknown> | undefined {
192
+ return buildThreadConfigOverrides({
193
+ reasoningEffort: this.opts.reasoningEffort,
194
+ capabilityBinDir: this.opts.capabilityBinDir,
195
+ inheritedPath: process.env.PATH,
196
+ });
197
+ }
198
+
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;
189
206
  const client = this.client;
190
207
  if (!client || client.isDisposed()) return false;
191
208
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
@@ -198,7 +215,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
198
215
  expectedTurnId: turnId,
199
216
  input: buildTurnInput(body, []),
200
217
  });
201
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
218
+ this.injections.register(sessionKey, inputLifecycle.deliveryKey);
202
219
  return true;
203
220
  } catch (err) {
204
221
  this.opts.log?.warn?.(`turn/steer failed: ${errToString(err)}`);
@@ -207,7 +224,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
207
224
  }
208
225
 
209
226
  abortDispatch(sessionKey: string): void {
210
- 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);
211
229
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
212
230
  if (!threadId) return;
213
231
  const sink = this.activeTurns.get(threadId);
@@ -232,7 +250,15 @@ export class CodexAppServerAdapter implements DispatchAdapter {
232
250
  }
233
251
 
234
252
  hasPendingInjections(sessionKey: string): boolean {
235
- 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);
236
262
  }
237
263
 
238
264
  async *dispatch({
@@ -240,27 +266,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
240
266
  bodyForAgent,
241
267
  sessionKey,
242
268
  context,
269
+ inputLifecycle,
243
270
  noteActivity,
244
271
  }: DispatchOpts): AsyncIterable<RuntimeEvent> {
245
- const pending = this.pendingInjections.get(sessionKey) ?? 0;
246
- if (pending > 0) {
247
- this.pendingInjections.delete(sessionKey);
248
- const threadId = this.opts.sessionManager.getThreadId(sessionKey);
249
- if (threadId && this.client && !this.client.isDisposed()) {
250
- (this.opts.log ?? context.log)?.info?.(
251
- `${pending} steer injection(s) already sent; skipping turn/start`,
252
- );
253
- yield {
254
- type: 'runtime_session',
255
- runtimeSessionId: threadId,
256
- runtimeLaneKey: sessionKey,
257
- };
258
- return;
259
- }
260
- (this.opts.log ?? context.log)?.warn?.(
261
- `pending steer invalidated (subprocess died); falling through to normal dispatch`,
262
- );
263
- }
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);
264
275
 
265
276
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
266
277
  if (isMainSession && this.mainLaneQuarantined) {
@@ -543,6 +554,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
543
554
  releasePreparedAttachments();
544
555
  this.activeTurns.delete(threadId!);
545
556
  this.activeTurnIds.delete(threadId!);
557
+ this.injections.settleAll(sessionKey); // the turn consumed (or lost) everything steered into it
546
558
  if (!sawTurnEnd) {
547
559
  sink.close();
548
560
  }
@@ -694,10 +706,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
694
706
  forkParams.developerInstructions = this.opts.developerInstructions;
695
707
  }
696
708
  if (this.opts.model) forkParams.model = this.opts.model;
697
- if (this.opts.reasoningEffort) {
698
- // Raw config.toml key — see openThread for the snake_case rationale.
699
- forkParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
700
- }
709
+ const threadConfig = this.threadConfigOverrides();
710
+ if (threadConfig) forkParams.config = threadConfig;
701
711
  const result = await client.sendRequest('thread/fork', forkParams);
702
712
  const forkedThreadId = extractThreadId(result);
703
713
  if (!forkedThreadId) {
@@ -763,7 +773,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
763
773
  this.client = null;
764
774
  this.initialized = false;
765
775
  this.activeTurnIds.clear();
766
- this.pendingInjections.clear();
776
+ this.injections.clear();
767
777
  // Resume markers are process-local facts. Clear them HERE, synchronously:
768
778
  // the old child's close callback sees the replaced refs and skips its
769
779
  // cleanup as stale, so a marker surviving stop() would make the next
@@ -790,7 +800,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
790
800
  }
791
801
  this.activeTurns.clear();
792
802
  this.activeTurnIds.clear();
793
- this.pendingInjections.clear();
803
+ this.injections.clear();
794
804
  this.client = null;
795
805
  this.proc = null;
796
806
  this.initialized = false;
@@ -970,7 +980,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
970
980
  }
971
981
  this.activeTurns.clear();
972
982
  this.activeTurnIds.clear();
973
- this.pendingInjections.clear();
983
+ this.injections.clear();
974
984
  this.resumedThreadIds.clear();
975
985
  this.instructionsRefresher.clearThreadState();
976
986
  this.mainLaneQuarantined = false;
@@ -1009,16 +1019,13 @@ export class CodexAppServerAdapter implements DispatchAdapter {
1009
1019
  commonParams.developerInstructions = this.opts.developerInstructions;
1010
1020
  }
1011
1021
  if (this.opts.model) commonParams.model = this.opts.model;
1012
- if (this.opts.reasoningEffort) {
1013
- // The nested `config` object is raw config.toml overrides and keeps the
1014
- // TOML key names `model_reasoning_effort` (snake_case) — unlike the
1015
- // typed top-level params (`approvalPolicy` / `sandbox`), which are
1016
- // camelCase. Empirically verified against both pinned CLI generations
1017
- // (0.133.0 and 0.144.1): the camelCase key is silently ignored
1018
- // (thread/start echoes reasoningEffort: null), the snake_case key is
1019
- // applied and echoed back.
1020
- commonParams.config = { model_reasoning_effort: this.opts.reasoningEffort };
1021
- }
1022
+ // The nested `config` object is raw config.toml overrides and keeps TOML
1023
+ // key names (for example model_reasoning_effort, allow_login_shell, and
1024
+ // shell_environment_policy), unlike the typed camelCase top-level params.
1025
+ // The reasoning key's casing is verified against pinned CLI generations;
1026
+ // the shell policy is the official Codex config surface.
1027
+ const threadConfig = this.threadConfigOverrides();
1028
+ if (threadConfig) commonParams.config = threadConfig;
1022
1029
 
1023
1030
  const method = opts.resumeId ? 'thread/resume' : 'thread/start';
1024
1031
  const params: Record<string, unknown> = opts.resumeId
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import * as path from 'node:path';
2
+
3
+ export type ThreadConfigOverrideOptions = {
4
+ reasoningEffort?: string;
5
+ capabilityBinDir?: string;
6
+ inheritedPath?: string;
7
+ platform?: NodeJS.Platform;
8
+ };
9
+
10
+ /**
11
+ * Build the raw config.toml overrides shared by thread start/resume/fork.
12
+ *
13
+ * Codex normally allows POSIX tools to use a login shell. On macOS,
14
+ * /etc/zprofile's path_helper (and an operator's own profile) can reorder PATH
15
+ * after the app-server inherited it, moving a globally-installed vendor CLI
16
+ * ahead of Parall's credential-broker pointer. Force non-login execution,
17
+ * disable profile-based command translation, and pin the already-resolved
18
+ * daemon PATH with the capability directory first. The daemon resolves the
19
+ * operator's runtime/tool PATH before spawning this bridge, so commands keep
20
+ * the same executable search path without a per-command profile reload.
21
+ */
22
+ export function buildThreadConfigOverrides({
23
+ reasoningEffort,
24
+ capabilityBinDir,
25
+ inheritedPath,
26
+ platform = process.platform,
27
+ }: ThreadConfigOverrideOptions): Record<string, unknown> | undefined {
28
+ const config: Record<string, unknown> = {};
29
+ if (reasoningEffort) config.model_reasoning_effort = reasoningEffort;
30
+
31
+ // Windows shells do not have the POSIX login-profile reorder that this
32
+ // override addresses; the app-server process PATH already carries the .cmd
33
+ // pointer directory there.
34
+ if (capabilityBinDir && platform !== 'win32') {
35
+ const pathEntries = (inheritedPath ?? '')
36
+ .split(path.delimiter)
37
+ .filter((entry) => entry && entry !== capabilityBinDir);
38
+ const effectivePath = [capabilityBinDir, ...pathEntries].join(path.delimiter);
39
+ config.allow_login_shell = false;
40
+ config.shell_environment_policy = {
41
+ experimental_use_profile: false,
42
+ set: { PATH: effectivePath },
43
+ };
44
+ }
45
+
46
+ return Object.keys(config).length > 0 ? config : undefined;
47
+ }