@parall/codex-agent 1.44.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-server-process.d.ts +9 -0
- package/dist/app-server-process.d.ts.map +1 -0
- package/dist/app-server-process.js +58 -0
- package/dist/app-server-protocol.d.ts +19 -0
- package/dist/app-server-protocol.d.ts.map +1 -0
- package/dist/app-server-protocol.js +42 -0
- package/dist/dispatch.d.ts +88 -5
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +364 -196
- package/dist/index.js +27 -11
- package/dist/instructions-refresh.d.ts +136 -0
- package/dist/instructions-refresh.d.ts.map +1 -0
- package/dist/instructions-refresh.js +244 -0
- package/dist/jsonrpc-client.d.ts +49 -1
- package/dist/jsonrpc-client.d.ts.map +1 -1
- package/dist/jsonrpc-client.js +77 -5
- package/dist/legacy-workspace-config-migration.d.ts +112 -0
- package/dist/legacy-workspace-config-migration.d.ts.map +1 -0
- package/dist/legacy-workspace-config-migration.js +229 -0
- package/dist/server-requests.d.ts +10 -0
- package/dist/server-requests.d.ts.map +1 -0
- package/dist/server-requests.js +39 -0
- package/dist/session-manager.d.ts +12 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +53 -3
- package/dist/turn-sink.d.ts +26 -0
- package/dist/turn-sink.d.ts.map +1 -0
- package/dist/turn-sink.js +45 -0
- package/dist/workspace.d.ts +23 -25
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +138 -138
- package/package.json +5 -5
- package/src/app-server-process.ts +59 -0
- package/src/app-server-protocol.ts +46 -0
- package/src/dispatch.ts +426 -204
- package/src/index.ts +35 -10
- package/src/instructions-refresh.ts +367 -0
- package/src/jsonrpc-client.ts +109 -7
- package/src/legacy-workspace-config-migration.ts +296 -0
- package/src/server-requests.ts +40 -0
- package/src/session-manager.ts +74 -6
- package/src/turn-sink.ts +54 -0
- package/src/workspace.ts +155 -155
package/src/dispatch.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import * as fs from 'node:fs';
|
|
4
3
|
import * as path from 'node:path';
|
|
5
|
-
import {
|
|
6
|
-
appendPreparedLocalAttachmentRefs,
|
|
7
|
-
ensureLocalAttachmentGitExclude,
|
|
8
|
-
pinLocalAttachmentPaths,
|
|
9
|
-
} from '@parall/agent-core/internal/attachment-input';
|
|
10
|
-
import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
|
|
11
4
|
import type {
|
|
12
5
|
CleanupForkOpts,
|
|
13
6
|
DispatchAdapter,
|
|
@@ -17,11 +10,29 @@ import type {
|
|
|
17
10
|
GatewayLogger,
|
|
18
11
|
RuntimeEvent,
|
|
19
12
|
} from '@parall/agent-core';
|
|
13
|
+
import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
|
|
14
|
+
import {
|
|
15
|
+
appendPreparedLocalAttachmentRefs,
|
|
16
|
+
pinLocalAttachmentPaths,
|
|
17
|
+
} from '@parall/agent-core/internal/attachment-input';
|
|
18
|
+
import { ensureGitRepo, IS_WIN32, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
|
|
19
|
+
import {
|
|
20
|
+
buildTurnInput,
|
|
21
|
+
extractThreadId,
|
|
22
|
+
extractThreadIdFromNotification,
|
|
23
|
+
extractTurnId,
|
|
24
|
+
} from './app-server-protocol.js';
|
|
20
25
|
import type { CodexAgentConfig } from './config.js';
|
|
21
26
|
import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
MainThreadInstructionsRefresher,
|
|
29
|
+
type NotificationTap,
|
|
30
|
+
type RefreshOutcome,
|
|
31
|
+
} from './instructions-refresh.js';
|
|
23
32
|
import { JsonRpcStdioClient } from './jsonrpc-client.js';
|
|
33
|
+
import { answerServerRequest } from './server-requests.js';
|
|
24
34
|
import type { CodexSessionManager } from './session-manager.js';
|
|
35
|
+
import { TurnSink } from './turn-sink.js';
|
|
25
36
|
|
|
26
37
|
type CodexAppServerAdapterOptions = Pick<
|
|
27
38
|
CodexAgentConfig,
|
|
@@ -46,13 +57,31 @@ type CodexAppServerAdapterOptions = Pick<
|
|
|
46
57
|
* on its next shell command — no respawn needed.
|
|
47
58
|
*/
|
|
48
59
|
capabilityBinDir?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Platform system prompt, delivered per-thread via the app-server's typed
|
|
62
|
+
* top-level `developerInstructions` param — the ONLY delivery channel. It
|
|
63
|
+
* replaces the legacy workspace `.codex/config.toml` + global trust entry,
|
|
64
|
+
* which coupled the prompt to codex's interactive workspace-trust concept
|
|
65
|
+
* and made the bridge write into the operator's own config on shared homes.
|
|
66
|
+
*
|
|
67
|
+
* Where it lands on 0.144.1: `thread/start` bakes it in (canonical AND
|
|
68
|
+
* model-visible at once); a fresh-process `thread/resume` updates only the
|
|
69
|
+
* thread's CANONICAL configuration (a resume of a still-running thread
|
|
70
|
+
* ignores every override); `thread/fork` inherits the parent's; compaction
|
|
71
|
+
* rebuilds the model-visible context from the canonical configuration.
|
|
72
|
+
*
|
|
73
|
+
* Consequence: updateConfig() changes what the NEXT thread open sends; for
|
|
74
|
+
* the persisted main thread, the lazy restart's fresh-process resume plus
|
|
75
|
+
* one explicit compaction converge both planes before the next turn — the
|
|
76
|
+
* full two-plane semantics and evidence live in instructions-refresh.ts.
|
|
77
|
+
*/
|
|
78
|
+
developerInstructions?: string;
|
|
79
|
+
/** Override the compaction wait budget for the instructions refresh (tests). */
|
|
80
|
+
instructionsCompactTimeoutMs?: number;
|
|
81
|
+
/** Override the post-interrupt grace for the instructions refresh (tests). */
|
|
82
|
+
instructionsInterruptGraceMs?: number;
|
|
49
83
|
};
|
|
50
84
|
|
|
51
|
-
type TurnEventEnvelope =
|
|
52
|
-
| { kind: 'runtime'; event: RuntimeEvent }
|
|
53
|
-
| { kind: 'turn_end'; threadId?: string }
|
|
54
|
-
| { kind: 'error'; message: string };
|
|
55
|
-
|
|
56
85
|
/**
|
|
57
86
|
* Bridge driver backed by `codex app-server --listen stdio://`.
|
|
58
87
|
*
|
|
@@ -67,22 +96,6 @@ type TurnEventEnvelope =
|
|
|
67
96
|
* main + fork can interleave turns on the same stdio pipe. We route
|
|
68
97
|
* notifications by threadId the server stamps on every item/turn event.
|
|
69
98
|
*/
|
|
70
|
-
const IS_WIN32 = process.platform === 'win32';
|
|
71
|
-
|
|
72
|
-
function quoteWin32Arg(arg: string): string {
|
|
73
|
-
if (!/[\s"&|^<>()]/.test(arg)) return arg;
|
|
74
|
-
return `"${arg.replace(/"/g, '""')}"`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function killWin32Tree(pid: number): boolean {
|
|
78
|
-
try {
|
|
79
|
-
execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: 'ignore' });
|
|
80
|
-
return true;
|
|
81
|
-
} catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
99
|
export class CodexAppServerAdapter implements DispatchAdapter {
|
|
87
100
|
private client: JsonRpcStdioClient | null = null;
|
|
88
101
|
private proc: ChildProcessWithoutNullStreams | null = null;
|
|
@@ -92,7 +105,22 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
92
105
|
private readonly activeTurnIds = new Map<string, string>();
|
|
93
106
|
private readonly pendingInjections = new Map<string, number>();
|
|
94
107
|
private readonly resumedThreadIds = new Set<string>();
|
|
108
|
+
/** Threads whose reconcile compaction is in flight — tap-only traffic. */
|
|
109
|
+
private readonly reconcilingThreadIds = new Set<string>();
|
|
110
|
+
/**
|
|
111
|
+
* Set when a stalled compaction left (possibly) a zombie turn on the main
|
|
112
|
+
* thread. Until the subprocess ACTUALLY restarts, main-lane redrives must
|
|
113
|
+
* not open/compact/turn-start the thread: re-entering the zombie process
|
|
114
|
+
* lets a second compaction's watcher misattribute the zombie's close as a
|
|
115
|
+
* plain failure, dropping the stalled marker and re-arming the
|
|
116
|
+
* fresh-thread rotation this state exists to prevent. Cleared where the
|
|
117
|
+
* zombie dies: subprocess teardown.
|
|
118
|
+
*/
|
|
119
|
+
private mainLaneQuarantined = false;
|
|
120
|
+
private readonly notificationTaps = new Set<NotificationTap>();
|
|
121
|
+
private readonly instructionsRefresher: MainThreadInstructionsRefresher;
|
|
95
122
|
private stopping = false;
|
|
123
|
+
private lastUnroutedNotificationWarnAt = 0;
|
|
96
124
|
|
|
97
125
|
/**
|
|
98
126
|
* Store an active turn sink keyed by threadId. If a sink already exists for
|
|
@@ -114,12 +142,47 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
114
142
|
this.activeTurns.set(threadId, sink);
|
|
115
143
|
}
|
|
116
144
|
|
|
117
|
-
constructor(private readonly opts: CodexAppServerAdapterOptions) {
|
|
145
|
+
constructor(private readonly opts: CodexAppServerAdapterOptions) {
|
|
146
|
+
this.instructionsRefresher = new MainThreadInstructionsRefresher({
|
|
147
|
+
sessionManager: opts.sessionManager,
|
|
148
|
+
log: opts.log,
|
|
149
|
+
compactTimeoutMs: opts.instructionsCompactTimeoutMs,
|
|
150
|
+
interruptGraceMs: opts.instructionsInterruptGraceMs,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Register a listener for every server notification; returns unregister. */
|
|
155
|
+
addNotificationTap(tap: NotificationTap): () => void {
|
|
156
|
+
this.notificationTaps.add(tap);
|
|
157
|
+
return () => this.notificationTaps.delete(tap);
|
|
158
|
+
}
|
|
118
159
|
|
|
119
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Tell tap waiters the subprocess is gone so they abort immediately —
|
|
162
|
+
* no further notifications will ever arrive, and a waiter left to its
|
|
163
|
+
* own timeout would stall its dispatch for the full compaction budget.
|
|
164
|
+
* Mirrors the global-error broadcast routeNotification does for sinks.
|
|
165
|
+
*/
|
|
166
|
+
private notifyTapsDisposed(reason: string): void {
|
|
167
|
+
for (const tap of this.notificationTaps) {
|
|
168
|
+
try {
|
|
169
|
+
tap('error', { message: reason });
|
|
170
|
+
} catch {
|
|
171
|
+
// Taps must never break teardown.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
updateConfig(config: {
|
|
177
|
+
model?: string | null;
|
|
178
|
+
reasoningEffort?: string | null;
|
|
179
|
+
developerInstructions?: string | null;
|
|
180
|
+
}): void {
|
|
120
181
|
if (config.model !== undefined) this.opts.model = config.model ?? undefined;
|
|
121
182
|
if (config.reasoningEffort !== undefined)
|
|
122
183
|
this.opts.reasoningEffort = config.reasoningEffort ?? undefined;
|
|
184
|
+
if (config.developerInstructions !== undefined)
|
|
185
|
+
this.opts.developerInstructions = config.developerInstructions ?? undefined;
|
|
123
186
|
}
|
|
124
187
|
|
|
125
188
|
async enqueueDuringDispatch(sessionKey: string, body: string): Promise<boolean> {
|
|
@@ -148,7 +211,22 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
148
211
|
const threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
149
212
|
if (!threadId) return;
|
|
150
213
|
const sink = this.activeTurns.get(threadId);
|
|
151
|
-
|
|
214
|
+
// Idempotence guard (DispatchAdapter contract): the deadline timer and
|
|
215
|
+
// the lane-flow error path can both abort the same dispatch before the
|
|
216
|
+
// generator's cleanup empties activeTurns — keying on the sink's own
|
|
217
|
+
// terminal state makes the repeat a full no-op (one turn/interrupt on
|
|
218
|
+
// the wire, not two) and also dedupes against dispose/process-exit
|
|
219
|
+
// having already closed the sink.
|
|
220
|
+
if (!sink || sink.isClosed) return;
|
|
221
|
+
// Best-effort runtime-side stop: closing only the local sink leaves the
|
|
222
|
+
// app-server turn running (or parked on a server request) after the
|
|
223
|
+
// gateway has abandoned the dispatch.
|
|
224
|
+
const turnId = this.activeTurnIds.get(threadId);
|
|
225
|
+
if (turnId && this.client && !this.client.isDisposed()) {
|
|
226
|
+
this.client
|
|
227
|
+
.sendRequest('turn/interrupt', { threadId, turnId })
|
|
228
|
+
.catch((err) => this.opts.log?.warn?.(`turn/interrupt failed: ${errToString(err)}`));
|
|
229
|
+
}
|
|
152
230
|
sink.push({ kind: 'error', message: 'dispatch deadline exceeded' });
|
|
153
231
|
sink.close();
|
|
154
232
|
}
|
|
@@ -183,68 +261,92 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
183
261
|
);
|
|
184
262
|
}
|
|
185
263
|
|
|
186
|
-
await this.applyPendingRestart(context.log);
|
|
187
|
-
await this.ensureStarted(context.log);
|
|
188
|
-
// After ensureStarted resolves the subprocess could still die before we
|
|
189
|
-
// capture the client (handleSubprocessClose nulls this.client). Yield a
|
|
190
|
-
// clean error event instead of relying on a non-null assertion that would
|
|
191
|
-
// throw a TypeError on the next sendRequest call.
|
|
192
|
-
const client = this.client;
|
|
193
|
-
if (!client) {
|
|
194
|
-
yield {
|
|
195
|
-
type: 'error',
|
|
196
|
-
message: 'Codex app-server not available (subprocess died during dispatch start)',
|
|
197
|
-
};
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const log = this.opts.log ?? context.log;
|
|
201
264
|
const isMainSession = this.opts.sessionManager.isMain(sessionKey);
|
|
265
|
+
if (isMainSession && this.mainLaneQuarantined) {
|
|
266
|
+
// Try the pending restart now — it may have been deferred behind an
|
|
267
|
+
// active fork turn when the quarantine was raised. If it still cannot
|
|
268
|
+
// run, fail this redrive WITHOUT touching the thread.
|
|
269
|
+
await this.applyPendingRestart(context.log);
|
|
270
|
+
if (this.mainLaneQuarantined) {
|
|
271
|
+
const quarantinedThreadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
272
|
+
if (quarantinedThreadId) {
|
|
273
|
+
yield {
|
|
274
|
+
type: 'runtime_session',
|
|
275
|
+
runtimeSessionId: quarantinedThreadId,
|
|
276
|
+
runtimeLaneKey: sessionKey,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
yield {
|
|
280
|
+
type: 'error',
|
|
281
|
+
message:
|
|
282
|
+
'main lane quarantined after a stalled compaction; the subprocess restart is still deferred behind an active turn — the thread is retained and this dispatch will be redriven',
|
|
283
|
+
};
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const liveThreadId = isMainSession
|
|
288
|
+
? this.opts.sessionManager.getThreadId(sessionKey)
|
|
289
|
+
: undefined;
|
|
290
|
+
if (
|
|
291
|
+
liveThreadId &&
|
|
292
|
+
this.opts.developerInstructions &&
|
|
293
|
+
this.resumedThreadIds.has(liveThreadId) &&
|
|
294
|
+
this.instructionsRefresher.canonicalFor(liveThreadId) !== this.opts.developerInstructions
|
|
295
|
+
) {
|
|
296
|
+
// Live thread opened with an older prompt: compacting now would
|
|
297
|
+
// rebuild that STALE text, so bounce the subprocess — only a
|
|
298
|
+
// fresh-process resume updates the canonical configuration. The
|
|
299
|
+
// platform-config refresh normally schedules this restart itself;
|
|
300
|
+
// this guard makes convergence independent of that ordering.
|
|
301
|
+
this.requestProcessRestart();
|
|
302
|
+
}
|
|
202
303
|
|
|
203
|
-
|
|
204
|
-
|
|
304
|
+
const log = this.opts.log ?? context.log;
|
|
305
|
+
let client!: JsonRpcStdioClient;
|
|
306
|
+
let threadId!: string;
|
|
307
|
+
let reconcileStalled = false;
|
|
308
|
+
for (let attempt = 0; ; attempt++) {
|
|
309
|
+
await this.applyPendingRestart(context.log);
|
|
310
|
+
// Opening counts as active work for restart gating (see
|
|
311
|
+
// applyPendingRestart): the main-session reconcile can run a real
|
|
312
|
+
// compaction turn BEFORE any TurnSink exists, and a concurrent fork
|
|
313
|
+
// dispatch hitting a pending restart in that window would otherwise
|
|
314
|
+
// stop() the subprocess out from under it.
|
|
315
|
+
this.openingDispatches += 1;
|
|
316
|
+
let opened: OpenDispatchOutcome;
|
|
205
317
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// created-thread as an attach-to-detached-thread operation, it could
|
|
213
|
-
// fail the resume path and replace the thread, losing the first turn.
|
|
214
|
-
this.resumedThreadIds.add(threadId);
|
|
215
|
-
} catch (err) {
|
|
216
|
-
yield { type: 'error', message: `Codex thread/start failed: ${errToString(err)}` };
|
|
318
|
+
opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
|
|
319
|
+
} finally {
|
|
320
|
+
this.openingDispatches -= 1;
|
|
321
|
+
}
|
|
322
|
+
if (!opened.ok) {
|
|
323
|
+
yield { type: 'error', message: opened.message };
|
|
217
324
|
return;
|
|
218
325
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
326
|
+
client = opened.client;
|
|
327
|
+
threadId = opened.threadId;
|
|
328
|
+
reconcileStalled = opened.reconcile === 'stalled';
|
|
329
|
+
// Re-check convergence AFTER the async open/reconcile work: on the cold
|
|
330
|
+
// path (spawn + resume + compaction) that window is seconds, and a
|
|
331
|
+
// platform-config update landing inside it would otherwise run this
|
|
332
|
+
// turn on the prompt captured at open time. One bounded retry loops
|
|
333
|
+
// back through the restart path with the newer prompt. If the restart
|
|
334
|
+
// stays deferred (a fork turn is active) or a second update lands
|
|
335
|
+
// during the retry, the turn proceeds and the next dispatch converges —
|
|
336
|
+
// an update after this point is equivalent to one arriving mid-turn.
|
|
337
|
+
if (
|
|
338
|
+
isMainSession &&
|
|
339
|
+
attempt === 0 &&
|
|
340
|
+
this.opts.developerInstructions &&
|
|
341
|
+
this.instructionsRefresher.canonicalFor(threadId) !== this.opts.developerInstructions
|
|
342
|
+
) {
|
|
343
|
+
log?.info?.(
|
|
344
|
+
'platform instructions changed while the dispatch was opening the thread; bouncing the subprocess to converge before this turn',
|
|
232
345
|
);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
236
|
-
} catch (innerErr) {
|
|
237
|
-
yield {
|
|
238
|
-
type: 'error',
|
|
239
|
-
message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
|
|
240
|
-
};
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
this.opts.sessionManager.clearMainThread();
|
|
244
|
-
this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
|
|
245
|
-
this.resumedThreadIds.add(freshThreadId);
|
|
246
|
-
threadId = freshThreadId;
|
|
346
|
+
this.requestProcessRestart();
|
|
347
|
+
continue;
|
|
247
348
|
}
|
|
349
|
+
break;
|
|
248
350
|
}
|
|
249
351
|
|
|
250
352
|
const sink = new TurnSink();
|
|
@@ -322,10 +424,35 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
322
424
|
yield { type: 'error', message: `Codex turn/start failed: ${message}` };
|
|
323
425
|
return;
|
|
324
426
|
}
|
|
427
|
+
if (reconcileStalled) {
|
|
428
|
+
// A stalled compaction may STILL be running on this thread — a
|
|
429
|
+
// busy-thread rejection here is expected, not evidence of a stale
|
|
430
|
+
// thread, and the fresh-thread retry below would ROTATE the
|
|
431
|
+
// persisted main thread (continuity loss). Quarantine the main
|
|
432
|
+
// lane (redrives must not touch the thread until the subprocess
|
|
433
|
+
// actually restarts — a deferred restart would otherwise send the
|
|
434
|
+
// NEXT dispatch back into the zombie, where a second compaction's
|
|
435
|
+
// watcher can misattribute the zombie's close as a plain failure
|
|
436
|
+
// and lose the stalled marker) and bounce the subprocess.
|
|
437
|
+
this.mainLaneQuarantined = true;
|
|
438
|
+
this.requestProcessRestart();
|
|
439
|
+
this.activeTurns.delete(threadId);
|
|
440
|
+
yield {
|
|
441
|
+
type: 'runtime_session',
|
|
442
|
+
runtimeSessionId: threadId,
|
|
443
|
+
runtimeLaneKey: sessionKey,
|
|
444
|
+
};
|
|
445
|
+
yield {
|
|
446
|
+
type: 'error',
|
|
447
|
+
message: `Codex turn/start failed after a stalled compaction (thread retained; subprocess will restart): ${message}`,
|
|
448
|
+
};
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
325
451
|
log?.warn?.(
|
|
326
452
|
`turn/start on thread ${threadId} failed (${message}); attempting one-shot fresh-thread retry`,
|
|
327
453
|
);
|
|
328
454
|
this.activeTurns.delete(threadId);
|
|
455
|
+
const sentInstructions = this.opts.developerInstructions;
|
|
329
456
|
let freshThreadId: string;
|
|
330
457
|
try {
|
|
331
458
|
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
@@ -363,7 +490,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
363
490
|
// Retry accepted — the original thread really was unusable. Now safe
|
|
364
491
|
// to discard the old persisted id and persist the fresh one.
|
|
365
492
|
this.opts.sessionManager.clearMainThread();
|
|
366
|
-
this.
|
|
493
|
+
this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
|
|
367
494
|
this.resumedThreadIds.add(freshThreadId);
|
|
368
495
|
threadId = freshThreadId;
|
|
369
496
|
}
|
|
@@ -416,6 +543,113 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
416
543
|
}
|
|
417
544
|
}
|
|
418
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Bring the app-server up and open (start or resume) the thread for this
|
|
548
|
+
* dispatch, then reconcile the main thread's EFFECTIVE instructions plane.
|
|
549
|
+
* Extracted from dispatch() so the convergence re-check there can loop back
|
|
550
|
+
* through the restart path without duplicating the open logic. Errors are
|
|
551
|
+
* returned, not thrown — the generator yields them as runtime events.
|
|
552
|
+
*/
|
|
553
|
+
private async openDispatchTarget(
|
|
554
|
+
sessionKey: string,
|
|
555
|
+
isMainSession: boolean,
|
|
556
|
+
log?: GatewayLogger,
|
|
557
|
+
): Promise<OpenDispatchOutcome> {
|
|
558
|
+
try {
|
|
559
|
+
await this.ensureStarted(log);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
// Spawn/handshake failures surface as a clean error event like every
|
|
562
|
+
// other open failure, instead of rejecting the dispatch generator.
|
|
563
|
+
return { ok: false, message: `Codex app-server failed to start: ${errToString(err)}` };
|
|
564
|
+
}
|
|
565
|
+
// After ensureStarted resolves the subprocess could still die before we
|
|
566
|
+
// capture the client (handleSubprocessClose nulls this.client). Return a
|
|
567
|
+
// clean error instead of relying on a non-null assertion that would
|
|
568
|
+
// throw a TypeError on the next sendRequest call.
|
|
569
|
+
const client = this.client;
|
|
570
|
+
if (!client) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
message: 'Codex app-server not available (subprocess died during dispatch start)',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
578
|
+
if (!threadId) {
|
|
579
|
+
try {
|
|
580
|
+
// Capture what openThread will send in the same tick as the call, so
|
|
581
|
+
// the recorded canonical/effective values can never reflect a NEWER
|
|
582
|
+
// instructions text swapped in by updateConfig() mid-flight.
|
|
583
|
+
const sentInstructions = this.opts.developerInstructions;
|
|
584
|
+
threadId = await this.openThread(client, { resumeId: undefined });
|
|
585
|
+
// recordBaked persists thread id + effective sha in ONE write.
|
|
586
|
+
this.instructionsRefresher.recordBaked(sessionKey, threadId, sentInstructions);
|
|
587
|
+
// Mark this freshly-started thread as already live in the current
|
|
588
|
+
// app-server process. Without this, the second dispatch after a cold
|
|
589
|
+
// start would enter the thread/resume branch below for a thread this
|
|
590
|
+
// process just created — and if the server treats resume-of-just-
|
|
591
|
+
// created-thread as an attach-to-detached-thread operation, it could
|
|
592
|
+
// fail the resume path and replace the thread, losing the first turn.
|
|
593
|
+
this.resumedThreadIds.add(threadId);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
return { ok: false, message: `Codex thread/start failed: ${errToString(err)}` };
|
|
596
|
+
}
|
|
597
|
+
} else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
|
|
598
|
+
// We have a persisted threadId from a previous bridge run — resume it.
|
|
599
|
+
// Mirror the deferred-clear pattern from the turn/start retry in
|
|
600
|
+
// dispatch(): only discard the persisted id once a fresh-thread start
|
|
601
|
+
// has actually succeeded, so a transient resume failure (network/
|
|
602
|
+
// timeout/upstream hiccup) doesn't permanently throw away the prior
|
|
603
|
+
// conversation context.
|
|
604
|
+
const sentInstructions = this.opts.developerInstructions;
|
|
605
|
+
try {
|
|
606
|
+
threadId = await this.openThread(client, { resumeId: threadId });
|
|
607
|
+
this.opts.sessionManager.recordThreadId(sessionKey, threadId);
|
|
608
|
+
this.instructionsRefresher.recordResumed(threadId, sentInstructions);
|
|
609
|
+
this.resumedThreadIds.add(threadId);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
log?.warn?.(
|
|
612
|
+
`thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`,
|
|
613
|
+
);
|
|
614
|
+
let freshThreadId: string;
|
|
615
|
+
try {
|
|
616
|
+
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
617
|
+
} catch (innerErr) {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
this.opts.sessionManager.clearMainThread();
|
|
624
|
+
this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
|
|
625
|
+
this.resumedThreadIds.add(freshThreadId);
|
|
626
|
+
threadId = freshThreadId;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let reconcile: RefreshOutcome | undefined;
|
|
631
|
+
if (isMainSession) {
|
|
632
|
+
// Converge the EFFECTIVE plane before the turn starts (posture incl.
|
|
633
|
+
// the stalled exception: instructions-refresh.ts). Cheap compare when
|
|
634
|
+
// nothing changed; one thread/compact/start when the last-known
|
|
635
|
+
// effective instructions differ from the thread's canonical config.
|
|
636
|
+
this.reconcilingThreadIds.add(threadId);
|
|
637
|
+
try {
|
|
638
|
+
reconcile = await this.instructionsRefresher.reconcileAfterOpen({
|
|
639
|
+
client,
|
|
640
|
+
taps: this,
|
|
641
|
+
sessionKey,
|
|
642
|
+
threadId,
|
|
643
|
+
log,
|
|
644
|
+
});
|
|
645
|
+
} finally {
|
|
646
|
+
this.reconcilingThreadIds.delete(threadId);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return { ok: true, client, threadId, reconcile };
|
|
651
|
+
}
|
|
652
|
+
|
|
419
653
|
getBranchPoint(_sessionKey: string): string | undefined {
|
|
420
654
|
// Codex app-server does not expose a branch-point API; fork scope prefix
|
|
421
655
|
// provides the behavioral fallback for this runtime.
|
|
@@ -432,10 +666,23 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
432
666
|
const forkParams: Record<string, unknown> = {
|
|
433
667
|
threadId: parentThreadId,
|
|
434
668
|
ephemeral: true,
|
|
669
|
+
// Forks must inherit the bridge's approval/sandbox contract. thread/fork
|
|
670
|
+
// does NOT inherit them from the parent thread — omitting them falls
|
|
671
|
+
// back to codex defaults (on-request approval, workspace-write), and an
|
|
672
|
+
// escalated command then emits an approval request the headless bridge
|
|
673
|
+
// can only deny, killing the fork's ability to run privileged commands
|
|
674
|
+
// the main session runs freely.
|
|
675
|
+
approvalPolicy: normalizeApprovalPolicy(this.opts.approvalPolicy),
|
|
676
|
+
sandbox: normalizeSandbox(this.opts.sandbox),
|
|
435
677
|
};
|
|
436
678
|
if (this.opts.useParallProvider) {
|
|
437
679
|
forkParams.modelProvider = 'parall';
|
|
438
680
|
}
|
|
681
|
+
if (this.opts.developerInstructions) {
|
|
682
|
+
// Accepted, but the fork inherits the parent's instructions instead —
|
|
683
|
+
// so it carries the platform prompt either way. See the option doc.
|
|
684
|
+
forkParams.developerInstructions = this.opts.developerInstructions;
|
|
685
|
+
}
|
|
439
686
|
if (this.opts.model) forkParams.model = this.opts.model;
|
|
440
687
|
if (this.opts.reasoningEffort) {
|
|
441
688
|
// Raw config.toml key — see openThread for the snake_case rationale.
|
|
@@ -464,11 +711,17 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
464
711
|
}
|
|
465
712
|
|
|
466
713
|
/**
|
|
467
|
-
* Lazily restart the app-server before the NEXT turn
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
714
|
+
* Lazily restart the app-server before the NEXT turn, so a subprocess that
|
|
715
|
+
* has been running since before a capability change starts clean. Deferred
|
|
716
|
+
* to the next dispatch with no active turns — never kills an in-flight turn;
|
|
717
|
+
* thread state survives via thread/resume.
|
|
718
|
+
*
|
|
719
|
+
* For the persisted main thread this restart is also the CANONICAL half of
|
|
720
|
+
* the instructions refresh: only a fresh-process thread/resume applies the
|
|
721
|
+
* updated developerInstructions to the thread's configuration (a resume of
|
|
722
|
+
* a still-running thread ignores it). The EFFECTIVE half is the explicit
|
|
723
|
+
* compaction in instructions-refresh.ts. The capability shim dir on PATH is
|
|
724
|
+
* what makes a grant/revocation's TOOLS effective immediately.
|
|
472
725
|
*/
|
|
473
726
|
requestProcessRestart(): void {
|
|
474
727
|
this.restartRequested = true;
|
|
@@ -476,11 +729,18 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
476
729
|
|
|
477
730
|
private restartRequested = false;
|
|
478
731
|
|
|
732
|
+
/**
|
|
733
|
+
* Dispatches currently inside their open/reconcile phase — real work on
|
|
734
|
+
* the subprocess (thread open, possibly a compaction turn) that predates
|
|
735
|
+
* any TurnSink registration, so activeTurns alone cannot gate the restart.
|
|
736
|
+
*/
|
|
737
|
+
private openingDispatches = 0;
|
|
738
|
+
|
|
479
739
|
private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
|
|
480
|
-
if (!this.restartRequested || this.activeTurns.size > 0) return;
|
|
740
|
+
if (!this.restartRequested || this.activeTurns.size > 0 || this.openingDispatches > 0) return;
|
|
481
741
|
this.restartRequested = false;
|
|
482
742
|
(log ?? this.opts.log)?.info?.(
|
|
483
|
-
'restarting codex app-server to
|
|
743
|
+
'restarting codex app-server after a capability change (the fresh-process thread/resume applies the refreshed developerInstructions to the persisted thread configuration; the model-visible context is reconciled before the next turn)',
|
|
484
744
|
);
|
|
485
745
|
await this.stop();
|
|
486
746
|
}
|
|
@@ -500,6 +760,10 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
500
760
|
// dispatch treat the persisted thread as already resumed in the NEW
|
|
501
761
|
// process and lose conversation continuity when turn/start rejects.
|
|
502
762
|
this.resumedThreadIds.clear();
|
|
763
|
+
this.instructionsRefresher.clearThreadState();
|
|
764
|
+
// The (possible) zombie compaction turn dies with the subprocess.
|
|
765
|
+
this.mainLaneQuarantined = false;
|
|
766
|
+
this.notifyTapsDisposed('Codex app-server stopped');
|
|
503
767
|
if (client) client.dispose(new Error('adapter stopped'));
|
|
504
768
|
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
505
769
|
if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
|
|
@@ -521,6 +785,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
521
785
|
this.proc = null;
|
|
522
786
|
this.initialized = false;
|
|
523
787
|
this.resumedThreadIds.clear();
|
|
788
|
+
this.instructionsRefresher.clearThreadState();
|
|
524
789
|
}
|
|
525
790
|
if (this.initialized && this.client) return;
|
|
526
791
|
if (this.startPromise) return this.startPromise;
|
|
@@ -540,6 +805,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
540
805
|
// previous stop() — otherwise handleSubprocessClose would mislabel the next
|
|
541
806
|
// unexpected exit as "during graceful stop".
|
|
542
807
|
this.stopping = false;
|
|
808
|
+
this.instructionsRefresher.resetForNewSubprocess();
|
|
543
809
|
ensureGitRepo(this.opts.workspaceDir);
|
|
544
810
|
|
|
545
811
|
// Only steer Codex's own state via CODEX_HOME. Leaving HOME untouched
|
|
@@ -613,6 +879,28 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
613
879
|
}
|
|
614
880
|
});
|
|
615
881
|
client.setNotificationHandler((method, params) => this.routeNotification(method, params));
|
|
882
|
+
// Server→client requests (approvals, elicitations) can never be answered
|
|
883
|
+
// interactively by a headless bridge, but each one MUST get a response —
|
|
884
|
+
// the app-server parks the requesting turn until one arrives. Known
|
|
885
|
+
// approval methods get an explicit denial; anything else falls through to
|
|
886
|
+
// the client's method-not-found error. With approvalPolicy "never" these
|
|
887
|
+
// should not occur at all, so surface every occurrence at warn.
|
|
888
|
+
client.setServerRequestHandler((method, params) => {
|
|
889
|
+
const denial = answerServerRequest(method);
|
|
890
|
+
// Log stable identifiers only — approval params carry command lines /
|
|
891
|
+
// file changes / patch content that must not reach stderr or OTLP.
|
|
892
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
893
|
+
const ids = ['threadId', 'turnId', 'itemId', 'callId']
|
|
894
|
+
.filter((k) => typeof p[k] === 'string')
|
|
895
|
+
.map((k) => `${k}=${p[k]}`)
|
|
896
|
+
.join(' ');
|
|
897
|
+
(log ?? this.opts.log)?.warn?.(
|
|
898
|
+
`answering app-server request "${method}" with ${
|
|
899
|
+
denial === undefined ? 'method-not-found error' : 'denial'
|
|
900
|
+
} (headless bridge cannot approve)${ids ? ` [${ids}]` : ''}`,
|
|
901
|
+
);
|
|
902
|
+
return denial === undefined ? undefined : { result: denial };
|
|
903
|
+
});
|
|
616
904
|
|
|
617
905
|
proc.once('close', (code, signal) => this.handleSubprocessClose(proc, code, signal, log));
|
|
618
906
|
proc.once('error', (err) => this.handleSubprocessClose(proc, null, null, log, err));
|
|
@@ -668,6 +956,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
668
956
|
this.activeTurnIds.clear();
|
|
669
957
|
this.pendingInjections.clear();
|
|
670
958
|
this.resumedThreadIds.clear();
|
|
959
|
+
this.instructionsRefresher.clearThreadState();
|
|
960
|
+
this.mainLaneQuarantined = false;
|
|
961
|
+
this.notifyTapsDisposed(`Codex app-server ${reason}`);
|
|
671
962
|
this.client = null;
|
|
672
963
|
this.proc = null;
|
|
673
964
|
this.initialized = false;
|
|
@@ -693,6 +984,14 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
693
984
|
commonParams.modelProvider = 'parall';
|
|
694
985
|
}
|
|
695
986
|
commonParams.sandbox = normalizeSandbox(this.opts.sandbox);
|
|
987
|
+
if (this.opts.developerInstructions) {
|
|
988
|
+
// Typed top-level param (camelCase), NOT a raw config.toml override —
|
|
989
|
+
// trust-independent, so the platform prompt loads regardless of any codex
|
|
990
|
+
// workspace-trust state. thread/start bakes it in; a fresh-process
|
|
991
|
+
// thread/resume applies it to the canonical configuration — see the
|
|
992
|
+
// option doc for the two-plane semantics.
|
|
993
|
+
commonParams.developerInstructions = this.opts.developerInstructions;
|
|
994
|
+
}
|
|
696
995
|
if (this.opts.model) commonParams.model = this.opts.model;
|
|
697
996
|
if (this.opts.reasoningEffort) {
|
|
698
997
|
// The nested `config` object is raw config.toml overrides and keeps the
|
|
@@ -719,6 +1018,16 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
719
1018
|
}
|
|
720
1019
|
|
|
721
1020
|
private routeNotification(method: string, params: unknown) {
|
|
1021
|
+
// Taps observe the raw stream (instructions refresh awaits its compaction
|
|
1022
|
+
// turn through one); the sink routing below is unaffected — including by
|
|
1023
|
+
// a tap that throws, which must never break turn delivery.
|
|
1024
|
+
for (const tap of this.notificationTaps) {
|
|
1025
|
+
try {
|
|
1026
|
+
tap(method, params);
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
this.opts.log?.warn?.(`notification tap threw: ${errToString(err)}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
722
1031
|
const threadId = extractThreadIdFromNotification(params);
|
|
723
1032
|
if (!threadId) {
|
|
724
1033
|
// Surface server-initiated generic errors to every active turn.
|
|
@@ -732,7 +1041,24 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
732
1041
|
}
|
|
733
1042
|
|
|
734
1043
|
const sink = this.activeTurns.get(threadId);
|
|
735
|
-
if (!sink)
|
|
1044
|
+
if (!sink) {
|
|
1045
|
+
// A reconciling thread's compaction turn is INTENTIONALLY tap-only —
|
|
1046
|
+
// it runs before any TurnSink exists. Warning here would pollute every
|
|
1047
|
+
// successful instructions refresh and burn the shared rate-limit
|
|
1048
|
+
// window that exists to surface REAL orphaned turns.
|
|
1049
|
+
if (this.reconcilingThreadIds.has(threadId)) return;
|
|
1050
|
+
// Notifications for a thread with no registered sink are dropped. A
|
|
1051
|
+
// sustained stream of these means a turn is running that nothing is
|
|
1052
|
+
// consuming (threadId mismatch, sink torn down early) — the dispatch
|
|
1053
|
+
// side would see indefinite silence, so keep the drop visible
|
|
1054
|
+
// (rate-limited: these arrive per-delta during an orphaned turn).
|
|
1055
|
+
const now = Date.now();
|
|
1056
|
+
if (now - this.lastUnroutedNotificationWarnAt > 10_000) {
|
|
1057
|
+
this.lastUnroutedNotificationWarnAt = now;
|
|
1058
|
+
this.opts.log?.warn?.(`dropping notification "${method}" for unknown thread ${threadId}`);
|
|
1059
|
+
}
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
736
1062
|
|
|
737
1063
|
// For turn/completed we still run the mapper first — it emits a
|
|
738
1064
|
// RuntimeEvent error if `turn.status === "failed"`. Enqueue those events
|
|
@@ -747,113 +1073,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
747
1073
|
}
|
|
748
1074
|
}
|
|
749
1075
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
private readonly queue: TurnEventEnvelope[] = [];
|
|
754
|
-
private resolver: ((value: TurnEventEnvelope) => void) | null = null;
|
|
755
|
-
private closed = false;
|
|
756
|
-
|
|
757
|
-
push(envelope: TurnEventEnvelope) {
|
|
758
|
-
if (this.closed) return;
|
|
759
|
-
if (this.resolver) {
|
|
760
|
-
const r = this.resolver;
|
|
761
|
-
this.resolver = null;
|
|
762
|
-
r(envelope);
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
this.queue.push(envelope);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
next(): Promise<TurnEventEnvelope> {
|
|
769
|
-
// Drain any queued envelopes first, even after close(). Otherwise a final
|
|
770
|
-
// error envelope enqueued right before close() (e.g. by
|
|
771
|
-
// handleSubprocessClose) is silently dropped because the consumer would
|
|
772
|
-
// see turn_end before it.
|
|
773
|
-
const pending = this.queue.shift();
|
|
774
|
-
if (pending) return Promise.resolve(pending);
|
|
775
|
-
if (this.closed) {
|
|
776
|
-
return Promise.resolve({ kind: 'turn_end' });
|
|
777
|
-
}
|
|
778
|
-
return new Promise((resolve) => {
|
|
779
|
-
this.resolver = resolve;
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
close() {
|
|
784
|
-
this.closed = true;
|
|
785
|
-
const r = this.resolver;
|
|
786
|
-
this.resolver = null;
|
|
787
|
-
r?.({ kind: 'turn_end' });
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function ensureGitRepo(workingDirectory: string): void {
|
|
792
|
-
fs.mkdirSync(workingDirectory, { recursive: true });
|
|
793
|
-
// Only `git init` if the workspace isn't already inside any git repo. A
|
|
794
|
-
// bare existsSync(.git) check would miss the common case of a user pointing
|
|
795
|
-
// PRLL_WORKSPACE_DIR at a subdirectory of their existing project,
|
|
796
|
-
// and silently creating a nested repo there would mangle their layout.
|
|
797
|
-
try {
|
|
798
|
-
execSync('git rev-parse --is-inside-work-tree', { cwd: workingDirectory, stdio: 'pipe' });
|
|
799
|
-
ensureLocalAttachmentGitExclude(workingDirectory);
|
|
800
|
-
return;
|
|
801
|
-
} catch {
|
|
802
|
-
// Not inside a repo — fall through to init.
|
|
803
|
-
}
|
|
804
|
-
const env = {
|
|
805
|
-
...process.env,
|
|
806
|
-
GIT_AUTHOR_NAME: 'parall-codex-agent',
|
|
807
|
-
GIT_AUTHOR_EMAIL: 'agent@parall.local',
|
|
808
|
-
GIT_COMMITTER_NAME: 'parall-codex-agent',
|
|
809
|
-
GIT_COMMITTER_EMAIL: 'agent@parall.local',
|
|
810
|
-
};
|
|
811
|
-
try {
|
|
812
|
-
execSync('git init', { cwd: workingDirectory, stdio: 'pipe', env });
|
|
813
|
-
execSync('git commit --allow-empty -m init', { cwd: workingDirectory, stdio: 'pipe', env });
|
|
814
|
-
ensureLocalAttachmentGitExclude(workingDirectory);
|
|
815
|
-
} catch {
|
|
816
|
-
// Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
type CodexTurnInput = { type: 'text'; text: string } | { type: 'localImage'; path: string };
|
|
821
|
-
|
|
822
|
-
function buildTurnInput(body: string, images: PreparedLocalImage[]): CodexTurnInput[] {
|
|
823
|
-
return [
|
|
824
|
-
{ type: 'text', text: body },
|
|
825
|
-
...images.map((image) => ({ type: 'localImage' as const, path: image.localPath })),
|
|
826
|
-
];
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function extractThreadId(result: unknown): string | undefined {
|
|
830
|
-
if (!result || typeof result !== 'object') return undefined;
|
|
831
|
-
const r = result as Record<string, unknown>;
|
|
832
|
-
if (typeof r.threadId === 'string') return r.threadId;
|
|
833
|
-
const thread = r.thread as Record<string, unknown> | undefined;
|
|
834
|
-
if (thread && typeof thread.id === 'string') return thread.id;
|
|
835
|
-
return undefined;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function extractTurnId(result: unknown): string | undefined {
|
|
839
|
-
if (!result || typeof result !== 'object') return undefined;
|
|
840
|
-
const r = result as Record<string, unknown>;
|
|
841
|
-
if (typeof r.turnId === 'string') return r.turnId;
|
|
842
|
-
const turn = r.turn as Record<string, unknown> | undefined;
|
|
843
|
-
if (turn && typeof turn.id === 'string') return turn.id;
|
|
844
|
-
return undefined;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function extractThreadIdFromNotification(params: unknown): string | undefined {
|
|
848
|
-
if (!params || typeof params !== 'object') return undefined;
|
|
849
|
-
const p = params as Record<string, unknown>;
|
|
850
|
-
if (typeof p.threadId === 'string') return p.threadId;
|
|
851
|
-
const thread = p.thread as Record<string, unknown> | undefined;
|
|
852
|
-
if (thread && typeof thread.id === 'string') return thread.id;
|
|
853
|
-
const meta = (p._meta ?? p.meta) as Record<string, unknown> | undefined;
|
|
854
|
-
if (meta && typeof meta.threadId === 'string') return meta.threadId;
|
|
855
|
-
return undefined;
|
|
856
|
-
}
|
|
1076
|
+
type OpenDispatchOutcome =
|
|
1077
|
+
| { ok: true; client: JsonRpcStdioClient; threadId: string; reconcile?: RefreshOutcome }
|
|
1078
|
+
| { ok: false; message: string };
|
|
857
1079
|
|
|
858
1080
|
function errToString(err: unknown): string {
|
|
859
1081
|
if (err instanceof Error) return err.message;
|