@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.
@@ -0,0 +1,143 @@
1
+ import { extractThreadInfo } from './app-server-protocol.js';
2
+ import { CodexRuntimeTurn } from './runtime-turn.js';
3
+ export class ForeignThreadRegistry {
4
+ host;
5
+ threads = new Map();
6
+ constructor(host) {
7
+ this.host = host;
8
+ }
9
+ /** Live (registered, not closed) subagent threads. */
10
+ get size() {
11
+ return this.threads.size;
12
+ }
13
+ openTurns() {
14
+ let count = 0;
15
+ for (const thread of this.threads.values())
16
+ if (thread.turn)
17
+ count += 1;
18
+ return count;
19
+ }
20
+ /**
21
+ * Notifications for a thread no dispatch owns. Returns false when the
22
+ * caller should keep treating the frame as unroutable.
23
+ */
24
+ route(method, params, threadId) {
25
+ if (method === 'thread/started') {
26
+ if (this.host.sessionManager.getSessionKey(threadId) || this.threads.has(threadId)) {
27
+ return true;
28
+ }
29
+ const info = extractThreadInfo(params);
30
+ // Only subagent spawns become child sessions; our own thread/start and
31
+ // thread/fork echoes (source appServer) are bound by their responses.
32
+ if (!info || info.source !== 'subAgent')
33
+ return true;
34
+ this.register(info);
35
+ return true;
36
+ }
37
+ let thread = this.threads.get(threadId);
38
+ if (method === 'thread/closed' || method === 'thread/archived' || method === 'thread/deleted') {
39
+ if (!thread)
40
+ return false;
41
+ this.close(thread, method.slice('thread/'.length));
42
+ return true;
43
+ }
44
+ if (!thread) {
45
+ // A turn on a thread we never saw start: our own thread without a
46
+ // sink is a genuine orphan (keep the warn); anything else is adopted
47
+ // under the main session (bridge restarted mid-subagent).
48
+ if (this.host.sessionManager.getSessionKey(threadId))
49
+ return false;
50
+ if (method !== 'turn/started' && method !== 'turn/completed' && !method.startsWith('item/')) {
51
+ return false;
52
+ }
53
+ thread = this.register({ id: threadId });
54
+ }
55
+ if (method === 'turn/started') {
56
+ if (thread.turn)
57
+ thread.turn.touch();
58
+ else
59
+ this.openTurn(thread);
60
+ return true;
61
+ }
62
+ if (!thread.turn) {
63
+ if (method !== 'turn/completed' && !method.startsWith('item/'))
64
+ return true;
65
+ this.openTurn(thread);
66
+ }
67
+ const turn = thread.turn;
68
+ turn.sink.touchActivity();
69
+ for (const event of turn.sink.mapper.map(method, params)) {
70
+ turn.sink.push({ kind: 'runtime', event });
71
+ }
72
+ if (method === 'turn/completed') {
73
+ this.endTurn(thread, { kind: 'turn_end', threadId });
74
+ this.host.log?.info?.(`runtime-initiated turn ${turn.groupKey} on subagent thread ${threadId} completed`);
75
+ }
76
+ return true;
77
+ }
78
+ /** Subagent threads die with the app-server: end open turns, close child sessions. */
79
+ dropAll(reason) {
80
+ for (const thread of [...this.threads.values()]) {
81
+ this.close(thread, reason);
82
+ }
83
+ }
84
+ register(info) {
85
+ // A subagent may itself spawn subagents: resolve the parent against the
86
+ // live foreign threads first, then main / fork threads, else main.
87
+ const parentSessionKey = (info.parentThreadId
88
+ ? (this.threads.get(info.parentThreadId)?.sessionKey ??
89
+ this.host.sessionManager.getSessionKey(info.parentThreadId))
90
+ : undefined) ?? this.host.sessionManager.mainSessionKey;
91
+ const thread = {
92
+ threadId: info.id,
93
+ sessionKey: `codex-sub:${info.id}`,
94
+ parentThreadId: info.parentThreadId,
95
+ parentSessionKey,
96
+ nickname: info.nickname,
97
+ role: info.role,
98
+ };
99
+ this.threads.set(info.id, thread);
100
+ this.host.log?.info?.(`subagent thread ${info.id} registered (parent ${info.parentThreadId ?? 'unknown'} → ${parentSessionKey}${info.nickname ? `, ${info.nickname}` : ''}${info.role ? ` / ${info.role}` : ''}); ${this.threads.size} live`);
101
+ return thread;
102
+ }
103
+ openTurn(thread) {
104
+ const turn = new CodexRuntimeTurn(thread.sessionKey, {
105
+ kind: 'subagent',
106
+ threadId: thread.threadId,
107
+ ...(thread.parentThreadId ? { parentThreadId: thread.parentThreadId } : {}),
108
+ ...(thread.nickname ? { nickname: thread.nickname } : {}),
109
+ ...(thread.role ? { role: thread.role } : {}),
110
+ }, {
111
+ parentSessionKey: thread.parentSessionKey,
112
+ onDetach: (reason) => {
113
+ if (thread.turn !== turn)
114
+ return;
115
+ this.endTurn(thread, { kind: 'error', message: `subagent turn detached: ${reason}` });
116
+ },
117
+ });
118
+ thread.turn = turn;
119
+ this.host.log?.info?.(`runtime-initiated turn ${turn.groupKey} opened on subagent thread ${thread.threadId}`);
120
+ this.host.activity.surfaceTurn(turn);
121
+ }
122
+ /** The thread's open turn is over: last envelope, sink closed, restart re-driven. */
123
+ endTurn(thread, last) {
124
+ const turn = thread.turn;
125
+ if (!turn)
126
+ return;
127
+ thread.turn = undefined;
128
+ turn.sink.push(last);
129
+ turn.sink.close();
130
+ this.host.afterTurnClosed();
131
+ }
132
+ close(thread, reason) {
133
+ if (this.threads.get(thread.threadId) !== thread)
134
+ return;
135
+ this.threads.delete(thread.threadId);
136
+ this.endTurn(thread, {
137
+ kind: 'error',
138
+ message: `subagent thread ${thread.threadId} ${reason}`,
139
+ });
140
+ this.host.log?.info?.(`subagent thread ${thread.threadId} ${reason}; ${this.threads.size} live`);
141
+ this.host.activity.emit({ kind: 'session_closed', sessionKey: thread.sessionKey, reason });
142
+ }
143
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as os from 'node:os';
3
- import { ParallAgentGateway, capabilityBinDir, configureHttpKeepAlive, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, identityFromMe, initAgentTelemetry, } from '@parall/agent-core';
3
+ import { ParallAgentGateway, capabilityBinDir, configureHttpKeepAlive, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, identityFromMe, initAgentTelemetry, resolveServiceVersion, } from '@parall/agent-core';
4
4
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
5
5
  import { buildCodexRuntimeKey, contextFilePathForSession, dispatchContextDirPath, resolveCodexAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from './config.js';
6
6
  import { CodexAppServerAdapter } from './dispatch.js';
@@ -40,7 +40,11 @@ function resolveProviderEnv() {
40
40
  async function main() {
41
41
  // Before any fetch: long-lived HTTP connections for every bridge→api call.
42
42
  configureHttpKeepAlive();
43
- const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex');
43
+ const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex', {
44
+ apiUrl: process.env.PRLL_API_URL,
45
+ apiKey: process.env.PRLL_API_KEY,
46
+ serviceVersion: resolveServiceVersion(import.meta.url),
47
+ });
44
48
  activeLog = createOtelLogger('agent', 'codex-agent');
45
49
  try {
46
50
  resolveProviderEnv();
@@ -231,6 +235,12 @@ async function main() {
231
235
  onSessionStale: () => {
232
236
  sessionManager.clearMainThread();
233
237
  },
238
+ // The app-server outlives one dispatch (subagent threads keep running
239
+ // after the parent turn): stop it INSIDE the gateway's shutdown so the
240
+ // runtime turns it ends still land their steps and idle/close writes
241
+ // before the step and lifecycle flushes — the finally below runs after
242
+ // those windows have closed.
243
+ onBeforeDisconnect: () => adapter.stop(),
234
244
  });
235
245
  const abortController = new AbortController();
236
246
  const abort = () => abortController.abort();
@@ -53,6 +53,8 @@ export interface NotificationTapSource {
53
53
  }
54
54
  export declare function sha256Hex(text: string): string;
55
55
  export type RefreshOutcome = 'noop' | 'adopted-baseline' | 'refreshed' | 'unsupported' | 'failed' | 'stalled';
56
+ export declare class CompactionStalledError extends Error {
57
+ }
56
58
  export declare class MainThreadInstructionsRefresher {
57
59
  private readonly opts;
58
60
  /**
@@ -110,6 +112,29 @@ export declare class MainThreadInstructionsRefresher {
110
112
  threadId: string;
111
113
  log?: GatewayLogger;
112
114
  }): Promise<RefreshOutcome>;
115
+ /** True once this subprocess rejected thread/compact/start with -32601. */
116
+ isCompactUnsupported(): boolean;
117
+ /**
118
+ * The unconditional compaction primitive — one `thread/compact/start`
119
+ * awaited to its turn close — shared by the instructions refresh (which
120
+ * decides WHETHER to run it by sha) and the idle auto-compact (which runs
121
+ * it whenever the server asks). Throws like the refresh's inner path:
122
+ * CompactionStalledError past budget/abort + interrupt grace, JsonRpcError
123
+ * -32601 (also latches compactUnsupported for this subprocess), or a plain
124
+ * Error for a turn that closed without compacting.
125
+ */
126
+ runCompaction(args: {
127
+ client: JsonRpcStdioClient;
128
+ taps: NotificationTapSource;
129
+ threadId: string;
130
+ signal?: AbortSignal;
131
+ }): Promise<void>;
132
+ /**
133
+ * A compaction that completed outside the refresh path (idle auto-compact)
134
+ * rebuilt the model-visible context from the canonical configuration: the
135
+ * effective plane now equals whatever this process opened the thread with.
136
+ */
137
+ recordCompacted(sessionKey: string, threadId: string): void;
113
138
  /**
114
139
  * Compaction runs as its own turn on the thread:
115
140
  * turn/started → item/started{contextCompaction} →
@@ -1 +1 @@
1
- {"version":3,"file":"instructions-refresh.d.ts","sourceRoot":"","sources":["../src/instructions-refresh.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAExE,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,IAAI,CAAC;CACtD;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAMD,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,kBAAkB,GAClB,WAAW,GACX,aAAa,GACb,QAAQ,GAIR,SAAS,CAAC;AAId,qBAAa,+BAA+B;IAUxC,OAAO,CAAC,QAAQ,CAAC,IAAI;IATvB;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB,CAAS;gBAGhB,IAAI,EAAE;QACrB,cAAc,EAAE,IAAI,CAClB,mBAAmB,EACnB,6BAA6B,GAAG,gCAAgC,GAAG,qBAAqB,CACzF,CAAC;QACF,GAAG,CAAC,EAAE,aAAa,CAAC;QACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,4DAA4D;QAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B;IAGH;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAyC;IAE3E,qBAAqB,IAAI,IAAI;IAI7B,qEAAqE;IACrE,gBAAgB,IAAI,IAAI;IAIxB;;;;;;;OAOG;IACH,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IASzF;;;;OAIG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAIvE,8EAA8E;IAC9E,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIlD;;;;;OAKG;IACG,kBAAkB,CAAC,IAAI,EAAE;QAC7B,MAAM,EAAE,kBAAkB,CAAC;QAC3B,IAAI,EAAE,qBAAqB,CAAC;QAC5B,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB,GAAG,OAAO,CAAC,cAAc,CAAC;IA4D3B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,kBAAkB;CAkG3B"}
1
+ {"version":3,"file":"instructions-refresh.d.ts","sourceRoot":"","sources":["../src/instructions-refresh.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAExE,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,IAAI,CAAC;CACtD;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAMD,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,kBAAkB,GAClB,WAAW,GACX,aAAa,GACb,QAAQ,GAIR,SAAS,CAAC;AAEd,qBAAa,sBAAuB,SAAQ,KAAK;CAAG;AAEpD,qBAAa,+BAA+B;IAUxC,OAAO,CAAC,QAAQ,CAAC,IAAI;IATvB;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB,CAAS;gBAGhB,IAAI,EAAE;QACrB,cAAc,EAAE,IAAI,CAClB,mBAAmB,EACnB,6BAA6B,GAAG,gCAAgC,GAAG,qBAAqB,CACzF,CAAC;QACF,GAAG,CAAC,EAAE,aAAa,CAAC;QACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,4DAA4D;QAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B;IAGH;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAyC;IAE3E,qBAAqB,IAAI,IAAI;IAI7B,qEAAqE;IACrE,gBAAgB,IAAI,IAAI;IAIxB;;;;;;;OAOG;IACH,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IASzF;;;;OAIG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAIvE,8EAA8E;IAC9E,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIlD;;;;;OAKG;IACG,kBAAkB,CAAC,IAAI,EAAE;QAC7B,MAAM,EAAE,kBAAkB,CAAC;QAC3B,IAAI,EAAE,qBAAqB,CAAC;QAC5B,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB,GAAG,OAAO,CAAC,cAAc,CAAC;IAkD3B,2EAA2E;IAC3E,oBAAoB,IAAI,OAAO;IAI/B;;;;;;;;OAQG;IACG,aAAa,CAAC,IAAI,EAAE;QACxB,MAAM,EAAE,kBAAkB,CAAC;QAC3B,IAAI,EAAE,qBAAqB,CAAC;QAC5B,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,CAAC,EAAE,WAAW,CAAC;KACtB,GAAG,OAAO,CAAC,IAAI,CAAC;IAqBjB;;;;OAIG;IACH,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAM3D;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,kBAAkB;CAiI3B"}
@@ -7,7 +7,7 @@ export function sha256Hex(text) {
7
7
  const DEFAULT_COMPACT_TIMEOUT_MS = 120_000;
8
8
  /** After an over-budget compaction is interrupted, how long to wait for its turn to close. */
9
9
  const COMPACT_INTERRUPT_GRACE_MS = 10_000;
10
- class CompactionStalledError extends Error {
10
+ export class CompactionStalledError extends Error {
11
11
  }
12
12
  export class MainThreadInstructionsRefresher {
13
13
  opts;
@@ -90,28 +90,18 @@ export class MainThreadInstructionsRefresher {
90
90
  }
91
91
  if (this.compactUnsupported)
92
92
  return 'unsupported';
93
- // The waiter registers its notification tap BEFORE the request goes out:
94
- // the response and the compaction-turn notifications can arrive in one
95
- // stdout chunk, and the client's line loop dispatches notifications
96
- // synchronously — a tap registered only after `await sendRequest` resolves
97
- // (a queued microtask) would miss every one of them and hang on the
98
- // timeout.
99
- const compaction = this.watchForCompaction(client, taps, threadId);
100
93
  try {
101
- await client.sendRequest('thread/compact/start', { threadId });
102
- await compaction.done;
94
+ await this.runCompaction({ client, taps, threadId });
103
95
  this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
104
96
  log?.info?.(`platform instructions refreshed on persisted thread ${threadId} (compaction rebuilt initial context from the resumed configuration)`);
105
97
  return 'refreshed';
106
98
  }
107
99
  catch (err) {
108
- compaction.cancel();
109
100
  if (err instanceof CompactionStalledError) {
110
101
  log?.warn?.(`platform instructions refresh stalled (${errToString(err)}); bouncing the subprocess before the next turn`);
111
102
  return 'stalled';
112
103
  }
113
104
  if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
114
- this.compactUnsupported = true;
115
105
  log?.warn?.('thread/compact/start not supported by this codex CLI; the persisted thread keeps its previous platform instructions until it is replaced or the CLI is upgraded (tools still refresh live via the capability shim dir)');
116
106
  return 'unsupported';
117
107
  }
@@ -119,6 +109,51 @@ export class MainThreadInstructionsRefresher {
119
109
  return 'failed';
120
110
  }
121
111
  }
112
+ /** True once this subprocess rejected thread/compact/start with -32601. */
113
+ isCompactUnsupported() {
114
+ return this.compactUnsupported;
115
+ }
116
+ /**
117
+ * The unconditional compaction primitive — one `thread/compact/start`
118
+ * awaited to its turn close — shared by the instructions refresh (which
119
+ * decides WHETHER to run it by sha) and the idle auto-compact (which runs
120
+ * it whenever the server asks). Throws like the refresh's inner path:
121
+ * CompactionStalledError past budget/abort + interrupt grace, JsonRpcError
122
+ * -32601 (also latches compactUnsupported for this subprocess), or a plain
123
+ * Error for a turn that closed without compacting.
124
+ */
125
+ async runCompaction(args) {
126
+ const { client, taps, threadId, signal } = args;
127
+ // The waiter registers its notification tap BEFORE the request goes out:
128
+ // the response and the compaction-turn notifications can arrive in one
129
+ // stdout chunk, and the client's line loop dispatches notifications
130
+ // synchronously — a tap registered only after `await sendRequest` resolves
131
+ // (a queued microtask) would miss every one of them and hang on the
132
+ // timeout.
133
+ const compaction = this.watchForCompaction(client, taps, threadId, signal);
134
+ try {
135
+ await client.sendRequest('thread/compact/start', { threadId });
136
+ await compaction.done;
137
+ }
138
+ catch (err) {
139
+ compaction.cancel();
140
+ if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
141
+ this.compactUnsupported = true;
142
+ }
143
+ throw err;
144
+ }
145
+ }
146
+ /**
147
+ * A compaction that completed outside the refresh path (idle auto-compact)
148
+ * rebuilt the model-visible context from the canonical configuration: the
149
+ * effective plane now equals whatever this process opened the thread with.
150
+ */
151
+ recordCompacted(sessionKey, threadId) {
152
+ const canonical = this.canonicalByThread.get(threadId);
153
+ if (canonical === undefined)
154
+ return;
155
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, sha256Hex(canonical));
156
+ }
122
157
  /**
123
158
  * Compaction runs as its own turn on the thread:
124
159
  * turn/started → item/started{contextCompaction} →
@@ -140,7 +175,7 @@ export class MainThreadInstructionsRefresher {
140
175
  * turn/started by the deadline leaves nothing to interrupt — pathological,
141
176
  * and the grace still absorbs a late-materializing close.
142
177
  */
143
- watchForCompaction(client, taps, threadId) {
178
+ watchForCompaction(client, taps, threadId, signal) {
144
179
  const timeoutMs = this.opts.compactTimeoutMs ?? DEFAULT_COMPACT_TIMEOUT_MS;
145
180
  const graceMs = this.opts.interruptGraceMs ?? COMPACT_INTERRUPT_GRACE_MS;
146
181
  let cancel = () => { };
@@ -151,6 +186,7 @@ export class MainThreadInstructionsRefresher {
151
186
  let settled = false;
152
187
  let unregister = () => { };
153
188
  let graceTimer;
189
+ const onAbort = () => expire('aborted by the caller');
154
190
  const finish = (err) => {
155
191
  if (settled)
156
192
  return;
@@ -158,14 +194,21 @@ export class MainThreadInstructionsRefresher {
158
194
  clearTimeout(timer);
159
195
  if (graceTimer)
160
196
  clearTimeout(graceTimer);
197
+ signal?.removeEventListener('abort', onAbort);
161
198
  unregister();
162
199
  if (err)
163
200
  reject(err);
164
201
  else
165
202
  resolve();
166
203
  };
167
- const timer = setTimeout(() => {
204
+ // Budget exhausted (timer) or the caller aborted (idle auto-compact
205
+ // budget): interrupt best-effort and hold the grace for the turn to
206
+ // close; past it the turn may still be running → stalled.
207
+ const expire = (why) => {
208
+ if (settled || interrupted)
209
+ return;
168
210
  interrupted = true;
211
+ clearTimeout(timer);
169
212
  if (compactionTurnId) {
170
213
  // Best-effort and non-lethal: an unanswered interrupt must expire
171
214
  // with the grace window, not arm the client's default assume-hung
@@ -174,8 +217,13 @@ export class MainThreadInstructionsRefresher {
174
217
  .sendRequest('turn/interrupt', { threadId, turnId: compactionTurnId }, { timeoutMs: graceMs, lethalTimeout: false })
175
218
  .catch(() => { });
176
219
  }
177
- graceTimer = setTimeout(() => finish(new CompactionStalledError(`compaction did not complete within ${timeoutMs}ms (interrupt grace elapsed; the compaction turn may still be running)`)), graceMs);
178
- }, timeoutMs);
220
+ graceTimer = setTimeout(() => finish(new CompactionStalledError(`compaction did not complete (${why}; interrupt grace elapsed; the compaction turn may still be running)`)), graceMs);
221
+ };
222
+ const timer = setTimeout(() => expire(`within ${timeoutMs}ms`), timeoutMs);
223
+ if (signal?.aborted)
224
+ onAbort();
225
+ else
226
+ signal?.addEventListener('abort', onAbort, { once: true });
179
227
  // Cancellation resolves (never rejects): the caller cancels only when
180
228
  // the request itself already failed, and that error is what it reports.
181
229
  cancel = () => finish();
@@ -187,8 +235,20 @@ export class MainThreadInstructionsRefresher {
187
235
  // notifications can arrive after that; waiting out the timeout
188
236
  // would stall the dispatch for the full budget on a dead client.
189
237
  if (notificationThreadId === undefined || notificationThreadId === threadId) {
190
- const msg = params?.message;
191
- finish(new Error(`app-server error during compaction: ${String(msg ?? 'unknown')}`));
238
+ // v2 ErrorNotification: { threadId?, turnId?, error: { message,
239
+ // codexErrorInfo? }, willRetry }. The adapter's synthetic
240
+ // disposal broadcast still carries a top-level `message`.
241
+ const p = params;
242
+ // A retryable error keeps the turn alive — codex retries the
243
+ // model call itself and the compaction still completes or fails
244
+ // through turn/completed; bailing here would report a failure
245
+ // for a compaction that is still running.
246
+ if (p?.willRetry === true)
247
+ return;
248
+ const msg = p?.error?.message ?? p?.message;
249
+ const info = p?.error?.codexErrorInfo;
250
+ const infoText = info == null ? '' : ` (${typeof info === 'string' ? info : JSON.stringify(info)})`;
251
+ finish(new Error(`app-server error during compaction: ${String(msg ?? 'unknown')}${infoText}`));
192
252
  }
193
253
  return;
194
254
  }
@@ -208,7 +268,7 @@ export class MainThreadInstructionsRefresher {
208
268
  finish();
209
269
  else
210
270
  finish(new Error(interrupted
211
- ? `compaction did not complete within ${timeoutMs}ms (turn closed after interrupt)`
271
+ ? 'compaction did not complete within budget (turn closed after interrupt)'
212
272
  : `compaction turn ended without completing (status=${status ?? 'unknown'})`));
213
273
  }
214
274
  });
@@ -0,0 +1,23 @@
1
+ import { type RuntimeEvent, RuntimeTurnBase, type RuntimeTurnTrigger } from '@parall/agent-core';
2
+ import { TurnSink } from './turn-sink.js';
3
+ /**
4
+ * One turn of a thread the app-server runs that no dispatch started — a
5
+ * `spawn_agent` subagent thread. Its notifications are routed into `sink`
6
+ * by the adapter; the gateway drains `events` as a turn of the subagent's
7
+ * child session (the first event announces that session with its parent).
8
+ */
9
+ export declare class CodexRuntimeTurn extends RuntimeTurnBase {
10
+ readonly trigger: Extract<RuntimeTurnTrigger, {
11
+ kind: 'subagent';
12
+ }>;
13
+ private readonly opts;
14
+ readonly sink: TurnSink;
15
+ constructor(sessionKey: string, trigger: Extract<RuntimeTurnTrigger, {
16
+ kind: 'subagent';
17
+ }>, opts: {
18
+ parentSessionKey?: string;
19
+ onDetach: (reason: string) => void;
20
+ });
21
+ protected drain(): AsyncGenerator<RuntimeEvent>;
22
+ }
23
+ //# sourceMappingURL=runtime-turn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-turn.d.ts","sourceRoot":"","sources":["../src/runtime-turn.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,YAAY,EACjB,eAAe,EACf,KAAK,kBAAkB,EACxB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C;;;;;GAKG;AACH,qBAAa,gBAAiB,SAAQ,eAAe;IAKjD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,kBAAkB,EAAE;QAAE,IAAI,EAAE,UAAU,CAAA;KAAE,CAAC;IACnE,OAAO,CAAC,QAAQ,CAAC,IAAI;IALvB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;gBAGtB,UAAU,EAAE,MAAM,EACT,OAAO,EAAE,OAAO,CAAC,kBAAkB,EAAE;QAAE,IAAI,EAAE,UAAU,CAAA;KAAE,CAAC,EAClD,IAAI,EAAE;QACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;KACpC;cAMc,KAAK,IAAI,cAAc,CAAC,YAAY,CAAC;CAkCvD"}
@@ -0,0 +1,56 @@
1
+ import { projectRuntimeEvent, RuntimeTurnBase, } from '@parall/agent-core';
2
+ import { TurnSink } from './turn-sink.js';
3
+ /**
4
+ * One turn of a thread the app-server runs that no dispatch started — a
5
+ * `spawn_agent` subagent thread. Its notifications are routed into `sink`
6
+ * by the adapter; the gateway drains `events` as a turn of the subagent's
7
+ * child session (the first event announces that session with its parent).
8
+ */
9
+ export class CodexRuntimeTurn extends RuntimeTurnBase {
10
+ trigger;
11
+ opts;
12
+ sink;
13
+ constructor(sessionKey, trigger, opts) {
14
+ super(sessionKey, trigger, opts.onDetach);
15
+ this.trigger = trigger;
16
+ this.opts = opts;
17
+ this.sink = new TurnSink(() => this.touch());
18
+ }
19
+ async *drain() {
20
+ yield {
21
+ type: 'runtime_session',
22
+ runtimeSessionId: this.trigger.threadId,
23
+ runtimeLaneKey: this.sessionKey,
24
+ ...(this.opts.parentSessionKey ? { parentSessionKey: this.opts.parentSessionKey } : {}),
25
+ };
26
+ let sawError = false;
27
+ let sawOutcome = false;
28
+ while (true) {
29
+ const envelope = await this.sink.next();
30
+ if (envelope.kind === 'turn_end') {
31
+ // A real turn/completed carries its threadId; the sink's close()
32
+ // sentinel does not — the thread died or the bridge stopped.
33
+ if (envelope.threadId)
34
+ return;
35
+ if (!sawError) {
36
+ yield {
37
+ type: 'error',
38
+ message: 'subagent turn ended without turn/completed',
39
+ groupKey: this.groupKey,
40
+ };
41
+ }
42
+ if (!sawOutcome)
43
+ yield { type: 'turn_outcome', outcome: 'runtime_crash' };
44
+ return;
45
+ }
46
+ const event = envelope.kind === 'error'
47
+ ? { type: 'error', message: envelope.message }
48
+ : envelope.event;
49
+ if (event.type === 'error')
50
+ sawError = true;
51
+ if (event.type === 'turn_outcome')
52
+ sawOutcome = true;
53
+ yield projectRuntimeEvent(event, this.groupKey);
54
+ }
55
+ }
56
+ }
@@ -17,6 +17,8 @@ export declare class CodexSessionManager {
17
17
  constructor(mainSessionKey: string, stateFilePath: string, logger?: Logger | undefined);
18
18
  isMain(sessionKey: string): boolean;
19
19
  getThreadId(sessionKey: string): string | undefined;
20
+ /** The session (main or fork) that owns a thread the bridge opened. */
21
+ getSessionKey(threadId: string): string | undefined;
20
22
  recordThreadId(sessionKey: string, threadId: string): void;
21
23
  /**
22
24
  * Record a freshly-STARTED thread together with the sha of the
@@ -1 +1 @@
1
- {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAuB5D,KAAK,MAAM,GAAG;IAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,mBAAmB;IAK5B,QAAQ,CAAC,cAAc,EAAE,MAAM;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAN1B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;IACvD,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAA6B;gBAG5D,cAAc,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,MAAM,YAAA;IAKlC,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAInC,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAInD,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAcnD;;;;;;;OAOG;IACH,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS;IAY1F,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAInE,8BAA8B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM;IAO9D,oBAAoB,IAAI,iBAAiB;IAOzC,WAAW,CAAC,UAAU,EAAE,MAAM;IAK9B,6EAA6E;IAC7E,eAAe;IAYf,OAAO,CAAC,OAAO;IA6Bf,OAAO,CAAC,OAAO;CAuBhB"}
1
+ {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAuB5D,KAAK,MAAM,GAAG;IAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,mBAAmB;IAK5B,QAAQ,CAAC,cAAc,EAAE,MAAM;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAN1B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;IACvD,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAA6B;gBAG5D,cAAc,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,MAAM,YAAA;IAKlC,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAInC,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAInD,uEAAuE;IACvE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAKnD,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAcnD;;;;;;;OAOG;IACH,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS;IAY1F,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAInE,8BAA8B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM;IAO9D,oBAAoB,IAAI,iBAAiB;IAOzC,WAAW,CAAC,UAAU,EAAE,MAAM;IAK9B,6EAA6E;IAC7E,eAAe;IAYf,OAAO,CAAC,OAAO;IA6Bf,OAAO,CAAC,OAAO;CAuBhB"}
@@ -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.59.0",
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.59.0",
29
- "@parall/cli": "1.59.0",
30
- "@parall/sdk": "1.59.0"
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",