@parall/codex-agent 1.42.0 → 1.43.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/workspace.js CHANGED
@@ -2,6 +2,406 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { parse as parseToml } from 'smol-toml';
4
4
  import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, buildIdentity, buildSkillReferences, writeSkillFiles, } from '@parall/agent-core';
5
+ /**
6
+ * Config-lock timings, exported for tests.
7
+ *
8
+ * `staleMs` — a queue ticket untouched this long is treated as a crashed
9
+ * owner (live contenders refresh their ticket's mtime every `retryMs`, so
10
+ * the margin is ~400×). `waitMs` deliberately exceeds `staleMs`: a waiter
11
+ * facing a crashed head-of-queue outlasts the staleness threshold and skips
12
+ * it instead of falling through to a lockless write — the lockless fallback
13
+ * is a last resort, not a normal path.
14
+ */
15
+ export const CONFIG_LOCK_TIMINGS = {
16
+ staleMs: 10_000,
17
+ waitMs: 15_000,
18
+ retryMs: 25,
19
+ };
20
+ // Reused wait signal: sleepSync fires every retryMs under contention, and a
21
+ // fresh SharedArrayBuffer per call is avoidable allocation churn.
22
+ const SLEEP_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
23
+ function sleepSync(ms) {
24
+ Atomics.wait(SLEEP_SIGNAL, 0, 0, ms);
25
+ }
26
+ /**
27
+ * Advisory cross-process lock serializing `<codexHome>/config.toml`
28
+ * read-modify-write. On a local daemon machine every runtime_auth codex
29
+ * bridge child shares the operator's CODEX_HOME, and the daemon starts them
30
+ * together — without a lock, a whole-file rewrite working from a stale read
31
+ * can erase a trust entry another agent appended in between.
32
+ *
33
+ * Queue design (why not wx-create + steal): any scheme that renames or
34
+ * unlinks the SHARED lock path can, between its staleness check and the
35
+ * destructive op, hit a fresh lock that replaced the stale one — deleting a
36
+ * live holder's lock and overlapping critical sections (reproduced under
37
+ * 12-process contention). Here contenders queue in `config.toml.lock.d/`
38
+ * using Lamport's bakery protocol, and the smallest live ticket holds the
39
+ * lock. Two phases per enqueue: (1) create a `choosing-<token>` marker,
40
+ * (2) take seq = max(existing ticket seqs) + 1 and publish by atomically
41
+ * RENAMING the marker into `t-<seq>-<token>`. Evaluators wait while any
42
+ * live choosing marker exists, so a contender that read the queue but
43
+ * hasn't published yet can never be missed — the classic bakery guarantee.
44
+ * (A naive self-chosen timestamp order would race: a process descheduled
45
+ * between choosing its stamp and writing its ticket could insert itself
46
+ * before an already-running holder.) The rename-publish closes the
47
+ * stale-mid-choosing hole: once a frozen chooser's marker has been GC'd,
48
+ * its publish fails and it must re-enqueue fresh — it can never surface an
49
+ * old low seq under a holder elected in its absence. ENTRY is the same
50
+ * pattern: the elected head atomically renames its ticket into a
51
+ * `held-<entry-ts>-…` entry, racing any staleness eviction of that ticket
52
+ * on the same path — a contender revived at the staleness boundary loses
53
+ * the rename and re-enqueues rather than entering behind an eviction. The
54
+ * holder's freshness is embedded in the held name (atomic with entry), so
55
+ * evicting a holder needs no stat. Evaluators mirror the atomicity: after
56
+ * any GC attempt, or on a marker that vanished mid-scan, they take a fresh
57
+ * snapshot instead of electing from the old one. Ticket order is
58
+ * (seq, token), identical for every observer.
59
+ *
60
+ * No process ever mutates a shared path: release unlinks only the caller's
61
+ * own ticket, and the only cross-process destructive op is GC of entries
62
+ * untouched for `staleMs` — safe because live contenders refresh their
63
+ * ticket's mtime every `retryMs`, and names embed a per-acquisition random
64
+ * token so they are never reused (no identity switch on unlink).
65
+ *
66
+ * Residual (documented, not fixable without OS-level flock, which Node core
67
+ * does not expose): a HOLDER frozen inside `fn` for longer than `staleMs`
68
+ * looks crashed, gets evicted, and the next head may overlap it — `fn` is a
69
+ * millisecond-scale sync config write, 400× within margin. Freezes anywhere
70
+ * else (mid-choosing, at entry) are safe: the atomic renames fail after an
71
+ * eviction and the process re-enqueues. On `waitMs` timeout
72
+ * the mutation proceeds without the lock (warn) — blocking would wedge
73
+ * bridge startup. The lock coordinates bridge processes only; codex itself
74
+ * does not observe it, which is why bridge writes to a shared config are
75
+ * additionally kept rare (the trust write is a no-op after the first boot
76
+ * per workspace, and runtime_auth agents never write the provider block)
77
+ * and whole-file writes are atomic (temp + rename) so codex never reads a
78
+ * truncated file.
79
+ *
80
+ * Exported for tests.
81
+ */
82
+ export function withConfigLock(codexHome, log, fn) {
83
+ const queueDir = path.join(codexHome, 'config.toml.lock.d');
84
+ let ticketName = bakeryEnqueue(queueDir);
85
+ let ticketPath = ticketName ? path.join(queueDir, ticketName) : '';
86
+ let acquired = false;
87
+ let heldPath = '';
88
+ const deadline = Date.now() + CONFIG_LOCK_TIMINGS.waitMs;
89
+ while (ticketName && Date.now() < deadline) {
90
+ let names;
91
+ try {
92
+ names = fs.readdirSync(queueDir).sort();
93
+ }
94
+ catch {
95
+ break; // dir vanished under us — lockless fallback
96
+ }
97
+ if (!names.includes(ticketName)) {
98
+ // Our ticket was GC'd (we looked frozen) or wiped — re-run the full
99
+ // bakery enqueue rather than silently proceeding without a position.
100
+ ticketName = bakeryEnqueue(queueDir);
101
+ if (!ticketName)
102
+ break;
103
+ ticketPath = path.join(queueDir, ticketName);
104
+ continue;
105
+ }
106
+ const now = Date.now();
107
+ let rescan = false; // snapshot invalidated — re-readdir before electing
108
+ let blocked = false; // a live chooser/holder is ahead of us
109
+ let head;
110
+ for (const name of names) {
111
+ const entryPath = path.join(queueDir, name);
112
+ if (name.startsWith('held-')) {
113
+ // The holder's freshness is embedded in the name at entry time
114
+ // (atomic with the entry rename), so there is no stat window here.
115
+ const enteredAt = Number.parseInt(name.slice(5, 20), 10);
116
+ if (Number.isFinite(enteredAt) && now - enteredAt > CONFIG_LOCK_TIMINGS.staleMs) {
117
+ // Holder frozen inside fn beyond staleMs — the documented
118
+ // residual. Evict and rescan.
119
+ try {
120
+ fs.unlinkSync(entryPath);
121
+ }
122
+ catch {
123
+ // released or GC'd concurrently
124
+ }
125
+ rescan = true;
126
+ break;
127
+ }
128
+ blocked = true; // live holder — the lock is taken
129
+ break;
130
+ }
131
+ let mtimeMs;
132
+ try {
133
+ mtimeMs = fs.statSync(entryPath).mtimeMs;
134
+ }
135
+ catch {
136
+ // The entry vanished between readdir and stat — it did not merely
137
+ // leave, it may have TRANSITIONED via an atomic rename this snapshot
138
+ // cannot see: a choosing marker into a published ticket, or a head
139
+ // ticket into a live held entry (caught overlapping under the
140
+ // 12-process barrier test when this path skipped tickets). Never
141
+ // elect from a snapshot that missed a transition — rescan.
142
+ rescan = true;
143
+ break;
144
+ }
145
+ if (now - mtimeMs > CONFIG_LOCK_TIMINGS.staleMs) {
146
+ // Crashed contender (live ones refresh every retryMs; choosing is
147
+ // microsecond-scale). Unique never-reused names make this unlink
148
+ // safe — it cannot hit a different file than the one just observed.
149
+ try {
150
+ fs.unlinkSync(entryPath);
151
+ }
152
+ catch {
153
+ // Lost to a concurrent GC — or to the owner's atomic rename
154
+ // (marker → ticket publish, or ticket → held entry).
155
+ }
156
+ // Whether the unlink won or lost, the snapshot no longer reflects
157
+ // the queue (a stale marker may have become a live ticket) — rescan
158
+ // instead of electing from stale names.
159
+ rescan = true;
160
+ break;
161
+ }
162
+ // Sorted names put `choosing-*` before `held-*` before `t-*`, so
163
+ // blockers are seen before any head candidate.
164
+ if (name.startsWith('choosing-')) {
165
+ blocked = true;
166
+ break;
167
+ }
168
+ if (name.startsWith('t-')) {
169
+ head = name;
170
+ break;
171
+ }
172
+ // Foreign file in the queue dir — ignore it.
173
+ }
174
+ if (rescan)
175
+ continue; // GC/publish made progress; take a fresh snapshot
176
+ if (!blocked && head === ticketName) {
177
+ // ENTRY is an atomic rename of our own ticket into a `held-<now>-…`
178
+ // entry. It races any GC eviction of the ticket on the same path, so
179
+ // exactly one side wins: if a GC saw us stale at the boundary and
180
+ // evicted first, our rename fails and we re-enqueue instead of
181
+ // entering — a revived contender can never slip into the critical
182
+ // section behind an eviction. The entry timestamp rides in the name,
183
+ // atomic with the transition itself.
184
+ CONFIG_LOCK_TEST_HOOKS.beforeTicketEntry?.();
185
+ const heldName = `held-${String(Date.now()).padStart(15, '0')}-${ticketName.slice(2)}`;
186
+ const candidateHeldPath = path.join(queueDir, heldName);
187
+ try {
188
+ fs.renameSync(ticketPath, candidateHeldPath);
189
+ }
190
+ catch {
191
+ continue; // evicted at the boundary — the missing-ticket branch re-enqueues
192
+ }
193
+ heldPath = candidateHeldPath;
194
+ acquired = true;
195
+ break;
196
+ }
197
+ try {
198
+ const t = new Date();
199
+ fs.utimesSync(ticketPath, t, t); // keep our ticket visibly live
200
+ }
201
+ catch {
202
+ // ticket missing — the next iteration re-enqueues
203
+ }
204
+ sleepSync(CONFIG_LOCK_TIMINGS.retryMs);
205
+ }
206
+ if (!acquired) {
207
+ log?.warn(`could not acquire ${queueDir}; writing config.toml without lock`);
208
+ // Leave the queue before writing lockless so our abandoned ticket does
209
+ // not block other contenders for another staleMs.
210
+ if (ticketName) {
211
+ try {
212
+ fs.unlinkSync(ticketPath);
213
+ }
214
+ catch {
215
+ // already GC'd
216
+ }
217
+ }
218
+ }
219
+ try {
220
+ fn();
221
+ }
222
+ finally {
223
+ if (acquired) {
224
+ try {
225
+ fs.unlinkSync(heldPath); // own unique name — no identity race
226
+ }
227
+ catch {
228
+ // evicted by a contender that saw us frozen inside fn — nothing to release
229
+ }
230
+ // The queue dir is deliberately NEVER removed: a contender may have
231
+ // finished mkdir but not yet created its choosing marker, and
232
+ // deleting the dir in that gap would send it down the lockless
233
+ // fallback. An empty config.toml.lock.d on disk is expected.
234
+ }
235
+ }
236
+ }
237
+ /**
238
+ * Test-only scheduling hooks for deterministic race tests. Production code
239
+ * never sets these; tests use them to pause a contender at the two points
240
+ * where another process can act in between (barrier files stand in for the
241
+ * OS scheduler).
242
+ */
243
+ export const CONFIG_LOCK_TEST_HOOKS = {};
244
+ /**
245
+ * Bakery enqueue: announce with a choosing marker, take
246
+ * seq = max(visible tickets) + 1, then PUBLISH BY RENAMING THE MARKER INTO
247
+ * THE TICKET. The rename is the linchpin against the stale-mid-choosing
248
+ * race: if this process froze after creating the marker and a contender
249
+ * GC'd it as stale (allowing others to elect and enter), the rename fails
250
+ * with ENOENT — the stale seq is discarded and we re-enqueue under a fresh
251
+ * token. Publishing and retiring the marker are one atomic step, so at no
252
+ * instant can an evaluator observe "no marker, and no ticket either" for an
253
+ * in-flight enqueue.
254
+ *
255
+ * Returns the ticket name, or null when the queue dir is unusable (caller
256
+ * falls back to a lockless warn-logged write).
257
+ */
258
+ function bakeryEnqueue(queueDir) {
259
+ // A GC'd marker aborts the attempt; retry under a FRESH token so names
260
+ // are never reused. Being GC'd requires a >staleMs freeze mid-choosing,
261
+ // so two spare attempts are already generous.
262
+ for (let attempt = 0; attempt < 3; attempt++) {
263
+ const token = `${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
264
+ const markerPath = path.join(queueDir, `choosing-${token}`);
265
+ try {
266
+ fs.mkdirSync(queueDir, { recursive: true });
267
+ CONFIG_LOCK_TEST_HOOKS.beforeChoosingMarker?.();
268
+ fs.writeFileSync(markerPath, '', { flag: 'wx' });
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ try {
274
+ let maxSeq = 0;
275
+ for (const name of fs.readdirSync(queueDir)) {
276
+ if (!name.startsWith('t-'))
277
+ continue;
278
+ const seq = Number.parseInt(name.slice(2, 12), 10);
279
+ if (Number.isFinite(seq) && seq > maxSeq)
280
+ maxSeq = seq;
281
+ }
282
+ const ticketName = `t-${String(maxSeq + 1).padStart(10, '0')}-${token}`;
283
+ const ticketPath = path.join(queueDir, ticketName);
284
+ CONFIG_LOCK_TEST_HOOKS.beforeTicketPublish?.();
285
+ fs.renameSync(markerPath, ticketPath);
286
+ try {
287
+ // The ticket inherits the marker's mtime; refresh it so a slow
288
+ // choose does not hand evaluators a stale-at-birth ticket. Runs
289
+ // before our first head evaluation, so if a GC wins the race on the
290
+ // stale-born ticket we simply re-enqueue — never elect on it.
291
+ const t = new Date();
292
+ fs.utimesSync(ticketPath, t, t);
293
+ }
294
+ catch {
295
+ // GC'd already — the eval loop re-enqueues on the missing ticket.
296
+ }
297
+ return ticketName;
298
+ }
299
+ catch (err) {
300
+ // Retire our marker if it still exists (e.g. readdir failed).
301
+ try {
302
+ fs.unlinkSync(markerPath);
303
+ }
304
+ catch {
305
+ // already renamed or GC'd
306
+ }
307
+ if (err.code === 'ENOENT')
308
+ continue; // marker GC'd — fresh token
309
+ return null;
310
+ }
311
+ }
312
+ return null;
313
+ }
314
+ /**
315
+ * Whole-file config writes go through temp + rename so a concurrent reader
316
+ * (including codex itself, which does not observe the advisory lock) never
317
+ * sees a truncated file. The trust-append path intentionally keeps
318
+ * appendFileSync — O_APPEND is atomic for these small writes and cannot
319
+ * clobber a concurrent append.
320
+ *
321
+ * The rename targets the file's REAL path: `config.toml` managed by a
322
+ * dotfiles setup is often a symlink, and renaming onto the link path would
323
+ * replace the link with a regular file while the real target keeps the old
324
+ * content. The temp file is born 0600 (never a world-readable window, even
325
+ * pre-chmod) and created with `wx` so a colliding path is never truncated;
326
+ * an existing file's mode is then restored explicitly (chmod, not
327
+ * open-mode, so umask cannot mask bits off), while a brand-new config stays
328
+ * at the restrictive 0600.
329
+ */
330
+ function writeConfigAtomic(filePath, content) {
331
+ const realPath = resolveWriteTarget(filePath);
332
+ let mode;
333
+ try {
334
+ mode = fs.statSync(realPath).mode & 0o777;
335
+ }
336
+ catch {
337
+ // New file — keep the restrictive 0600 creation mode.
338
+ }
339
+ const tmpPath = `${realPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
340
+ let tmpCreated = false;
341
+ try {
342
+ fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
343
+ tmpCreated = true;
344
+ if (mode !== undefined)
345
+ fs.chmodSync(tmpPath, mode);
346
+ fs.renameSync(tmpPath, realPath);
347
+ }
348
+ catch (err) {
349
+ // Clean up only what THIS call put on disk: on a wx EEXIST the path
350
+ // belongs to someone else and must not be unlinked. (A create that
351
+ // failed mid-write still left our file — code ≠ EEXIST — so it is
352
+ // removed.)
353
+ if (tmpCreated || err.code !== 'EEXIST') {
354
+ try {
355
+ fs.unlinkSync(tmpPath);
356
+ }
357
+ catch {
358
+ // tmp never made it to disk
359
+ }
360
+ }
361
+ throw err;
362
+ }
363
+ }
364
+ /**
365
+ * Follow symlinks (including dangling ones) to the path a write should land
366
+ * on. Never returns an unresolved symlink: a cycle or an over-deep chain
367
+ * throws instead, so the atomic rename can never silently replace a link.
368
+ */
369
+ function resolveWriteTarget(filePath) {
370
+ try {
371
+ return fs.realpathSync(filePath);
372
+ }
373
+ catch (err) {
374
+ // ELOOP (cycle among existing links) and every other real error must
375
+ // surface; only a missing target falls through to the manual walk.
376
+ if (err.code !== 'ENOENT')
377
+ throw err;
378
+ }
379
+ // Some component is missing — the path may still be a dangling symlink
380
+ // chain; walk it manually so the file is created where the links point.
381
+ const seen = new Set();
382
+ let p = path.resolve(filePath);
383
+ for (let depth = 0; depth < 40; depth++) {
384
+ if (seen.has(p)) {
385
+ throw new Error(`symlink cycle at ${p} while resolving ${filePath}`);
386
+ }
387
+ seen.add(p);
388
+ let link;
389
+ try {
390
+ link = fs.readlinkSync(p);
391
+ }
392
+ catch (err) {
393
+ const code = err.code;
394
+ // ENOENT (nothing here yet) and EINVAL (a real non-link file) are the
395
+ // two legitimate ends of a chain — create/replace at this path. Any
396
+ // other error is a real failure and must surface.
397
+ if (code === 'ENOENT' || code === 'EINVAL')
398
+ return p;
399
+ throw err;
400
+ }
401
+ p = path.resolve(path.dirname(p), link);
402
+ }
403
+ throw new Error(`symlink chain deeper than 40 while resolving ${filePath}`);
404
+ }
5
405
  /**
6
406
  * Ensure the workspace directory is marked as trusted in the global Codex
7
407
  * config so that project-level `developer_instructions` are loaded at
@@ -13,6 +413,9 @@ import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, bui
13
413
  * the bridge from starting.
14
414
  */
15
415
  export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
416
+ withConfigLock(codexHome, log, () => ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log));
417
+ }
418
+ function ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log) {
16
419
  const configPath = path.join(codexHome, 'config.toml');
17
420
  const normalizedPath = path.resolve(workspaceDir);
18
421
  try {
@@ -37,8 +440,17 @@ export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
37
440
  }
38
441
  }
39
442
  const projects = parsed?.projects;
40
- if (projects?.[normalizedPath]?.trust_level === 'trusted')
443
+ const existingTrust = projects?.[normalizedPath]?.trust_level;
444
+ if (existingTrust === 'trusted')
445
+ return;
446
+ if (existingTrust !== undefined) {
447
+ // An explicit non-trusted value is a human decision — on a local
448
+ // runtime_auth daemon this file IS the operator's own ~/.codex config,
449
+ // and a workspace they deliberately marked untrusted must never be
450
+ // silently flipped by an agent. Leave it and surface the consequence.
451
+ log?.warn(`Codex config marks ${normalizedPath} as trust_level=${JSON.stringify(existingTrust)}; respecting the explicit decision — project-level developer_instructions will not load for this workspace`);
41
452
  return;
453
+ }
42
454
  // TOML basic-string keys require backslash and double-quote escaping.
43
455
  const escapedPath = normalizedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
44
456
  const sectionHeader = `[projects."${escapedPath}"]`;
@@ -68,7 +480,7 @@ export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
68
480
  content.substring(0, afterHeader) + trustLine + '\n' + content.substring(afterHeader);
69
481
  }
70
482
  }
71
- fs.writeFileSync(configPath, content, 'utf8');
483
+ writeConfigAtomic(configPath, content);
72
484
  }
73
485
  else if (projects?.[normalizedPath] !== undefined) {
74
486
  // smol-toml found the section but indexOf missed it — the header
@@ -119,6 +531,9 @@ export function isParallProxyMode(env = process.env) {
119
531
  * Provider-managed: overwritten on every boot (env vars are the SSOT).
120
532
  */
121
533
  export function ensureParallProvider(codexHome, apiUrl, log) {
534
+ withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
535
+ }
536
+ function ensureParallProviderLocked(codexHome, apiUrl) {
122
537
  const configPath = path.join(codexHome, 'config.toml');
123
538
  const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
124
539
  try {
@@ -158,7 +573,7 @@ export function ensureParallProvider(codexHome, apiUrl, log) {
158
573
  content = content.trimEnd() + '\n\n' + providerBlock + '\n';
159
574
  }
160
575
  fs.mkdirSync(codexHome, { recursive: true });
161
- fs.writeFileSync(configPath, content, 'utf8');
576
+ writeConfigAtomic(configPath, content);
162
577
  }
163
578
  catch (err) {
164
579
  throw new Error(`failed to write Parall provider config: ${String(err)}`);
@@ -170,21 +585,42 @@ function findSectionEnd(content, fromIndex) {
170
585
  const nextHeader = content.indexOf('\n[', fromIndex);
171
586
  return nextHeader === -1 ? content.length : nextHeader;
172
587
  }
173
- export function ensureCodexWorkspace(workspaceDir, log, agentIdentity) {
174
- const systemPrompt = [
588
+ // writeCodexSystemPrompt (re)writes the standing platform prompt surfaces:
589
+ // .parall/system-prompt.md (human-inspectable copy) and the workspace
590
+ // .codex/config.toml developer_instructions (what the CLI actually loads).
591
+ // capabilityFragments are platform-derived capability declarations
592
+ // (agents.capabilities[].fragment) — placed after the platform reference
593
+ // guide, before skill references; empty/absent = no capability section.
594
+ // Split out from ensureCodexWorkspace so the bridge can rewrite on
595
+ // platform-config heat-update; the app-server reads workspace config at
596
+ // process start, so the caller pairs a CHANGED fragment set with
597
+ // adapter.requestProcessRestart(). Throws on failure BY DESIGN — at boot the
598
+ // prompt is mandatory, so a write failure must fail startup loudly; the
599
+ // refresh hot path wraps this in try/catch and retries next refresh.
600
+ export function writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments) {
601
+ const parts = [
175
602
  buildIdentity(agentIdentity),
176
603
  BRIDGE_WORKSPACE_INSTRUCTIONS,
177
604
  PRLL_BEHAVIOR,
178
605
  PRLL_REFERENCE_GUIDE,
179
- buildSkillReferences(workspaceDir),
180
- ].join('\n\n');
181
- fs.mkdirSync(workspaceDir, { recursive: true });
606
+ ];
607
+ if (capabilityFragments && capabilityFragments.length > 0) {
608
+ parts.push(capabilityFragments.join('\n\n'));
609
+ }
610
+ parts.push(buildSkillReferences(workspaceDir));
611
+ const systemPrompt = parts.join('\n\n');
182
612
  const parallDir = path.join(workspaceDir, '.parall');
183
613
  fs.mkdirSync(parallDir, { recursive: true });
184
614
  fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), systemPrompt, 'utf8');
185
- writeSkillFiles(path.join(parallDir, 'skills'));
186
615
  const codexConfigDir = path.join(workspaceDir, '.codex');
187
616
  fs.mkdirSync(codexConfigDir, { recursive: true });
188
617
  const toml = `developer_instructions = """\n${systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
189
618
  fs.writeFileSync(path.join(codexConfigDir, 'config.toml'), toml, 'utf8');
190
619
  }
620
+ export function ensureCodexWorkspace(workspaceDir, log, agentIdentity, capabilityFragments) {
621
+ fs.mkdirSync(workspaceDir, { recursive: true });
622
+ // Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
623
+ // without the platform prompt. The refresh path tolerates failure.
624
+ writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
625
+ writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
626
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
4
4
  "description": "Codex CLI bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "smol-toml": "^1.6.1",
29
- "@parall/agent-core": "1.42.0",
30
- "@parall/cli": "1.42.0",
31
- "@parall/sdk": "1.42.0"
29
+ "@parall/agent-core": "1.43.0",
30
+ "@parall/cli": "1.43.0",
31
+ "@parall/sdk": "1.43.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^22.0.0",
package/src/dispatch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
4
5
  import {
5
6
  appendPreparedLocalAttachmentRefs,
6
7
  ensureLocalAttachmentGitExclude,
@@ -37,6 +38,14 @@ type CodexAppServerAdapterOptions = Pick<
37
38
  contextFilePath?: string;
38
39
  contextDirPath?: string;
39
40
  useParallProvider?: boolean;
41
+ /**
42
+ * Channel-capability shim directory (agent-core's capabilityBinDir),
43
+ * prepended to the subprocess PATH unconditionally. The DIRECTORY is
44
+ * constant for the bridge's lifetime; its CONTENT tracks capability
45
+ * grants, so a grant or revocation reaches even a long-lived app-server
46
+ * on its next shell command — no respawn needed.
47
+ */
48
+ capabilityBinDir?: string;
40
49
  };
41
50
 
42
51
  type TurnEventEnvelope =
@@ -174,6 +183,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
174
183
  );
175
184
  }
176
185
 
186
+ await this.applyPendingRestart(context.log);
177
187
  await this.ensureStarted(context.log);
178
188
  // After ensureStarted resolves the subprocess could still die before we
179
189
  // capture the client (handleSubprocessClose nulls this.client). Yield a
@@ -453,6 +463,28 @@ export class CodexAppServerAdapter implements DispatchAdapter {
453
463
  return null;
454
464
  }
455
465
 
466
+ /**
467
+ * Lazily restart the app-server before the NEXT turn: the workspace
468
+ * config.toml (developer_instructions, carrying capability fragments) is
469
+ * loaded at process start, so a changed fragment set needs a respawn to
470
+ * reach the prompt. Deferred to the next dispatch with no active turns —
471
+ * never kills an in-flight turn; thread state survives via thread/resume.
472
+ */
473
+ requestProcessRestart(): void {
474
+ this.restartRequested = true;
475
+ }
476
+
477
+ private restartRequested = false;
478
+
479
+ private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
480
+ if (!this.restartRequested || this.activeTurns.size > 0) return;
481
+ this.restartRequested = false;
482
+ (log ?? this.opts.log)?.info?.(
483
+ 'restarting codex app-server to pick up updated developer_instructions',
484
+ );
485
+ await this.stop();
486
+ }
487
+
456
488
  async stop() {
457
489
  this.stopping = true;
458
490
  const proc = this.proc;
@@ -462,6 +494,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
462
494
  this.initialized = false;
463
495
  this.activeTurnIds.clear();
464
496
  this.pendingInjections.clear();
497
+ // Resume markers are process-local facts. Clear them HERE, synchronously:
498
+ // the old child's close callback sees the replaced refs and skips its
499
+ // cleanup as stale, so a marker surviving stop() would make the next
500
+ // dispatch treat the persisted thread as already resumed in the NEW
501
+ // process and lose conversation continuity when turn/start rejects.
502
+ this.resumedThreadIds.clear();
465
503
  if (client) client.dispose(new Error('adapter stopped'));
466
504
  if (proc && proc.exitCode === null && proc.signalCode === null) {
467
505
  if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
@@ -515,6 +553,22 @@ export class CodexAppServerAdapter implements DispatchAdapter {
515
553
  FORCE_COLOR: '0',
516
554
  NO_COLOR: '1',
517
555
  };
556
+ // Channel-capability shims resolve ahead of any globally-installed CLI of
557
+ // the same name (the shim owns auth; the real binary is found further
558
+ // down the PATH by the shim itself). On Windows env keys are
559
+ // case-insensitive and the host key is usually `Path`; after the
560
+ // plain-object spread a literal `PATH` write would SPLIT the key
561
+ // (undefined which wins in the child), so reuse the parent's casing. On
562
+ // POSIX keys are case-SENSITIVE — match `PATH` exactly.
563
+ if (this.opts.capabilityBinDir) {
564
+ const pathKey = IS_WIN32
565
+ ? (Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH')
566
+ : 'PATH';
567
+ const existing = env[pathKey];
568
+ env[pathKey] = existing
569
+ ? `${this.opts.capabilityBinDir}${path.delimiter}${existing}`
570
+ : this.opts.capabilityBinDir;
571
+ }
518
572
  if (this.opts.contextFilePath) {
519
573
  env.PRLL_CONTEXT_FILE = this.opts.contextFilePath;
520
574
  }