@parall/codex-agent 1.18.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/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +90 -0
- package/dist/dispatch.d.ts +53 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +515 -0
- package/dist/event-mapping.d.ts +36 -0
- package/dist/event-mapping.d.ts.map +1 -0
- package/dist/event-mapping.js +246 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/jsonrpc-client.d.ts +51 -0
- package/dist/jsonrpc-client.d.ts.map +1 -0
- package/dist/jsonrpc-client.js +111 -0
- package/dist/session-manager.d.ts +28 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +82 -0
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +26 -0
- package/package.json +41 -0
- package/src/config.ts +118 -0
- package/src/dispatch.ts +559 -0
- package/src/event-mapping.ts +258 -0
- package/src/index.ts +107 -0
- package/src/jsonrpc-client.ts +148 -0
- package/src/session-manager.ts +104 -0
- package/src/workspace.ts +32 -0
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import { normalizeApprovalPolicy, normalizeSandbox } from "./config.js";
|
|
6
|
+
import { EventMapper } from "./event-mapping.js";
|
|
7
|
+
import { JsonRpcStdioClient } from "./jsonrpc-client.js";
|
|
8
|
+
/**
|
|
9
|
+
* Bridge driver backed by `codex app-server --listen stdio://`.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle:
|
|
12
|
+
* - bridge startup: spawn one `codex app-server` subprocess, do the
|
|
13
|
+
* `initialize` / `initialized` handshake once, keep the pipe open
|
|
14
|
+
* - per Parall dispatch: `thread/start` (first time) or `thread/resume`,
|
|
15
|
+
* then `turn/start`; forward server notifications until `turn/completed`
|
|
16
|
+
* - fork-on-busy: `thread/fork` creates a disposable sibling thread
|
|
17
|
+
*
|
|
18
|
+
* Concurrency: agent-core routes one dispatch per sessionKey at a time, so
|
|
19
|
+
* main + fork can interleave turns on the same stdio pipe. We route
|
|
20
|
+
* notifications by threadId the server stamps on every item/turn event.
|
|
21
|
+
*/
|
|
22
|
+
export class CodexAppServerAdapter {
|
|
23
|
+
opts;
|
|
24
|
+
client = null;
|
|
25
|
+
proc = null;
|
|
26
|
+
initialized = false;
|
|
27
|
+
startPromise = null;
|
|
28
|
+
activeTurns = new Map();
|
|
29
|
+
resumedThreadIds = new Set();
|
|
30
|
+
stopping = false;
|
|
31
|
+
/**
|
|
32
|
+
* Store an active turn sink keyed by threadId. If a sink already exists for
|
|
33
|
+
* the same threadId, log a warning and fail the existing sink — this
|
|
34
|
+
* shouldn't happen in practice (agent-core serialises per sessionKey, and
|
|
35
|
+
* thread/fork returns a distinct id) but the failure mode of a silent
|
|
36
|
+
* replacement would be a perpetually hung dispatch generator, which is
|
|
37
|
+
* hard to debug.
|
|
38
|
+
*/
|
|
39
|
+
setActiveTurn(threadId, sink, log) {
|
|
40
|
+
const existing = this.activeTurns.get(threadId);
|
|
41
|
+
if (existing) {
|
|
42
|
+
(log ?? this.opts.log)?.warn?.(`codex-agent: thread ${threadId} already had an active turn; failing the previous dispatch`);
|
|
43
|
+
existing.push({ kind: "error", message: `thread ${threadId} replaced by concurrent turn` });
|
|
44
|
+
existing.close();
|
|
45
|
+
}
|
|
46
|
+
this.activeTurns.set(threadId, sink);
|
|
47
|
+
}
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
this.opts = opts;
|
|
50
|
+
}
|
|
51
|
+
async *dispatch({ bodyForAgent, sessionKey, context }) {
|
|
52
|
+
await this.ensureStarted(context.log);
|
|
53
|
+
// After ensureStarted resolves the subprocess could still die before we
|
|
54
|
+
// capture the client (handleSubprocessClose nulls this.client). Yield a
|
|
55
|
+
// clean error event instead of relying on a non-null assertion that would
|
|
56
|
+
// throw a TypeError on the next sendRequest call.
|
|
57
|
+
const client = this.client;
|
|
58
|
+
if (!client) {
|
|
59
|
+
yield { type: "error", message: "Codex app-server not available (subprocess died during dispatch start)" };
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const log = this.opts.log ?? context.log;
|
|
63
|
+
const isMainSession = this.opts.sessionManager.isMain(sessionKey);
|
|
64
|
+
let threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
65
|
+
if (!threadId) {
|
|
66
|
+
try {
|
|
67
|
+
threadId = await this.openThread(client, { resumeId: undefined });
|
|
68
|
+
this.opts.sessionManager.recordThreadId(sessionKey, threadId);
|
|
69
|
+
// Mark this freshly-started thread as already live in the current
|
|
70
|
+
// app-server process. Without this, the second dispatch after a cold
|
|
71
|
+
// start would enter the thread/resume branch below for a thread this
|
|
72
|
+
// process just created — and if the server treats resume-of-just-
|
|
73
|
+
// created-thread as an attach-to-detached-thread operation, it could
|
|
74
|
+
// fail the resume path and replace the thread, losing the first turn.
|
|
75
|
+
this.resumedThreadIds.add(threadId);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
yield { type: "error", message: `Codex thread/start failed: ${errToString(err)}` };
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
|
|
83
|
+
// We have a persisted threadId from a previous bridge run — resume it.
|
|
84
|
+
// Mirror the deferred-clear pattern from the turn/start retry below: only
|
|
85
|
+
// discard the persisted id once a fresh-thread start has actually
|
|
86
|
+
// succeeded, so a transient resume failure (network/timeout/upstream
|
|
87
|
+
// hiccup) doesn't permanently throw away the prior conversation context.
|
|
88
|
+
try {
|
|
89
|
+
threadId = await this.openThread(client, { resumeId: threadId });
|
|
90
|
+
this.opts.sessionManager.recordThreadId(sessionKey, threadId);
|
|
91
|
+
this.resumedThreadIds.add(threadId);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
log?.warn?.(`codex-agent: thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`);
|
|
95
|
+
let freshThreadId;
|
|
96
|
+
try {
|
|
97
|
+
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
98
|
+
}
|
|
99
|
+
catch (innerErr) {
|
|
100
|
+
yield {
|
|
101
|
+
type: "error",
|
|
102
|
+
message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
|
|
103
|
+
};
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.opts.sessionManager.clearMainThread();
|
|
107
|
+
this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
|
|
108
|
+
this.resumedThreadIds.add(freshThreadId);
|
|
109
|
+
threadId = freshThreadId;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const sink = new TurnSink();
|
|
113
|
+
this.setActiveTurn(threadId, sink, log);
|
|
114
|
+
const groupKey = randomUUID();
|
|
115
|
+
let sawTurnEnd = false;
|
|
116
|
+
try {
|
|
117
|
+
// Start the turn. If the stored threadId is dead, we retry once with a fresh thread.
|
|
118
|
+
let turnStartResult;
|
|
119
|
+
try {
|
|
120
|
+
turnStartResult = await client.sendRequest("turn/start", {
|
|
121
|
+
threadId,
|
|
122
|
+
input: buildTurnInput(bodyForAgent),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const message = errToString(err);
|
|
127
|
+
// For the main session we attempt one fresh-thread retry. Codex CLI's
|
|
128
|
+
// wording for stale threads shifts across versions (not found / unknown
|
|
129
|
+
// / invalid / expired / gone / ...), so we don't gate the retry on a
|
|
130
|
+
// regex. But we only commit to discarding the persisted threadId after
|
|
131
|
+
// the retry succeeds — if the failure was transient (network, timeout,
|
|
132
|
+
// upstream model hiccup), keeping the old id lets the next dispatch
|
|
133
|
+
// resume normally instead of permanently losing conversation context.
|
|
134
|
+
// Fork sessions don't retry: agent-core spawns a fresh fork next trigger.
|
|
135
|
+
if (!isMainSession) {
|
|
136
|
+
yield { type: "error", message: `Codex turn/start failed: ${message}` };
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
log?.warn?.(`codex-agent: turn/start on thread ${threadId} failed (${message}); attempting one-shot fresh-thread retry`);
|
|
140
|
+
this.activeTurns.delete(threadId);
|
|
141
|
+
let freshThreadId;
|
|
142
|
+
try {
|
|
143
|
+
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
144
|
+
}
|
|
145
|
+
catch (createErr) {
|
|
146
|
+
yield { type: "error", message: `Codex turn/start failed; could not create replacement thread: ${errToString(createErr)}` };
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.setActiveTurn(freshThreadId, sink, log);
|
|
150
|
+
try {
|
|
151
|
+
turnStartResult = await client.sendRequest("turn/start", {
|
|
152
|
+
threadId: freshThreadId,
|
|
153
|
+
input: buildTurnInput(bodyForAgent),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch (retryErr) {
|
|
157
|
+
// Retry also failed — likely transient or systemic, not a stale-thread
|
|
158
|
+
// issue. Don't clobber the persisted threadId; next dispatch will try
|
|
159
|
+
// resume again.
|
|
160
|
+
this.activeTurns.delete(freshThreadId);
|
|
161
|
+
yield { type: "error", message: `Codex turn/start failed after retry: ${errToString(retryErr)}` };
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Retry accepted — the original thread really was unusable. Now safe
|
|
165
|
+
// to discard the old persisted id and persist the fresh one.
|
|
166
|
+
this.opts.sessionManager.clearMainThread();
|
|
167
|
+
this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
|
|
168
|
+
this.resumedThreadIds.add(freshThreadId);
|
|
169
|
+
threadId = freshThreadId;
|
|
170
|
+
}
|
|
171
|
+
void turnStartResult; // turnId comes back here but isn't needed — we key by threadId
|
|
172
|
+
while (true) {
|
|
173
|
+
const envelope = await sink.next();
|
|
174
|
+
if (envelope.kind === "turn_end") {
|
|
175
|
+
sawTurnEnd = true;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
if (envelope.kind === "error") {
|
|
179
|
+
yield { type: "error", message: envelope.message };
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const event = envelope.event;
|
|
183
|
+
if (event.type === "error") {
|
|
184
|
+
yield event;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (event.type === "text") {
|
|
188
|
+
// Layer 0 symmetric output contract: Codex's plain text is never
|
|
189
|
+
// projected as a chat message. Outbound messages must come from the
|
|
190
|
+
// agent explicitly invoking `@parall/cli messages send` / `dm` via
|
|
191
|
+
// the shell/exec tool. Text events are still yielded so agent-core
|
|
192
|
+
// records them as suppressed session steps for audit.
|
|
193
|
+
yield { ...event, project: false, groupKey };
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
yield { ...event, groupKey };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
this.activeTurns.delete(threadId);
|
|
201
|
+
if (!sawTurnEnd) {
|
|
202
|
+
// Defensive: if we bailed early, make sure we leave no dangling sink.
|
|
203
|
+
sink.close();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async forkSession({ sessionKey: parentSessionKey }) {
|
|
208
|
+
const client = this.client;
|
|
209
|
+
if (!client)
|
|
210
|
+
return null;
|
|
211
|
+
const parentThreadId = this.opts.sessionManager.getThreadId(parentSessionKey);
|
|
212
|
+
if (!parentThreadId)
|
|
213
|
+
return null;
|
|
214
|
+
const handle = this.opts.sessionManager.createForkSessionKey();
|
|
215
|
+
try {
|
|
216
|
+
const result = await client.sendRequest("thread/fork", {
|
|
217
|
+
threadId: parentThreadId,
|
|
218
|
+
ephemeral: true,
|
|
219
|
+
});
|
|
220
|
+
const forkedThreadId = extractThreadId(result);
|
|
221
|
+
if (!forkedThreadId) {
|
|
222
|
+
this.opts.log?.warn?.("codex-agent: thread/fork returned no thread id");
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
this.opts.sessionManager.recordThreadId(handle.sessionKey, forkedThreadId);
|
|
226
|
+
return handle;
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
return this.logForkFailure(err);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
cleanupFork({ fork }) {
|
|
233
|
+
this.opts.sessionManager.cleanupFork(fork.sessionKey);
|
|
234
|
+
}
|
|
235
|
+
logForkFailure(err) {
|
|
236
|
+
this.opts.log?.warn?.(`codex-agent: thread/fork failed: ${errToString(err)}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
async stop() {
|
|
240
|
+
this.stopping = true;
|
|
241
|
+
const proc = this.proc;
|
|
242
|
+
const client = this.client;
|
|
243
|
+
this.proc = null;
|
|
244
|
+
this.client = null;
|
|
245
|
+
this.initialized = false;
|
|
246
|
+
if (client)
|
|
247
|
+
client.dispose(new Error("adapter stopped"));
|
|
248
|
+
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
249
|
+
proc.kill("SIGTERM");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async ensureStarted(log) {
|
|
253
|
+
if (this.initialized && this.client)
|
|
254
|
+
return;
|
|
255
|
+
if (this.startPromise)
|
|
256
|
+
return this.startPromise;
|
|
257
|
+
this.startPromise = this.doStart(log);
|
|
258
|
+
try {
|
|
259
|
+
await this.startPromise;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
// Keep startPromise set only while actively starting; reset once resolved
|
|
263
|
+
// (or rejected) so the next ensureStarted() after a subprocess death can
|
|
264
|
+
// restart cleanly.
|
|
265
|
+
this.startPromise = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async doStart(log) {
|
|
269
|
+
// Reset graceful-stop flag in case the adapter is being restarted after a
|
|
270
|
+
// previous stop() — otherwise handleSubprocessClose would mislabel the next
|
|
271
|
+
// unexpected exit as "during graceful stop".
|
|
272
|
+
this.stopping = false;
|
|
273
|
+
ensureGitRepo(this.opts.workspaceDir);
|
|
274
|
+
// Only steer Codex's own state via CODEX_HOME. Leaving HOME untouched
|
|
275
|
+
// preserves the user's real dotfiles for any subprocess Codex spawns
|
|
276
|
+
// (git/ssh/npm/etc.). Earlier versions also rewrote HOME, which broke
|
|
277
|
+
// tooling for users who set a custom CODEX_HOME pointing somewhere
|
|
278
|
+
// other than their shell home.
|
|
279
|
+
const env = {
|
|
280
|
+
...process.env,
|
|
281
|
+
CODEX_HOME: this.opts.codexHome,
|
|
282
|
+
FORCE_COLOR: "0",
|
|
283
|
+
NO_COLOR: "1",
|
|
284
|
+
};
|
|
285
|
+
const args = ["app-server", "--listen", "stdio://"];
|
|
286
|
+
(log ?? this.opts.log)?.info?.(`codex-agent: spawning ${this.opts.codexBin} ${args.join(" ")}`);
|
|
287
|
+
const proc = spawn(this.opts.codexBin, args, {
|
|
288
|
+
cwd: this.opts.workspaceDir,
|
|
289
|
+
env,
|
|
290
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
291
|
+
});
|
|
292
|
+
proc.stderr.setEncoding("utf8");
|
|
293
|
+
proc.stderr.on("data", (chunk) => {
|
|
294
|
+
(log ?? this.opts.log)?.warn?.(`codex-agent[stderr]: ${chunk.trim()}`);
|
|
295
|
+
});
|
|
296
|
+
// Don't publish proc/client until the initialize handshake succeeds. If
|
|
297
|
+
// initialize rejects (timeout, malformed handshake, app-server crash on
|
|
298
|
+
// start), we kill the half-spawned subprocess and clear local refs here
|
|
299
|
+
// — leaving them on `this` would leak the orphan process and cause the
|
|
300
|
+
// next ensureStarted() to spawn a second app-server on top of it.
|
|
301
|
+
const client = new JsonRpcStdioClient(proc);
|
|
302
|
+
client.setNotificationHandler((method, params) => this.routeNotification(method, params));
|
|
303
|
+
proc.once("close", (code, signal) => this.handleSubprocessClose(proc, code, signal, log));
|
|
304
|
+
proc.once("error", (err) => this.handleSubprocessClose(proc, null, null, log, err));
|
|
305
|
+
try {
|
|
306
|
+
await client.sendRequest("initialize", {
|
|
307
|
+
clientInfo: { name: "parall-codex-agent", version: "1" },
|
|
308
|
+
capabilities: { experimentalApi: false },
|
|
309
|
+
});
|
|
310
|
+
client.sendNotification("initialized", {});
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
client.dispose(err instanceof Error ? err : new Error(String(err)));
|
|
314
|
+
if (proc.exitCode === null && proc.signalCode === null)
|
|
315
|
+
proc.kill("SIGTERM");
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
this.proc = proc;
|
|
319
|
+
this.client = client;
|
|
320
|
+
this.initialized = true;
|
|
321
|
+
}
|
|
322
|
+
handleSubprocessClose(proc, code, signal, log, err) {
|
|
323
|
+
// Ignore close/error callbacks from a stale subprocess instance: the
|
|
324
|
+
// adapter may already have spawned a fresh app-server (e.g. after a
|
|
325
|
+
// recovered handshake failure), and we don't want a dying old child to
|
|
326
|
+
// tear down the new healthy one.
|
|
327
|
+
if (this.proc !== null && this.proc !== proc)
|
|
328
|
+
return;
|
|
329
|
+
const reason = err
|
|
330
|
+
? `spawn error: ${err.message}`
|
|
331
|
+
: `exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`;
|
|
332
|
+
const logger = log ?? this.opts.log;
|
|
333
|
+
if (this.stopping) {
|
|
334
|
+
logger?.info?.(`codex-agent: app-server subprocess ${reason} during graceful stop`);
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
logger?.warn?.(`codex-agent: app-server subprocess ${reason}; resetting adapter`);
|
|
338
|
+
}
|
|
339
|
+
this.client?.dispose(err ?? new Error(`app-server ${reason}`));
|
|
340
|
+
for (const activeSink of this.activeTurns.values()) {
|
|
341
|
+
activeSink.push({ kind: "error", message: `Codex app-server ${reason}` });
|
|
342
|
+
activeSink.close();
|
|
343
|
+
}
|
|
344
|
+
this.activeTurns.clear();
|
|
345
|
+
this.resumedThreadIds.clear();
|
|
346
|
+
this.client = null;
|
|
347
|
+
this.proc = null;
|
|
348
|
+
this.initialized = false;
|
|
349
|
+
}
|
|
350
|
+
async openThread(client, opts) {
|
|
351
|
+
// Per the app-server protocol, `thread/resume` accepts the same
|
|
352
|
+
// configuration overrides as `thread/start` (sandbox, approvalPolicy,
|
|
353
|
+
// model, reasoningEffort). Passing them on resume lets the user change
|
|
354
|
+
// PRLL_CODEX_* env vars and have the bridge pick them up on the next
|
|
355
|
+
// restart instead of being stuck on the values baked into the persisted
|
|
356
|
+
// thread. Sandbox / approval values are normalised to the camelCase enum
|
|
357
|
+
// the protocol expects (`workspaceWrite`, `onRequest`, etc.), so users
|
|
358
|
+
// can supply either CLI-style or protocol-style env values.
|
|
359
|
+
const commonParams = {
|
|
360
|
+
approvalPolicy: normalizeApprovalPolicy(this.opts.approvalPolicy),
|
|
361
|
+
sandbox: normalizeSandbox(this.opts.sandbox),
|
|
362
|
+
};
|
|
363
|
+
if (this.opts.model)
|
|
364
|
+
commonParams.model = this.opts.model;
|
|
365
|
+
if (this.opts.reasoningEffort) {
|
|
366
|
+
// The app-server JSON-RPC surface uses camelCase for overrides —
|
|
367
|
+
// `modelReasoningEffort` parallels `approvalPolicy` / `sandbox` on the
|
|
368
|
+
// top-level params. The corresponding config.toml key is
|
|
369
|
+
// `model_reasoning_effort` (snake_case), but the nested `config` on
|
|
370
|
+
// `thread/start` / `thread/resume` takes the camelCase form.
|
|
371
|
+
commonParams.config = { modelReasoningEffort: this.opts.reasoningEffort };
|
|
372
|
+
}
|
|
373
|
+
const method = opts.resumeId ? "thread/resume" : "thread/start";
|
|
374
|
+
const params = opts.resumeId
|
|
375
|
+
? { threadId: opts.resumeId, ...commonParams }
|
|
376
|
+
: { cwd: this.opts.workspaceDir, ...commonParams };
|
|
377
|
+
const result = await client.sendRequest(method, params);
|
|
378
|
+
const threadId = extractThreadId(result) ?? opts.resumeId;
|
|
379
|
+
if (!threadId) {
|
|
380
|
+
throw new Error(`${method} returned no thread id`);
|
|
381
|
+
}
|
|
382
|
+
return threadId;
|
|
383
|
+
}
|
|
384
|
+
routeNotification(method, params) {
|
|
385
|
+
const threadId = extractThreadIdFromNotification(params);
|
|
386
|
+
if (!threadId) {
|
|
387
|
+
// Surface server-initiated generic errors to every active turn.
|
|
388
|
+
if (method === "error") {
|
|
389
|
+
const msg = params?.message ?? "Codex app-server error";
|
|
390
|
+
for (const sink of this.activeTurns.values()) {
|
|
391
|
+
sink.push({ kind: "error", message: String(msg) });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const sink = this.activeTurns.get(threadId);
|
|
397
|
+
if (!sink)
|
|
398
|
+
return;
|
|
399
|
+
// For turn/completed we still run the mapper first — it emits a
|
|
400
|
+
// RuntimeEvent error if `turn.status === "failed"`. Enqueue those events
|
|
401
|
+
// before the turn_end sentinel so the dispatch loop can yield them.
|
|
402
|
+
for (const event of sink.mapper.map(method, params)) {
|
|
403
|
+
sink.push({ kind: "runtime", event });
|
|
404
|
+
}
|
|
405
|
+
if (method === "turn/completed") {
|
|
406
|
+
sink.push({ kind: "turn_end", threadId });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Per-turn buffered sink backed by an unbounded promise queue. */
|
|
411
|
+
class TurnSink {
|
|
412
|
+
mapper = new EventMapper();
|
|
413
|
+
queue = [];
|
|
414
|
+
resolver = null;
|
|
415
|
+
closed = false;
|
|
416
|
+
push(envelope) {
|
|
417
|
+
if (this.closed)
|
|
418
|
+
return;
|
|
419
|
+
if (this.resolver) {
|
|
420
|
+
const r = this.resolver;
|
|
421
|
+
this.resolver = null;
|
|
422
|
+
r(envelope);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
this.queue.push(envelope);
|
|
426
|
+
}
|
|
427
|
+
next() {
|
|
428
|
+
// Drain any queued envelopes first, even after close(). Otherwise a final
|
|
429
|
+
// error envelope enqueued right before close() (e.g. by
|
|
430
|
+
// handleSubprocessClose) is silently dropped because the consumer would
|
|
431
|
+
// see turn_end before it.
|
|
432
|
+
const pending = this.queue.shift();
|
|
433
|
+
if (pending)
|
|
434
|
+
return Promise.resolve(pending);
|
|
435
|
+
if (this.closed) {
|
|
436
|
+
return Promise.resolve({ kind: "turn_end" });
|
|
437
|
+
}
|
|
438
|
+
return new Promise((resolve) => {
|
|
439
|
+
this.resolver = resolve;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
close() {
|
|
443
|
+
this.closed = true;
|
|
444
|
+
const r = this.resolver;
|
|
445
|
+
this.resolver = null;
|
|
446
|
+
r?.({ kind: "turn_end" });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Codex app-server's `turn/start` expects `input` as an array of content
|
|
451
|
+
* items (each `{ type: "text", text: "..." }` or similar), not a raw string.
|
|
452
|
+
* Wrap the event body so the protocol contract is honoured — sending a bare
|
|
453
|
+
* string has worked historically via undocumented coercion but isn't stable.
|
|
454
|
+
*/
|
|
455
|
+
function buildTurnInput(body) {
|
|
456
|
+
return [{ type: "text", text: body }];
|
|
457
|
+
}
|
|
458
|
+
function ensureGitRepo(workingDirectory) {
|
|
459
|
+
fs.mkdirSync(workingDirectory, { recursive: true });
|
|
460
|
+
// Only `git init` if the workspace isn't already inside any git repo. A
|
|
461
|
+
// bare existsSync(.git) check would miss the common case of a user pointing
|
|
462
|
+
// PRLL_CODEX_WORKSPACE_DIR at a subdirectory of their existing project,
|
|
463
|
+
// and silently creating a nested repo there would mangle their layout.
|
|
464
|
+
try {
|
|
465
|
+
execSync("git rev-parse --is-inside-work-tree", { cwd: workingDirectory, stdio: "pipe" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// Not inside a repo — fall through to init.
|
|
470
|
+
}
|
|
471
|
+
const env = {
|
|
472
|
+
...process.env,
|
|
473
|
+
GIT_AUTHOR_NAME: "parall-codex-agent",
|
|
474
|
+
GIT_AUTHOR_EMAIL: "agent@parall.local",
|
|
475
|
+
GIT_COMMITTER_NAME: "parall-codex-agent",
|
|
476
|
+
GIT_COMMITTER_EMAIL: "agent@parall.local",
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
execSync("git init", { cwd: workingDirectory, stdio: "pipe", env });
|
|
480
|
+
execSync("git commit --allow-empty -m init", { cwd: workingDirectory, stdio: "pipe", env });
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function extractThreadId(result) {
|
|
487
|
+
if (!result || typeof result !== "object")
|
|
488
|
+
return undefined;
|
|
489
|
+
const r = result;
|
|
490
|
+
if (typeof r.threadId === "string")
|
|
491
|
+
return r.threadId;
|
|
492
|
+
const thread = r.thread;
|
|
493
|
+
if (thread && typeof thread.id === "string")
|
|
494
|
+
return thread.id;
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
function extractThreadIdFromNotification(params) {
|
|
498
|
+
if (!params || typeof params !== "object")
|
|
499
|
+
return undefined;
|
|
500
|
+
const p = params;
|
|
501
|
+
if (typeof p.threadId === "string")
|
|
502
|
+
return p.threadId;
|
|
503
|
+
const thread = p.thread;
|
|
504
|
+
if (thread && typeof thread.id === "string")
|
|
505
|
+
return thread.id;
|
|
506
|
+
const meta = (p._meta ?? p.meta);
|
|
507
|
+
if (meta && typeof meta.threadId === "string")
|
|
508
|
+
return meta.threadId;
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
function errToString(err) {
|
|
512
|
+
if (err instanceof Error)
|
|
513
|
+
return err.message;
|
|
514
|
+
return String(err);
|
|
515
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RuntimeEvent } from "@parall/agent-core";
|
|
2
|
+
/**
|
|
3
|
+
* Translation layer between `codex app-server` JSON-RPC notifications and
|
|
4
|
+
* `@parall/agent-core` RuntimeEvents. The adapter owns one of these per
|
|
5
|
+
* in-flight turn.
|
|
6
|
+
*
|
|
7
|
+
* Delta policy: Codex emits streaming `item/*\/delta` chunks plus a final
|
|
8
|
+
* `item/completed` with the aggregated text. We drop all `*_delta`
|
|
9
|
+
* notifications entirely and only emit the aggregated `item/completed`
|
|
10
|
+
* text — symmetric with the Claude bridge, which similarly projects the
|
|
11
|
+
* assistant's final message as one event.
|
|
12
|
+
*
|
|
13
|
+
* Output contract: every `text` event is emitted with `project: false`.
|
|
14
|
+
* Under the symmetric Layer 0 contract (see
|
|
15
|
+
* `docs/engineering-design/agent-dm-loop-prevention.md`), plain text is
|
|
16
|
+
* recorded as a suppressed session step for audit but never surfaces as a
|
|
17
|
+
* chat message. Outbound messages happen exclusively via the agent
|
|
18
|
+
* explicitly invoking `@parall/cli messages send` / `dm`.
|
|
19
|
+
*/
|
|
20
|
+
export declare class EventMapper {
|
|
21
|
+
private readonly toolCallStart;
|
|
22
|
+
/**
|
|
23
|
+
* Map a single server notification to zero or more RuntimeEvents.
|
|
24
|
+
* Return `null` for notifications that have no chat surface.
|
|
25
|
+
*/
|
|
26
|
+
map(method: string, params: unknown): RuntimeEvent[];
|
|
27
|
+
private mapItem;
|
|
28
|
+
/**
|
|
29
|
+
* Compute the durationMs for a tool call completion, preferring the server's
|
|
30
|
+
* value and falling back to the locally measured interval. Always clears the
|
|
31
|
+
* tracked start timestamp so long-lived bridges don't accumulate stale
|
|
32
|
+
* entries when the server routinely supplies durationMs.
|
|
33
|
+
*/
|
|
34
|
+
private resolveDuration;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=event-mapping.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-mapping.d.ts","sourceRoot":"","sources":["../src/event-mapping.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAE3D;;;OAGG;IACH,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,YAAY,EAAE;IA8CpD,OAAO,CAAC,OAAO;IA4If;;;;;OAKG;IACH,OAAO,CAAC,eAAe;CASxB"}
|