@parall/codex-agent 1.58.2 → 1.60.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.
Files changed (39) hide show
  1. package/dist/app-server-protocol.d.ts +13 -0
  2. package/dist/app-server-protocol.d.ts.map +1 -1
  3. package/dist/app-server-protocol.js +21 -0
  4. package/dist/compact.d.ts +33 -0
  5. package/dist/compact.d.ts.map +1 -0
  6. package/dist/compact.js +92 -0
  7. package/dist/dispatch.d.ts +37 -5
  8. package/dist/dispatch.d.ts.map +1 -1
  9. package/dist/dispatch.js +124 -61
  10. package/dist/foreign-threads.d.ts +49 -0
  11. package/dist/foreign-threads.d.ts.map +1 -0
  12. package/dist/foreign-threads.js +143 -0
  13. package/dist/index.js +12 -2
  14. package/dist/injection-registry.d.ts +39 -0
  15. package/dist/injection-registry.d.ts.map +1 -0
  16. package/dist/injection-registry.js +103 -0
  17. package/dist/instructions-refresh.d.ts +25 -0
  18. package/dist/instructions-refresh.d.ts.map +1 -1
  19. package/dist/instructions-refresh.js +79 -19
  20. package/dist/runtime-turn.d.ts +23 -0
  21. package/dist/runtime-turn.d.ts.map +1 -0
  22. package/dist/runtime-turn.js +56 -0
  23. package/dist/session-manager.d.ts +2 -0
  24. package/dist/session-manager.d.ts.map +1 -1
  25. package/dist/session-manager.js +7 -0
  26. package/dist/turn-sink.d.ts +5 -3
  27. package/dist/turn-sink.d.ts.map +1 -1
  28. package/dist/turn-sink.js +12 -31
  29. package/package.json +4 -4
  30. package/src/app-server-protocol.ts +37 -0
  31. package/src/compact.ts +132 -0
  32. package/src/dispatch.ts +149 -64
  33. package/src/foreign-threads.ts +187 -0
  34. package/src/index.ts +12 -1
  35. package/src/injection-registry.ts +100 -0
  36. package/src/instructions-refresh.ts +90 -18
  37. package/src/runtime-turn.ts +64 -0
  38. package/src/session-manager.ts +6 -0
  39. package/src/turn-sink.ts +12 -29
@@ -24,6 +24,13 @@ export class CodexSessionManager {
24
24
  getThreadId(sessionKey) {
25
25
  return this.threadIds.get(sessionKey);
26
26
  }
27
+ /** The session (main or fork) that owns a thread the bridge opened. */
28
+ getSessionKey(threadId) {
29
+ for (const [sessionKey, id] of this.threadIds)
30
+ if (id === threadId)
31
+ return sessionKey;
32
+ return undefined;
33
+ }
27
34
  recordThreadId(sessionKey, threadId) {
28
35
  // An effective-instructions sha is a fact about one specific thread.
29
36
  // Recording a DIFFERENT thread id under the same session invalidates it;
@@ -10,13 +10,15 @@ export type TurnEventEnvelope = {
10
10
  kind: 'error';
11
11
  message: string;
12
12
  };
13
- /** Per-turn buffered sink backed by an unbounded promise queue. */
13
+ /**
14
+ * Per-turn buffered sink between notification routing and the turn's
15
+ * drain. Envelopes queued before close() still drain (a final error pushed
16
+ * by handleSubprocessClose must reach the consumer before turn_end).
17
+ */
14
18
  export declare class TurnSink {
15
19
  private readonly noteActivity?;
16
20
  readonly mapper: EventMapper;
17
21
  private readonly queue;
18
- private resolver;
19
- private closed;
20
22
  constructor(noteActivity?: (() => void) | undefined);
21
23
  touchActivity(): void;
22
24
  push(envelope: TurnEventEnvelope): void;
@@ -1 +1 @@
1
- {"version":3,"file":"turn-sink.d.ts","sourceRoot":"","sources":["../src/turn-sink.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC,mEAAmE;AACnE,qBAAa,QAAQ;IAMP,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAL1C,QAAQ,CAAC,MAAM,cAAqB;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2B;IACjD,OAAO,CAAC,QAAQ,CAAqD;IACrE,OAAO,CAAC,MAAM,CAAS;gBAEM,YAAY,CAAC,GAAE,MAAM,IAAI,aAAA;IAEtD,aAAa;IAIb,IAAI,CAAC,QAAQ,EAAE,iBAAiB;IAWhC,IAAI,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAelC,KAAK;IAOL;kCAC8B;IAC9B,IAAI,QAAQ,IAAI,OAAO,CAEtB;CACF"}
1
+ {"version":3,"file":"turn-sink.d.ts","sourceRoot":"","sources":["../src/turn-sink.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC;;;;GAIG;AACH,qBAAa,QAAQ;IAIP,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAH1C,QAAQ,CAAC,MAAM,cAAqB;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;gBAEhC,YAAY,CAAC,GAAE,MAAM,IAAI,aAAA;IAEtD,aAAa;IAIb,IAAI,CAAC,QAAQ,EAAE,iBAAiB;IAI1B,IAAI,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAKxC,KAAK;IAIL;kCAC8B;IAC9B,IAAI,QAAQ,IAAI,OAAO,CAEtB;CACF"}
package/dist/turn-sink.js CHANGED
@@ -1,11 +1,14 @@
1
+ import { AsyncQueue } from '@parall/agent-core/internal/async-queue';
1
2
  import { EventMapper } from './event-mapping.js';
2
- /** Per-turn buffered sink backed by an unbounded promise queue. */
3
+ /**
4
+ * Per-turn buffered sink between notification routing and the turn's
5
+ * drain. Envelopes queued before close() still drain (a final error pushed
6
+ * by handleSubprocessClose must reach the consumer before turn_end).
7
+ */
3
8
  export class TurnSink {
4
9
  noteActivity;
5
10
  mapper = new EventMapper();
6
- queue = [];
7
- resolver = null;
8
- closed = false;
11
+ queue = new AsyncQueue();
9
12
  constructor(noteActivity) {
10
13
  this.noteActivity = noteActivity;
11
14
  }
@@ -13,40 +16,18 @@ export class TurnSink {
13
16
  this.noteActivity?.();
14
17
  }
15
18
  push(envelope) {
16
- if (this.closed)
17
- return;
18
- if (this.resolver) {
19
- const r = this.resolver;
20
- this.resolver = null;
21
- r(envelope);
22
- return;
23
- }
24
19
  this.queue.push(envelope);
25
20
  }
26
- next() {
27
- // Drain any queued envelopes first, even after close(). Otherwise a final
28
- // error envelope enqueued right before close() (e.g. by
29
- // handleSubprocessClose) is silently dropped because the consumer would
30
- // see turn_end before it.
31
- const pending = this.queue.shift();
32
- if (pending)
33
- return Promise.resolve(pending);
34
- if (this.closed) {
35
- return Promise.resolve({ kind: 'turn_end' });
36
- }
37
- return new Promise((resolve) => {
38
- this.resolver = resolve;
39
- });
21
+ async next() {
22
+ const result = await this.queue.next();
23
+ return result.done ? { kind: 'turn_end' } : result.value;
40
24
  }
41
25
  close() {
42
- this.closed = true;
43
- const r = this.resolver;
44
- this.resolver = null;
45
- r?.({ kind: 'turn_end' });
26
+ this.queue.close();
46
27
  }
47
28
  /** True once close() ran — the sink's terminal state (queued envelopes may
48
29
  * still drain via next()). */
49
30
  get isClosed() {
50
- return this.closed;
31
+ return this.queue.isClosed;
51
32
  }
52
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.58.2",
3
+ "version": "1.60.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/cli": "1.60.0",
29
+ "@parall/agent-core": "1.60.0",
30
+ "@parall/sdk": "1.60.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",
@@ -34,6 +34,43 @@ export function extractTurnId(result: unknown): string | undefined {
34
34
  return undefined;
35
35
  }
36
36
 
37
+ /**
38
+ * The `thread` object a `thread/started` notification carries. Subagent
39
+ * threads (`spawn_agent`) report `threadSource: "subAgent"` plus the parent
40
+ * thread and the agent's nickname/role; other fields are tolerated absent.
41
+ */
42
+ export type CodexThreadInfo = {
43
+ id: string;
44
+ parentThreadId?: string;
45
+ source?: string;
46
+ nickname?: string;
47
+ role?: string;
48
+ };
49
+
50
+ export function extractThreadInfo(params: unknown): CodexThreadInfo | undefined {
51
+ if (!params || typeof params !== 'object') return undefined;
52
+ const p = params as Record<string, unknown>;
53
+ const thread = (p.thread && typeof p.thread === 'object' ? p.thread : p) as Record<
54
+ string,
55
+ unknown
56
+ >;
57
+ const id =
58
+ typeof thread.id === 'string'
59
+ ? thread.id
60
+ : typeof p.threadId === 'string'
61
+ ? p.threadId
62
+ : undefined;
63
+ if (!id) return undefined;
64
+ const str = (value: unknown) => (typeof value === 'string' && value.trim() ? value : undefined);
65
+ return {
66
+ id,
67
+ parentThreadId: str(thread.parentThreadId),
68
+ source: str(thread.threadSource) ?? str(thread.source),
69
+ nickname: str(thread.agentNickname),
70
+ role: str(thread.agentRole),
71
+ };
72
+ }
73
+
37
74
  export function extractThreadIdFromNotification(params: unknown): string | undefined {
38
75
  if (!params || typeof params !== 'object') return undefined;
39
76
  const p = params as Record<string, unknown>;
package/src/compact.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { CompactOpts, CompactResult, GatewayLogger } from '@parall/agent-core';
2
+ import type { OpenDispatchOutcome } from './dispatch.js';
3
+ import {
4
+ CompactionStalledError,
5
+ type MainThreadInstructionsRefresher,
6
+ type NotificationTapSource,
7
+ } from './instructions-refresh.js';
8
+ import { JSON_RPC_METHOD_NOT_FOUND, JsonRpcError } from './jsonrpc-client.js';
9
+
10
+ /**
11
+ * Idle auto-compact on the Codex bridge (idle-auto-compact-design.md §3.4):
12
+ * open the MAIN thread exactly the way a dispatch would (start/resume + the
13
+ * instructions reconcile), then run the unconditional compaction primitive
14
+ * as a tap-only turn — no TurnSink, no activeTurnIds entry, so it is neither
15
+ * a user turn nor a steer target (PFB-84 bookkeeping untouched). Outcomes
16
+ * follow the refresh path's posture: a stalled compaction quarantines the
17
+ * main lane and bounces the subprocess; -32601 latches unsupported for the
18
+ * subprocess lifetime.
19
+ *
20
+ * The host is the narrow slice of CodexAppServerAdapter this needs; the
21
+ * adapter's `compact()` is a thin delegate.
22
+ */
23
+ export interface CodexCompactHost {
24
+ log?: GatewayLogger;
25
+ taps: NotificationTapSource;
26
+ refresher: Pick<
27
+ MainThreadInstructionsRefresher,
28
+ 'isCompactUnsupported' | 'runCompaction' | 'recordCompacted'
29
+ >;
30
+ isMainSession(sessionKey: string): boolean;
31
+ isMainLaneQuarantined(): boolean;
32
+ /** Quarantine the main lane and request a subprocess restart (applied when free). */
33
+ quarantineAndBounce(log?: GatewayLogger): Promise<void>;
34
+ applyPendingRestart(log?: GatewayLogger): Promise<void>;
35
+ /** Open the main thread with restart gating (openingDispatches) held. */
36
+ openMainThread(sessionKey: string, log?: GatewayLogger): Promise<OpenDispatchOutcome>;
37
+ hasActiveTurn(threadId: string): boolean;
38
+ /** Run `fn` as tap-only work on the thread: restart-gated, no unrouted-notification warnings. */
39
+ withTapOnlyTurn<T>(threadId: string, fn: () => Promise<T>): Promise<T>;
40
+ }
41
+
42
+ export async function runCodexCompact(
43
+ host: CodexCompactHost,
44
+ { sessionKey, signal, log }: CompactOpts,
45
+ ): Promise<CompactResult> {
46
+ const logger = log ?? host.log;
47
+ if (!host.isMainSession(sessionKey)) {
48
+ return { status: 'unsupported', detail: 'compact is only supported on the main session' };
49
+ }
50
+ if (signal.aborted) return { status: 'timeout' };
51
+ if (host.isMainLaneQuarantined()) {
52
+ await host.applyPendingRestart(logger);
53
+ if (host.isMainLaneQuarantined()) {
54
+ return {
55
+ status: 'failed',
56
+ detail:
57
+ 'main lane quarantined after a stalled compaction; the subprocess restart is still deferred behind an active turn',
58
+ };
59
+ }
60
+ }
61
+ if (host.refresher.isCompactUnsupported()) {
62
+ return {
63
+ status: 'unsupported',
64
+ detail: 'thread/compact/start not supported by this codex CLI',
65
+ };
66
+ }
67
+ await host.applyPendingRestart(logger);
68
+ const opened = await host.openMainThread(sessionKey, logger);
69
+ if (!opened.ok) return { status: 'failed', detail: opened.message };
70
+ if (opened.reconcile === 'stalled') {
71
+ // Same posture as dispatch(): the refresh compaction may still be
72
+ // running on the thread — never rotate it; quarantine and bounce.
73
+ await host.quarantineAndBounce(logger);
74
+ return {
75
+ status: 'timeout',
76
+ detail:
77
+ 'instructions-refresh compaction stalled while opening the thread; subprocess bounced',
78
+ };
79
+ }
80
+ if (opened.reconcile === 'unsupported') {
81
+ return {
82
+ status: 'unsupported',
83
+ detail: 'thread/compact/start not supported by this codex CLI',
84
+ };
85
+ }
86
+ if (opened.reconcile === 'refreshed') {
87
+ // The open just ran a compaction of this very thread (instructions
88
+ // refresh) — the history is already folded; a second one is waste.
89
+ return { status: 'done', detail: 'compacted by the instructions refresh during open' };
90
+ }
91
+ if (signal.aborted) {
92
+ return { status: 'timeout', detail: 'budget elapsed while opening the thread' };
93
+ }
94
+ const { client, threadId } = opened;
95
+ if (host.hasActiveTurn(threadId)) {
96
+ return { status: 'failed', detail: 'a turn is active on the main thread' };
97
+ }
98
+ let stalled = false;
99
+ try {
100
+ return await host.withTapOnlyTurn(threadId, async () => {
101
+ try {
102
+ await host.refresher.runCompaction({ client, taps: host.taps, threadId, signal });
103
+ host.refresher.recordCompacted(sessionKey, threadId);
104
+ return { status: 'done' } as CompactResult;
105
+ } catch (err) {
106
+ if (err instanceof CompactionStalledError) {
107
+ // Budget elapsed (the gateway's abort or the refresher's own
108
+ // ceiling) and the turn ignored the interrupt: the compaction is
109
+ // torn down with the subprocess below.
110
+ stalled = true;
111
+ return { status: 'timeout', detail: errToString(err) };
112
+ }
113
+ if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
114
+ return { status: 'unsupported', detail: errToString(err) };
115
+ }
116
+ return { status: 'failed', detail: errToString(err) };
117
+ }
118
+ });
119
+ } finally {
120
+ if (stalled) {
121
+ // The compaction turn may still be running: quarantine the main lane
122
+ // and bounce the subprocess (deferred behind an active fork turn,
123
+ // retried by the next main dispatch) — exactly the dispatch posture.
124
+ await host.quarantineAndBounce(logger);
125
+ }
126
+ }
127
+ }
128
+
129
+ function errToString(err: unknown): string {
130
+ if (err instanceof Error) return err.message;
131
+ return String(err);
132
+ }
package/src/dispatch.ts CHANGED
@@ -3,14 +3,20 @@ import { randomUUID } from 'node:crypto';
3
3
  import * as path from 'node:path';
4
4
  import type {
5
5
  CleanupForkOpts,
6
+ CompactOpts,
7
+ CompactResult,
6
8
  DispatchAdapter,
9
+ DispatchInputLifecycle,
7
10
  DispatchOpts,
8
11
  ForkOpts,
9
12
  ForkSessionHandle,
10
13
  GatewayLogger,
14
+ RuntimeActivityEvent,
15
+ RuntimeBusyState,
11
16
  RuntimeEvent,
12
17
  } from '@parall/agent-core';
13
18
  import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
19
+ import { isRuntimeBusy, projectRuntimeEvent, RuntimeActivityPort } from '@parall/agent-core';
14
20
  import {
15
21
  appendPreparedLocalAttachmentRefs,
16
22
  pinLocalAttachmentPaths,
@@ -24,12 +30,16 @@ import {
24
30
  } from './app-server-protocol.js';
25
31
  import type { CodexAgentConfig } from './config.js';
26
32
  import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
33
+ import { runCodexCompact } from './compact.js';
34
+ import { ForeignThreadRegistry } from './foreign-threads.js';
35
+ import { CodexInjectionRegistry } from './injection-registry.js';
27
36
  import {
28
37
  MainThreadInstructionsRefresher,
29
38
  type NotificationTap,
30
39
  type RefreshOutcome,
31
40
  } from './instructions-refresh.js';
32
41
  import { JsonRpcStdioClient } from './jsonrpc-client.js';
42
+ import type { CodexRuntimeTurn } from './runtime-turn.js';
33
43
  import { answerServerRequest } from './server-requests.js';
34
44
  import type { CodexSessionManager } from './session-manager.js';
35
45
  import { buildThreadConfigOverrides } from './thread-config.js';
@@ -104,7 +114,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
104
114
  private startPromise: Promise<void> | null = null;
105
115
  private readonly activeTurns = new Map<string, TurnSink>();
106
116
  private readonly activeTurnIds = new Map<string, string>();
107
- private readonly pendingInjections = new Map<string, number>();
117
+ private readonly injections = new CodexInjectionRegistry();
108
118
  private readonly resumedThreadIds = new Set<string>();
109
119
  /** Threads whose reconcile compaction is in flight — tap-only traffic. */
110
120
  private readonly reconcilingThreadIds = new Set<string>();
@@ -122,6 +132,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
122
132
  private readonly instructionsRefresher: MainThreadInstructionsRefresher;
123
133
  private stopping = false;
124
134
  private lastUnroutedNotificationWarnAt = 0;
135
+ /** Subagent threads and their runtime-initiated turns (foreign-threads.ts). */
136
+ private readonly foreignThreads: ForeignThreadRegistry;
137
+ private readonly activity: RuntimeActivityPort;
125
138
 
126
139
  /**
127
140
  * Store an active turn sink keyed by threadId. If a sink already exists for
@@ -150,6 +163,19 @@ export class CodexAppServerAdapter implements DispatchAdapter {
150
163
  compactTimeoutMs: opts.instructionsCompactTimeoutMs,
151
164
  interruptGraceMs: opts.instructionsInterruptGraceMs,
152
165
  });
166
+ this.activity = new RuntimeActivityPort('CodexAppServerAdapter', opts.log);
167
+ this.foreignThreads = new ForeignThreadRegistry({
168
+ sessionManager: opts.sessionManager,
169
+ log: opts.log,
170
+ activity: this.activity,
171
+ // A restart deferred behind subagent work must not starve once it ends.
172
+ afterTurnClosed: () => {
173
+ if (!this.restartRequested) return;
174
+ void this.applyPendingRestart().catch((err) =>
175
+ this.opts.log?.warn?.(`deferred restart failed: ${errToString(err)}`),
176
+ );
177
+ },
178
+ });
153
179
  }
154
180
 
155
181
  /** Register a listener for every server notification; returns unregister. */
@@ -194,7 +220,13 @@ export class CodexAppServerAdapter implements DispatchAdapter {
194
220
  });
195
221
  }
196
222
 
197
- async enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean> {
223
+ async enqueueDuringDispatch(
224
+ sessionKey: string,
225
+ body: string,
226
+ inputLifecycle?: DispatchInputLifecycle,
227
+ ): Promise<boolean> {
228
+ // Identity is mandatory — the drain releases the injection by this key (injection-registry.ts).
229
+ if (!inputLifecycle) return false;
198
230
  const client = this.client;
199
231
  if (!client || client.isDisposed()) return false;
200
232
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
@@ -207,7 +239,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
207
239
  expectedTurnId: turnId,
208
240
  input: buildTurnInput(body, []),
209
241
  });
210
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
242
+ this.injections.register(sessionKey, inputLifecycle.deliveryKey);
211
243
  return true;
212
244
  } catch (err) {
213
245
  this.opts.log?.warn?.(`turn/steer failed: ${errToString(err)}`);
@@ -216,7 +248,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
216
248
  }
217
249
 
218
250
  abortDispatch(sessionKey: string): void {
219
- this.pendingInjections.delete(sessionKey);
251
+ // A torn-down turn owes nothing: the error complete releases its members for fresh turns.
252
+ this.injections.clear(sessionKey);
220
253
  const threadId = this.opts.sessionManager.getThreadId(sessionKey);
221
254
  if (!threadId) return;
222
255
  const sink = this.activeTurns.get(threadId);
@@ -240,8 +273,87 @@ export class CodexAppServerAdapter implements DispatchAdapter {
240
273
  sink.close();
241
274
  }
242
275
 
276
+ /** Idle auto-compact (compact.ts): the compaction primitive as a tap-only main-thread turn. */
277
+ compact(opts: CompactOpts): Promise<CompactResult> {
278
+ return runCodexCompact(
279
+ {
280
+ log: this.opts.log,
281
+ taps: this,
282
+ refresher: this.instructionsRefresher,
283
+ isMainSession: (sessionKey) => this.opts.sessionManager.isMain(sessionKey),
284
+ isMainLaneQuarantined: () => this.mainLaneQuarantined,
285
+ quarantineAndBounce: async (log) => {
286
+ this.mainLaneQuarantined = true;
287
+ this.requestProcessRestart();
288
+ await this.applyPendingRestart(log);
289
+ },
290
+ applyPendingRestart: (log) => this.applyPendingRestart(log),
291
+ openMainThread: (sessionKey, log) =>
292
+ this.withOpening(() => this.openDispatchTarget(sessionKey, true, log)),
293
+ hasActiveTurn: (threadId) => this.activeTurns.has(threadId),
294
+ withTapOnlyTurn: async (threadId, fn) => {
295
+ this.reconcilingThreadIds.add(threadId);
296
+ try {
297
+ return await this.withOpening(fn);
298
+ } finally {
299
+ this.reconcilingThreadIds.delete(threadId);
300
+ }
301
+ },
302
+ },
303
+ opts,
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Real work on the subprocess that predates any TurnSink (thread open,
309
+ * a tap-only compaction turn): counted so applyPendingRestart cannot
310
+ * stop() the subprocess out from under it.
311
+ */
312
+ private async withOpening<T>(fn: () => Promise<T>): Promise<T> {
313
+ this.openingDispatches += 1;
314
+ try {
315
+ return await fn();
316
+ } finally {
317
+ this.openingDispatches -= 1;
318
+ }
319
+ }
320
+
243
321
  hasPendingInjections(sessionKey: string): boolean {
244
- return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
322
+ return this.injections.hasPending(sessionKey);
323
+ }
324
+
325
+ hasUnsettledInjections(sessionKey: string): boolean {
326
+ return this.injections.hasUnsettled(sessionKey);
327
+ }
328
+
329
+ acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void {
330
+ this.injections.markDrained(sessionKey, deliveryKey);
331
+ }
332
+
333
+ // --- runtime-initiated work -------------------------------------------------
334
+
335
+ subscribeRuntimeActivity(handler: (event: RuntimeActivityEvent) => void): () => void {
336
+ return this.activity.subscribe(handler);
337
+ }
338
+
339
+ /**
340
+ * Busy = a dispatch turn, a dispatch opening its thread (possibly running
341
+ * the instructions-refresh compaction turn), or a subagent thread's turn
342
+ * is executing. Live subagent threads between turns are reported as
343
+ * background work, not busy.
344
+ */
345
+ isBusy(): boolean {
346
+ return isRuntimeBusy(this.busyState());
347
+ }
348
+
349
+ busyState(): RuntimeBusyState {
350
+ return {
351
+ activeTurns:
352
+ this.activeTurns.size +
353
+ (this.openingDispatches > 0 || this.reconcilingThreadIds.size > 0 ? 1 : 0) +
354
+ this.foreignThreads.openTurns(),
355
+ backgroundWork: this.foreignThreads.size,
356
+ };
245
357
  }
246
358
 
247
359
  async *dispatch({
@@ -249,27 +361,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
249
361
  bodyForAgent,
250
362
  sessionKey,
251
363
  context,
364
+ inputLifecycle,
252
365
  noteActivity,
253
366
  }: 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
- }
367
+ // This dispatch drains the injected copies it lists; it ALWAYS runs its own
368
+ // turn — never skipped on outstanding injections (PFB-84, injection-registry.ts).
369
+ if (inputLifecycle) this.injections.markDrained(sessionKey, inputLifecycle.deliveryKey);
273
370
 
274
371
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
275
372
  if (isMainSession && this.mainLaneQuarantined) {
@@ -322,13 +419,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
322
419
  // compaction turn BEFORE any TurnSink exists, and a concurrent fork
323
420
  // dispatch hitting a pending restart in that window would otherwise
324
421
  // stop() the subprocess out from under it.
325
- this.openingDispatches += 1;
326
- let opened: OpenDispatchOutcome;
327
- try {
328
- opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
329
- } finally {
330
- this.openingDispatches -= 1;
331
- }
422
+ const opened = await this.withOpening(() =>
423
+ this.openDispatchTarget(sessionKey, isMainSession, log),
424
+ );
332
425
  if (!opened.ok) {
333
426
  yield { type: 'error', message: opened.message };
334
427
  return;
@@ -519,39 +612,20 @@ export class CodexAppServerAdapter implements DispatchAdapter {
519
612
  sawTurnEnd = true;
520
613
  break;
521
614
  }
522
- if (envelope.kind === 'error') {
523
- yield { type: 'error', message: envelope.message };
524
- continue;
525
- }
526
- const runtimeEvent = envelope.event;
527
- if (runtimeEvent.type === 'error') {
528
- yield runtimeEvent;
529
- continue;
530
- }
531
- if (runtimeEvent.type === 'runtime_session') {
532
- yield runtimeEvent;
533
- continue;
534
- }
535
- if (runtimeEvent.type === 'text') {
536
- // Layer 0 symmetric output contract: Codex's plain text is never
537
- // projected as a chat message. Outbound messages must come from the
538
- // agent explicitly invoking `@parall/cli messages send` / `dm` via
539
- // the shell/exec tool. Text events are still yielded so agent-core
540
- // records them as suppressed session steps for audit.
541
- yield { ...runtimeEvent, project: false, groupKey };
542
- continue;
543
- }
544
- if (runtimeEvent.type === 'turn_outcome') {
545
- // Turn-boundary summary — no groupKey, passed through as-is.
546
- yield runtimeEvent;
547
- continue;
548
- }
549
- yield { ...runtimeEvent, groupKey };
615
+ // Layer 0 symmetric output contract (projectRuntimeEvent): Codex's
616
+ // plain text is never projected as a chat message — outbound
617
+ // messages come from the agent invoking `parall messages send` /
618
+ // `dm`; text is still yielded as a suppressed audit step.
619
+ yield projectRuntimeEvent(
620
+ envelope.kind === 'error' ? { type: 'error', message: envelope.message } : envelope.event,
621
+ groupKey,
622
+ );
550
623
  }
551
624
  } finally {
552
625
  releasePreparedAttachments();
553
626
  this.activeTurns.delete(threadId!);
554
627
  this.activeTurnIds.delete(threadId!);
628
+ this.injections.settleAll(sessionKey); // the turn consumed (or lost) everything steered into it
555
629
  if (!sawTurnEnd) {
556
630
  sink.close();
557
631
  }
@@ -754,7 +828,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
754
828
  private openingDispatches = 0;
755
829
 
756
830
  private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
757
- if (!this.restartRequested || this.activeTurns.size > 0 || this.openingDispatches > 0) return;
831
+ // isBusy folds in subagent turns: a capability change must not SIGTERM
832
+ // the app-server under a running spawn_agent thread.
833
+ if (!this.restartRequested || this.isBusy()) return;
758
834
  this.restartRequested = false;
759
835
  (log ?? this.opts.log)?.info?.(
760
836
  '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)',
@@ -770,7 +846,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
770
846
  this.client = null;
771
847
  this.initialized = false;
772
848
  this.activeTurnIds.clear();
773
- this.pendingInjections.clear();
849
+ this.injections.clear();
774
850
  // Resume markers are process-local facts. Clear them HERE, synchronously:
775
851
  // the old child's close callback sees the replaced refs and skips its
776
852
  // cleanup as stale, so a marker surviving stop() would make the next
@@ -780,6 +856,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
780
856
  this.instructionsRefresher.clearThreadState();
781
857
  // The (possible) zombie compaction turn dies with the subprocess.
782
858
  this.mainLaneQuarantined = false;
859
+ // Subagent threads die with the app-server: end their open turns
860
+ // (runtime_crash) and close their child sessions before the client goes.
861
+ this.foreignThreads.dropAll('lost: Codex app-server stopped');
783
862
  this.notifyTapsDisposed('Codex app-server stopped');
784
863
  if (client) client.dispose(new Error('adapter stopped'));
785
864
  if (proc && proc.exitCode === null && proc.signalCode === null) {
@@ -797,7 +876,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
797
876
  }
798
877
  this.activeTurns.clear();
799
878
  this.activeTurnIds.clear();
800
- this.pendingInjections.clear();
879
+ this.injections.clear();
880
+ this.foreignThreads.dropAll('lost: Codex app-server disposed');
801
881
  this.client = null;
802
882
  this.proc = null;
803
883
  this.initialized = false;
@@ -977,10 +1057,11 @@ export class CodexAppServerAdapter implements DispatchAdapter {
977
1057
  }
978
1058
  this.activeTurns.clear();
979
1059
  this.activeTurnIds.clear();
980
- this.pendingInjections.clear();
1060
+ this.injections.clear();
981
1061
  this.resumedThreadIds.clear();
982
1062
  this.instructionsRefresher.clearThreadState();
983
1063
  this.mainLaneQuarantined = false;
1064
+ this.foreignThreads.dropAll(`lost: Codex app-server ${reason}`);
984
1065
  this.notifyTapsDisposed(`Codex app-server ${reason}`);
985
1066
  this.client = null;
986
1067
  this.proc = null;
@@ -1073,6 +1154,10 @@ export class CodexAppServerAdapter implements DispatchAdapter {
1073
1154
  // successful instructions refresh and burn the shared rate-limit
1074
1155
  // window that exists to surface REAL orphaned turns.
1075
1156
  if (this.reconcilingThreadIds.has(threadId)) return;
1157
+ // Subagent threads and their turns are runtime-initiated work: routed
1158
+ // into their own sinks and surfaced to the gateway as child-session
1159
+ // turns instead of being dropped.
1160
+ if (this.foreignThreads.route(method, params, threadId)) return;
1076
1161
  // Notifications for a thread with no registered sink are dropped. A
1077
1162
  // sustained stream of these means a turn is running that nothing is
1078
1163
  // consuming (threadId mismatch, sink torn down early) — the dispatch
@@ -1104,7 +1189,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
1104
1189
  }
1105
1190
  }
1106
1191
 
1107
- type OpenDispatchOutcome =
1192
+ export type OpenDispatchOutcome =
1108
1193
  | { ok: true; client: JsonRpcStdioClient; threadId: string; reconcile?: RefreshOutcome }
1109
1194
  | { ok: false; message: string };
1110
1195