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