@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/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import * as os from 'node:os';
3
- import { ParallAgentGateway, capabilityBinDir, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, initAgentTelemetry, } from '@parall/agent-core';
3
+ import { ParallAgentGateway, capabilityBinDir, configureHttpKeepAlive, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, initAgentTelemetry, } from '@parall/agent-core';
4
4
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
5
5
  import { buildCodexRuntimeKey, contextFilePathForSession, dispatchContextDirPath, resolveCodexAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from './config.js';
6
6
  import { CodexAppServerAdapter } from './dispatch.js';
7
7
  import { CodexSessionManager } from './session-manager.js';
8
- import { ensureCodexWorkspace, ensureParallProvider, ensureWorkspaceTrusted, isParallProxyMode, writeCodexSystemPrompt, } from './workspace.js';
8
+ import { ensureCodexWorkspace, ensureParallProvider, isParallProxyMode, writeCodexSystemPrompt, } from './workspace.js';
9
9
  const log = createLogger('codex-agent');
10
10
  let activeLog = log;
11
11
  async function getAgentMeWithLegacyFallback(client, orgId) {
@@ -38,6 +38,8 @@ function resolveProviderEnv() {
38
38
  }
39
39
  }
40
40
  async function main() {
41
+ // Before any fetch: long-lived HTTP connections for every bridge→api call.
42
+ configureHttpKeepAlive();
41
43
  const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex');
42
44
  activeLog = createOtelLogger('agent', 'codex-agent');
43
45
  try {
@@ -52,7 +54,6 @@ async function main() {
52
54
  const agentUserId = me.id;
53
55
  const agentLog = childLogger(activeLog, agentUserId);
54
56
  activeLog = agentLog;
55
- ensureWorkspaceTrusted(config.codexHome, config.workspaceDir, agentLog);
56
57
  const useParallProvider = isParallProxyMode();
57
58
  if (useParallProvider) {
58
59
  ensureParallProvider(config.codexHome, config.apiUrl, agentLog);
@@ -89,11 +90,14 @@ async function main() {
89
90
  materializeChannelCapabilities(config.stateDir, caps, agentLog);
90
91
  return caps.map((c) => c.fragment);
91
92
  };
92
- // Assemble the workspace AFTER the first config fetch so
93
- // developer_instructions carries the capability declarations from boot.
93
+ // Assemble the workspace AFTER the first config fetch so the platform
94
+ // prompt carries the capability declarations from boot.
94
95
  const bootCapabilityFragments = applyChannelCapabilities();
95
96
  let lastCapabilityFragments = bootCapabilityFragments.join('\n\n');
96
- ensureCodexWorkspace(config.workspaceDir, agentLog, agentIdentity, bootCapabilityFragments);
97
+ // Platform instructions are delivered per-thread via the app-server's
98
+ // developerInstructions param — no workspace config file, no dependence
99
+ // on codex's workspace-trust state, no writes to the operator's config.
100
+ const developerInstructions = ensureCodexWorkspace(config.workspaceDir, agentLog, agentIdentity, bootCapabilityFragments);
97
101
  // Model precedence: operator PIN (override) > env > server FLOOR. The server
98
102
  // now says which it is via model_is_pin (deriveModelIsPin presence-gates the
99
103
  // dual-read: old servers omit it → fall back to model_management). A PIN beats
@@ -120,6 +124,7 @@ async function main() {
120
124
  contextDirPath: dispatchContextDirPath(config.stateDir),
121
125
  useParallProvider,
122
126
  capabilityBinDir: capabilityBinDir(config.stateDir),
127
+ developerInstructions,
123
128
  });
124
129
  // Shared by onConfigUpdate + onSessionReady. /agents/me is only consumed as
125
130
  // deriveModelIsPin's legacy fallback when the server omits model_is_pin
@@ -145,15 +150,26 @@ async function main() {
145
150
  model: resolveRuntimeModel(isPin, updated.model, config.model) ?? null,
146
151
  reasoningEffort: updated.thinkingEffort ?? config.reasoningEffort ?? null,
147
152
  });
148
- // Capability heat-update: re-materialize shims + rewrite the prompt
149
- // surfaces. The write is refresh-tolerant (warn + retry next refresh);
150
- // only a SUCCESSFUL write with a changed fragment set schedules the
151
- // lazy app-server restart (developer_instructions loads at spawn).
153
+ // Capability heat-update: re-materialize shims + rebuild the prompt.
154
+ // The write is refresh-tolerant (warn + retry next refresh); only a
155
+ // SUCCESSFUL rebuild with a changed fragment set schedules the lazy
156
+ // app-server restart.
157
+ //
158
+ // What each half of the refresh actually reaches: the shim dir on PATH
159
+ // makes the granted TOOL work on the agent's next shell command, live,
160
+ // no restart needed. The refreshed PROMPT reaches (a) every thread
161
+ // started from here on (thread/start bakes it in), and (b) the
162
+ // persisted main thread on its next safe dispatch: the restart below
163
+ // makes the next open a fresh-process thread/resume, which applies the
164
+ // new value to the thread's canonical configuration, and the adapter
165
+ // then compacts the thread so the model-visible context is rebuilt from
166
+ // it (two-plane semantics: src/instructions-refresh.ts).
152
167
  const fragments = applyChannelCapabilities();
153
168
  const joinedFragments = fragments.join('\n\n');
154
169
  let promptWritten = true;
155
170
  try {
156
- writeCodexSystemPrompt(config.workspaceDir, agentIdentity, fragments);
171
+ const refreshedPrompt = writeCodexSystemPrompt(config.workspaceDir, agentIdentity, fragments);
172
+ adapter.updateConfig({ developerInstructions: refreshedPrompt });
157
173
  }
158
174
  catch (err) {
159
175
  promptWritten = false;
@@ -0,0 +1,136 @@
1
+ import type { GatewayLogger } from '@parall/agent-core';
2
+ import { type JsonRpcStdioClient } from './jsonrpc-client.js';
3
+ import type { CodexSessionManager } from './session-manager.js';
4
+ /**
5
+ * Convergence of platform `developerInstructions` onto the persisted main
6
+ * thread. codex 0.144.1 keeps the instructions on two planes (verified by
7
+ * source reading of rust-v0.144.1 plus a raw-request probe against the
8
+ * pinned CLI with a mock Responses API):
9
+ *
10
+ * - CANONICAL — the session configuration. A `thread/resume` in a process
11
+ * where the thread is not already running DOES apply the
12
+ * `developerInstructions` param to the reconstructed session (a resume of
13
+ * a still-running thread ignores every override and logs a mismatch).
14
+ * - EFFECTIVE — what the model actually sees: the initial-context developer
15
+ * message living in conversation history. An ordinary post-resume turn
16
+ * does NOT re-emit it (`TurnContextItem` does not carry instructions and
17
+ * the settings-update diff does not cover them), so after a resume with
18
+ * new instructions the model keeps seeing the old text.
19
+ *
20
+ * Compaction (`thread/compact/start`, also the auto-compaction path) is the
21
+ * official convergence point: it rebuilds the initial context from the
22
+ * CANONICAL configuration into the replacement history — after which every
23
+ * turn, restart, resume, and further compaction carries the new instructions,
24
+ * and exactly one instruction block exists (rebuild, not append).
25
+ *
26
+ * So the refresh recipe is: get canonical right (the existing lazy-restart →
27
+ * fresh-process `thread/resume` chain already sends the current instructions),
28
+ * then trigger one explicit compaction when the thread's last-known EFFECTIVE
29
+ * instructions differ. The session state file remembers the sha256 of the
30
+ * effective instructions: recorded when a thread is STARTED (baking makes
31
+ * them effective immediately) and after a compaction completes — never on
32
+ * resume alone, which is precisely the plane it does not touch.
33
+ *
34
+ * Failure posture (at-least-once, never exactly-once): a refresh that did not
35
+ * complete is never recorded, so the next dispatch retries. A cleanly FAILED
36
+ * compaction (turn closed: error, interrupt honored, subprocess died) does
37
+ * not block the current turn — canonical is already current after the
38
+ * resume, so it degrades to "old text until the next retry or organic
39
+ * compaction". The ONE exception is 'stalled': the compaction turn ignored
40
+ * the interrupt past the grace and may still be running, so dispatch() must
41
+ * not race turn/start into the busy thread (a rejection there would look
42
+ * like a stale thread and rotate it) — it keeps the persisted thread,
43
+ * bounces the subprocess, and errors THIS dispatch for ledger redrive onto a
44
+ * clean process. Continuity outranks single-dispatch availability. A CLI
45
+ * without `thread/compact/start` (JSON-RPC -32601) disables further attempts
46
+ * for the subprocess lifetime (a CLI upgrade implies a respawn) and keeps
47
+ * the sha unrecorded so a capable CLI converges later.
48
+ */
49
+ export type NotificationTap = (method: string, params: unknown) => void;
50
+ export interface NotificationTapSource {
51
+ /** Register a listener for every server notification; returns unregister. */
52
+ addNotificationTap(tap: NotificationTap): () => void;
53
+ }
54
+ export declare function sha256Hex(text: string): string;
55
+ export type RefreshOutcome = 'noop' | 'adopted-baseline' | 'refreshed' | 'unsupported' | 'failed' | 'stalled';
56
+ export declare class MainThreadInstructionsRefresher {
57
+ private readonly opts;
58
+ /**
59
+ * Set when this subprocess rejected thread/compact/start with
60
+ * method-not-found (an older CLI). Reset on every spawn via
61
+ * resetForNewSubprocess() — a CLI upgrade implies a respawn, so each
62
+ * subprocess gets exactly one probe.
63
+ */
64
+ private compactUnsupported;
65
+ constructor(opts: {
66
+ sessionManager: Pick<CodexSessionManager, 'getEffectiveInstructionsSha' | 'recordEffectiveInstructionsSha' | 'recordStartedThread'>;
67
+ log?: GatewayLogger;
68
+ compactTimeoutMs?: number;
69
+ /** Test knob for the post-interrupt grace (default 10s). */
70
+ interruptGraceMs?: number;
71
+ });
72
+ /**
73
+ * Instructions each thread was opened WITH in this process — its live
74
+ * CANONICAL configuration (same-tick capture of the thread/start /
75
+ * thread/resume param). Process-local: the adapter clears it whenever the
76
+ * subprocess goes away (clearThreadState), because a canonical fact only
77
+ * describes a thread loaded in the CURRENT app-server.
78
+ */
79
+ private readonly canonicalByThread;
80
+ resetForNewSubprocess(): void;
81
+ /** Canonical facts die with the subprocess that held the threads. */
82
+ clearThreadState(): void;
83
+ /**
84
+ * Thread opened via thread/start: instructions are baked into the initial
85
+ * context, so canonical AND effective converge the moment the start
86
+ * succeeds — no compaction involved. Thread id and effective sha persist in
87
+ * ONE state-file write: a crash between two separate writes would be
88
+ * indistinguishable from a legacy file, and legacy state is deliberately
89
+ * adopted without compaction.
90
+ */
91
+ recordBaked(sessionKey: string, threadId: string, instructions: string | undefined): void;
92
+ /**
93
+ * Thread opened via thread/resume: a fresh-process resume applies the
94
+ * param to the CANONICAL configuration only — the effective plane is
95
+ * reconciled separately, never recorded here.
96
+ */
97
+ recordResumed(threadId: string, instructions: string | undefined): void;
98
+ /** What the thread's live canonical configuration carries, if opened here. */
99
+ canonicalFor(threadId: string): string | undefined;
100
+ /**
101
+ * Converge the EFFECTIVE plane after the thread is open in this process.
102
+ * Compares against the thread's recorded canonical value — the dispatch
103
+ * guard has already bounced the subprocess if the desired instructions
104
+ * changed after the open, so canonical is current by the time this runs.
105
+ */
106
+ reconcileAfterOpen(args: {
107
+ client: JsonRpcStdioClient;
108
+ taps: NotificationTapSource;
109
+ sessionKey: string;
110
+ threadId: string;
111
+ log?: GatewayLogger;
112
+ }): Promise<RefreshOutcome>;
113
+ /**
114
+ * Compaction runs as its own turn on the thread:
115
+ * turn/started → item/started{contextCompaction} →
116
+ * item/completed{contextCompaction} → turn/completed{status}
117
+ * (wire sequence captured against the pinned 0.144.1 CLI). Success = the
118
+ * contextCompaction item completed AND the turn closed; a turn that closes
119
+ * without the item having completed is a failure. Waiting for turn close —
120
+ * not just the item — keeps the next turn/start from racing into a thread
121
+ * that is still finishing the compaction turn.
122
+ *
123
+ * Timeout does NOT simply return: past the budget the compaction turn may
124
+ * still be RUNNING, and handing control back would let dispatch() issue
125
+ * turn/start against a busy thread — a rejection there is treated as a
126
+ * stale thread and would ROTATE the persisted main thread (continuity
127
+ * loss). Instead the watcher interrupts the compaction turn best-effort
128
+ * and holds a short grace for it to close. A compaction that completes
129
+ * during the grace still counts as success (late, but the effective plane
130
+ * DID converge). Residual: a server that has not even emitted
131
+ * turn/started by the deadline leaves nothing to interrupt — pathological,
132
+ * and the grace still absorbs a late-materializing close.
133
+ */
134
+ private watchForCompaction;
135
+ }
136
+ //# sourceMappingURL=instructions-refresh.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instructions-refresh.d.ts","sourceRoot":"","sources":["../src/instructions-refresh.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAExE,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,IAAI,CAAC;CACtD;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAMD,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,kBAAkB,GAClB,WAAW,GACX,aAAa,GACb,QAAQ,GAIR,SAAS,CAAC;AAId,qBAAa,+BAA+B;IAUxC,OAAO,CAAC,QAAQ,CAAC,IAAI;IATvB;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB,CAAS;gBAGhB,IAAI,EAAE;QACrB,cAAc,EAAE,IAAI,CAClB,mBAAmB,EACnB,6BAA6B,GAAG,gCAAgC,GAAG,qBAAqB,CACzF,CAAC;QACF,GAAG,CAAC,EAAE,aAAa,CAAC;QACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,4DAA4D;QAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B;IAGH;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAyC;IAE3E,qBAAqB,IAAI,IAAI;IAI7B,qEAAqE;IACrE,gBAAgB,IAAI,IAAI;IAIxB;;;;;;;OAOG;IACH,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IASzF;;;;OAIG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAIvE,8EAA8E;IAC9E,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIlD;;;;;OAKG;IACG,kBAAkB,CAAC,IAAI,EAAE;QAC7B,MAAM,EAAE,kBAAkB,CAAC;QAC3B,IAAI,EAAE,qBAAqB,CAAC;QAC5B,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,aAAa,CAAC;KACrB,GAAG,OAAO,CAAC,cAAc,CAAC;IA4D3B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,kBAAkB;CAkG3B"}
@@ -0,0 +1,244 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { extractThreadIdFromNotification } from './app-server-protocol.js';
3
+ import { JSON_RPC_METHOD_NOT_FOUND, JsonRpcError, } from './jsonrpc-client.js';
4
+ export function sha256Hex(text) {
5
+ return createHash('sha256').update(text, 'utf8').digest('hex');
6
+ }
7
+ const DEFAULT_COMPACT_TIMEOUT_MS = 120_000;
8
+ /** After an over-budget compaction is interrupted, how long to wait for its turn to close. */
9
+ const COMPACT_INTERRUPT_GRACE_MS = 10_000;
10
+ class CompactionStalledError extends Error {
11
+ }
12
+ export class MainThreadInstructionsRefresher {
13
+ opts;
14
+ /**
15
+ * Set when this subprocess rejected thread/compact/start with
16
+ * method-not-found (an older CLI). Reset on every spawn via
17
+ * resetForNewSubprocess() — a CLI upgrade implies a respawn, so each
18
+ * subprocess gets exactly one probe.
19
+ */
20
+ compactUnsupported = false;
21
+ constructor(opts) {
22
+ this.opts = opts;
23
+ }
24
+ /**
25
+ * Instructions each thread was opened WITH in this process — its live
26
+ * CANONICAL configuration (same-tick capture of the thread/start /
27
+ * thread/resume param). Process-local: the adapter clears it whenever the
28
+ * subprocess goes away (clearThreadState), because a canonical fact only
29
+ * describes a thread loaded in the CURRENT app-server.
30
+ */
31
+ canonicalByThread = new Map();
32
+ resetForNewSubprocess() {
33
+ this.compactUnsupported = false;
34
+ }
35
+ /** Canonical facts die with the subprocess that held the threads. */
36
+ clearThreadState() {
37
+ this.canonicalByThread.clear();
38
+ }
39
+ /**
40
+ * Thread opened via thread/start: instructions are baked into the initial
41
+ * context, so canonical AND effective converge the moment the start
42
+ * succeeds — no compaction involved. Thread id and effective sha persist in
43
+ * ONE state-file write: a crash between two separate writes would be
44
+ * indistinguishable from a legacy file, and legacy state is deliberately
45
+ * adopted without compaction.
46
+ */
47
+ recordBaked(sessionKey, threadId, instructions) {
48
+ this.canonicalByThread.set(threadId, instructions);
49
+ this.opts.sessionManager.recordStartedThread(sessionKey, threadId, instructions ? sha256Hex(instructions) : undefined);
50
+ }
51
+ /**
52
+ * Thread opened via thread/resume: a fresh-process resume applies the
53
+ * param to the CANONICAL configuration only — the effective plane is
54
+ * reconciled separately, never recorded here.
55
+ */
56
+ recordResumed(threadId, instructions) {
57
+ this.canonicalByThread.set(threadId, instructions);
58
+ }
59
+ /** What the thread's live canonical configuration carries, if opened here. */
60
+ canonicalFor(threadId) {
61
+ return this.canonicalByThread.get(threadId);
62
+ }
63
+ /**
64
+ * Converge the EFFECTIVE plane after the thread is open in this process.
65
+ * Compares against the thread's recorded canonical value — the dispatch
66
+ * guard has already bounced the subprocess if the desired instructions
67
+ * changed after the open, so canonical is current by the time this runs.
68
+ */
69
+ async reconcileAfterOpen(args) {
70
+ const { client, taps, sessionKey, threadId } = args;
71
+ const log = args.log ?? this.opts.log;
72
+ const sentInstructions = this.canonicalByThread.get(threadId);
73
+ if (!sentInstructions)
74
+ return 'noop';
75
+ const canonicalSha = sha256Hex(sentInstructions);
76
+ const effectiveSha = this.opts.sessionManager.getEffectiveInstructionsSha(sessionKey);
77
+ if (effectiveSha === canonicalSha)
78
+ return 'noop';
79
+ if (effectiveSha === undefined) {
80
+ // State file predates effective-plane tracking (recordBaked persists
81
+ // thread id + sha in one atomic write, so a crash cannot manufacture
82
+ // this state for a tracked thread). Adopt the current value as the
83
+ // baseline WITHOUT forcing a compaction: rolling this feature out
84
+ // must not compress every existing thread. A staleness inherited from
85
+ // the pre-tracking era converges at the next instructions change or at
86
+ // the next organic compaction (canonical is already current by then).
87
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
88
+ log?.info?.(`adopted current platform instructions as effective baseline for thread ${threadId} (no prior record)`);
89
+ return 'adopted-baseline';
90
+ }
91
+ if (this.compactUnsupported)
92
+ return 'unsupported';
93
+ // The waiter registers its notification tap BEFORE the request goes out:
94
+ // the response and the compaction-turn notifications can arrive in one
95
+ // stdout chunk, and the client's line loop dispatches notifications
96
+ // synchronously — a tap registered only after `await sendRequest` resolves
97
+ // (a queued microtask) would miss every one of them and hang on the
98
+ // timeout.
99
+ const compaction = this.watchForCompaction(client, taps, threadId);
100
+ try {
101
+ await client.sendRequest('thread/compact/start', { threadId });
102
+ await compaction.done;
103
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
104
+ log?.info?.(`platform instructions refreshed on persisted thread ${threadId} (compaction rebuilt initial context from the resumed configuration)`);
105
+ return 'refreshed';
106
+ }
107
+ catch (err) {
108
+ compaction.cancel();
109
+ if (err instanceof CompactionStalledError) {
110
+ log?.warn?.(`platform instructions refresh stalled (${errToString(err)}); bouncing the subprocess before the next turn`);
111
+ return 'stalled';
112
+ }
113
+ if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
114
+ this.compactUnsupported = true;
115
+ log?.warn?.('thread/compact/start not supported by this codex CLI; the persisted thread keeps its previous platform instructions until it is replaced or the CLI is upgraded (tools still refresh live via the capability shim dir)');
116
+ return 'unsupported';
117
+ }
118
+ log?.warn?.(`platform instructions refresh did not complete (will retry next dispatch; the resumed configuration already carries the new value): ${errToString(err)}`);
119
+ return 'failed';
120
+ }
121
+ }
122
+ /**
123
+ * Compaction runs as its own turn on the thread:
124
+ * turn/started → item/started{contextCompaction} →
125
+ * item/completed{contextCompaction} → turn/completed{status}
126
+ * (wire sequence captured against the pinned 0.144.1 CLI). Success = the
127
+ * contextCompaction item completed AND the turn closed; a turn that closes
128
+ * without the item having completed is a failure. Waiting for turn close —
129
+ * not just the item — keeps the next turn/start from racing into a thread
130
+ * that is still finishing the compaction turn.
131
+ *
132
+ * Timeout does NOT simply return: past the budget the compaction turn may
133
+ * still be RUNNING, and handing control back would let dispatch() issue
134
+ * turn/start against a busy thread — a rejection there is treated as a
135
+ * stale thread and would ROTATE the persisted main thread (continuity
136
+ * loss). Instead the watcher interrupts the compaction turn best-effort
137
+ * and holds a short grace for it to close. A compaction that completes
138
+ * during the grace still counts as success (late, but the effective plane
139
+ * DID converge). Residual: a server that has not even emitted
140
+ * turn/started by the deadline leaves nothing to interrupt — pathological,
141
+ * and the grace still absorbs a late-materializing close.
142
+ */
143
+ watchForCompaction(client, taps, threadId) {
144
+ const timeoutMs = this.opts.compactTimeoutMs ?? DEFAULT_COMPACT_TIMEOUT_MS;
145
+ const graceMs = this.opts.interruptGraceMs ?? COMPACT_INTERRUPT_GRACE_MS;
146
+ let cancel = () => { };
147
+ const done = new Promise((resolve, reject) => {
148
+ let itemCompleted = false;
149
+ let compactionTurnId;
150
+ let interrupted = false;
151
+ let settled = false;
152
+ let unregister = () => { };
153
+ let graceTimer;
154
+ const finish = (err) => {
155
+ if (settled)
156
+ return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ if (graceTimer)
160
+ clearTimeout(graceTimer);
161
+ unregister();
162
+ if (err)
163
+ reject(err);
164
+ else
165
+ resolve();
166
+ };
167
+ const timer = setTimeout(() => {
168
+ interrupted = true;
169
+ if (compactionTurnId) {
170
+ // Best-effort and non-lethal: an unanswered interrupt must expire
171
+ // with the grace window, not arm the client's default assume-hung
172
+ // timeout into killing the subprocess minutes later mid-something.
173
+ client
174
+ .sendRequest('turn/interrupt', { threadId, turnId: compactionTurnId }, { timeoutMs: graceMs, lethalTimeout: false })
175
+ .catch(() => { });
176
+ }
177
+ graceTimer = setTimeout(() => finish(new CompactionStalledError(`compaction did not complete within ${timeoutMs}ms (interrupt grace elapsed; the compaction turn may still be running)`)), graceMs);
178
+ }, timeoutMs);
179
+ // Cancellation resolves (never rejects): the caller cancels only when
180
+ // the request itself already failed, and that error is what it reports.
181
+ cancel = () => finish();
182
+ unregister = taps.addNotificationTap((method, params) => {
183
+ const notificationThreadId = extractThreadIdFromNotification(params);
184
+ if (method === 'error') {
185
+ // A thread-less error is a global one — including the adapter's
186
+ // synthetic subprocess-disposal broadcast. No further compaction
187
+ // notifications can arrive after that; waiting out the timeout
188
+ // would stall the dispatch for the full budget on a dead client.
189
+ if (notificationThreadId === undefined || notificationThreadId === threadId) {
190
+ const msg = params?.message;
191
+ finish(new Error(`app-server error during compaction: ${String(msg ?? 'unknown')}`));
192
+ }
193
+ return;
194
+ }
195
+ if (notificationThreadId !== threadId)
196
+ return;
197
+ if (method === 'turn/started') {
198
+ compactionTurnId = turnIdOf(params) ?? compactionTurnId;
199
+ return;
200
+ }
201
+ if (method === 'item/completed' && itemType(params) === 'contextCompaction') {
202
+ itemCompleted = true;
203
+ return;
204
+ }
205
+ if (method === 'turn/completed') {
206
+ const status = turnStatus(params);
207
+ if (itemCompleted && status !== 'failed')
208
+ finish();
209
+ else
210
+ finish(new Error(interrupted
211
+ ? `compaction did not complete within ${timeoutMs}ms (turn closed after interrupt)`
212
+ : `compaction turn ended without completing (status=${status ?? 'unknown'})`));
213
+ }
214
+ });
215
+ });
216
+ // Detached-consumer guard: if the subprocess dies while the
217
+ // thread/compact/start REQUEST is still in flight, the disposal
218
+ // broadcast rejects this waiter before reconcileAfterOpen ever awaits
219
+ // it — and its catch path only cancels, it never consumes `done`. An
220
+ // unconsumed rejection would crash the bridge (Node's default
221
+ // unhandled-rejection behavior) on the exact path that is supposed to
222
+ // degrade and retry. The extra consumer does not affect the success
223
+ // path's own await.
224
+ done.catch(() => { });
225
+ return { done, cancel };
226
+ }
227
+ }
228
+ function itemType(params) {
229
+ const item = params?.item;
230
+ return typeof item?.type === 'string' ? item.type : undefined;
231
+ }
232
+ function turnStatus(params) {
233
+ const turn = params?.turn;
234
+ return typeof turn?.status === 'string' ? turn.status : undefined;
235
+ }
236
+ function turnIdOf(params) {
237
+ const turn = params?.turn;
238
+ return typeof turn?.id === 'string' ? turn.id : undefined;
239
+ }
240
+ function errToString(err) {
241
+ if (err instanceof Error)
242
+ return err.message;
243
+ return String(err);
244
+ }
@@ -22,6 +22,40 @@ export type JsonRpcResponse = {
22
22
  };
23
23
  };
24
24
  export type NotificationHandler = (method: string, params: unknown) => void;
25
+ /**
26
+ * A handled server request's response payload. The wrapper (rather than a
27
+ * bare `unknown`) makes the "undefined = unhandled" sentinel explicit in the
28
+ * type — `unknown | undefined` would collapse and hide the contract.
29
+ *
30
+ * A `result` of `undefined` is normalized to `null` on the wire: JSON.stringify
31
+ * DROPS an undefined member, which would emit a frame carrying neither
32
+ * `result` nor `error` — the app-server may ignore such a frame and keep
33
+ * waiting, parking the turn (the exact failure this handler exists to
34
+ * prevent). `null` is a valid JSON-RPC success result.
35
+ */
36
+ export type ServerRequestAnswer = {
37
+ result: unknown;
38
+ } | undefined;
39
+ /**
40
+ * Answers a server→client request. Return `{ result }` to respond, or
41
+ * `undefined` to have the client reply with a method-not-found error. A
42
+ * request must never go unanswered — the app-server blocks its turn until a
43
+ * response arrives, so a dropped request parks that turn forever.
44
+ */
45
+ export type ServerRequestHandler = (method: string, params: unknown) => ServerRequestAnswer;
46
+ /** JSON-RPC 2.0 spec code for "Method not found". */
47
+ export declare const JSON_RPC_METHOD_NOT_FOUND = -32601;
48
+ /**
49
+ * Rejection error that preserves the JSON-RPC error object's code (and data),
50
+ * so callers can branch on protocol-level conditions — e.g. method-not-found
51
+ * on an older CLI — without matching on server message text, whose wording
52
+ * shifts between codex versions.
53
+ */
54
+ export declare class JsonRpcError extends Error {
55
+ readonly code: number;
56
+ readonly data?: unknown | undefined;
57
+ constructor(code: number, message: string, data?: unknown | undefined);
58
+ }
25
59
  /**
26
60
  * Minimal JSON-RPC 2.0 stdio client used to drive a long-running
27
61
  * `codex app-server --listen stdio://` subprocess. Frames are newline
@@ -38,10 +72,23 @@ export declare class JsonRpcStdioClient {
38
72
  private readonly pending;
39
73
  private buffer;
40
74
  private onNotification;
75
+ private onServerRequest;
41
76
  private disposed;
42
77
  constructor(proc: ChildProcessWithoutNullStreams, requestTimeoutMs?: number, killProcess?: ((proc: ChildProcessWithoutNullStreams) => void) | undefined);
43
78
  setNotificationHandler(handler: NotificationHandler): void;
44
- sendRequest(method: string, params?: unknown): Promise<unknown>;
79
+ setServerRequestHandler(handler: ServerRequestHandler): void;
80
+ /**
81
+ * `options.timeoutMs` overrides the client-wide budget for this request.
82
+ * `options.lethalTimeout: false` makes a timeout reject WITHOUT killing the
83
+ * subprocess — for best-effort side requests (e.g. the compaction watcher's
84
+ * turn/interrupt) where "no answer" must not translate into a delayed
85
+ * subprocess kill landing on an unrelated later dispatch. Default (true)
86
+ * keeps the existing assume-hung semantics.
87
+ */
88
+ sendRequest(method: string, params?: unknown, options?: {
89
+ timeoutMs?: number;
90
+ lethalTimeout?: boolean;
91
+ }): Promise<unknown>;
45
92
  sendNotification(method: string, params?: unknown): void;
46
93
  isDisposed(): boolean;
47
94
  dispose(err: Error): void;
@@ -49,6 +96,7 @@ export declare class JsonRpcStdioClient {
49
96
  private killUnhealthy;
50
97
  private ingest;
51
98
  private handleLine;
99
+ private answerServerRequest;
52
100
  }
53
101
  export {};
54
102
  //# sourceMappingURL=jsonrpc-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"jsonrpc-client.d.ts","sourceRoot":"","sources":["../src/jsonrpc-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AAEzE,KAAK,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjC,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC3D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAW5E;;;;;;;GAOG;AACH,qBAAa,kBAAkB;IAQ3B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAT/B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,QAAQ,CAAS;gBAGN,IAAI,EAAE,8BAA8B,EACpC,gBAAgB,GAAE,MAAmC,EACrD,WAAW,CAAC,GAAE,CAAC,IAAI,EAAE,8BAA8B,KAAK,IAAI,aAAA;IAS/E,sBAAsB,CAAC,OAAO,EAAE,mBAAmB;IAInD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAsB/D,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO;IAMjD,UAAU,IAAI,OAAO;IAIrB,OAAO,CAAC,GAAG,EAAE,KAAK;IAUlB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,aAAa;IAWrB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,UAAU;CAyBnB"}
1
+ {"version":3,"file":"jsonrpc-client.d.ts","sourceRoot":"","sources":["../src/jsonrpc-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AAEzE,KAAK,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjC,MAAM,MAAM,cAAc,GAAG;IAC3B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,KAAK,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,KAAK,CAAC;IACf,EAAE,EAAE,SAAS,CAAC;IACd,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;CAC3D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;AAE5E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAAC;AAElE;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,mBAAmB,CAAC;AAE5F,qDAAqD;AACrD,eAAO,MAAM,yBAAyB,SAAS,CAAC;AAEhD;;;;;GAKG;AACH,qBAAa,YAAa,SAAQ,KAAK;IAEnC,QAAQ,CAAC,IAAI,EAAE,MAAM;IAErB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO;gBAFd,IAAI,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACN,IAAI,CAAC,EAAE,OAAO,YAAA;CAK1B;AAWD;;;;;;;GAOG;AACH,qBAAa,kBAAkB;IAS3B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAV/B,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAiC;IACzD,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,cAAc,CAAoC;IAC1D,OAAO,CAAC,eAAe,CAAqC;IAC5D,OAAO,CAAC,QAAQ,CAAS;gBAGN,IAAI,EAAE,8BAA8B,EACpC,gBAAgB,GAAE,MAAmC,EACrD,WAAW,CAAC,GAAE,CAAC,IAAI,EAAE,8BAA8B,KAAK,IAAI,aAAA;IAS/E,sBAAsB,CAAC,OAAO,EAAE,mBAAmB;IAInD,uBAAuB,CAAC,OAAO,EAAE,oBAAoB;IAIrD;;;;;;;OAOG;IACH,WAAW,CACT,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GACxD,OAAO,CAAC,OAAO,CAAC;IAwBnB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO;IAMjD,UAAU,IAAI,OAAO;IAIrB,OAAO,CAAC,GAAG,EAAE,KAAK;IAUlB,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,aAAa;IAWrB,OAAO,CAAC,MAAM;IAWd,OAAO,CAAC,UAAU;IAiClB,OAAO,CAAC,mBAAmB;CA4B5B"}