@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
@@ -0,0 +1,26 @@
1
+ import type { RuntimeEvent } from '@parall/agent-core';
2
+ import { EventMapper } from './event-mapping.js';
3
+ export type TurnEventEnvelope = {
4
+ kind: 'runtime';
5
+ event: RuntimeEvent;
6
+ } | {
7
+ kind: 'turn_end';
8
+ threadId?: string;
9
+ } | {
10
+ kind: 'error';
11
+ message: string;
12
+ };
13
+ /** Per-turn buffered sink backed by an unbounded promise queue. */
14
+ export declare class TurnSink {
15
+ readonly mapper: EventMapper;
16
+ private readonly queue;
17
+ private resolver;
18
+ private closed;
19
+ push(envelope: TurnEventEnvelope): void;
20
+ next(): Promise<TurnEventEnvelope>;
21
+ close(): void;
22
+ /** True once close() ran — the sink's terminal state (queued envelopes may
23
+ * still drain via next()). */
24
+ get isClosed(): boolean;
25
+ }
26
+ //# sourceMappingURL=turn-sink.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-sink.d.ts","sourceRoot":"","sources":["../src/turn-sink.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC,mEAAmE;AACnE,qBAAa,QAAQ;IACnB,QAAQ,CAAC,MAAM,cAAqB;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2B;IACjD,OAAO,CAAC,QAAQ,CAAqD;IACrE,OAAO,CAAC,MAAM,CAAS;IAEvB,IAAI,CAAC,QAAQ,EAAE,iBAAiB;IAWhC,IAAI,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAelC,KAAK;IAOL;kCAC8B;IAC9B,IAAI,QAAQ,IAAI,OAAO,CAEtB;CACF"}
@@ -0,0 +1,45 @@
1
+ import { EventMapper } from './event-mapping.js';
2
+ /** Per-turn buffered sink backed by an unbounded promise queue. */
3
+ export class TurnSink {
4
+ mapper = new EventMapper();
5
+ queue = [];
6
+ resolver = null;
7
+ closed = false;
8
+ push(envelope) {
9
+ if (this.closed)
10
+ return;
11
+ if (this.resolver) {
12
+ const r = this.resolver;
13
+ this.resolver = null;
14
+ r(envelope);
15
+ return;
16
+ }
17
+ this.queue.push(envelope);
18
+ }
19
+ next() {
20
+ // Drain any queued envelopes first, even after close(). Otherwise a final
21
+ // error envelope enqueued right before close() (e.g. by
22
+ // handleSubprocessClose) is silently dropped because the consumer would
23
+ // see turn_end before it.
24
+ const pending = this.queue.shift();
25
+ if (pending)
26
+ return Promise.resolve(pending);
27
+ if (this.closed) {
28
+ return Promise.resolve({ kind: 'turn_end' });
29
+ }
30
+ return new Promise((resolve) => {
31
+ this.resolver = resolve;
32
+ });
33
+ }
34
+ close() {
35
+ this.closed = true;
36
+ const r = this.resolver;
37
+ this.resolver = null;
38
+ r?.({ kind: 'turn_end' });
39
+ }
40
+ /** True once close() ran — the sink's terminal state (queued envelopes may
41
+ * still drain via next()). */
42
+ get isClosed() {
43
+ return this.closed;
44
+ }
45
+ }
@@ -16,10 +16,10 @@ export declare const CONFIG_LOCK_TIMINGS: {
16
16
  };
17
17
  /**
18
18
  * Advisory cross-process lock serializing `<codexHome>/config.toml`
19
- * read-modify-write. On a local daemon machine every runtime_auth codex
20
- * bridge child shares the operator's CODEX_HOME, and the daemon starts them
21
- * together — without a lock, a whole-file rewrite working from a stale read
22
- * can erase a trust entry another agent appended in between.
19
+ * read-modify-write. Several bridge children can share one CODEX_HOME (an
20
+ * operator-set PRLL_CODEX_HOME reaches every child on the machine), and the
21
+ * daemon starts them together — without a lock, concurrent whole-file
22
+ * rewrites working from stale reads lose updates.
23
23
  *
24
24
  * Queue design (why not wx-create + steal): any scheme that renames or
25
25
  * unlinks the SHARED lock path can, between its staleness check and the
@@ -62,11 +62,12 @@ export declare const CONFIG_LOCK_TIMINGS: {
62
62
  * eviction and the process re-enqueues. On `waitMs` timeout
63
63
  * the mutation proceeds without the lock (warn) — blocking would wedge
64
64
  * bridge startup. The lock coordinates bridge processes only; codex itself
65
- * does not observe it, which is why bridge writes to a shared config are
66
- * additionally kept rare (the trust write is a no-op after the first boot
67
- * per workspace, and runtime_auth agents never write the provider block)
68
- * and whole-file writes are atomic (temp + rename) so codex never reads a
69
- * truncated file.
65
+ * does not observe it, which is why bridge writes to the global config are
66
+ * additionally kept rare the only remaining mutation is the parall
67
+ * provider block (runtime_auth agents, which share the operator's ~/.codex,
68
+ * write nothing at all: platform instructions ride the app-server
69
+ * `developerInstructions` param) — and whole-file writes are atomic
70
+ * (temp + rename) so codex never reads a truncated file.
70
71
  *
71
72
  * Exported for tests.
72
73
  */
@@ -84,19 +85,6 @@ export declare const CONFIG_LOCK_TEST_HOOKS: {
84
85
  beforeTicketPublish?: () => void;
85
86
  beforeTicketEntry?: () => void;
86
87
  };
87
- /**
88
- * Ensure the workspace directory is marked as trusted in the global Codex
89
- * config so that project-level `developer_instructions` are loaded at
90
- * app-server startup.
91
- *
92
- * Uses smol-toml for structured reads (avoids substring false-positives),
93
- * but writes via raw text manipulation (preserves comments and formatting).
94
- * Failures are swallowed (warn-only) so a trust write issue never blocks
95
- * the bridge from starting.
96
- */
97
- export declare function ensureWorkspaceTrusted(codexHome: string, workspaceDir: string, log?: {
98
- warn: (msg: string) => void;
99
- }): void;
100
88
  /**
101
89
  * Returns true when the agent is using the Parall LLM proxy (llm_source=parall)
102
90
  * rather than a BYO custom provider or runtime_auth. Detected by checking
@@ -117,11 +105,21 @@ export declare function isParallProxyMode(env?: NodeJS.ProcessEnv): boolean;
117
105
  *
118
106
  * Provider-managed: overwritten on every boot (env vars are the SSOT).
119
107
  */
108
+ /**
109
+ * Escape a value as a TOML basic (double-quoted) string. Needed for Windows
110
+ * paths (backslashes) in the auth command; harmless hardening everywhere else.
111
+ */
112
+ export declare function tomlBasicString(value: string): string;
113
+ /** Injectable auth-command inputs — production callers pass nothing. */
114
+ export interface ParallProviderAuthOptions {
115
+ platform?: NodeJS.Platform;
116
+ nodeBin?: string;
117
+ }
120
118
  export declare function ensureParallProvider(codexHome: string, apiUrl: string, log?: {
121
119
  warn: (msg: string) => void;
122
- }): void;
123
- export declare function writeCodexSystemPrompt(workspaceDir: string, agentIdentity?: AgentIdentity, capabilityFragments?: string[]): void;
120
+ }, opts?: ParallProviderAuthOptions): void;
121
+ export declare function writeCodexSystemPrompt(workspaceDir: string, agentIdentity?: AgentIdentity, capabilityFragments?: string[]): string;
124
122
  export declare function ensureCodexWorkspace(workspaceDir: string, log?: {
125
123
  warn: (msg: string) => void;
126
- }, agentIdentity?: AgentIdentity, capabilityFragments?: string[]): void;
124
+ }, agentIdentity?: AgentIdentity, capabilityFragments?: string[]): string;
127
125
  //# sourceMappingURL=workspace.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;CAI/B,CAAC;AAUF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GAAG,SAAS,EAChD,EAAE,EAAE,MAAM,IAAI,GACb,IAAI,CAkJN;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE;IACnC,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;IAClC,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;IACjC,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAuJP;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CAEN;AAqGD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAK/E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GACpC,IAAI,CAEN;AAoED,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,aAAa,CAAC,EAAE,aAAa,EAC7B,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAC7B,IAAI,CAqBN;AAED,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,aAAa,CAAC,EAAE,aAAa,EAC7B,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAC7B,IAAI,CAQN"}
1
+ {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAIxD;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;CAI/B,CAAC;AAUF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,GAAG,SAAS,EAChD,EAAE,EAAE,MAAM,IAAI,GACb,IAAI,CAkJN;AAED;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE;IACnC,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;IAClC,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;IACjC,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAuJP;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAK/E;AAED;;;;;;;;;;;;;GAaG;AACH;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAerD;AAED,wEAAwE;AACxE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA0BD,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,IAAI,CAAC,EAAE,yBAAyB,GAC/B,IAAI,CAEN;AAiFD,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,aAAa,CAAC,EAAE,aAAa,EAC7B,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAC7B,MAAM,CAwBR;AAED,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,GAAG,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EACrC,aAAa,CAAC,EAAE,aAAa,EAC7B,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAC7B,MAAM,CA+BR"}
package/dist/workspace.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { parse as parseToml } from 'smol-toml';
4
3
  import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, buildIdentity, buildSkillReferences, writeSkillFiles, } from '@parall/agent-core';
4
+ import { runLegacyWorkspaceConfigMigration } from './legacy-workspace-config-migration.js';
5
5
  /**
6
6
  * Config-lock timings, exported for tests.
7
7
  *
@@ -25,10 +25,10 @@ function sleepSync(ms) {
25
25
  }
26
26
  /**
27
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.
28
+ * read-modify-write. Several bridge children can share one CODEX_HOME (an
29
+ * operator-set PRLL_CODEX_HOME reaches every child on the machine), and the
30
+ * daemon starts them together — without a lock, concurrent whole-file
31
+ * rewrites working from stale reads lose updates.
32
32
  *
33
33
  * Queue design (why not wx-create + steal): any scheme that renames or
34
34
  * unlinks the SHARED lock path can, between its staleness check and the
@@ -71,11 +71,12 @@ function sleepSync(ms) {
71
71
  * eviction and the process re-enqueues. On `waitMs` timeout
72
72
  * the mutation proceeds without the lock (warn) — blocking would wedge
73
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.
74
+ * does not observe it, which is why bridge writes to the global config are
75
+ * additionally kept rare the only remaining mutation is the parall
76
+ * provider block (runtime_auth agents, which share the operator's ~/.codex,
77
+ * write nothing at all: platform instructions ride the app-server
78
+ * `developerInstructions` param) — and whole-file writes are atomic
79
+ * (temp + rename) so codex never reads a truncated file.
79
80
  *
80
81
  * Exported for tests.
81
82
  */
@@ -314,9 +315,9 @@ function bakeryEnqueue(queueDir) {
314
315
  /**
315
316
  * Whole-file config writes go through temp + rename so a concurrent reader
316
317
  * (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.
318
+ * sees a truncated file. This is now the only way the bridge writes the global
319
+ * config: the append-based trust path it used to share this file with is gone
320
+ * with `ensureWorkspaceTrusted`, and the provider block is a whole-file rewrite.
320
321
  *
321
322
  * The rename targets the file's REAL path: `config.toml` managed by a
322
323
  * dotfiles setup is often a symlink, and renaming onto the link path would
@@ -402,108 +403,6 @@ function resolveWriteTarget(filePath) {
402
403
  }
403
404
  throw new Error(`symlink chain deeper than 40 while resolving ${filePath}`);
404
405
  }
405
- /**
406
- * Ensure the workspace directory is marked as trusted in the global Codex
407
- * config so that project-level `developer_instructions` are loaded at
408
- * app-server startup.
409
- *
410
- * Uses smol-toml for structured reads (avoids substring false-positives),
411
- * but writes via raw text manipulation (preserves comments and formatting).
412
- * Failures are swallowed (warn-only) so a trust write issue never blocks
413
- * the bridge from starting.
414
- */
415
- export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
416
- withConfigLock(codexHome, log, () => ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log));
417
- }
418
- function ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log) {
419
- const configPath = path.join(codexHome, 'config.toml');
420
- const normalizedPath = path.resolve(workspaceDir);
421
- try {
422
- let content = '';
423
- try {
424
- content = fs.readFileSync(configPath, 'utf8');
425
- }
426
- catch (err) {
427
- if (err.code !== 'ENOENT')
428
- throw err;
429
- }
430
- // Structured read — know exactly what state we're in.
431
- let parsed;
432
- if (content) {
433
- try {
434
- parsed = parseToml(content);
435
- }
436
- catch {
437
- // File is already broken TOML — don't make it worse.
438
- log?.warn(`Codex config.toml is not valid TOML; skipping trust write for ${normalizedPath}`);
439
- return;
440
- }
441
- }
442
- const projects = parsed?.projects;
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`);
452
- return;
453
- }
454
- // TOML basic-string keys require backslash and double-quote escaping.
455
- const escapedPath = normalizedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
456
- const sectionHeader = `[projects."${escapedPath}"]`;
457
- const trustLine = 'trust_level = "trusted"';
458
- const headerIdx = content.indexOf(sectionHeader);
459
- if (headerIdx !== -1) {
460
- // Section exists but trust_level isn't "trusted". Scan all lines
461
- // within the section (up to the next `[` header or EOF) for an
462
- // existing trust_level key — it may not be the first line after
463
- // the header if the user added comments or other keys.
464
- const headerLineEnd = content.indexOf('\n', headerIdx);
465
- if (headerLineEnd === -1) {
466
- content = `${content}\n${trustLine}\n`;
467
- }
468
- else {
469
- const afterHeader = headerLineEnd + 1;
470
- const sectionEnd = findSectionEnd(content, afterHeader);
471
- const sectionBody = content.substring(afterHeader, sectionEnd);
472
- const trustMatch = sectionBody.match(/^[ \t]*trust_level[ \t]*=.*$/m);
473
- if (trustMatch) {
474
- const matchStart = afterHeader + trustMatch.index;
475
- const matchEnd = matchStart + trustMatch[0].length;
476
- content = content.substring(0, matchStart) + trustLine + content.substring(matchEnd);
477
- }
478
- else {
479
- content =
480
- content.substring(0, afterHeader) + trustLine + '\n' + content.substring(afterHeader);
481
- }
482
- }
483
- writeConfigAtomic(configPath, content);
484
- }
485
- else if (projects?.[normalizedPath] !== undefined) {
486
- // smol-toml found the section but indexOf missed it — the header
487
- // uses non-canonical TOML formatting. Appending would create a
488
- // duplicate table. Skip rather than corrupt the file.
489
- log?.warn(`Codex config.toml has non-canonical header for ${normalizedPath}; skipping trust write`);
490
- }
491
- else if (projects && !content.includes('[projects.')) {
492
- // `projects` exists in parsed output but no `[projects.` table
493
- // headers in the raw text — it's an inline table. Appending a
494
- // standard table header would produce invalid TOML.
495
- log?.warn(`Codex config.toml uses inline table for projects; skipping trust write for ${normalizedPath}`);
496
- }
497
- else {
498
- // Section doesn't exist — append.
499
- fs.mkdirSync(codexHome, { recursive: true });
500
- fs.appendFileSync(configPath, `\n${sectionHeader}\n${trustLine}\n`, 'utf8');
501
- }
502
- }
503
- catch (err) {
504
- log?.warn(`failed to write Codex project trust for ${normalizedPath}: ${String(err)}`);
505
- }
506
- }
507
406
  /**
508
407
  * Returns true when the agent is using the Parall LLM proxy (llm_source=parall)
509
408
  * rather than a BYO custom provider or runtime_auth. Detected by checking
@@ -530,10 +429,61 @@ export function isParallProxyMode(env = process.env) {
530
429
  *
531
430
  * Provider-managed: overwritten on every boot (env vars are the SSOT).
532
431
  */
533
- export function ensureParallProvider(codexHome, apiUrl, log) {
534
- withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
432
+ /**
433
+ * Escape a value as a TOML basic (double-quoted) string. Needed for Windows
434
+ * paths (backslashes) in the auth command; harmless hardening everywhere else.
435
+ */
436
+ export function tomlBasicString(value) {
437
+ let out = '';
438
+ for (const ch of value) {
439
+ const code = ch.codePointAt(0) ?? 0;
440
+ if (ch === '\\')
441
+ out += '\\\\';
442
+ else if (ch === '"')
443
+ out += '\\"';
444
+ else if (ch === '\b')
445
+ out += '\\b';
446
+ else if (ch === '\t')
447
+ out += '\\t';
448
+ else if (ch === '\n')
449
+ out += '\\n';
450
+ else if (ch === '\f')
451
+ out += '\\f';
452
+ else if (ch === '\r')
453
+ out += '\\r';
454
+ else if (code < 0x20 || code === 0x7f)
455
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
456
+ else
457
+ out += ch;
458
+ }
459
+ return `"${out}"`;
460
+ }
461
+ /**
462
+ * The `[model_providers.parall.auth]` body. auth.command is the only path
463
+ * that actually sends the Bearer header on custom providers (see
464
+ * codex-responses-api-proxy.md), so both platforms stay in that shape:
465
+ * - POSIX keeps the empirically validated `printenv OPENAI_API_KEY`,
466
+ * byte-for-byte — do not touch it.
467
+ * - Windows has no printenv; reuse the bridge's own Node runtime
468
+ * (process.execPath — on disk by construction, and the config is
469
+ * provider-managed / rewritten every boot, so a moved Node self-heals).
470
+ * The -e script mirrors printenv exactly: missing var → exit 1, value →
471
+ * stdout with no trailing newline. Keep the script free of `"` and `\`
472
+ * so it stays inert inside a TOML basic string.
473
+ */
474
+ function parallAuthLines(opts) {
475
+ const platform = opts?.platform ?? process.platform;
476
+ if (platform !== 'win32') {
477
+ return ['command = "printenv"', 'args = ["OPENAI_API_KEY"]'];
478
+ }
479
+ const nodeBin = opts?.nodeBin ?? process.execPath;
480
+ const script = 'const v=process.env.OPENAI_API_KEY;if(v===undefined)process.exit(1);process.stdout.write(v)';
481
+ return [`command = ${tomlBasicString(nodeBin)}`, `args = ["-e", ${tomlBasicString(script)}]`];
482
+ }
483
+ export function ensureParallProvider(codexHome, apiUrl, log, opts) {
484
+ withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl, opts));
535
485
  }
536
- function ensureParallProviderLocked(codexHome, apiUrl) {
486
+ function ensureParallProviderLocked(codexHome, apiUrl, opts) {
537
487
  const configPath = path.join(codexHome, 'config.toml');
538
488
  const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
539
489
  try {
@@ -550,14 +500,13 @@ function ensureParallProviderLocked(codexHome, apiUrl) {
550
500
  const providerBlock = [
551
501
  sectionHeader,
552
502
  'name = "Parall Proxy"',
553
- `base_url = "${baseUrl}"`,
503
+ `base_url = ${tomlBasicString(baseUrl)}`,
554
504
  'wire_api = "responses"',
555
505
  'supports_websockets = false',
556
506
  'requires_openai_auth = false',
557
507
  '',
558
508
  authHeader,
559
- 'command = "printenv"',
560
- 'args = ["OPENAI_API_KEY"]',
509
+ ...parallAuthLines(opts),
561
510
  ].join('\n');
562
511
  const headerIdx = content.indexOf(sectionHeader);
563
512
  if (headerIdx !== -1) {
@@ -585,18 +534,27 @@ function findSectionEnd(content, fromIndex) {
585
534
  const nextHeader = content.indexOf('\n[', fromIndex);
586
535
  return nextHeader === -1 ? content.length : nextHeader;
587
536
  }
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).
537
+ /** Human-inspectable copy of the prompt; also the migration's authorship proof. */
538
+ function systemPromptCopyPath(workspaceDir) {
539
+ return path.join(workspaceDir, '.parall', 'system-prompt.md');
540
+ }
541
+ // writeCodexSystemPrompt (re)builds the platform prompt and refreshes its
542
+ // human-inspectable reference copy (.parall/system-prompt.md). Delivery to
543
+ // the CLI is the app-server per-thread `developerInstructions` param — NOT
544
+ // a workspace config file.
591
545
  // capabilityFragments are platform-derived capability declarations
592
546
  // (agents.capabilities[].fragment) — placed after the platform reference
593
547
  // 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.
548
+ // Split out from ensureCodexWorkspace so the bridge can rebuild the prompt on
549
+ // a platform-config heat-update and hand the new value to
550
+ // adapter.updateConfig. Delivery to threads is the adapter's job: baked into
551
+ // new threads at thread/start; converged onto the persisted main thread via
552
+ // the lazy restart's fresh-process thread/resume (canonical configuration)
553
+ // plus an explicit compaction (model-visible context) see
554
+ // src/instructions-refresh.ts for the two-plane semantics.
555
+ // Throws on failure BY DESIGN — at boot the prompt is mandatory, so a write
556
+ // failure must fail startup loudly; the refresh hot path wraps this in
557
+ // try/catch and retries next refresh.
600
558
  export function writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments) {
601
559
  const parts = [
602
560
  buildIdentity(agentIdentity),
@@ -609,18 +567,60 @@ export function writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFr
609
567
  }
610
568
  parts.push(buildSkillReferences(workspaceDir));
611
569
  const systemPrompt = parts.join('\n\n');
612
- const parallDir = path.join(workspaceDir, '.parall');
613
- fs.mkdirSync(parallDir, { recursive: true });
614
- fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), systemPrompt, 'utf8');
615
- const codexConfigDir = path.join(workspaceDir, '.codex');
616
- fs.mkdirSync(codexConfigDir, { recursive: true });
617
- const toml = `developer_instructions = """\n${systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
618
- fs.writeFileSync(path.join(codexConfigDir, 'config.toml'), toml, 'utf8');
570
+ // Reference copy the agent can read as a file. The prompt itself is
571
+ // delivered per-thread via the app-server `developerInstructions` param —
572
+ // deliberately NOT via workspace `.codex/config.toml`, which codex only
573
+ // loads for workspaces marked trusted in the operator's global config:
574
+ // that coupled the platform prompt to codex's interactive workspace-trust
575
+ // concept and forced the bridge to write trust entries into the
576
+ // operator's own ~/.codex on shared homes.
577
+ fs.mkdirSync(path.join(workspaceDir, '.parall'), { recursive: true });
578
+ fs.writeFileSync(systemPromptCopyPath(workspaceDir), systemPrompt, 'utf8');
579
+ return systemPrompt;
619
580
  }
620
581
  export function ensureCodexWorkspace(workspaceDir, log, agentIdentity, capabilityFragments) {
621
582
  fs.mkdirSync(workspaceDir, { recursive: true });
583
+ // The ONE ordering rule this bootstrap has to get right.
584
+ //
585
+ // `.parall/system-prompt.md` is the migration's authorship proof, and
586
+ // writeCodexSystemPrompt below overwrites it. It is therefore legacy-era
587
+ // evidence for exactly one boot, so the migration must take its one-way claim
588
+ // and consume the proof BEFORE that write. The migration refuses to delete
589
+ // anything once the claim exists, so no later boot can judge the legacy config
590
+ // against a prompt this bridge wrote itself. A claim that cannot be persisted
591
+ // throws from here — the boot dies with the evidence intact rather than
592
+ // manufacturing a prompt a later boot would mistake for it.
593
+ //
594
+ // The proof goes in as a THUNK: reading it warns when it exists but cannot be
595
+ // read, and a boot that already lost (or never had) the claim must stay
596
+ // silent. Handing over an evaluated value would fire that warning before the
597
+ // claim is even checked.
598
+ runLegacyWorkspaceConfigMigration(workspaceDir, () => readAuthorshipProof(workspaceDir, log), log);
622
599
  // Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
623
600
  // without the platform prompt. The refresh path tolerates failure.
624
- writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
601
+ const systemPrompt = writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
625
602
  writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
603
+ return systemPrompt;
604
+ }
605
+ /**
606
+ * The previous boot's prompt copy — the migration's authorship proof.
607
+ *
608
+ * ENOENT is the ordinary first boot in a workspace: absent, no warning. Any
609
+ * other error means the proof EXISTS but could not be read, which is a
610
+ * different fact and must not be reported as "never bootstrapped here" — the
611
+ * migration would otherwise preserve a genuinely stale config while telling the
612
+ * operator the wrong reason. Both map to "don't delete"; only the diagnostic
613
+ * differs.
614
+ */
615
+ function readAuthorshipProof(workspaceDir, log) {
616
+ const proofPath = systemPromptCopyPath(workspaceDir);
617
+ try {
618
+ return { kind: 'present', prompt: fs.readFileSync(proofPath, 'utf8') };
619
+ }
620
+ catch (err) {
621
+ if (err.code === 'ENOENT')
622
+ return { kind: 'absent' };
623
+ log?.warn(`could not read the authorship proof ${proofPath}: ${String(err)}`);
624
+ return { kind: 'unreadable' };
625
+ }
626
626
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "description": "Codex CLI bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,13 +25,13 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "smol-toml": "^1.6.1",
29
- "@parall/agent-core": "1.44.0",
30
- "@parall/cli": "1.44.0",
31
- "@parall/sdk": "1.44.0"
28
+ "@parall/agent-core": "1.46.0",
29
+ "@parall/cli": "1.46.0",
30
+ "@parall/sdk": "1.46.0"
32
31
  },
33
32
  "devDependencies": {
34
33
  "@types/node": "^22.0.0",
34
+ "smol-toml": "^1.6.1",
35
35
  "typescript": "^5.7.0"
36
36
  },
37
37
  "scripts": {
@@ -0,0 +1,59 @@
1
+ import { execSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import { ensureLocalAttachmentGitExclude } from '@parall/agent-core/internal/attachment-input';
4
+
5
+ /**
6
+ * OS-level plumbing for the `codex app-server` child process: the cwd invariant
7
+ * it requires (a git repo), and the Windows spawn/kill quirks.
8
+ */
9
+
10
+ export const IS_WIN32 = process.platform === 'win32';
11
+
12
+ export function quoteWin32Arg(arg: string): string {
13
+ if (!/[\s"&|^<>()]/.test(arg)) return arg;
14
+ return `"${arg.replace(/"/g, '""')}"`;
15
+ }
16
+
17
+ export function killWin32Tree(pid: number): boolean {
18
+ try {
19
+ execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: 'ignore' });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function ensureGitRepo(workingDirectory: string): void {
27
+ fs.mkdirSync(workingDirectory, { recursive: true });
28
+ // Only `git init` if the workspace isn't already inside any git repo. A
29
+ // bare existsSync(.git) check would miss the common case of a user pointing
30
+ // PRLL_WORKSPACE_DIR at a subdirectory of their existing project,
31
+ // and silently creating a nested repo there would mangle their layout.
32
+ //
33
+ // The exclude write sits inside this try, so in principle its failure would
34
+ // read as "not a repo" and fall through to `git init` in the user's existing
35
+ // repository. It cannot: the helper swallows its own errors. Deliberately left
36
+ // as-is rather than fixed under review — latent, not live, and out of this
37
+ // PR's scope: docs/tech-debt/codex-ensure-git-repo-exclude-coupling.md.
38
+ try {
39
+ execSync('git rev-parse --is-inside-work-tree', { cwd: workingDirectory, stdio: 'pipe' });
40
+ ensureLocalAttachmentGitExclude(workingDirectory);
41
+ return;
42
+ } catch {
43
+ // Not inside a repo — fall through to init.
44
+ }
45
+ const env = {
46
+ ...process.env,
47
+ GIT_AUTHOR_NAME: 'parall-codex-agent',
48
+ GIT_AUTHOR_EMAIL: 'agent@parall.local',
49
+ GIT_COMMITTER_NAME: 'parall-codex-agent',
50
+ GIT_COMMITTER_EMAIL: 'agent@parall.local',
51
+ };
52
+ try {
53
+ execSync('git init', { cwd: workingDirectory, stdio: 'pipe', env });
54
+ execSync('git commit --allow-empty -m init', { cwd: workingDirectory, stdio: 'pipe', env });
55
+ ensureLocalAttachmentGitExclude(workingDirectory);
56
+ } catch {
57
+ // Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
58
+ }
59
+ }
@@ -0,0 +1,46 @@
1
+ import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
2
+
3
+ /**
4
+ * Wire shapes of the `codex app-server` JSON-RPC payloads: what we send as turn
5
+ * input, and how we read ids back out of responses and notifications. The
6
+ * protocol has shifted between CLI versions, so the tolerant field probing lives
7
+ * here rather than being spread through the adapter.
8
+ */
9
+
10
+ export type CodexTurnInput = { type: 'text'; text: string } | { type: 'localImage'; path: string };
11
+
12
+ export function buildTurnInput(body: string, images: PreparedLocalImage[]): CodexTurnInput[] {
13
+ return [
14
+ { type: 'text', text: body },
15
+ ...images.map((image) => ({ type: 'localImage' as const, path: image.localPath })),
16
+ ];
17
+ }
18
+
19
+ export function extractThreadId(result: unknown): string | undefined {
20
+ if (!result || typeof result !== 'object') return undefined;
21
+ const r = result as Record<string, unknown>;
22
+ if (typeof r.threadId === 'string') return r.threadId;
23
+ const thread = r.thread as Record<string, unknown> | undefined;
24
+ if (thread && typeof thread.id === 'string') return thread.id;
25
+ return undefined;
26
+ }
27
+
28
+ export function extractTurnId(result: unknown): string | undefined {
29
+ if (!result || typeof result !== 'object') return undefined;
30
+ const r = result as Record<string, unknown>;
31
+ if (typeof r.turnId === 'string') return r.turnId;
32
+ const turn = r.turn as Record<string, unknown> | undefined;
33
+ if (turn && typeof turn.id === 'string') return turn.id;
34
+ return undefined;
35
+ }
36
+
37
+ export function extractThreadIdFromNotification(params: unknown): string | undefined {
38
+ if (!params || typeof params !== 'object') return undefined;
39
+ const p = params as Record<string, unknown>;
40
+ if (typeof p.threadId === 'string') return p.threadId;
41
+ const thread = p.thread as Record<string, unknown> | undefined;
42
+ if (thread && typeof thread.id === 'string') return thread.id;
43
+ const meta = (p._meta ?? p.meta) as Record<string, unknown> | undefined;
44
+ if (meta && typeof meta.threadId === 'string') return meta.threadId;
45
+ return undefined;
46
+ }