@parall/codex-agent 1.59.0 → 1.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-server-protocol.d.ts +13 -0
- package/dist/app-server-protocol.d.ts.map +1 -1
- package/dist/app-server-protocol.js +21 -0
- package/dist/compact.d.ts +33 -0
- package/dist/compact.d.ts.map +1 -0
- package/dist/compact.js +92 -0
- package/dist/dispatch.d.ts +32 -2
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +99 -37
- package/dist/foreign-threads.d.ts +49 -0
- package/dist/foreign-threads.d.ts.map +1 -0
- package/dist/foreign-threads.js +143 -0
- package/dist/index.js +12 -2
- package/dist/instructions-refresh.d.ts +25 -0
- package/dist/instructions-refresh.d.ts.map +1 -1
- package/dist/instructions-refresh.js +79 -19
- package/dist/runtime-turn.d.ts +23 -0
- package/dist/runtime-turn.d.ts.map +1 -0
- package/dist/runtime-turn.js +56 -0
- package/dist/session-manager.d.ts +2 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +7 -0
- package/dist/turn-sink.d.ts +5 -3
- package/dist/turn-sink.d.ts.map +1 -1
- package/dist/turn-sink.js +12 -31
- package/package.json +4 -4
- package/src/app-server-protocol.ts +37 -0
- package/src/compact.ts +132 -0
- package/src/dispatch.ts +119 -37
- package/src/foreign-threads.ts +187 -0
- package/src/index.ts +12 -1
- package/src/instructions-refresh.ts +90 -18
- package/src/runtime-turn.ts +64 -0
- package/src/session-manager.ts +6 -0
- package/src/turn-sink.ts +12 -29
|
@@ -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.
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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();
|