@parall/codex-agent 1.59.0 → 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.
@@ -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,15 +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,
7
9
  DispatchInputLifecycle,
8
10
  DispatchOpts,
9
11
  ForkOpts,
10
12
  ForkSessionHandle,
11
13
  GatewayLogger,
14
+ RuntimeActivityEvent,
15
+ RuntimeBusyState,
12
16
  RuntimeEvent,
13
17
  } from '@parall/agent-core';
14
18
  import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
19
+ import { isRuntimeBusy, projectRuntimeEvent, RuntimeActivityPort } from '@parall/agent-core';
15
20
  import {
16
21
  appendPreparedLocalAttachmentRefs,
17
22
  pinLocalAttachmentPaths,
@@ -25,6 +30,8 @@ import {
25
30
  } from './app-server-protocol.js';
26
31
  import type { CodexAgentConfig } from './config.js';
27
32
  import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
33
+ import { runCodexCompact } from './compact.js';
34
+ import { ForeignThreadRegistry } from './foreign-threads.js';
28
35
  import { CodexInjectionRegistry } from './injection-registry.js';
29
36
  import {
30
37
  MainThreadInstructionsRefresher,
@@ -32,6 +39,7 @@ import {
32
39
  type RefreshOutcome,
33
40
  } from './instructions-refresh.js';
34
41
  import { JsonRpcStdioClient } from './jsonrpc-client.js';
42
+ import type { CodexRuntimeTurn } from './runtime-turn.js';
35
43
  import { answerServerRequest } from './server-requests.js';
36
44
  import type { CodexSessionManager } from './session-manager.js';
37
45
  import { buildThreadConfigOverrides } from './thread-config.js';
@@ -124,6 +132,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
124
132
  private readonly instructionsRefresher: MainThreadInstructionsRefresher;
125
133
  private stopping = false;
126
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;
127
138
 
128
139
  /**
129
140
  * Store an active turn sink keyed by threadId. If a sink already exists for
@@ -152,6 +163,19 @@ export class CodexAppServerAdapter implements DispatchAdapter {
152
163
  compactTimeoutMs: opts.instructionsCompactTimeoutMs,
153
164
  interruptGraceMs: opts.instructionsInterruptGraceMs,
154
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
+ });
155
179
  }
156
180
 
157
181
  /** Register a listener for every server notification; returns unregister. */
@@ -249,6 +273,51 @@ export class CodexAppServerAdapter implements DispatchAdapter {
249
273
  sink.close();
250
274
  }
251
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
+
252
321
  hasPendingInjections(sessionKey: string): boolean {
253
322
  return this.injections.hasPending(sessionKey);
254
323
  }
@@ -261,6 +330,32 @@ export class CodexAppServerAdapter implements DispatchAdapter {
261
330
  this.injections.markDrained(sessionKey, deliveryKey);
262
331
  }
263
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
+ };
357
+ }
358
+
264
359
  async *dispatch({
265
360
  event,
266
361
  bodyForAgent,
@@ -324,13 +419,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
324
419
  // compaction turn BEFORE any TurnSink exists, and a concurrent fork
325
420
  // dispatch hitting a pending restart in that window would otherwise
326
421
  // stop() the subprocess out from under it.
327
- this.openingDispatches += 1;
328
- let opened: OpenDispatchOutcome;
329
- try {
330
- opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
331
- } finally {
332
- this.openingDispatches -= 1;
333
- }
422
+ const opened = await this.withOpening(() =>
423
+ this.openDispatchTarget(sessionKey, isMainSession, log),
424
+ );
334
425
  if (!opened.ok) {
335
426
  yield { type: 'error', message: opened.message };
336
427
  return;
@@ -521,34 +612,14 @@ export class CodexAppServerAdapter implements DispatchAdapter {
521
612
  sawTurnEnd = true;
522
613
  break;
523
614
  }
524
- if (envelope.kind === 'error') {
525
- yield { type: 'error', message: envelope.message };
526
- continue;
527
- }
528
- const runtimeEvent = envelope.event;
529
- if (runtimeEvent.type === 'error') {
530
- yield runtimeEvent;
531
- continue;
532
- }
533
- if (runtimeEvent.type === 'runtime_session') {
534
- yield runtimeEvent;
535
- continue;
536
- }
537
- if (runtimeEvent.type === 'text') {
538
- // Layer 0 symmetric output contract: Codex's plain text is never
539
- // projected as a chat message. Outbound messages must come from the
540
- // agent explicitly invoking `@parall/cli messages send` / `dm` via
541
- // the shell/exec tool. Text events are still yielded so agent-core
542
- // records them as suppressed session steps for audit.
543
- yield { ...runtimeEvent, project: false, groupKey };
544
- continue;
545
- }
546
- if (runtimeEvent.type === 'turn_outcome') {
547
- // Turn-boundary summary — no groupKey, passed through as-is.
548
- yield runtimeEvent;
549
- continue;
550
- }
551
- 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
+ );
552
623
  }
553
624
  } finally {
554
625
  releasePreparedAttachments();
@@ -757,7 +828,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
757
828
  private openingDispatches = 0;
758
829
 
759
830
  private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
760
- 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;
761
834
  this.restartRequested = false;
762
835
  (log ?? this.opts.log)?.info?.(
763
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)',
@@ -783,6 +856,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
783
856
  this.instructionsRefresher.clearThreadState();
784
857
  // The (possible) zombie compaction turn dies with the subprocess.
785
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');
786
862
  this.notifyTapsDisposed('Codex app-server stopped');
787
863
  if (client) client.dispose(new Error('adapter stopped'));
788
864
  if (proc && proc.exitCode === null && proc.signalCode === null) {
@@ -801,6 +877,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
801
877
  this.activeTurns.clear();
802
878
  this.activeTurnIds.clear();
803
879
  this.injections.clear();
880
+ this.foreignThreads.dropAll('lost: Codex app-server disposed');
804
881
  this.client = null;
805
882
  this.proc = null;
806
883
  this.initialized = false;
@@ -984,6 +1061,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
984
1061
  this.resumedThreadIds.clear();
985
1062
  this.instructionsRefresher.clearThreadState();
986
1063
  this.mainLaneQuarantined = false;
1064
+ this.foreignThreads.dropAll(`lost: Codex app-server ${reason}`);
987
1065
  this.notifyTapsDisposed(`Codex app-server ${reason}`);
988
1066
  this.client = null;
989
1067
  this.proc = null;
@@ -1076,6 +1154,10 @@ export class CodexAppServerAdapter implements DispatchAdapter {
1076
1154
  // successful instructions refresh and burn the shared rate-limit
1077
1155
  // window that exists to surface REAL orphaned turns.
1078
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;
1079
1161
  // Notifications for a thread with no registered sink are dropped. A
1080
1162
  // sustained stream of these means a turn is running that nothing is
1081
1163
  // consuming (threadId mismatch, sink torn down early) — the dispatch
@@ -1107,7 +1189,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
1107
1189
  }
1108
1190
  }
1109
1191
 
1110
- type OpenDispatchOutcome =
1192
+ export type OpenDispatchOutcome =
1111
1193
  | { ok: true; client: JsonRpcStdioClient; threadId: string; reconcile?: RefreshOutcome }
1112
1194
  | { ok: false; message: string };
1113
1195
 
@@ -0,0 +1,187 @@
1
+ import type { GatewayLogger, RuntimeActivityPort } from '@parall/agent-core';
2
+ import { extractThreadInfo } from './app-server-protocol.js';
3
+ import { CodexRuntimeTurn } from './runtime-turn.js';
4
+ import type { CodexSessionManager } from './session-manager.js';
5
+ import type { TurnEventEnvelope } from './turn-sink.js';
6
+
7
+ /**
8
+ * Threads the app-server runs that no dispatch opened — `spawn_agent`
9
+ * subagent threads (`thread/started` with `threadSource: "subAgent"`), or a
10
+ * thread whose turn showed up with no `thread/started` (bridge restarted
11
+ * mid-subagent). Each is a child session of its parent thread's session;
12
+ * every `turn/started`…`turn/completed` on it is a RuntimeInitiatedTurn
13
+ * (runtime-initiated-turns-design.md §5).
14
+ */
15
+ export type ForeignThread = {
16
+ threadId: string;
17
+ sessionKey: string;
18
+ parentThreadId?: string;
19
+ parentSessionKey: string;
20
+ nickname?: string;
21
+ role?: string;
22
+ turn?: CodexRuntimeTurn;
23
+ };
24
+
25
+ export type ForeignThreadHost = {
26
+ sessionManager: CodexSessionManager;
27
+ log?: GatewayLogger;
28
+ /** Turns opened and child sessions closed go to the gateway through it. */
29
+ activity: RuntimeActivityPort;
30
+ /** A subagent turn ended — a deferred restart may apply now. */
31
+ afterTurnClosed(): void;
32
+ };
33
+
34
+ export class ForeignThreadRegistry {
35
+ private readonly threads = new Map<string, ForeignThread>();
36
+
37
+ constructor(private readonly host: ForeignThreadHost) {}
38
+
39
+ /** Live (registered, not closed) subagent threads. */
40
+ get size(): number {
41
+ return this.threads.size;
42
+ }
43
+
44
+ openTurns(): number {
45
+ let count = 0;
46
+ for (const thread of this.threads.values()) if (thread.turn) count += 1;
47
+ return count;
48
+ }
49
+
50
+ /**
51
+ * Notifications for a thread no dispatch owns. Returns false when the
52
+ * caller should keep treating the frame as unroutable.
53
+ */
54
+ route(method: string, params: unknown, threadId: string): boolean {
55
+ if (method === 'thread/started') {
56
+ if (this.host.sessionManager.getSessionKey(threadId) || this.threads.has(threadId)) {
57
+ return true;
58
+ }
59
+ const info = extractThreadInfo(params);
60
+ // Only subagent spawns become child sessions; our own thread/start and
61
+ // thread/fork echoes (source appServer) are bound by their responses.
62
+ if (!info || info.source !== 'subAgent') return true;
63
+ this.register(info);
64
+ return true;
65
+ }
66
+ let thread = this.threads.get(threadId);
67
+ if (method === 'thread/closed' || method === 'thread/archived' || method === 'thread/deleted') {
68
+ if (!thread) return false;
69
+ this.close(thread, method.slice('thread/'.length));
70
+ return true;
71
+ }
72
+ if (!thread) {
73
+ // A turn on a thread we never saw start: our own thread without a
74
+ // sink is a genuine orphan (keep the warn); anything else is adopted
75
+ // under the main session (bridge restarted mid-subagent).
76
+ if (this.host.sessionManager.getSessionKey(threadId)) return false;
77
+ if (method !== 'turn/started' && method !== 'turn/completed' && !method.startsWith('item/')) {
78
+ return false;
79
+ }
80
+ thread = this.register({ id: threadId });
81
+ }
82
+ if (method === 'turn/started') {
83
+ if (thread.turn) thread.turn.touch();
84
+ else this.openTurn(thread);
85
+ return true;
86
+ }
87
+ if (!thread.turn) {
88
+ if (method !== 'turn/completed' && !method.startsWith('item/')) return true;
89
+ this.openTurn(thread);
90
+ }
91
+ const turn = thread.turn as CodexRuntimeTurn;
92
+ turn.sink.touchActivity();
93
+ for (const event of turn.sink.mapper.map(method, params)) {
94
+ turn.sink.push({ kind: 'runtime', event });
95
+ }
96
+ if (method === 'turn/completed') {
97
+ this.endTurn(thread, { kind: 'turn_end', threadId });
98
+ this.host.log?.info?.(
99
+ `runtime-initiated turn ${turn.groupKey} on subagent thread ${threadId} completed`,
100
+ );
101
+ }
102
+ return true;
103
+ }
104
+
105
+ /** Subagent threads die with the app-server: end open turns, close child sessions. */
106
+ dropAll(reason: string): void {
107
+ for (const thread of [...this.threads.values()]) {
108
+ this.close(thread, reason);
109
+ }
110
+ }
111
+
112
+ private register(info: {
113
+ id: string;
114
+ parentThreadId?: string;
115
+ nickname?: string;
116
+ role?: string;
117
+ }): ForeignThread {
118
+ // A subagent may itself spawn subagents: resolve the parent against the
119
+ // live foreign threads first, then main / fork threads, else main.
120
+ const parentSessionKey =
121
+ (info.parentThreadId
122
+ ? (this.threads.get(info.parentThreadId)?.sessionKey ??
123
+ this.host.sessionManager.getSessionKey(info.parentThreadId))
124
+ : undefined) ?? this.host.sessionManager.mainSessionKey;
125
+ const thread: ForeignThread = {
126
+ threadId: info.id,
127
+ sessionKey: `codex-sub:${info.id}`,
128
+ parentThreadId: info.parentThreadId,
129
+ parentSessionKey,
130
+ nickname: info.nickname,
131
+ role: info.role,
132
+ };
133
+ this.threads.set(info.id, thread);
134
+ this.host.log?.info?.(
135
+ `subagent thread ${info.id} registered (parent ${info.parentThreadId ?? 'unknown'} → ${parentSessionKey}${info.nickname ? `, ${info.nickname}` : ''}${info.role ? ` / ${info.role}` : ''}); ${this.threads.size} live`,
136
+ );
137
+ return thread;
138
+ }
139
+
140
+ private openTurn(thread: ForeignThread): void {
141
+ const turn = new CodexRuntimeTurn(
142
+ thread.sessionKey,
143
+ {
144
+ kind: 'subagent',
145
+ threadId: thread.threadId,
146
+ ...(thread.parentThreadId ? { parentThreadId: thread.parentThreadId } : {}),
147
+ ...(thread.nickname ? { nickname: thread.nickname } : {}),
148
+ ...(thread.role ? { role: thread.role } : {}),
149
+ },
150
+ {
151
+ parentSessionKey: thread.parentSessionKey,
152
+ onDetach: (reason) => {
153
+ if (thread.turn !== turn) return;
154
+ this.endTurn(thread, { kind: 'error', message: `subagent turn detached: ${reason}` });
155
+ },
156
+ },
157
+ );
158
+ thread.turn = turn;
159
+ this.host.log?.info?.(
160
+ `runtime-initiated turn ${turn.groupKey} opened on subagent thread ${thread.threadId}`,
161
+ );
162
+ this.host.activity.surfaceTurn(turn);
163
+ }
164
+
165
+ /** The thread's open turn is over: last envelope, sink closed, restart re-driven. */
166
+ private endTurn(thread: ForeignThread, last: TurnEventEnvelope): void {
167
+ const turn = thread.turn;
168
+ if (!turn) return;
169
+ thread.turn = undefined;
170
+ turn.sink.push(last);
171
+ turn.sink.close();
172
+ this.host.afterTurnClosed();
173
+ }
174
+
175
+ private close(thread: ForeignThread, reason: string): void {
176
+ if (this.threads.get(thread.threadId) !== thread) return;
177
+ this.threads.delete(thread.threadId);
178
+ this.endTurn(thread, {
179
+ kind: 'error',
180
+ message: `subagent thread ${thread.threadId} ${reason}`,
181
+ });
182
+ this.host.log?.info?.(
183
+ `subagent thread ${thread.threadId} ${reason}; ${this.threads.size} live`,
184
+ );
185
+ this.host.activity.emit({ kind: 'session_closed', sessionKey: thread.sessionKey, reason });
186
+ }
187
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  llmSource,
21
21
  identityFromMe,
22
22
  initAgentTelemetry,
23
+ resolveServiceVersion,
23
24
  } from '@parall/agent-core';
24
25
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
25
26
  import {
@@ -74,7 +75,11 @@ function resolveProviderEnv(): void {
74
75
  async function main() {
75
76
  // Before any fetch: long-lived HTTP connections for every bridge→api call.
76
77
  configureHttpKeepAlive();
77
- const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex');
78
+ const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex', {
79
+ apiUrl: process.env.PRLL_API_URL,
80
+ apiKey: process.env.PRLL_API_KEY,
81
+ serviceVersion: resolveServiceVersion(import.meta.url),
82
+ });
78
83
  activeLog = createOtelLogger('agent', 'codex-agent');
79
84
  try {
80
85
  resolveProviderEnv();
@@ -291,6 +296,12 @@ async function main() {
291
296
  onSessionStale: () => {
292
297
  sessionManager.clearMainThread();
293
298
  },
299
+ // The app-server outlives one dispatch (subagent threads keep running
300
+ // after the parent turn): stop it INSIDE the gateway's shutdown so the
301
+ // runtime turns it ends still land their steps and idle/close writes
302
+ // before the step and lifecycle flushes — the finally below runs after
303
+ // those windows have closed.
304
+ onBeforeDisconnect: () => adapter.stop(),
294
305
  });
295
306
 
296
307
  const abortController = new AbortController();