@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.
Files changed (43) hide show
  1. package/dist/app-server-process.d.ts +9 -0
  2. package/dist/app-server-process.d.ts.map +1 -0
  3. package/dist/app-server-process.js +58 -0
  4. package/dist/app-server-protocol.d.ts +19 -0
  5. package/dist/app-server-protocol.d.ts.map +1 -0
  6. package/dist/app-server-protocol.js +42 -0
  7. package/dist/dispatch.d.ts +88 -5
  8. package/dist/dispatch.d.ts.map +1 -1
  9. package/dist/dispatch.js +364 -196
  10. package/dist/index.js +27 -11
  11. package/dist/instructions-refresh.d.ts +136 -0
  12. package/dist/instructions-refresh.d.ts.map +1 -0
  13. package/dist/instructions-refresh.js +244 -0
  14. package/dist/jsonrpc-client.d.ts +49 -1
  15. package/dist/jsonrpc-client.d.ts.map +1 -1
  16. package/dist/jsonrpc-client.js +77 -5
  17. package/dist/legacy-workspace-config-migration.d.ts +112 -0
  18. package/dist/legacy-workspace-config-migration.d.ts.map +1 -0
  19. package/dist/legacy-workspace-config-migration.js +229 -0
  20. package/dist/server-requests.d.ts +10 -0
  21. package/dist/server-requests.d.ts.map +1 -0
  22. package/dist/server-requests.js +39 -0
  23. package/dist/session-manager.d.ts +12 -0
  24. package/dist/session-manager.d.ts.map +1 -1
  25. package/dist/session-manager.js +53 -3
  26. package/dist/turn-sink.d.ts +26 -0
  27. package/dist/turn-sink.d.ts.map +1 -0
  28. package/dist/turn-sink.js +45 -0
  29. package/dist/workspace.d.ts +23 -25
  30. package/dist/workspace.d.ts.map +1 -1
  31. package/dist/workspace.js +138 -138
  32. package/package.json +5 -5
  33. package/src/app-server-process.ts +59 -0
  34. package/src/app-server-protocol.ts +46 -0
  35. package/src/dispatch.ts +426 -204
  36. package/src/index.ts +35 -10
  37. package/src/instructions-refresh.ts +367 -0
  38. package/src/jsonrpc-client.ts +109 -7
  39. package/src/legacy-workspace-config-migration.ts +296 -0
  40. package/src/server-requests.ts +40 -0
  41. package/src/session-manager.ts +74 -6
  42. package/src/turn-sink.ts +54 -0
  43. package/src/workspace.ts +155 -155
package/dist/dispatch.js CHANGED
@@ -1,11 +1,14 @@
1
- import { execSync, spawn } from 'node:child_process';
1
+ import { 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 { appendPreparedLocalAttachmentRefs, ensureLocalAttachmentGitExclude, pinLocalAttachmentPaths, } from '@parall/agent-core/internal/attachment-input';
4
+ import { appendPreparedLocalAttachmentRefs, pinLocalAttachmentPaths, } from '@parall/agent-core/internal/attachment-input';
5
+ import { ensureGitRepo, IS_WIN32, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
6
+ import { buildTurnInput, extractThreadId, extractThreadIdFromNotification, extractTurnId, } from './app-server-protocol.js';
6
7
  import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
7
- import { EventMapper } from './event-mapping.js';
8
+ import { MainThreadInstructionsRefresher, } from './instructions-refresh.js';
8
9
  import { JsonRpcStdioClient } from './jsonrpc-client.js';
10
+ import { answerServerRequest } from './server-requests.js';
11
+ import { TurnSink } from './turn-sink.js';
9
12
  /**
10
13
  * Bridge driver backed by `codex app-server --listen stdio://`.
11
14
  *
@@ -20,21 +23,6 @@ import { JsonRpcStdioClient } from './jsonrpc-client.js';
20
23
  * main + fork can interleave turns on the same stdio pipe. We route
21
24
  * notifications by threadId the server stamps on every item/turn event.
22
25
  */
23
- const IS_WIN32 = process.platform === 'win32';
24
- function quoteWin32Arg(arg) {
25
- if (!/[\s"&|^<>()]/.test(arg))
26
- return arg;
27
- return `"${arg.replace(/"/g, '""')}"`;
28
- }
29
- function killWin32Tree(pid) {
30
- try {
31
- execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: 'ignore' });
32
- return true;
33
- }
34
- catch {
35
- return false;
36
- }
37
- }
38
26
  export class CodexAppServerAdapter {
39
27
  opts;
40
28
  client = null;
@@ -45,7 +33,22 @@ export class CodexAppServerAdapter {
45
33
  activeTurnIds = new Map();
46
34
  pendingInjections = new Map();
47
35
  resumedThreadIds = new Set();
36
+ /** Threads whose reconcile compaction is in flight — tap-only traffic. */
37
+ reconcilingThreadIds = new Set();
38
+ /**
39
+ * Set when a stalled compaction left (possibly) a zombie turn on the main
40
+ * thread. Until the subprocess ACTUALLY restarts, main-lane redrives must
41
+ * not open/compact/turn-start the thread: re-entering the zombie process
42
+ * lets a second compaction's watcher misattribute the zombie's close as a
43
+ * plain failure, dropping the stalled marker and re-arming the
44
+ * fresh-thread rotation this state exists to prevent. Cleared where the
45
+ * zombie dies: subprocess teardown.
46
+ */
47
+ mainLaneQuarantined = false;
48
+ notificationTaps = new Set();
49
+ instructionsRefresher;
48
50
  stopping = false;
51
+ lastUnroutedNotificationWarnAt = 0;
49
52
  /**
50
53
  * Store an active turn sink keyed by threadId. If a sink already exists for
51
54
  * the same threadId, log a warning and fail the existing sink — this
@@ -65,12 +68,41 @@ export class CodexAppServerAdapter {
65
68
  }
66
69
  constructor(opts) {
67
70
  this.opts = opts;
71
+ this.instructionsRefresher = new MainThreadInstructionsRefresher({
72
+ sessionManager: opts.sessionManager,
73
+ log: opts.log,
74
+ compactTimeoutMs: opts.instructionsCompactTimeoutMs,
75
+ interruptGraceMs: opts.instructionsInterruptGraceMs,
76
+ });
77
+ }
78
+ /** Register a listener for every server notification; returns unregister. */
79
+ addNotificationTap(tap) {
80
+ this.notificationTaps.add(tap);
81
+ return () => this.notificationTaps.delete(tap);
82
+ }
83
+ /**
84
+ * Tell tap waiters the subprocess is gone so they abort immediately —
85
+ * no further notifications will ever arrive, and a waiter left to its
86
+ * own timeout would stall its dispatch for the full compaction budget.
87
+ * Mirrors the global-error broadcast routeNotification does for sinks.
88
+ */
89
+ notifyTapsDisposed(reason) {
90
+ for (const tap of this.notificationTaps) {
91
+ try {
92
+ tap('error', { message: reason });
93
+ }
94
+ catch {
95
+ // Taps must never break teardown.
96
+ }
97
+ }
68
98
  }
69
99
  updateConfig(config) {
70
100
  if (config.model !== undefined)
71
101
  this.opts.model = config.model ?? undefined;
72
102
  if (config.reasoningEffort !== undefined)
73
103
  this.opts.reasoningEffort = config.reasoningEffort ?? undefined;
104
+ if (config.developerInstructions !== undefined)
105
+ this.opts.developerInstructions = config.developerInstructions ?? undefined;
74
106
  }
75
107
  async enqueueDuringDispatch(sessionKey, body) {
76
108
  const client = this.client;
@@ -102,8 +134,23 @@ export class CodexAppServerAdapter {
102
134
  if (!threadId)
103
135
  return;
104
136
  const sink = this.activeTurns.get(threadId);
105
- if (!sink)
137
+ // Idempotence guard (DispatchAdapter contract): the deadline timer and
138
+ // the lane-flow error path can both abort the same dispatch before the
139
+ // generator's cleanup empties activeTurns — keying on the sink's own
140
+ // terminal state makes the repeat a full no-op (one turn/interrupt on
141
+ // the wire, not two) and also dedupes against dispose/process-exit
142
+ // having already closed the sink.
143
+ if (!sink || sink.isClosed)
106
144
  return;
145
+ // Best-effort runtime-side stop: closing only the local sink leaves the
146
+ // app-server turn running (or parked on a server request) after the
147
+ // gateway has abandoned the dispatch.
148
+ const turnId = this.activeTurnIds.get(threadId);
149
+ if (turnId && this.client && !this.client.isDisposed()) {
150
+ this.client
151
+ .sendRequest('turn/interrupt', { threadId, turnId })
152
+ .catch((err) => this.opts.log?.warn?.(`turn/interrupt failed: ${errToString(err)}`));
153
+ }
107
154
  sink.push({ kind: 'error', message: 'dispatch deadline exceeded' });
108
155
  sink.close();
109
156
  }
@@ -126,69 +173,85 @@ export class CodexAppServerAdapter {
126
173
  }
127
174
  (this.opts.log ?? context.log)?.warn?.(`pending steer invalidated (subprocess died); falling through to normal dispatch`);
128
175
  }
129
- await this.applyPendingRestart(context.log);
130
- await this.ensureStarted(context.log);
131
- // After ensureStarted resolves the subprocess could still die before we
132
- // capture the client (handleSubprocessClose nulls this.client). Yield a
133
- // clean error event instead of relying on a non-null assertion that would
134
- // throw a TypeError on the next sendRequest call.
135
- const client = this.client;
136
- if (!client) {
137
- yield {
138
- type: 'error',
139
- message: 'Codex app-server not available (subprocess died during dispatch start)',
140
- };
141
- return;
142
- }
143
- const log = this.opts.log ?? context.log;
144
176
  const isMainSession = this.opts.sessionManager.isMain(sessionKey);
145
- let threadId = this.opts.sessionManager.getThreadId(sessionKey);
146
- if (!threadId) {
147
- try {
148
- threadId = await this.openThread(client, { resumeId: undefined });
149
- this.opts.sessionManager.recordThreadId(sessionKey, threadId);
150
- // Mark this freshly-started thread as already live in the current
151
- // app-server process. Without this, the second dispatch after a cold
152
- // start would enter the thread/resume branch below for a thread this
153
- // process just created — and if the server treats resume-of-just-
154
- // created-thread as an attach-to-detached-thread operation, it could
155
- // fail the resume path and replace the thread, losing the first turn.
156
- this.resumedThreadIds.add(threadId);
157
- }
158
- catch (err) {
159
- yield { type: 'error', message: `Codex thread/start failed: ${errToString(err)}` };
177
+ if (isMainSession && this.mainLaneQuarantined) {
178
+ // Try the pending restart now — it may have been deferred behind an
179
+ // active fork turn when the quarantine was raised. If it still cannot
180
+ // run, fail this redrive WITHOUT touching the thread.
181
+ await this.applyPendingRestart(context.log);
182
+ if (this.mainLaneQuarantined) {
183
+ const quarantinedThreadId = this.opts.sessionManager.getThreadId(sessionKey);
184
+ if (quarantinedThreadId) {
185
+ yield {
186
+ type: 'runtime_session',
187
+ runtimeSessionId: quarantinedThreadId,
188
+ runtimeLaneKey: sessionKey,
189
+ };
190
+ }
191
+ yield {
192
+ type: 'error',
193
+ message: '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',
194
+ };
160
195
  return;
161
196
  }
162
197
  }
163
- else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
164
- // We have a persisted threadId from a previous bridge run — resume it.
165
- // Mirror the deferred-clear pattern from the turn/start retry below: only
166
- // discard the persisted id once a fresh-thread start has actually
167
- // succeeded, so a transient resume failure (network/timeout/upstream
168
- // hiccup) doesn't permanently throw away the prior conversation context.
198
+ const liveThreadId = isMainSession
199
+ ? this.opts.sessionManager.getThreadId(sessionKey)
200
+ : undefined;
201
+ if (liveThreadId &&
202
+ this.opts.developerInstructions &&
203
+ this.resumedThreadIds.has(liveThreadId) &&
204
+ this.instructionsRefresher.canonicalFor(liveThreadId) !== this.opts.developerInstructions) {
205
+ // Live thread opened with an older prompt: compacting now would
206
+ // rebuild that STALE text, so bounce the subprocess — only a
207
+ // fresh-process resume updates the canonical configuration. The
208
+ // platform-config refresh normally schedules this restart itself;
209
+ // this guard makes convergence independent of that ordering.
210
+ this.requestProcessRestart();
211
+ }
212
+ const log = this.opts.log ?? context.log;
213
+ let client;
214
+ let threadId;
215
+ let reconcileStalled = false;
216
+ for (let attempt = 0;; attempt++) {
217
+ await this.applyPendingRestart(context.log);
218
+ // Opening counts as active work for restart gating (see
219
+ // applyPendingRestart): the main-session reconcile can run a real
220
+ // compaction turn BEFORE any TurnSink exists, and a concurrent fork
221
+ // dispatch hitting a pending restart in that window would otherwise
222
+ // stop() the subprocess out from under it.
223
+ this.openingDispatches += 1;
224
+ let opened;
169
225
  try {
170
- threadId = await this.openThread(client, { resumeId: threadId });
171
- this.opts.sessionManager.recordThreadId(sessionKey, threadId);
172
- this.resumedThreadIds.add(threadId);
226
+ opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
173
227
  }
174
- catch (err) {
175
- log?.warn?.(`thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`);
176
- let freshThreadId;
177
- try {
178
- freshThreadId = await this.openThread(client, { resumeId: undefined });
179
- }
180
- catch (innerErr) {
181
- yield {
182
- type: 'error',
183
- message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
184
- };
185
- return;
186
- }
187
- this.opts.sessionManager.clearMainThread();
188
- this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
189
- this.resumedThreadIds.add(freshThreadId);
190
- threadId = freshThreadId;
228
+ finally {
229
+ this.openingDispatches -= 1;
191
230
  }
231
+ if (!opened.ok) {
232
+ yield { type: 'error', message: opened.message };
233
+ return;
234
+ }
235
+ client = opened.client;
236
+ threadId = opened.threadId;
237
+ reconcileStalled = opened.reconcile === 'stalled';
238
+ // Re-check convergence AFTER the async open/reconcile work: on the cold
239
+ // path (spawn + resume + compaction) that window is seconds, and a
240
+ // platform-config update landing inside it would otherwise run this
241
+ // turn on the prompt captured at open time. One bounded retry loops
242
+ // back through the restart path with the newer prompt. If the restart
243
+ // stays deferred (a fork turn is active) or a second update lands
244
+ // during the retry, the turn proceeds and the next dispatch converges —
245
+ // an update after this point is equivalent to one arriving mid-turn.
246
+ if (isMainSession &&
247
+ attempt === 0 &&
248
+ this.opts.developerInstructions &&
249
+ this.instructionsRefresher.canonicalFor(threadId) !== this.opts.developerInstructions) {
250
+ log?.info?.('platform instructions changed while the dispatch was opening the thread; bouncing the subprocess to converge before this turn');
251
+ this.requestProcessRestart();
252
+ continue;
253
+ }
254
+ break;
192
255
  }
193
256
  const sink = new TurnSink();
194
257
  this.setActiveTurn(threadId, sink, log);
@@ -267,8 +330,33 @@ export class CodexAppServerAdapter {
267
330
  yield { type: 'error', message: `Codex turn/start failed: ${message}` };
268
331
  return;
269
332
  }
333
+ if (reconcileStalled) {
334
+ // A stalled compaction may STILL be running on this thread — a
335
+ // busy-thread rejection here is expected, not evidence of a stale
336
+ // thread, and the fresh-thread retry below would ROTATE the
337
+ // persisted main thread (continuity loss). Quarantine the main
338
+ // lane (redrives must not touch the thread until the subprocess
339
+ // actually restarts — a deferred restart would otherwise send the
340
+ // NEXT dispatch back into the zombie, where a second compaction's
341
+ // watcher can misattribute the zombie's close as a plain failure
342
+ // and lose the stalled marker) and bounce the subprocess.
343
+ this.mainLaneQuarantined = true;
344
+ this.requestProcessRestart();
345
+ this.activeTurns.delete(threadId);
346
+ yield {
347
+ type: 'runtime_session',
348
+ runtimeSessionId: threadId,
349
+ runtimeLaneKey: sessionKey,
350
+ };
351
+ yield {
352
+ type: 'error',
353
+ message: `Codex turn/start failed after a stalled compaction (thread retained; subprocess will restart): ${message}`,
354
+ };
355
+ return;
356
+ }
270
357
  log?.warn?.(`turn/start on thread ${threadId} failed (${message}); attempting one-shot fresh-thread retry`);
271
358
  this.activeTurns.delete(threadId);
359
+ const sentInstructions = this.opts.developerInstructions;
272
360
  let freshThreadId;
273
361
  try {
274
362
  freshThreadId = await this.openThread(client, { resumeId: undefined });
@@ -308,7 +396,7 @@ export class CodexAppServerAdapter {
308
396
  // Retry accepted — the original thread really was unusable. Now safe
309
397
  // to discard the old persisted id and persist the fresh one.
310
398
  this.opts.sessionManager.clearMainThread();
311
- this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
399
+ this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
312
400
  this.resumedThreadIds.add(freshThreadId);
313
401
  threadId = freshThreadId;
314
402
  }
@@ -360,6 +448,109 @@ export class CodexAppServerAdapter {
360
448
  }
361
449
  }
362
450
  }
451
+ /**
452
+ * Bring the app-server up and open (start or resume) the thread for this
453
+ * dispatch, then reconcile the main thread's EFFECTIVE instructions plane.
454
+ * Extracted from dispatch() so the convergence re-check there can loop back
455
+ * through the restart path without duplicating the open logic. Errors are
456
+ * returned, not thrown — the generator yields them as runtime events.
457
+ */
458
+ async openDispatchTarget(sessionKey, isMainSession, log) {
459
+ try {
460
+ await this.ensureStarted(log);
461
+ }
462
+ catch (err) {
463
+ // Spawn/handshake failures surface as a clean error event like every
464
+ // other open failure, instead of rejecting the dispatch generator.
465
+ return { ok: false, message: `Codex app-server failed to start: ${errToString(err)}` };
466
+ }
467
+ // After ensureStarted resolves the subprocess could still die before we
468
+ // capture the client (handleSubprocessClose nulls this.client). Return a
469
+ // clean error instead of relying on a non-null assertion that would
470
+ // throw a TypeError on the next sendRequest call.
471
+ const client = this.client;
472
+ if (!client) {
473
+ return {
474
+ ok: false,
475
+ message: 'Codex app-server not available (subprocess died during dispatch start)',
476
+ };
477
+ }
478
+ let threadId = this.opts.sessionManager.getThreadId(sessionKey);
479
+ if (!threadId) {
480
+ try {
481
+ // Capture what openThread will send in the same tick as the call, so
482
+ // the recorded canonical/effective values can never reflect a NEWER
483
+ // instructions text swapped in by updateConfig() mid-flight.
484
+ const sentInstructions = this.opts.developerInstructions;
485
+ threadId = await this.openThread(client, { resumeId: undefined });
486
+ // recordBaked persists thread id + effective sha in ONE write.
487
+ this.instructionsRefresher.recordBaked(sessionKey, threadId, sentInstructions);
488
+ // Mark this freshly-started thread as already live in the current
489
+ // app-server process. Without this, the second dispatch after a cold
490
+ // start would enter the thread/resume branch below for a thread this
491
+ // process just created — and if the server treats resume-of-just-
492
+ // created-thread as an attach-to-detached-thread operation, it could
493
+ // fail the resume path and replace the thread, losing the first turn.
494
+ this.resumedThreadIds.add(threadId);
495
+ }
496
+ catch (err) {
497
+ return { ok: false, message: `Codex thread/start failed: ${errToString(err)}` };
498
+ }
499
+ }
500
+ else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
501
+ // We have a persisted threadId from a previous bridge run — resume it.
502
+ // Mirror the deferred-clear pattern from the turn/start retry in
503
+ // dispatch(): only discard the persisted id once a fresh-thread start
504
+ // has actually succeeded, so a transient resume failure (network/
505
+ // timeout/upstream hiccup) doesn't permanently throw away the prior
506
+ // conversation context.
507
+ const sentInstructions = this.opts.developerInstructions;
508
+ try {
509
+ threadId = await this.openThread(client, { resumeId: threadId });
510
+ this.opts.sessionManager.recordThreadId(sessionKey, threadId);
511
+ this.instructionsRefresher.recordResumed(threadId, sentInstructions);
512
+ this.resumedThreadIds.add(threadId);
513
+ }
514
+ catch (err) {
515
+ log?.warn?.(`thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`);
516
+ let freshThreadId;
517
+ try {
518
+ freshThreadId = await this.openThread(client, { resumeId: undefined });
519
+ }
520
+ catch (innerErr) {
521
+ return {
522
+ ok: false,
523
+ message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
524
+ };
525
+ }
526
+ this.opts.sessionManager.clearMainThread();
527
+ this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
528
+ this.resumedThreadIds.add(freshThreadId);
529
+ threadId = freshThreadId;
530
+ }
531
+ }
532
+ let reconcile;
533
+ if (isMainSession) {
534
+ // Converge the EFFECTIVE plane before the turn starts (posture incl.
535
+ // the stalled exception: instructions-refresh.ts). Cheap compare when
536
+ // nothing changed; one thread/compact/start when the last-known
537
+ // effective instructions differ from the thread's canonical config.
538
+ this.reconcilingThreadIds.add(threadId);
539
+ try {
540
+ reconcile = await this.instructionsRefresher.reconcileAfterOpen({
541
+ client,
542
+ taps: this,
543
+ sessionKey,
544
+ threadId,
545
+ log,
546
+ });
547
+ }
548
+ finally {
549
+ this.reconcilingThreadIds.delete(threadId);
550
+ }
551
+ }
552
+ return { ok: true, client, threadId, reconcile };
553
+ }
363
554
  getBranchPoint(_sessionKey) {
364
555
  // Codex app-server does not expose a branch-point API; fork scope prefix
365
556
  // provides the behavioral fallback for this runtime.
@@ -377,10 +568,23 @@ export class CodexAppServerAdapter {
377
568
  const forkParams = {
378
569
  threadId: parentThreadId,
379
570
  ephemeral: true,
571
+ // Forks must inherit the bridge's approval/sandbox contract. thread/fork
572
+ // does NOT inherit them from the parent thread — omitting them falls
573
+ // back to codex defaults (on-request approval, workspace-write), and an
574
+ // escalated command then emits an approval request the headless bridge
575
+ // can only deny, killing the fork's ability to run privileged commands
576
+ // the main session runs freely.
577
+ approvalPolicy: normalizeApprovalPolicy(this.opts.approvalPolicy),
578
+ sandbox: normalizeSandbox(this.opts.sandbox),
380
579
  };
381
580
  if (this.opts.useParallProvider) {
382
581
  forkParams.modelProvider = 'parall';
383
582
  }
583
+ if (this.opts.developerInstructions) {
584
+ // Accepted, but the fork inherits the parent's instructions instead —
585
+ // so it carries the platform prompt either way. See the option doc.
586
+ forkParams.developerInstructions = this.opts.developerInstructions;
587
+ }
384
588
  if (this.opts.model)
385
589
  forkParams.model = this.opts.model;
386
590
  if (this.opts.reasoningEffort) {
@@ -408,21 +612,33 @@ export class CodexAppServerAdapter {
408
612
  return null;
409
613
  }
410
614
  /**
411
- * Lazily restart the app-server before the NEXT turn: the workspace
412
- * config.toml (developer_instructions, carrying capability fragments) is
413
- * loaded at process start, so a changed fragment set needs a respawn to
414
- * reach the prompt. Deferred to the next dispatch with no active turns —
415
- * never kills an in-flight turn; thread state survives via thread/resume.
615
+ * Lazily restart the app-server before the NEXT turn, so a subprocess that
616
+ * has been running since before a capability change starts clean. Deferred
617
+ * to the next dispatch with no active turns never kills an in-flight turn;
618
+ * thread state survives via thread/resume.
619
+ *
620
+ * For the persisted main thread this restart is also the CANONICAL half of
621
+ * the instructions refresh: only a fresh-process thread/resume applies the
622
+ * updated developerInstructions to the thread's configuration (a resume of
623
+ * a still-running thread ignores it). The EFFECTIVE half is the explicit
624
+ * compaction in instructions-refresh.ts. The capability shim dir on PATH is
625
+ * what makes a grant/revocation's TOOLS effective immediately.
416
626
  */
417
627
  requestProcessRestart() {
418
628
  this.restartRequested = true;
419
629
  }
420
630
  restartRequested = false;
631
+ /**
632
+ * Dispatches currently inside their open/reconcile phase — real work on
633
+ * the subprocess (thread open, possibly a compaction turn) that predates
634
+ * any TurnSink registration, so activeTurns alone cannot gate the restart.
635
+ */
636
+ openingDispatches = 0;
421
637
  async applyPendingRestart(log) {
422
- if (!this.restartRequested || this.activeTurns.size > 0)
638
+ if (!this.restartRequested || this.activeTurns.size > 0 || this.openingDispatches > 0)
423
639
  return;
424
640
  this.restartRequested = false;
425
- (log ?? this.opts.log)?.info?.('restarting codex app-server to pick up updated developer_instructions');
641
+ (log ?? this.opts.log)?.info?.('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)');
426
642
  await this.stop();
427
643
  }
428
644
  async stop() {
@@ -440,6 +656,10 @@ export class CodexAppServerAdapter {
440
656
  // dispatch treat the persisted thread as already resumed in the NEW
441
657
  // process and lose conversation continuity when turn/start rejects.
442
658
  this.resumedThreadIds.clear();
659
+ this.instructionsRefresher.clearThreadState();
660
+ // The (possible) zombie compaction turn dies with the subprocess.
661
+ this.mainLaneQuarantined = false;
662
+ this.notifyTapsDisposed('Codex app-server stopped');
443
663
  if (client)
444
664
  client.dispose(new Error('adapter stopped'));
445
665
  if (proc && proc.exitCode === null && proc.signalCode === null) {
@@ -461,6 +681,7 @@ export class CodexAppServerAdapter {
461
681
  this.proc = null;
462
682
  this.initialized = false;
463
683
  this.resumedThreadIds.clear();
684
+ this.instructionsRefresher.clearThreadState();
464
685
  }
465
686
  if (this.initialized && this.client)
466
687
  return;
@@ -482,6 +703,7 @@ export class CodexAppServerAdapter {
482
703
  // previous stop() — otherwise handleSubprocessClose would mislabel the next
483
704
  // unexpected exit as "during graceful stop".
484
705
  this.stopping = false;
706
+ this.instructionsRefresher.resetForNewSubprocess();
485
707
  ensureGitRepo(this.opts.workspaceDir);
486
708
  // Only steer Codex's own state via CODEX_HOME. Leaving HOME untouched
487
709
  // preserves the user's real dotfiles for any subprocess Codex spawns
@@ -548,6 +770,24 @@ export class CodexAppServerAdapter {
548
770
  }
549
771
  });
550
772
  client.setNotificationHandler((method, params) => this.routeNotification(method, params));
773
+ // Server→client requests (approvals, elicitations) can never be answered
774
+ // interactively by a headless bridge, but each one MUST get a response —
775
+ // the app-server parks the requesting turn until one arrives. Known
776
+ // approval methods get an explicit denial; anything else falls through to
777
+ // the client's method-not-found error. With approvalPolicy "never" these
778
+ // should not occur at all, so surface every occurrence at warn.
779
+ client.setServerRequestHandler((method, params) => {
780
+ const denial = answerServerRequest(method);
781
+ // Log stable identifiers only — approval params carry command lines /
782
+ // file changes / patch content that must not reach stderr or OTLP.
783
+ const p = (params ?? {});
784
+ const ids = ['threadId', 'turnId', 'itemId', 'callId']
785
+ .filter((k) => typeof p[k] === 'string')
786
+ .map((k) => `${k}=${p[k]}`)
787
+ .join(' ');
788
+ (log ?? this.opts.log)?.warn?.(`answering app-server request "${method}" with ${denial === undefined ? 'method-not-found error' : 'denial'} (headless bridge cannot approve)${ids ? ` [${ids}]` : ''}`);
789
+ return denial === undefined ? undefined : { result: denial };
790
+ });
551
791
  proc.once('close', (code, signal) => this.handleSubprocessClose(proc, code, signal, log));
552
792
  proc.once('error', (err) => this.handleSubprocessClose(proc, null, null, log, err));
553
793
  try {
@@ -596,6 +836,9 @@ export class CodexAppServerAdapter {
596
836
  this.activeTurnIds.clear();
597
837
  this.pendingInjections.clear();
598
838
  this.resumedThreadIds.clear();
839
+ this.instructionsRefresher.clearThreadState();
840
+ this.mainLaneQuarantined = false;
841
+ this.notifyTapsDisposed(`Codex app-server ${reason}`);
599
842
  this.client = null;
600
843
  this.proc = null;
601
844
  this.initialized = false;
@@ -617,6 +860,14 @@ export class CodexAppServerAdapter {
617
860
  commonParams.modelProvider = 'parall';
618
861
  }
619
862
  commonParams.sandbox = normalizeSandbox(this.opts.sandbox);
863
+ if (this.opts.developerInstructions) {
864
+ // Typed top-level param (camelCase), NOT a raw config.toml override —
865
+ // trust-independent, so the platform prompt loads regardless of any codex
866
+ // workspace-trust state. thread/start bakes it in; a fresh-process
867
+ // thread/resume applies it to the canonical configuration — see the
868
+ // option doc for the two-plane semantics.
869
+ commonParams.developerInstructions = this.opts.developerInstructions;
870
+ }
620
871
  if (this.opts.model)
621
872
  commonParams.model = this.opts.model;
622
873
  if (this.opts.reasoningEffort) {
@@ -641,6 +892,17 @@ export class CodexAppServerAdapter {
641
892
  return threadId;
642
893
  }
643
894
  routeNotification(method, params) {
895
+ // Taps observe the raw stream (instructions refresh awaits its compaction
896
+ // turn through one); the sink routing below is unaffected — including by
897
+ // a tap that throws, which must never break turn delivery.
898
+ for (const tap of this.notificationTaps) {
899
+ try {
900
+ tap(method, params);
901
+ }
902
+ catch (err) {
903
+ this.opts.log?.warn?.(`notification tap threw: ${errToString(err)}`);
904
+ }
905
+ }
644
906
  const threadId = extractThreadIdFromNotification(params);
645
907
  if (!threadId) {
646
908
  // Surface server-initiated generic errors to every active turn.
@@ -653,8 +915,25 @@ export class CodexAppServerAdapter {
653
915
  return;
654
916
  }
655
917
  const sink = this.activeTurns.get(threadId);
656
- if (!sink)
918
+ if (!sink) {
919
+ // A reconciling thread's compaction turn is INTENTIONALLY tap-only —
920
+ // it runs before any TurnSink exists. Warning here would pollute every
921
+ // successful instructions refresh and burn the shared rate-limit
922
+ // window that exists to surface REAL orphaned turns.
923
+ if (this.reconcilingThreadIds.has(threadId))
924
+ return;
925
+ // Notifications for a thread with no registered sink are dropped. A
926
+ // sustained stream of these means a turn is running that nothing is
927
+ // consuming (threadId mismatch, sink torn down early) — the dispatch
928
+ // side would see indefinite silence, so keep the drop visible
929
+ // (rate-limited: these arrive per-delta during an orphaned turn).
930
+ const now = Date.now();
931
+ if (now - this.lastUnroutedNotificationWarnAt > 10_000) {
932
+ this.lastUnroutedNotificationWarnAt = now;
933
+ this.opts.log?.warn?.(`dropping notification "${method}" for unknown thread ${threadId}`);
934
+ }
657
935
  return;
936
+ }
658
937
  // For turn/completed we still run the mapper first — it emits a
659
938
  // RuntimeEvent error if `turn.status === "failed"`. Enqueue those events
660
939
  // before the turn_end sentinel so the dispatch loop can yield them.
@@ -667,117 +946,6 @@ export class CodexAppServerAdapter {
667
946
  }
668
947
  }
669
948
  }
670
- /** Per-turn buffered sink backed by an unbounded promise queue. */
671
- class TurnSink {
672
- mapper = new EventMapper();
673
- queue = [];
674
- resolver = null;
675
- closed = false;
676
- push(envelope) {
677
- if (this.closed)
678
- return;
679
- if (this.resolver) {
680
- const r = this.resolver;
681
- this.resolver = null;
682
- r(envelope);
683
- return;
684
- }
685
- this.queue.push(envelope);
686
- }
687
- next() {
688
- // Drain any queued envelopes first, even after close(). Otherwise a final
689
- // error envelope enqueued right before close() (e.g. by
690
- // handleSubprocessClose) is silently dropped because the consumer would
691
- // see turn_end before it.
692
- const pending = this.queue.shift();
693
- if (pending)
694
- return Promise.resolve(pending);
695
- if (this.closed) {
696
- return Promise.resolve({ kind: 'turn_end' });
697
- }
698
- return new Promise((resolve) => {
699
- this.resolver = resolve;
700
- });
701
- }
702
- close() {
703
- this.closed = true;
704
- const r = this.resolver;
705
- this.resolver = null;
706
- r?.({ kind: 'turn_end' });
707
- }
708
- }
709
- function ensureGitRepo(workingDirectory) {
710
- fs.mkdirSync(workingDirectory, { recursive: true });
711
- // Only `git init` if the workspace isn't already inside any git repo. A
712
- // bare existsSync(.git) check would miss the common case of a user pointing
713
- // PRLL_WORKSPACE_DIR at a subdirectory of their existing project,
714
- // and silently creating a nested repo there would mangle their layout.
715
- try {
716
- execSync('git rev-parse --is-inside-work-tree', { cwd: workingDirectory, stdio: 'pipe' });
717
- ensureLocalAttachmentGitExclude(workingDirectory);
718
- return;
719
- }
720
- catch {
721
- // Not inside a repo — fall through to init.
722
- }
723
- const env = {
724
- ...process.env,
725
- GIT_AUTHOR_NAME: 'parall-codex-agent',
726
- GIT_AUTHOR_EMAIL: 'agent@parall.local',
727
- GIT_COMMITTER_NAME: 'parall-codex-agent',
728
- GIT_COMMITTER_EMAIL: 'agent@parall.local',
729
- };
730
- try {
731
- execSync('git init', { cwd: workingDirectory, stdio: 'pipe', env });
732
- execSync('git commit --allow-empty -m init', { cwd: workingDirectory, stdio: 'pipe', env });
733
- ensureLocalAttachmentGitExclude(workingDirectory);
734
- }
735
- catch {
736
- // Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
737
- }
738
- }
739
- function buildTurnInput(body, images) {
740
- return [
741
- { type: 'text', text: body },
742
- ...images.map((image) => ({ type: 'localImage', path: image.localPath })),
743
- ];
744
- }
745
- function extractThreadId(result) {
746
- if (!result || typeof result !== 'object')
747
- return undefined;
748
- const r = result;
749
- if (typeof r.threadId === 'string')
750
- return r.threadId;
751
- const thread = r.thread;
752
- if (thread && typeof thread.id === 'string')
753
- return thread.id;
754
- return undefined;
755
- }
756
- function extractTurnId(result) {
757
- if (!result || typeof result !== 'object')
758
- return undefined;
759
- const r = result;
760
- if (typeof r.turnId === 'string')
761
- return r.turnId;
762
- const turn = r.turn;
763
- if (turn && typeof turn.id === 'string')
764
- return turn.id;
765
- return undefined;
766
- }
767
- function extractThreadIdFromNotification(params) {
768
- if (!params || typeof params !== 'object')
769
- return undefined;
770
- const p = params;
771
- if (typeof p.threadId === 'string')
772
- return p.threadId;
773
- const thread = p.thread;
774
- if (thread && typeof thread.id === 'string')
775
- return thread.id;
776
- const meta = (p._meta ?? p.meta);
777
- if (meta && typeof meta.threadId === 'string')
778
- return meta.threadId;
779
- return undefined;
780
- }
781
949
  function errToString(err) {
782
950
  if (err instanceof Error)
783
951
  return err.message;