@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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Translation layer between `codex app-server` JSON-RPC notifications and
3
+ * `@parall/agent-core` RuntimeEvents. The adapter owns one of these per
4
+ * in-flight turn.
5
+ *
6
+ * Delta policy: Codex emits streaming `item/*\/delta` chunks plus a final
7
+ * `item/completed` with the aggregated text. We drop all `*_delta`
8
+ * notifications entirely and only emit the aggregated `item/completed`
9
+ * text — symmetric with the Claude bridge, which similarly projects the
10
+ * assistant's final message as one event.
11
+ *
12
+ * Output contract: every `text` event is emitted with `project: false`.
13
+ * Under the symmetric Layer 0 contract (see
14
+ * `docs/engineering-design/agent-dm-loop-prevention.md`), plain text is
15
+ * recorded as a suppressed session step for audit but never surfaces as a
16
+ * chat message. Outbound messages happen exclusively via the agent
17
+ * explicitly invoking `@parall/cli messages send` / `dm`.
18
+ */
19
+ export class EventMapper {
20
+ toolCallStart = new Map();
21
+ /**
22
+ * Map a single server notification to zero or more RuntimeEvents.
23
+ * Return `null` for notifications that have no chat surface.
24
+ */
25
+ map(method, params) {
26
+ const events = [];
27
+ const p = (params ?? {});
28
+ switch (method) {
29
+ // Streaming deltas are intentionally dropped — see the class comment.
30
+ case "item/agentMessage/delta":
31
+ case "item/reasoning/textDelta":
32
+ case "item/reasoning/summaryTextDelta":
33
+ break;
34
+ case "item/started": {
35
+ const item = p.item;
36
+ if (!item || typeof item.type !== "string")
37
+ break;
38
+ events.push(...this.mapItem(item, "started"));
39
+ break;
40
+ }
41
+ case "item/completed": {
42
+ const item = p.item;
43
+ if (!item || typeof item.type !== "string")
44
+ break;
45
+ events.push(...this.mapItem(item, "completed"));
46
+ break;
47
+ }
48
+ case "turn/completed": {
49
+ const turn = p.turn;
50
+ const status = turn ? asString(turn.status) : undefined;
51
+ const error = turn?.error;
52
+ if (status === "failed") {
53
+ const message = asString(error?.message) ?? "Codex turn failed";
54
+ events.push({ type: "error", message });
55
+ }
56
+ break;
57
+ }
58
+ case "error": {
59
+ const message = asString(p.message) ?? "Codex error";
60
+ events.push({ type: "error", message });
61
+ break;
62
+ }
63
+ }
64
+ return events;
65
+ }
66
+ mapItem(item, phase) {
67
+ const type = asString(item.type);
68
+ const id = asString(item.id);
69
+ const now = Date.now();
70
+ if (type === "agentMessage") {
71
+ if (phase !== "completed")
72
+ return [];
73
+ const text = asString(item.text);
74
+ if (!text)
75
+ return [];
76
+ // Layer 0 symmetric output contract: plain text is never auto-projected;
77
+ // agents must explicitly call `@parall/cli messages send` / `dm` to reach
78
+ // the chat. The text is still recorded as a session step for audit.
79
+ return [{ type: "text", text, project: false }];
80
+ }
81
+ if (type === "reasoning") {
82
+ if (phase !== "completed")
83
+ return [];
84
+ const text = joinReasoningText(item);
85
+ if (!text)
86
+ return [];
87
+ return [{ type: "thinking", text }];
88
+ }
89
+ if (type === "commandExecution") {
90
+ const command = formatCommand(item.command);
91
+ const callId = id ?? `shell-${now}`;
92
+ if (phase === "started") {
93
+ this.toolCallStart.set(callId, now);
94
+ return [{
95
+ type: "tool_call",
96
+ callId,
97
+ toolName: "shell",
98
+ input: { command, cwd: asString(item.cwd) },
99
+ startedAt: new Date(now).toISOString(),
100
+ }];
101
+ }
102
+ // Codex app-server uses camelCase: aggregatedOutput / exitCode / durationMs
103
+ // (see openai/codex codex-rs/app-server-protocol). Earlier snake_case
104
+ // probing dropped exit codes and split outputs that arrive as one string.
105
+ const durationMs = this.resolveDuration(callId, now, item.durationMs);
106
+ const output = asString(item.aggregatedOutput) ?? joinStreams(item.stdout, item.stderr);
107
+ const exitCode = typeof item.exitCode === "number" ? item.exitCode : undefined;
108
+ const status = asString(item.status);
109
+ const failed = (exitCode !== undefined && exitCode !== 0)
110
+ || status === "failed"
111
+ || status === "declined";
112
+ return [{
113
+ type: "tool_result",
114
+ callId,
115
+ toolName: "shell",
116
+ output,
117
+ ...(failed ? { error: output || `shell ${status ?? "exited"} (code=${exitCode ?? "?"})` } : {}),
118
+ ...(durationMs !== undefined ? { durationMs } : {}),
119
+ }];
120
+ }
121
+ if (type === "mcpToolCall") {
122
+ const server = asString(item.server);
123
+ const tool = asString(item.tool);
124
+ const toolName = server && tool ? `${server}:${tool}` : tool ?? server ?? "mcp";
125
+ const callId = id ?? `mcp-${now}`;
126
+ if (phase === "started") {
127
+ this.toolCallStart.set(callId, now);
128
+ return [{
129
+ type: "tool_call",
130
+ callId,
131
+ toolName,
132
+ input: item.arguments ?? {},
133
+ startedAt: new Date(now).toISOString(),
134
+ }];
135
+ }
136
+ const durationMs = this.resolveDuration(callId, now, item.durationMs);
137
+ const result = item.result;
138
+ // result === null/undefined must collapse to an empty string, not the
139
+ // literal `'""'` that JSON.stringify("") would produce — otherwise the
140
+ // success path renders an empty-quoted output and the failure path's
141
+ // `output || "MCP tool call failed"` fallback gets eaten because the
142
+ // literal `'""'` is truthy.
143
+ const output = result == null
144
+ ? ""
145
+ : typeof result === "string" ? result : JSON.stringify(result);
146
+ const success = item.isSuccess !== false;
147
+ return [{
148
+ type: "tool_result",
149
+ callId,
150
+ toolName,
151
+ output,
152
+ ...(success ? {} : { error: output || "MCP tool call failed" }),
153
+ ...(durationMs !== undefined ? { durationMs } : {}),
154
+ }];
155
+ }
156
+ if (type === "fileChange") {
157
+ const callId = id ?? `patch-${now}`;
158
+ if (phase === "started") {
159
+ this.toolCallStart.set(callId, now);
160
+ return [{
161
+ type: "tool_call",
162
+ callId,
163
+ toolName: "patch",
164
+ input: { changes: item.changes ?? [] },
165
+ startedAt: new Date(now).toISOString(),
166
+ }];
167
+ }
168
+ const durationMs = this.resolveDuration(callId, now, undefined);
169
+ return [{
170
+ type: "tool_result",
171
+ callId,
172
+ toolName: "patch",
173
+ output: "",
174
+ ...(durationMs !== undefined ? { durationMs } : {}),
175
+ }];
176
+ }
177
+ if (type === "webSearch") {
178
+ const callId = id ?? `search-${now}`;
179
+ if (phase === "started") {
180
+ this.toolCallStart.set(callId, now);
181
+ return [{
182
+ type: "tool_call",
183
+ callId,
184
+ toolName: "web_search",
185
+ input: { query: asString(item.query) ?? "" },
186
+ startedAt: new Date(now).toISOString(),
187
+ }];
188
+ }
189
+ const durationMs = this.resolveDuration(callId, now, item.durationMs);
190
+ const results = Array.isArray(item.results) ? item.results : [];
191
+ const output = results.length > 0 ? JSON.stringify(results) : "";
192
+ return [{
193
+ type: "tool_result",
194
+ callId,
195
+ toolName: "web_search",
196
+ output,
197
+ ...(durationMs !== undefined ? { durationMs } : {}),
198
+ }];
199
+ }
200
+ return [];
201
+ }
202
+ /**
203
+ * Compute the durationMs for a tool call completion, preferring the server's
204
+ * value and falling back to the locally measured interval. Always clears the
205
+ * tracked start timestamp so long-lived bridges don't accumulate stale
206
+ * entries when the server routinely supplies durationMs.
207
+ */
208
+ resolveDuration(callId, now, serverDurationMs) {
209
+ const start = this.toolCallStart.get(callId);
210
+ this.toolCallStart.delete(callId);
211
+ if (typeof serverDurationMs === "number" && Number.isFinite(serverDurationMs)) {
212
+ return Math.max(0, serverDurationMs);
213
+ }
214
+ if (start === undefined)
215
+ return undefined;
216
+ return Math.max(0, now - start);
217
+ }
218
+ }
219
+ function asString(value) {
220
+ if (typeof value !== "string")
221
+ return undefined;
222
+ const trimmed = value.trim();
223
+ return trimmed.length > 0 ? trimmed : undefined;
224
+ }
225
+ function joinReasoningText(item) {
226
+ const summary = Array.isArray(item.summary) ? item.summary.filter((x) => typeof x === "string") : [];
227
+ const content = Array.isArray(item.content) ? item.content.filter((x) => typeof x === "string") : [];
228
+ const parts = [...summary, ...content, asString(item.text)].filter(Boolean);
229
+ return parts.join("\n").trim();
230
+ }
231
+ function joinStreams(stdout, stderr) {
232
+ const out = asString(stdout);
233
+ const err = asString(stderr);
234
+ if (out && err)
235
+ return `${out}\n${err}`;
236
+ return out ?? err ?? "";
237
+ }
238
+ /** Codex sends `command` either as a plain string or as an argv array (e.g. ["sh", "-c", "ls"]). */
239
+ function formatCommand(command) {
240
+ if (typeof command === "string")
241
+ return command;
242
+ if (Array.isArray(command)) {
243
+ return command.map((part) => (typeof part === "string" ? part : JSON.stringify(part))).join(" ");
244
+ }
245
+ return "";
246
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/dist/index.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import * as os from "node:os";
3
+ import { ParallAgentGateway } from "@parall/agent-core";
4
+ import { ParallClient, ParallWs } from "@parall/sdk";
5
+ import { buildCodexRuntimeKey, resolveCodexAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from "./config.js";
6
+ import { CodexAppServerAdapter } from "./dispatch.js";
7
+ import { CodexSessionManager } from "./session-manager.js";
8
+ import { ensureCodexWorkspace } from "./workspace.js";
9
+ function createLogger(prefix) {
10
+ return {
11
+ info: (msg) => console.log(`[${prefix}] ${msg}`),
12
+ warn: (msg) => console.warn(`[${prefix}] ${msg}`),
13
+ error: (msg) => console.error(`[${prefix}] ${msg}`),
14
+ };
15
+ }
16
+ async function main() {
17
+ const config = resolveCodexAgentConfig(process.env);
18
+ const log = createLogger("codex-agent");
19
+ ensureCodexWorkspace(config.workspaceDir, log);
20
+ const client = new ParallClient({
21
+ baseUrl: config.apiUrl,
22
+ token: config.apiKey,
23
+ swimlaneName: config.swimlaneName,
24
+ });
25
+ const me = await client.getMe();
26
+ const agentUserId = me.id;
27
+ const runtimeKey = config.runtimeKey || buildCodexRuntimeKey(agentUserId);
28
+ const sessionStateFilePath = sessionStateFilePathForRuntime(config.stateDir, runtimeKey);
29
+ const sessionManager = new CodexSessionManager(runtimeKey, sessionStateFilePath, log);
30
+ const resolvedWsUrl = resolveWsUrl(config.apiUrl, config.wsUrl, config.swimlaneName);
31
+ const ws = new ParallWs({
32
+ getTicket: () => client.getWsTicket(),
33
+ wsUrl: resolvedWsUrl,
34
+ });
35
+ const adapter = new CodexAppServerAdapter({
36
+ codexBin: config.codexBin,
37
+ codexHome: config.codexHome,
38
+ workspaceDir: config.workspaceDir,
39
+ model: config.model,
40
+ reasoningEffort: config.reasoningEffort,
41
+ sandbox: config.sandbox,
42
+ approvalPolicy: config.approvalPolicy,
43
+ sessionManager,
44
+ log,
45
+ });
46
+ const gateway = new ParallAgentGateway({
47
+ accountId: agentUserId,
48
+ client,
49
+ ws,
50
+ connectionLabel: resolvedWsUrl,
51
+ config: {
52
+ parall_url: config.apiUrl,
53
+ api_key: config.apiKey,
54
+ org_id: config.orgId,
55
+ },
56
+ agentUserId,
57
+ runtimeType: "codex",
58
+ runtimeKey,
59
+ runtimeRef: {
60
+ hostname: os.hostname(),
61
+ pid: process.pid,
62
+ workspace_dir: config.workspaceDir,
63
+ codex_home: config.codexHome,
64
+ driver: "app-server",
65
+ },
66
+ dispatchAdapter: adapter,
67
+ log,
68
+ stepIdFilePathForSession: (sessionKey) => stepIdFilePathForSession(config.stateDir, sessionKey),
69
+ });
70
+ const abortController = new AbortController();
71
+ const abort = () => abortController.abort();
72
+ process.on("SIGINT", abort);
73
+ process.on("SIGTERM", abort);
74
+ try {
75
+ log.info(`starting self-hosted Codex runtime (app-server driver) for ${me.display_name} (${agentUserId})`);
76
+ await gateway.run(abortController.signal);
77
+ }
78
+ finally {
79
+ await adapter.stop();
80
+ process.off("SIGINT", abort);
81
+ process.off("SIGTERM", abort);
82
+ }
83
+ }
84
+ main().catch((err) => {
85
+ console.error(`[codex-agent] fatal: ${String(err)}`);
86
+ process.exitCode = 1;
87
+ });
@@ -0,0 +1,51 @@
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ type JsonRpcId = number | string;
3
+ export type JsonRpcRequest = {
4
+ jsonrpc: "2.0";
5
+ id: JsonRpcId;
6
+ method: string;
7
+ params?: unknown;
8
+ };
9
+ export type JsonRpcNotification = {
10
+ jsonrpc: "2.0";
11
+ method: string;
12
+ params?: unknown;
13
+ };
14
+ export type JsonRpcResponse = {
15
+ jsonrpc: "2.0";
16
+ id: JsonRpcId;
17
+ result?: unknown;
18
+ error?: {
19
+ code: number;
20
+ message: string;
21
+ data?: unknown;
22
+ };
23
+ };
24
+ export type NotificationHandler = (method: string, params: unknown) => void;
25
+ /**
26
+ * Minimal JSON-RPC 2.0 stdio client used to drive a long-running
27
+ * `codex app-server --listen stdio://` subprocess. Frames are newline
28
+ * delimited JSON — one message per line. The client dispatches server
29
+ * responses to the originating request promise and forwards every
30
+ * notification to a single handler; dispatch-side routing (per thread,
31
+ * per turn) lives in the adapter, not here.
32
+ */
33
+ export declare class JsonRpcStdioClient {
34
+ private readonly proc;
35
+ private readonly requestTimeoutMs;
36
+ private nextId;
37
+ private readonly pending;
38
+ private buffer;
39
+ private onNotification;
40
+ private disposed;
41
+ constructor(proc: ChildProcessWithoutNullStreams, requestTimeoutMs?: number);
42
+ setNotificationHandler(handler: NotificationHandler): void;
43
+ sendRequest(method: string, params?: unknown): Promise<unknown>;
44
+ sendNotification(method: string, params?: unknown): void;
45
+ dispose(err: Error): void;
46
+ private writeFrame;
47
+ private ingest;
48
+ private handleLine;
49
+ }
50
+ export {};
51
+ //# sourceMappingURL=jsonrpc-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jsonrpc-client.d.ts","sourceRoot":"","sources":["../src/jsonrpc-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AAEzE,KAAK,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjC,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC3D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAW5E;;;;;;;GAOG;AACH,qBAAa,kBAAkB;IAQ3B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IARnC,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,QAAQ,CAAS;gBAGN,IAAI,EAAE,8BAA8B,EACpC,gBAAgB,GAAE,MAAmC;IAQxE,sBAAsB,CAAC,OAAO,EAAE,mBAAmB;IAInD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAmB/D,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO;IAMjD,OAAO,CAAC,GAAG,EAAE,KAAK;IAUlB,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,UAAU;CAyBnB"}
@@ -0,0 +1,111 @@
1
+ /** Maximum time to wait for a JSON-RPC response before rejecting. */
2
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
3
+ /**
4
+ * Minimal JSON-RPC 2.0 stdio client used to drive a long-running
5
+ * `codex app-server --listen stdio://` subprocess. Frames are newline
6
+ * delimited JSON — one message per line. The client dispatches server
7
+ * responses to the originating request promise and forwards every
8
+ * notification to a single handler; dispatch-side routing (per thread,
9
+ * per turn) lives in the adapter, not here.
10
+ */
11
+ export class JsonRpcStdioClient {
12
+ proc;
13
+ requestTimeoutMs;
14
+ nextId = 1;
15
+ pending = new Map();
16
+ buffer = "";
17
+ onNotification = null;
18
+ disposed = false;
19
+ constructor(proc, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
20
+ this.proc = proc;
21
+ this.requestTimeoutMs = requestTimeoutMs;
22
+ proc.stdout.setEncoding("utf8");
23
+ proc.stdout.on("data", (chunk) => this.ingest(chunk));
24
+ proc.once("close", () => this.dispose(new Error("app-server subprocess closed")));
25
+ proc.once("error", (err) => this.dispose(err));
26
+ }
27
+ setNotificationHandler(handler) {
28
+ this.onNotification = handler;
29
+ }
30
+ sendRequest(method, params) {
31
+ if (this.disposed) {
32
+ return Promise.reject(new Error("JSON-RPC client disposed"));
33
+ }
34
+ const id = this.nextId++;
35
+ const request = { jsonrpc: "2.0", id, method, params };
36
+ const promise = new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => {
38
+ const pending = this.pending.get(id);
39
+ if (!pending)
40
+ return;
41
+ this.pending.delete(id);
42
+ reject(new Error(`JSON-RPC request "${method}" timed out after ${this.requestTimeoutMs}ms`));
43
+ }, this.requestTimeoutMs);
44
+ this.pending.set(id, { resolve, reject, timer });
45
+ });
46
+ this.writeFrame(request);
47
+ return promise;
48
+ }
49
+ sendNotification(method, params) {
50
+ if (this.disposed)
51
+ return;
52
+ const notification = { jsonrpc: "2.0", method, params };
53
+ this.writeFrame(notification);
54
+ }
55
+ dispose(err) {
56
+ if (this.disposed)
57
+ return;
58
+ this.disposed = true;
59
+ for (const pending of this.pending.values()) {
60
+ clearTimeout(pending.timer);
61
+ pending.reject(err);
62
+ }
63
+ this.pending.clear();
64
+ }
65
+ writeFrame(message) {
66
+ this.proc.stdin.write(`${JSON.stringify(message)}\n`);
67
+ }
68
+ ingest(chunk) {
69
+ this.buffer += chunk;
70
+ let newlineIndex = this.buffer.indexOf("\n");
71
+ while (newlineIndex >= 0) {
72
+ const line = this.buffer.slice(0, newlineIndex).trim();
73
+ this.buffer = this.buffer.slice(newlineIndex + 1);
74
+ if (line)
75
+ this.handleLine(line);
76
+ newlineIndex = this.buffer.indexOf("\n");
77
+ }
78
+ }
79
+ handleLine(line) {
80
+ let message;
81
+ try {
82
+ message = JSON.parse(line);
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (isResponse(message)) {
88
+ const pending = this.pending.get(message.id);
89
+ if (!pending)
90
+ return;
91
+ this.pending.delete(message.id);
92
+ clearTimeout(pending.timer);
93
+ if (message.error) {
94
+ pending.reject(new Error(message.error.message || "JSON-RPC error"));
95
+ }
96
+ else {
97
+ pending.resolve(message.result);
98
+ }
99
+ return;
100
+ }
101
+ if (isNotification(message)) {
102
+ this.onNotification?.(message.method, message.params);
103
+ }
104
+ }
105
+ }
106
+ function isResponse(m) {
107
+ return !!m && typeof m === "object" && "id" in m && ("result" in m || "error" in m);
108
+ }
109
+ function isNotification(m) {
110
+ return !!m && typeof m === "object" && "method" in m && !("id" in m);
111
+ }
@@ -0,0 +1,28 @@
1
+ import type { ForkSessionHandle } from "@parall/agent-core";
2
+ type Logger = {
3
+ warn(message: string): void;
4
+ };
5
+ /**
6
+ * Persists the app-server `threadId` for the main Parall session so the
7
+ * bridge can resume across restarts via `thread/resume`. Fork sessions
8
+ * get an ephemeral threadId created via `thread/fork`; they are not
9
+ * persisted because agent-core's routing treats each fork as disposable.
10
+ */
11
+ export declare class CodexSessionManager {
12
+ readonly mainSessionKey: string;
13
+ private readonly stateFilePath;
14
+ private readonly logger?;
15
+ private readonly threadIds;
16
+ constructor(mainSessionKey: string, stateFilePath: string, logger?: Logger | undefined);
17
+ isMain(sessionKey: string): boolean;
18
+ getThreadId(sessionKey: string): string | undefined;
19
+ recordThreadId(sessionKey: string, threadId: string): void;
20
+ createForkSessionKey(): ForkSessionHandle;
21
+ cleanupFork(sessionKey: string): void;
22
+ /** Clear the main thread id when app-server rejects it (stale / deleted). */
23
+ clearMainThread(): void;
24
+ private restore;
25
+ private persist;
26
+ }
27
+ export {};
28
+ //# sourceMappingURL=session-manager.d.ts.map
@@ -0,0 +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;AAO5D,KAAK,MAAM,GAAG;IAAE,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE9C;;;;;GAKG;AACH,qBAAa,mBAAmB;IAI5B,QAAQ,CAAC,cAAc,EAAE,MAAM;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAL1B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;gBAG5C,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;IAOnD,oBAAoB,IAAI,iBAAiB;IAOzC,WAAW,CAAC,UAAU,EAAE,MAAM;IAI9B,6EAA6E;IAC7E,eAAe;IAWf,OAAO,CAAC,OAAO;IAgBf,OAAO,CAAC,OAAO;CAqBhB"}
@@ -0,0 +1,82 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Persists the app-server `threadId` for the main Parall session so the
5
+ * bridge can resume across restarts via `thread/resume`. Fork sessions
6
+ * get an ephemeral threadId created via `thread/fork`; they are not
7
+ * persisted because agent-core's routing treats each fork as disposable.
8
+ */
9
+ export class CodexSessionManager {
10
+ mainSessionKey;
11
+ stateFilePath;
12
+ logger;
13
+ threadIds = new Map();
14
+ constructor(mainSessionKey, stateFilePath, logger) {
15
+ this.mainSessionKey = mainSessionKey;
16
+ this.stateFilePath = stateFilePath;
17
+ this.logger = logger;
18
+ this.restore();
19
+ }
20
+ isMain(sessionKey) {
21
+ return sessionKey === this.mainSessionKey;
22
+ }
23
+ getThreadId(sessionKey) {
24
+ return this.threadIds.get(sessionKey);
25
+ }
26
+ recordThreadId(sessionKey, threadId) {
27
+ this.threadIds.set(sessionKey, threadId);
28
+ if (sessionKey === this.mainSessionKey) {
29
+ this.persist(threadId);
30
+ }
31
+ }
32
+ createForkSessionKey() {
33
+ const suffix = Math.random().toString(36).slice(2, 10);
34
+ return {
35
+ sessionKey: `codex-fork:${Date.now().toString(36)}-${suffix}`,
36
+ };
37
+ }
38
+ cleanupFork(sessionKey) {
39
+ this.threadIds.delete(sessionKey);
40
+ }
41
+ /** Clear the main thread id when app-server rejects it (stale / deleted). */
42
+ clearMainThread() {
43
+ this.threadIds.delete(this.mainSessionKey);
44
+ try {
45
+ fs.rmSync(this.stateFilePath, { force: true });
46
+ }
47
+ catch (error) {
48
+ this.logger?.warn(`codex-agent: failed to clear main thread state at ${this.stateFilePath}: ${String(error)}`);
49
+ }
50
+ }
51
+ restore() {
52
+ try {
53
+ const raw = fs.readFileSync(this.stateFilePath, "utf8");
54
+ const parsed = JSON.parse(raw);
55
+ if (parsed.runtimeKey === this.mainSessionKey && typeof parsed.threadId === "string" && parsed.threadId.trim()) {
56
+ this.threadIds.set(this.mainSessionKey, parsed.threadId.trim());
57
+ }
58
+ }
59
+ catch (error) {
60
+ if (error?.code !== "ENOENT") {
61
+ this.logger?.warn(`codex-agent: could not restore main thread state from ${this.stateFilePath}: ${String(error)}`);
62
+ }
63
+ }
64
+ }
65
+ persist(threadId) {
66
+ // Atomic write: truncate-in-place risks leaving a half-written / zero-byte
67
+ // state file if the process dies between `open(O_TRUNC)` and the final
68
+ // fsync. Since this file is the sole source of cross-restart `resume`
69
+ // context for the main session, losing it would silently drop
70
+ // conversation history. Write to a sibling temp file first, then rename —
71
+ // POSIX rename is atomic within the same directory.
72
+ try {
73
+ fs.mkdirSync(path.dirname(this.stateFilePath), { recursive: true });
74
+ const tmpPath = `${this.stateFilePath}.tmp`;
75
+ fs.writeFileSync(tmpPath, JSON.stringify({ runtimeKey: this.mainSessionKey, threadId }, null, 2));
76
+ fs.renameSync(tmpPath, this.stateFilePath);
77
+ }
78
+ catch (error) {
79
+ this.logger?.warn(`codex-agent: failed to persist thread state at ${this.stateFilePath}: ${String(error)}`);
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,4 @@
1
+ export declare function ensureCodexWorkspace(workspaceDir: string, log?: {
2
+ warn: (msg: string) => void;
3
+ }): void;
4
+ //# sourceMappingURL=workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAIA,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,QAyBtC"}
@@ -0,0 +1,26 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { BRIDGE_WORKSPACE_INSTRUCTIONS } from "@parall/agent-core";
4
+ export function ensureCodexWorkspace(workspaceDir, log) {
5
+ fs.mkdirSync(workspaceDir, { recursive: true });
6
+ // AGENTS.md is bridge-managed: always overwrite so the workspace picks up
7
+ // updated guardrails / command templates on upgrade. Operators must not
8
+ // hand-edit this file — workspace customisations should go elsewhere in
9
+ // the Codex home dir. Surface a warning when we replace divergent content
10
+ // so an operator who did edit it locally sees a signal instead of silently
11
+ // losing changes. Mirrors the Claude bridge behaviour.
12
+ const agentsMdPath = path.join(workspaceDir, "AGENTS.md");
13
+ if (log) {
14
+ try {
15
+ const existing = fs.readFileSync(agentsMdPath, "utf8");
16
+ if (existing !== BRIDGE_WORKSPACE_INSTRUCTIONS) {
17
+ log.warn(`codex-agent: overwriting divergent ${agentsMdPath} with bridge-managed template ` +
18
+ `(local edits to AGENTS.md are not preserved — customize other files in the workspace instead)`);
19
+ }
20
+ }
21
+ catch {
22
+ // file missing or unreadable — first-boot case, no warning needed
23
+ }
24
+ }
25
+ fs.writeFileSync(agentsMdPath, BRIDGE_WORKSPACE_INSTRUCTIONS, "utf8");
26
+ }