agent-relay-server 0.121.5 → 0.122.1

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 (179) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +6 -6
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/adapters/claude-delivery.ts +127 -0
  88. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  89. package/runner/src/adapters/claude-session-probe.ts +225 -0
  90. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  91. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  92. package/runner/src/adapters/claude-transcript.ts +414 -0
  93. package/runner/src/adapters/claude.ts +672 -0
  94. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  95. package/runner/src/adapters/codex-client.ts +279 -0
  96. package/runner/src/adapters/codex.ts +1345 -0
  97. package/runner/src/attachment-cache.ts +189 -0
  98. package/runner/src/busy-reconciler.ts +102 -0
  99. package/runner/src/claim-tracker.ts +140 -0
  100. package/runner/src/claude-prompt-gates.ts +268 -0
  101. package/runner/src/config.ts +1 -4
  102. package/runner/src/context-probe-state.ts +6 -0
  103. package/runner/src/continuation-archive.ts +179 -0
  104. package/runner/src/control-server.ts +639 -0
  105. package/runner/src/index.ts +396 -0
  106. package/runner/src/launch-assembly.ts +508 -0
  107. package/runner/src/liveness.ts +50 -0
  108. package/runner/src/logger.ts +99 -0
  109. package/runner/src/mcp-outbox.ts +120 -0
  110. package/runner/src/message-body-config/config.ts +3 -0
  111. package/runner/src/message-body-config.ts +1 -0
  112. package/runner/src/native-self-resume.ts +202 -0
  113. package/runner/src/outbox.ts +342 -0
  114. package/runner/src/process-meta.ts +9 -0
  115. package/runner/src/profile-home.ts +260 -0
  116. package/runner/src/profile-projection.ts +305 -0
  117. package/runner/src/providers.ts +20 -0
  118. package/runner/src/provisioning.ts +314 -0
  119. package/runner/src/quota.ts +40 -0
  120. package/runner/src/rate-limit.ts +189 -0
  121. package/runner/src/relay-injection-events.ts +102 -0
  122. package/runner/src/relay-instructions.ts +60 -0
  123. package/runner/src/relay-mcp-proxy.ts +383 -0
  124. package/runner/src/relay-mcp.ts +156 -0
  125. package/runner/src/reply-obligation-cache.ts +136 -0
  126. package/runner/src/response-capture-report.ts +63 -0
  127. package/runner/src/runner-core.ts +2409 -0
  128. package/runner/src/runner-helpers.ts +435 -0
  129. package/runner/src/runner-insights.ts +105 -0
  130. package/runner/src/runner.ts +14 -0
  131. package/runner/src/session-destroy.ts +22 -0
  132. package/runner/src/session-insights.ts +118 -0
  133. package/runner/src/session-scratch.ts +271 -0
  134. package/runner/src/template-resolver.ts +29 -0
  135. package/runner/src/version.ts +43 -0
  136. package/src/agent-card-projection.ts +5 -1
  137. package/src/agent-command-activity.ts +39 -0
  138. package/src/automations/reconcile.ts +2 -2
  139. package/src/cli/workspace.ts +3 -1
  140. package/src/config.ts +10 -1
  141. package/src/db/agents.ts +118 -37
  142. package/src/db/message-reads.ts +50 -0
  143. package/src/db/messages.ts +48 -9
  144. package/src/db/migrations.ts +54 -0
  145. package/src/db/provider-quota-burn.ts +96 -67
  146. package/src/db/provider-quotas.ts +29 -7
  147. package/src/db/scheduled-timers.ts +21 -0
  148. package/src/db/schema.ts +1 -0
  149. package/src/db/tasks.ts +33 -3
  150. package/src/db/work-evidence.ts +10 -7
  151. package/src/event-loop-lag.ts +15 -0
  152. package/src/gate-resolver.ts +19 -1
  153. package/src/maintenance/jobs.ts +23 -6
  154. package/src/managed-policy.ts +5 -12
  155. package/src/mcp-plan-tools.ts +13 -1
  156. package/src/project-instructions.ts +36 -2
  157. package/src/quota-advisory.ts +6 -4
  158. package/src/quota-band-transition.ts +23 -14
  159. package/src/routes/agent-sessions.ts +39 -7
  160. package/src/routes/agents.ts +3 -2
  161. package/src/routes/tasks.ts +16 -4
  162. package/src/routes/workspaces.ts +35 -10
  163. package/src/services/plan-graph.ts +27 -9
  164. package/src/services/register-agent.ts +12 -12
  165. package/src/services/send-message.ts +43 -15
  166. package/src/services/spawn-agent.ts +3 -0
  167. package/src/spawn-command.ts +8 -0
  168. package/src/spawn-targets.ts +19 -34
  169. package/src/sse.ts +25 -3
  170. package/src/workspace-merge.ts +25 -5
  171. package/src/workspace-orphans.ts +13 -6
  172. package/public/assets/display-exQDWum3.js +0 -3
  173. package/public/assets/display-exQDWum3.js.map +0 -1
  174. package/public/assets/index-D9OogaB5.js +0 -25
  175. package/public/assets/projects-DdPNPFJI.js +0 -2
  176. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  177. package/public/assets/store-DKTLubBT.js.map +0 -1
  178. package/public/assets/teams-B8f2pGJe.js +0 -2
  179. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -0,0 +1,1345 @@
1
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, resolve } from "node:path";
4
+ import type { ContextState, InteractivePrompt, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
5
+ import { isRecord, stringValue } from "agent-relay-sdk";
6
+ import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
7
+ import { profileAllowsRelayFeature, providerMessageText, relayContext, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
8
+ import { workspaceDepsNoteFromEnv } from "../relay-instructions";
9
+ import { tomlString } from "../relay-mcp";
10
+ import { assembleLaunch, bundledCodexSkillDirs, bundledSkillConfigArgs, materializeLaunchAssembly } from "../launch-assembly";
11
+ import { logger } from "../logger";
12
+ import type { SessionEvent } from "../session-insights";
13
+ import { CodexAgentMessageCapture } from "./codex-agent-message-capture";
14
+ import { computeLivenessSignal, type LivenessInputs } from "../liveness";
15
+
16
+ /** Relay context prepended to a Codex agent's first turn: the standard relay
17
+ * blurb plus, when running in an isolated workspace, the deps caveat (#159). */
18
+ function codexRelayContextBlock(): string {
19
+ return [relayContext(), workspaceDepsNoteFromEnv()].filter(Boolean).join("\n\n");
20
+ }
21
+ import { prepareCodexProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
22
+ import { CodexAppClient, type ClientEvent } from "./codex-client";
23
+
24
+ export { processTreePidsFromTable };
25
+ export const DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 12_000;
26
+
27
+ type PendingCodexApproval = {
28
+ id: string;
29
+ requestId: string | number;
30
+ method: string;
31
+ params: Record<string, unknown>;
32
+ view: InteractivePrompt;
33
+ };
34
+
35
+ type PendingCodexCompact = {
36
+ threadId: string;
37
+ timeout: ReturnType<typeof setTimeout>;
38
+ resolve: () => void;
39
+ reject: (error: Error) => void;
40
+ };
41
+
42
+ const CODEX_COMPACT_TIMEOUT_MS = 10 * 60 * 1000;
43
+
44
+ export class CodexAdapter implements ProviderAdapter {
45
+ readonly provider = "codex";
46
+ private statusCb: (status: ProviderStatusUpdate) => void = () => {};
47
+ private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
48
+ private readonly subagentThreads = new Map<string, { label?: string; role?: string; parentId?: string }>();
49
+ private readonly pendingApprovals = new Map<string, PendingCodexApproval>();
50
+ private pendingCompact?: PendingCodexCompact;
51
+ // Active turn id for the main thread, captured from turn/started so an interrupt
52
+ // can target the in-flight turn. Cleared on turn/completed.
53
+ private activeTurnId?: string;
54
+ // #653: monotonic id for a turn we synthesize from live item activity when a real
55
+ // turn/started never reached us — so a working Codex agent isn't reported `idle`.
56
+ private syntheticTurnSeq = 0;
57
+ private readonly agentMessageCapture = new CodexAgentMessageCapture();
58
+ private readonly itemTextBuffers = new Map<string, string>();
59
+ private readonly itemTextBufferTypes = new Map<string, string>();
60
+ // #183/#184: the normalized session-event log for the current process lifetime, fed
61
+ // from the same completed-item stream that drives the chat mirror. The runner slices
62
+ // this per-segment (since the last compact/clear/restart) via its own cursor, so we
63
+ // accumulate and never clear mid-session; spawn() resets it for a fresh process. Soft
64
+ // cap below keeps a runaway session from growing memory unbounded.
65
+ private sessionEvents: SessionEvent[] = [];
66
+ private static readonly SESSION_EVENTS_CAP = 50_000;
67
+
68
+ onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
69
+ this.statusCb = cb;
70
+ }
71
+
72
+ // §4.1 contract verdict (#746). Codex reference (#724): a Codex provider-turn can wedge
73
+ // while a long-running subprocess child (a `bun test` / build gate) is still alive and
74
+ // making real progress. That subprocess is tracked as a non-`provider-turn` work-kind, so
75
+ // `hasLiveWork` stays true and the turn is never mis-read as stuck; app-server item events
76
+ // also refresh `lastProviderEventAt` → lastProgressAt. The fold is shared; this override
77
+ // documents and owns Codex's progress sources behind the contract.
78
+ liveness(inputs: LivenessInputs): LivenessSignal {
79
+ return computeLivenessSignal(inputs);
80
+ }
81
+
82
+ onSessionEvent(cb: (event: ProviderSessionEvent) => void): void {
83
+ this.sessionEventCb = cb;
84
+ }
85
+
86
+ private resetProcessState(): void {
87
+ this.resetThreadState();
88
+ this.sessionEvents = []; // fresh process -> fresh segment cursor (#184)
89
+ }
90
+
91
+ private resetThreadState(): void {
92
+ this.cancelPendingCompact(new Error("Codex compact canceled by thread reset"));
93
+ this.subagentThreads.clear();
94
+ this.pendingApprovals.clear();
95
+ this.activeTurnId = undefined;
96
+ this.agentMessageCapture.reset();
97
+ this.itemTextBuffers.clear();
98
+ this.itemTextBufferTypes.clear();
99
+ }
100
+
101
+ async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
102
+ const client = process.meta?.client as CodexAppClient | undefined;
103
+ if (!client) throw new Error("Codex App Server client is unavailable");
104
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
105
+ if (!threadId) throw new Error("Codex thread is not ready");
106
+ if (!this.activeTurnId) throw new Error("no active Codex turn to interrupt");
107
+ await client.turnInterrupt(threadId, this.activeTurnId);
108
+ return { method: "turn-interrupt", turnId: this.activeTurnId };
109
+ }
110
+
111
+ async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
112
+ const client = process.meta?.client as CodexAppClient | undefined;
113
+ if (!client?.isConnected()) return "unknown";
114
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
115
+ // #651: when no local activeTurnId, don't short-circuit to idle — query the app-server.
116
+ // turn/started can arrive after processing begins (or be missed entirely); a stale-idle
117
+ // probe would make a busy agent appear idle to the coordinator. Same fallback as #653's
118
+ // ensureMainTurnActive path, but for the pull (probe) direction, not the push (event) one.
119
+ if (!this.activeTurnId) {
120
+ if (!threadId) return "idle";
121
+ try {
122
+ const read = await client.threadRead(threadId, true);
123
+ const thread = isRecord(read.thread) ? read.thread : undefined;
124
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
125
+ const inProgressTurn = turns.find((t) => isRecord(t) && stringValue(t.status) === "inProgress");
126
+ if (inProgressTurn) {
127
+ // Synthesize the active turn (same path as ensureMainTurnActive on item events).
128
+ this.ensureMainTurnActive(stringValue(inProgressTurn.id));
129
+ return "busy";
130
+ }
131
+ const threadStatus = statusType(thread?.status);
132
+ if (threadStatus === "active") return "busy";
133
+ return "idle";
134
+ } catch {
135
+ return "unknown";
136
+ }
137
+ }
138
+ if (!threadId) return "busy";
139
+ try {
140
+ const read = await client.threadRead(threadId, true);
141
+ const thread = isRecord(read.thread) ? read.thread : undefined;
142
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
143
+ const activeTurn = turns.find((turn) => isRecord(turn) && stringValue(turn.id) === this.activeTurnId);
144
+ const turnStatus = isRecord(activeTurn) ? stringValue(activeTurn.status) : undefined;
145
+ if (turnStatus === "inProgress") return "busy";
146
+ if (turnStatus === "completed" || turnStatus === "interrupted" || turnStatus === "failed") {
147
+ this.finishMainTurn();
148
+ return "idle";
149
+ }
150
+ const threadStatus = statusType(thread?.status);
151
+ if (threadStatus === "active") return "busy";
152
+ if (threadStatus === "idle" || threadStatus === "notLoaded" || threadStatus === "systemError") {
153
+ this.finishMainTurn();
154
+ return "idle";
155
+ }
156
+ } catch {
157
+ return "unknown";
158
+ }
159
+ return "busy";
160
+ }
161
+
162
+ // The Codex app-server is headless and has no tmux session, but an unexpected
163
+ // exit should still be restarted with backoff rather than resolved as a final exit.
164
+ supportsUnexpectedExitRestart(): boolean {
165
+ return true;
166
+ }
167
+
168
+ async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
169
+ this.resetProcessState();
170
+ const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
171
+ const appServer = Bun.spawn([args.command, ...args.args], {
172
+ cwd: args.cwd,
173
+ env: args.env,
174
+ stdin: "ignore",
175
+ stdout: "inherit",
176
+ stderr: "inherit",
177
+ });
178
+ const appServerUrl = String(config.appServerUrl || args.env.CODEX_APP_SERVER_URL || "");
179
+ const client = appServerUrl ? new CodexAppClient(appServerUrl, () => {}) : undefined;
180
+ if (client) {
181
+ await connectWithRetry(client);
182
+ await client.initialize().catch(() => undefined);
183
+ }
184
+
185
+ const relaySkillArgs = profileAllowsRelayFeature(config, "skills") ? bundledSkillConfigArgs() : [];
186
+ const defaultArgs = profileUsesHostProviderGlobals(config) ? config.providerConfig.defaultArgs : [];
187
+ const tui = !config.headless && isCodexCliCommand(config.providerConfig.command)
188
+ ? Bun.spawn([config.providerConfig.command, "--remote", appServerUrl, ...relaySkillArgs, ...defaultArgs, ...config.providerArgs, ...codexManagedConfigArgs()], {
189
+ cwd: config.cwd,
190
+ env: args.env,
191
+ stdin: "inherit",
192
+ stdout: "inherit",
193
+ stderr: "inherit",
194
+ })
195
+ : undefined;
196
+
197
+ // app-server and tui exit independently; the first exit defines the agent's
198
+ // fate. Latch so a trailing second exit can't fire a duplicate terminal event.
199
+ let exitReported = false;
200
+ const reportExit = (status: ProviderStatusUpdate): void => {
201
+ if (exitReported) return;
202
+ exitReported = true;
203
+ this.statusCb(status);
204
+ };
205
+ void appServer.exited.then((code) => reportExit(code === 0 ? "offline" : "error"));
206
+ if (tui) void tui.exited.then((code) => reportExit(code === 0 ? "idle" : "error"));
207
+ const process: ManagedProcess = {
208
+ pid: tui?.pid ?? appServer.pid,
209
+ process: appServer,
210
+ meta: {
211
+ client,
212
+ cwd: config.cwd,
213
+ appServer,
214
+ appServerUrl,
215
+ tui,
216
+ config,
217
+ attachCommand: resolveRealCodexCommand(config.providerConfig.command, args.env.PATH),
218
+ },
219
+ };
220
+ process.meta = { ...process.meta, getContext: () => latestCodexContext(process) };
221
+ if (client) client.onEvent((event) => this.handleCodexEvent(event, process));
222
+ return process;
223
+ }
224
+
225
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
226
+ const client = process.meta?.client as CodexAppClient | undefined;
227
+ client?.close();
228
+ await terminateProcessTree(process, opts);
229
+ }
230
+
231
+ async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
232
+ const client = process.meta?.client as CodexAppClient | undefined;
233
+ if (!client) throw new Error("Codex App Server client is unavailable");
234
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
235
+ if (!threadId) throw new Error("Codex thread is not ready");
236
+ this.statusCb({ status: "busy", reason: "provider-turn", timeline: { status: "compacting", timestamp: Date.now() } });
237
+ const currentContext = isContextState(process.meta?.context) ? process.meta.context : undefined;
238
+ if (currentContext) {
239
+ process.meta = {
240
+ ...(process.meta ?? {}),
241
+ context: { ...currentContext, lifecycleState: "compacting", lastUpdatedAt: Date.now() },
242
+ };
243
+ }
244
+ const completion = this.waitForPendingCompact(threadId);
245
+ try {
246
+ await client.threadCompactStart(threadId);
247
+ await completion;
248
+ } catch (error) {
249
+ this.clearPendingCompact(threadId);
250
+ this.statusCb({ status: "idle", reason: "provider-turn" });
251
+ throw error;
252
+ }
253
+ const compactedAt = Date.now();
254
+ const compactingContext = isContextState(process.meta?.context) ? process.meta.context : currentContext;
255
+ if (compactingContext) {
256
+ process.meta = {
257
+ ...(process.meta ?? {}),
258
+ context: {
259
+ ...compactingContext,
260
+ lifecycleState: "cooling",
261
+ tasksSinceCompact: 0,
262
+ lastCompactedAt: compactedAt,
263
+ lastUpdatedAt: compactedAt,
264
+ },
265
+ };
266
+ }
267
+ this.statusCb({ status: "idle", reason: "provider-turn", timeline: { status: "compacted", timestamp: compactedAt } });
268
+ return { threadId };
269
+ }
270
+
271
+ async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
272
+ const client = process.meta?.client as CodexAppClient | undefined;
273
+ if (!client) throw new Error("Codex App Server client is unavailable");
274
+ const previousThreadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : undefined;
275
+ this.statusCb({ status: "busy", reason: "provider-turn", timeline: { status: "clearing-context", timestamp: Date.now() } });
276
+ let started: Awaited<ReturnType<CodexAppClient["threadStart"]>>;
277
+ try {
278
+ started = await client.threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() });
279
+ } catch (error) {
280
+ this.statusCb({ status: "idle", reason: "provider-turn" });
281
+ throw error;
282
+ }
283
+ const clearedAt = Date.now();
284
+ this.resetThreadState();
285
+ process.meta = {
286
+ ...(process.meta ?? {}),
287
+ threadId: started.thread.id,
288
+ relayContextSent: false,
289
+ context: {
290
+ utilization: 0,
291
+ lifecycleState: "fresh",
292
+ warmTopics: [],
293
+ activeMemories: [],
294
+ tasksSinceCompact: 0,
295
+ lastCompactedAt: clearedAt,
296
+ lastUpdatedAt: clearedAt,
297
+ source: "api",
298
+ confidence: "reported",
299
+ } satisfies ContextState,
300
+ };
301
+ this.statusCb({ status: "idle", reason: "provider-turn", clear: ["subagent"], timeline: { status: "context-cleared", timestamp: clearedAt } });
302
+ return { previousThreadId, threadId: started.thread.id };
303
+ }
304
+
305
+ async terminalAttachSpec(process: ManagedProcess): Promise<TerminalAttachSpec> {
306
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
307
+ if (!threadId) throw new Error("Codex thread is not ready");
308
+ const appServerUrl = typeof process.meta?.appServerUrl === "string" ? process.meta.appServerUrl : "";
309
+ if (!appServerUrl) throw new Error("Codex app-server URL is unavailable");
310
+ const command = typeof process.meta?.attachCommand === "string" && process.meta.attachCommand
311
+ ? process.meta.attachCommand
312
+ : resolveRealCodexCommand("codex");
313
+ return {
314
+ mode: "guest",
315
+ provider: "codex",
316
+ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd(),
317
+ command: [command, ...codexManagedConfigArgs(), "resume", threadId, "--remote", appServerUrl, "--no-alt-screen"],
318
+ title: `Codex ${threadId.slice(0, 8)}`,
319
+ ttlMs: 60 * 60 * 1000,
320
+ };
321
+ }
322
+
323
+ async deliverInitialPrompt(process: ManagedProcess, prompt: string, _options?: { readyTimeoutMs?: number }): Promise<void> {
324
+ const text = prompt.trim(); // _options.readyTimeoutMs (#329) is a Claude-TUI concern; ignored here
325
+ if (!text) return;
326
+ const threadId = await ensureCodexThread(process);
327
+ let input = [
328
+ codexLaunchContext(process),
329
+ "[agent-relay initial prompt from dashboard]",
330
+ text,
331
+ ].filter(Boolean).join("\n\n");
332
+ if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
333
+ input = codexRelayContextBlock() + "\n\n" + input;
334
+ process.meta = { ...(process.meta ?? {}), relayContextSent: true };
335
+ }
336
+ logger.info("codex", `starting Codex initial prompt in thread ${threadId}`);
337
+ const client = process.meta?.client as CodexAppClient;
338
+ await client.turnStart(threadId, input);
339
+ }
340
+
341
+ async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
342
+ const threadId = await ensureCodexThread(process);
343
+ let text = [codexLaunchContext(process), providerMessageText(messages)].filter(Boolean).join("\n\n");
344
+ if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
345
+ text = codexRelayContextBlock() + "\n\n" + text;
346
+ process.meta = { ...(process.meta ?? {}), relayContextSent: true };
347
+ }
348
+ logger.info("codex", codexDeliveryNotice(messages, threadId));
349
+ const client = process.meta?.client as CodexAppClient;
350
+ await client.turnStart(threadId, text);
351
+ }
352
+
353
+ async respondToPermissionDecision(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown>> {
354
+ const pending = this.pendingApprovals.get(input.approvalId);
355
+ if (!pending) throw new Error("approval request not found or already resolved");
356
+ const client = process.meta?.client as CodexAppClient | undefined;
357
+ if (!client) throw new Error("Codex App Server client is unavailable");
358
+ const response = codexApprovalResponse(pending.method, pending.params, input.decision);
359
+ // Always drop the pending entry: a failed send means the request is dead on
360
+ // the Codex side too, so a stale entry would only mislead later lookups.
361
+ try {
362
+ client.respondToServerRequest(pending.requestId, response);
363
+ } finally {
364
+ this.pendingApprovals.delete(input.approvalId);
365
+ }
366
+ this.statusCb({
367
+ status: "busy",
368
+ reason: "provider-turn",
369
+ providerState: {
370
+ state: "active",
371
+ reason: "permissionResolved",
372
+ label: "Codex approval resolved",
373
+ source: "codex",
374
+ updatedAt: Date.now(),
375
+ },
376
+ });
377
+ return { approvalId: input.approvalId, decision: input.decision, provider: "codex" };
378
+ }
379
+
380
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
381
+ const appServerUrl = String(config.appServerUrl || process.env.CODEX_APP_SERVER_URL || `ws://127.0.0.1:${config.controlPort + 1000}`);
382
+ // Create + bootstrap the isolated CODEX_HOME (no-op for host base) so the assembler's
383
+ // materialize step has somewhere to write the provisioned skills.
384
+ prepareCodexProfileHome(config);
385
+ const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
386
+ // #557 — ONE assembler owns the provisioning/vanilla surface (skills.config, relay +
387
+ // provisioned -c mcp_servers.*, project_doc_max_bytes); profile-projection describes the
388
+ // SAME assembled result, so report == launch. The adapter only adds core launch config the
389
+ // assembler does not own: app-server wiring, model/approval/tool-output, managed flags, --listen.
390
+ const assembled = assembleLaunch("codex", config, providerConfig);
391
+ materializeLaunchAssembly("codex", config);
392
+ const args = isCodexCliCommand(providerConfig.command)
393
+ ? [
394
+ "app-server",
395
+ ...codexAppServerConfigArgs(defaultArgs, config.providerArgs),
396
+ ...codexModelConfigArgs(config.model, config.effort),
397
+ ...codexApprovalConfigArgs(config.approvalMode),
398
+ ...codexToolOutputTokenLimitConfigArgs(config),
399
+ ...assembled.args,
400
+ ...codexManagedConfigArgs(),
401
+ "--listen",
402
+ appServerUrl,
403
+ ]
404
+ : [...defaultArgs, ...config.providerArgs];
405
+ return {
406
+ command: providerConfig.command,
407
+ args,
408
+ cwd: config.cwd,
409
+ env: {
410
+ ...config.env,
411
+ // #557 — the assembler owns the isolated config-home env (CODEX_HOME).
412
+ ...assembled.env,
413
+ CODEX_APP_SERVER_URL: appServerUrl,
414
+ AGENT_RELAY_PROVIDER: "codex",
415
+ },
416
+ };
417
+ }
418
+
419
+ private handleCodexEvent(event: ClientEvent, process?: ManagedProcess): void {
420
+ if (event.type === "server-request" && process) {
421
+ const approval = codexApprovalFromServerRequest(event.message);
422
+ if (approval) {
423
+ // #435/#723 ordering invariant: mirror every reasoning/narrative step of the turn
424
+ // BEFORE the approval form surfaces — the same guarantee Claude makes by pre-flushing
425
+ // the transcript at its hook seam, here enforced from the Codex app-server mechanism.
426
+ // approval.view.reasoningSettled === true asserts this flush has run.
427
+ this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
428
+ this.flushBufferedReasoning();
429
+ this.pendingApprovals.set(approval.pending.id, approval.pending);
430
+ this.statusCb({
431
+ status: "busy",
432
+ reason: "provider-turn",
433
+ providerState: {
434
+ state: "blocked",
435
+ reason: "permissionRequest",
436
+ label: approval.view.title,
437
+ recommendedAction: "Review the permission request in Agent Relay.",
438
+ source: "codex",
439
+ pendingApproval: approval.view,
440
+ updatedAt: Date.now(),
441
+ },
442
+ });
443
+ } else if (event.message.method === CODEX_MCP_ELICITATION_METHOD && isCodexHeadless(process)) {
444
+ // Codex routes per-MCP-tool-call approval through the MCP elicitation channel
445
+ // (request_id `mcp_tool_call_approval_*`): even with `approval_policy="never"`, a
446
+ // downstream server's elicitation — or codex's own per-call approval gate — is
447
+ // forwarded to the app-server client as `mcpServer/elicitation/request`. A headless
448
+ // managed agent has no human to answer it; the old -32601 reject makes codex resolve
449
+ // the elicitation as `Decline`, so the MCP tool call dies with "user rejected MCP tool
450
+ // call" — stranding read-only recon/reviewer workers with no egress to report back
451
+ // over the bus (#395). The agent is already bounded by its scoped relay token + the
452
+ // codex sandbox, so auto-accept (spec-compliant `{ action: "accept" }`, not codex's
453
+ // legacy `{ decision: "approved" }`) so the agent's reporting/recon MCP tools run.
454
+ const client = process.meta?.client as CodexAppClient | undefined;
455
+ const serverName = isRecord(event.message.params) ? stringValue(event.message.params.serverName) : undefined;
456
+ logger.info("codex", `auto-accepting headless MCP elicitation from ${serverName ?? "unknown server"} so the agent's MCP tools can run (#395)`);
457
+ client?.respondToServerRequest(event.message.id, codexHeadlessElicitationResponse());
458
+ } else {
459
+ const client = process.meta?.client as CodexAppClient | undefined;
460
+ logger.warn("codex", `rejecting unknown Codex server-request method: ${event.message.method}`);
461
+ client?.rejectServerRequest(event.message.id, -32601, `Method not found: ${event.message.method}`, {
462
+ method: event.message.method,
463
+ });
464
+ }
465
+ return;
466
+ }
467
+
468
+ const method = event.type === "notification" ? event.message.method : "";
469
+ const params = event.type === "notification" ? event.message.params : undefined;
470
+ const thread = isRecord(params?.thread) ? params.thread : undefined;
471
+ const threadId = stringValue(params?.threadId) ?? stringValue(thread?.id);
472
+ const isSubagent = Boolean(threadId && this.subagentThreads.has(threadId)) || isSubagentThread(thread, params);
473
+
474
+ if (threadId && process && !isSubagent) {
475
+ const threadPath = stringValue(thread?.path) ?? stringValue(params?.path);
476
+ process.meta = {
477
+ ...(process.meta ?? {}),
478
+ threadId,
479
+ ...(threadPath ? { threadPath } : {}),
480
+ };
481
+ }
482
+
483
+ if (method.includes("thread/tokenUsage/updated") || method.includes("thread.tokenUsage.updated") || method.includes("thread/token_usage/updated") || method.includes("thread.token_usage.updated")) {
484
+ const context = codexTokenUsageContext(params);
485
+ if (context && process) process.meta = { ...(process.meta ?? {}), context };
486
+ }
487
+
488
+ if ((method.includes("thread/started") || method.includes("thread.started")) && threadId && isSubagent) {
489
+ const details = subagentThreadDetails(thread, params);
490
+ this.subagentThreads.set(threadId, details);
491
+ this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...details });
492
+ return;
493
+ }
494
+
495
+ if (method.includes("turn/started") || method.includes("turn.started")) {
496
+ if (threadId && this.subagentThreads.has(threadId)) {
497
+ this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
498
+ } else {
499
+ const turn = isRecord(params?.turn) ? params.turn : undefined;
500
+ this.activeTurnId = stringValue(turn?.id);
501
+ this.agentMessageCapture.reset();
502
+ this.itemTextBuffers.clear();
503
+ this.itemTextBufferTypes.clear();
504
+ this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
505
+ }
506
+ }
507
+ if (method.includes("turn/completed") || method.includes("turn.completed") || method.includes("turn/failed") || method.includes("turn.failed") || method.includes("turn/interrupted") || method.includes("turn.interrupted")) {
508
+ if (threadId && this.subagentThreads.has(threadId)) {
509
+ this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
510
+ } else {
511
+ this.finishMainTurn();
512
+ }
513
+ }
514
+ if ((method.includes("thread/compacted") || method.includes("thread.compacted")) && threadId) {
515
+ this.completePendingCompact(threadId);
516
+ }
517
+ if ((method.includes("item/completed") || method.includes("item.completed")) && !isSubagent) {
518
+ const item = isRecord(params?.item) ? params.item : undefined;
519
+ this.handleCodexItem(item);
520
+ if (codexItemIsCompactionComplete(item) && threadId) this.completePendingCompact(threadId);
521
+ }
522
+ if (!isSubagent) this.handleCodexItemDelta(method, params);
523
+ if (method.includes("thread/status") || method.includes("thread.status")) {
524
+ const status = statusType(params?.status);
525
+ if (threadId && this.subagentThreads.has(threadId)) {
526
+ if (status === "active") this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
527
+ if (status === "idle" || status === "notLoaded") this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
528
+ } else {
529
+ if (status === "active") this.statusCb({ status: "busy", reason: "provider-turn", providerState: this.providerStateFromThreadStatus(params?.status, params) });
530
+ if (status === "idle" || status === "notLoaded" || status === "systemError") this.finishMainTurn();
531
+ }
532
+ }
533
+ }
534
+
535
+ // Turn one completed Codex thread item into a session-mirror event. agentMessage text is
536
+ // mirrored as narration as it lands; the captured turn-final response repeats the substantive
537
+ // assistant answer, including a substantive wrap-up segment before a terse terminal status.
538
+ // The rest (user prompt echo, reasoning, tool steps) is surfaced as it lands.
539
+ private handleCodexItem(item: Record<string, unknown> | undefined): void {
540
+ if (!item) return;
541
+ const type = stringValue(item.type);
542
+ const turnId = this.activeTurnId;
543
+ const itemId = codexItemId(item);
544
+ if (type !== "agentMessage") this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
545
+ // A completed non-reasoning item ends the reasoning segment that preceded it — flush the
546
+ // buffered reasoning first so it lands in transcript order, ahead of this tool/message.
547
+ if (type !== "reasoning") this.flushBufferedReasoning();
548
+ if (type === "agentMessage") {
549
+ this.agentMessageCapture.completeItem(itemId, stringValue(item.text), (body) => this.emitAgentNarration(body), { phase: item.phase });
550
+ return;
551
+ }
552
+ if (type === "userMessage") {
553
+ const text = codexUserMessageText(item.content);
554
+ if (text) {
555
+ this.recordInsightEvent({ type: "user_prompt" });
556
+ this.sessionEventCb({ type: "prompt", origin: "terminal", body: text, ...(turnId ? { turnId } : {}) });
557
+ }
558
+ return;
559
+ }
560
+ if (type === "reasoning") {
561
+ // Codex has no reliable reasoning item/completed carrying text — the stream IS the deltas.
562
+ // Keep the longer of (accumulated deltas, item.content) in the buffer and emit at the next
563
+ // boundary (next item / turn end), the same coarse signal the Claude reasoning tail uses.
564
+ const fromItem = codexReasoningText(item);
565
+ if (itemId && fromItem) {
566
+ const existing = this.itemTextBuffers.get(itemId) ?? "";
567
+ if (fromItem.length >= existing.length) this.itemTextBuffers.set(itemId, fromItem);
568
+ this.itemTextBufferTypes.set(itemId, "reasoning");
569
+ }
570
+ return;
571
+ }
572
+ const tool = codexToolSummary(type, item);
573
+ if (tool) {
574
+ this.recordInsightEvent({ type: "tool", name: codexInsightToolName(type, item) });
575
+ if (codexItemFailed(item)) this.recordInsightEvent({ type: "tool_error" });
576
+ // stepId = the app-server item id: the server upserts the running step emitted on
577
+ // item/started into this completed state IN PLACE, so the tool persists as ONE row.
578
+ this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "completed", ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
579
+ }
580
+ if (itemId) this.itemTextBuffers.delete(itemId);
581
+ if (itemId) this.itemTextBufferTypes.delete(itemId);
582
+ }
583
+
584
+ private emitAgentNarration(body: string): void {
585
+ this.recordInsightEvent({ type: "turn" }); // a substantive assistant turn
586
+ // Stream the assistant text into the trace as narration (Claude parity). The closing
587
+ // response bubble at turn end repeats the final block; the dashboard suppresses the
588
+ // matching narration so it isn't shown twice.
589
+ this.sessionEventCb({ type: "narration", origin: "provider", body, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
590
+ }
591
+
592
+ // Emit any buffered reasoning as discrete trace blocks (appended, in transcript order). Codex
593
+ // streams reasoning as deltas with no reliable completion event, so we flush coarsely at item
594
+ // and turn boundaries — the signal the Claude reasoning tail uses — never a codex-only timer.
595
+ private flushBufferedReasoning(): void {
596
+ const turnId = this.activeTurnId;
597
+ for (const [itemId, text] of [...this.itemTextBuffers.entries()]) {
598
+ if (this.itemTextBufferTypes.get(itemId) !== "reasoning") continue;
599
+ this.itemTextBuffers.delete(itemId);
600
+ this.itemTextBufferTypes.delete(itemId);
601
+ const body = text.trim();
602
+ if (body) this.sessionEventCb({ type: "reasoning", origin: "provider", body, ...(turnId ? { turnId } : {}) });
603
+ }
604
+ }
605
+
606
+ // #183/#184: append to the session-event log with a soft cap. On overflow we drop the
607
+ // oldest half; the runner detects the resulting length shrink and resets its segment
608
+ // cursor (worst case: one slightly-truncated datapoint on a pathologically long session).
609
+ private recordInsightEvent(event: SessionEvent): void {
610
+ this.sessionEvents.push(event);
611
+ if (this.sessionEvents.length > CodexAdapter.SESSION_EVENTS_CAP) {
612
+ this.sessionEvents = this.sessionEvents.slice(this.sessionEvents.length >> 1);
613
+ }
614
+ }
615
+
616
+ // Whole-session event stream for the Insights context-ratio signal (#184). Codex learns
617
+ // about activity through app-server item events, not a transcript, so we replay the log
618
+ // accumulated since this process started. The runner owns per-segment slicing.
619
+ async collectSessionEvents(): Promise<SessionEvent[] | null> {
620
+ return [...this.sessionEvents];
621
+ }
622
+
623
+ // #653: Codex busy-state normally flips on turn/started, but the app-server can stream
624
+ // item activity (which already drives the dashboard chat mirror) before — or without —
625
+ // a turn/started reaching us, leaving a visibly-working agent reported `idle` (a reap
626
+ // hazard). Derive busy from that SAME live activity: any in-flight item event with no
627
+ // active turn means a turn IS running we missed, so synthesize one and report busy.
628
+ // turn/completed → finishMainTurn clears it; we never synthesize on a closing
629
+ // item.completed, so a legitimately ended turn is not re-flipped to busy.
630
+ private ensureMainTurnActive(turnId?: string): void {
631
+ if (this.activeTurnId) return;
632
+ this.activeTurnId = turnId ?? `codex-synth-turn-${++this.syntheticTurnSeq}`;
633
+ this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
634
+ }
635
+
636
+ private handleCodexItemDelta(method: string, params: Record<string, unknown> | undefined): void {
637
+ if (!method.includes("item/") && !method.includes("item.")) return;
638
+ // Live item stream (start/delta/progress) = a turn in flight. A closing
639
+ // item.completed is handled by handleCodexItem and must not synthesize a turn.
640
+ if (!method.includes("/completed") && !method.includes(".completed")) {
641
+ this.ensureMainTurnActive(stringValue(params?.turnId));
642
+ }
643
+ const item = isRecord(params?.item) ? params.item : undefined;
644
+ const itemId = codexItemId(params) ?? codexItemId(item);
645
+ const type = codexItemTypeFromMethod(method) ?? stringValue(item?.type);
646
+ const turnId = this.activeTurnId;
647
+
648
+ if (method.includes("/started") || method.includes(".started")) {
649
+ if (type !== "agentMessage") this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
650
+ // A new item starting ends the prior reasoning segment — flush it ahead of this step.
651
+ this.flushBufferedReasoning();
652
+ const tool = codexToolSummary(type, item ?? params ?? {});
653
+ // stepId = item id so the server upserts this running step → completed IN PLACE (one row).
654
+ if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, status: "running", streaming: true, ...(turnId ? { turnId } : {}), ...(itemId ? { stepId: itemId } : {}) });
655
+ return;
656
+ }
657
+
658
+ if (type === "agentMessage") {
659
+ const delta = codexDeltaText(params);
660
+ if (delta) this.agentMessageCapture.appendDelta(itemId, delta);
661
+ return;
662
+ }
663
+
664
+ if (type === "reasoning" || type === "plan") {
665
+ this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
666
+ const delta = codexDeltaText(params);
667
+ if (delta && itemId) {
668
+ this.itemTextBuffers.set(itemId, `${this.itemTextBuffers.get(itemId) ?? ""}${delta}`);
669
+ if (type) this.itemTextBufferTypes.set(itemId, type);
670
+ }
671
+ return;
672
+ }
673
+
674
+ // Raw stdout/stderr deltas can be huge. Surface the tool lifecycle via started/completed
675
+ // summaries only; never persist raw process output into the chat mirror.
676
+ if (method.includes("outputDelta") || method.includes("output_delta") || method.includes("progress")) return;
677
+ }
678
+
679
+ private flushTurnResponse(): void {
680
+ const text = this.agentMessageCapture.finish((body) => this.emitAgentNarration(body));
681
+ if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, final: true, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
682
+ }
683
+
684
+ private finishMainTurn(): void {
685
+ this.flushBufferedReasoning(); // surface any trailing reasoning before the closing response
686
+ this.flushTurnResponse();
687
+ const turnId = this.activeTurnId;
688
+ this.activeTurnId = undefined;
689
+ this.pendingApprovals.clear();
690
+ this.itemTextBuffers.clear();
691
+ this.itemTextBufferTypes.clear();
692
+ this.agentMessageCapture.reset();
693
+ this.statusCb({ status: "idle", reason: "provider-turn", id: turnId });
694
+ }
695
+
696
+ private waitForPendingCompact(threadId: string): Promise<void> {
697
+ if (this.pendingCompact) throw new Error("Codex compact is already in progress");
698
+ return new Promise<void>((resolve, reject) => {
699
+ const pending: PendingCodexCompact = {
700
+ threadId,
701
+ resolve,
702
+ reject,
703
+ timeout: setTimeout(() => {
704
+ if (this.pendingCompact !== pending) return;
705
+ this.pendingCompact = undefined;
706
+ reject(new Error(`Codex compact timed out after ${Math.round(CODEX_COMPACT_TIMEOUT_MS / 1000)}s`));
707
+ }, CODEX_COMPACT_TIMEOUT_MS),
708
+ };
709
+ this.pendingCompact = pending;
710
+ });
711
+ }
712
+
713
+ private completePendingCompact(threadId: string): boolean {
714
+ const pending = this.pendingCompact;
715
+ if (!pending || pending.threadId !== threadId) return false;
716
+ clearTimeout(pending.timeout);
717
+ this.pendingCompact = undefined;
718
+ pending.resolve();
719
+ return true;
720
+ }
721
+
722
+ private clearPendingCompact(threadId?: string): void {
723
+ const pending = this.pendingCompact;
724
+ if (!pending || (threadId && pending.threadId !== threadId)) return;
725
+ clearTimeout(pending.timeout);
726
+ this.pendingCompact = undefined;
727
+ }
728
+
729
+ private cancelPendingCompact(error: Error): void {
730
+ const pending = this.pendingCompact;
731
+ if (!pending) return;
732
+ clearTimeout(pending.timeout);
733
+ this.pendingCompact = undefined;
734
+ pending.reject(error);
735
+ }
736
+
737
+ private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): ProviderState | undefined {
738
+ const state = codexProviderStateFromThreadStatus(status, params);
739
+ if (state?.state !== "blocked" || state.reason !== "waitingOnApproval" || state.pendingApproval) return state;
740
+ const pending = [...this.pendingApprovals.values()].at(-1);
741
+ return pending ? { ...state, pendingApproval: pending.view } : state;
742
+ }
743
+ }
744
+
745
+ function codexItemId(item: Record<string, unknown> | undefined): string | undefined {
746
+ return stringValue(item?.itemId) ?? stringValue(item?.id);
747
+ }
748
+
749
+ function codexItemIsCompactionComplete(item: Record<string, unknown> | undefined): boolean {
750
+ const type = stringValue(item?.type);
751
+ return type === "context_compaction" || type === "contextCompaction" || type === "compaction";
752
+ }
753
+
754
+ function codexDeltaText(params: Record<string, unknown> | undefined): string {
755
+ const delta = params?.delta;
756
+ if (typeof delta === "string") return delta;
757
+ if (isRecord(delta)) {
758
+ const text = stringValue(delta.text) ?? stringValue(delta.value) ?? stringValue(delta.content);
759
+ if (text) return text;
760
+ }
761
+ return stringValue(params?.text) ?? stringValue(params?.chunk) ?? stringValue(params?.content) ?? "";
762
+ }
763
+
764
+ function codexItemTypeFromMethod(method: string): string | undefined {
765
+ const match = method.match(/item[/.]([^/.]+)[/.]/);
766
+ return match?.[1];
767
+ }
768
+
769
+ // Codex forwards MCP elicitation (incl. its per-tool-call approval gate) to the app-server
770
+ // client under this server-request method (#395). See handleCodexEvent for why a headless
771
+ // agent must answer it instead of rejecting it.
772
+ export const CODEX_MCP_ELICITATION_METHOD = "mcpServer/elicitation/request";
773
+
774
+ // A managed agent with no attended TUI (the spawn intent, `config.headless`) has no human to
775
+ // resolve an elicitation. Such agents are the #395 case; interactive (TUI) sessions leave the
776
+ // elicitation to the human in the attached terminal.
777
+ function isCodexHeadless(process: ManagedProcess): boolean {
778
+ return (process.meta?.config as RunnerSpawnConfig | undefined)?.headless === true;
779
+ }
780
+
781
+ // Spec-compliant auto-accept for a headless agent's MCP elicitation. `action` (not codex's
782
+ // legacy `decision`) is the load-bearing field; `content: {}` approves codex's per-tool-call
783
+ // gate and any no-required-field form without fabricating runtime field values for the agent.
784
+ export function codexHeadlessElicitationResponse(): Record<string, unknown> {
785
+ return { action: "accept", content: {}, _meta: null };
786
+ }
787
+
788
+ function codexApprovalFromServerRequest(message: { id: string | number; method: string; params?: unknown }): { pending: PendingCodexApproval; view: InteractivePrompt } | null {
789
+ if (!isRecord(message.params)) return null;
790
+ const method = message.method;
791
+ if (!codexApprovalMethod(method)) return null;
792
+ const id = `${method}:${String(message.id)}`;
793
+ const view = codexApprovalView(id, method, message.params);
794
+ return {
795
+ pending: { id, requestId: message.id, method, params: message.params, view },
796
+ view,
797
+ };
798
+ }
799
+
800
+ /** Extract the human text from a Codex userMessage item's content (UserInput[]). */
801
+ export function codexUserMessageText(content: unknown): string {
802
+ if (typeof content === "string") return content.trim();
803
+ if (!Array.isArray(content)) return "";
804
+ return content
805
+ .filter(isRecord)
806
+ .filter((part) => part.type === "text" || part.type === "input_text" || part.type === "output_text")
807
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
808
+ .filter(Boolean)
809
+ .join("")
810
+ .trim();
811
+ }
812
+
813
+ /** Extract reasoning text from a Codex reasoning item (content[] preferred, summary[] fallback). */
814
+ export function codexReasoningText(item: Record<string, unknown>): string {
815
+ const stringsOf = (value: unknown): string[] =>
816
+ Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && v.trim().length > 0) : [];
817
+ const content = stringsOf(item.content);
818
+ const text = (content.length ? content : stringsOf(item.summary)).join("\n\n").trim();
819
+ return text;
820
+ }
821
+
822
+ /** Build a compact { label, body } activity summary for a Codex tool item. */
823
+ // Canonical tool name for the #184 gathering/action classifier. Codex item types map to
824
+ // names the shared classifier already understands: commandExecution executes (→ Bash, an
825
+ // action), fileChange mutates (→ Edit), webSearch gathers (→ WebSearch, in the set), and
826
+ // MCP/dynamic calls carry their real tool name so name-shape classification applies.
827
+ export function codexInsightToolName(type: string | undefined, item: Record<string, unknown>): string {
828
+ switch (type) {
829
+ case "commandExecution": return "Bash";
830
+ case "fileChange": return "Edit";
831
+ case "webSearch": return "WebSearch";
832
+ case "mcpToolCall":
833
+ case "dynamicToolCall":
834
+ case "collabAgentToolCall":
835
+ return stringValue(item.tool) ?? type ?? "tool";
836
+ default: return type ?? "tool";
837
+ }
838
+ }
839
+
840
+ // Did a completed tool item fail? Mirrors Claude's tool_result is_error outcome proxy.
841
+ export function codexItemFailed(item: Record<string, unknown>): boolean {
842
+ if (stringValue(item.status) === "failed") return true;
843
+ if (item.error != null && item.error !== false) return true;
844
+ const exitCode = item.exitCode ?? item.exit_code;
845
+ return typeof exitCode === "number" && exitCode !== 0;
846
+ }
847
+
848
+ export function codexToolSummary(type: string | undefined, item: Record<string, unknown>): { label: string; body: string } | null {
849
+ const oneLine = (value: unknown): string => (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "");
850
+ const clip = (text: string): string => (text.length > 200 ? `${text.slice(0, 197)}…` : text);
851
+ if (type === "commandExecution") {
852
+ const command = oneLine(item.command);
853
+ return { label: "Shell", body: clip(command || "command") };
854
+ }
855
+ if (type === "fileChange") {
856
+ const changes = Array.isArray(item.changes) ? item.changes.filter(isRecord) : [];
857
+ const files = changes.map((c) => stringValue(c.path) ?? stringValue(c.file) ?? "").filter(Boolean);
858
+ return { label: "Edit", body: clip(files.length ? files.join(", ") : "file changes") };
859
+ }
860
+ if (type === "mcpToolCall" || type === "dynamicToolCall") {
861
+ const tool = stringValue(item.tool) ?? "tool";
862
+ const server = stringValue(item.server) ?? stringValue(item.namespace);
863
+ return { label: server ? `${server}/${tool}` : tool, body: clip(oneLine(JSON.stringify(item.arguments ?? {})) || tool) };
864
+ }
865
+ if (type === "webSearch") {
866
+ return { label: "Search", body: clip(oneLine(item.query) || "web search") };
867
+ }
868
+ if (type === "plan") {
869
+ return { label: "Plan", body: clip(oneLine(item.text) || "updated plan") };
870
+ }
871
+ if (type === "collabAgentToolCall") {
872
+ const tool = stringValue(item.tool) ?? "collab";
873
+ const prompt = oneLine(item.prompt);
874
+ const targets = Array.isArray(item.receiverThreadIds)
875
+ ? item.receiverThreadIds.filter((t): t is string => typeof t === "string").length
876
+ : 0;
877
+ const detail = prompt || (targets ? `${targets} agent${targets === 1 ? "" : "s"}` : tool);
878
+ return { label: `Collab/${tool}`, body: clip(detail) };
879
+ }
880
+ return null;
881
+ }
882
+
883
+ function codexApprovalMethod(method: string): boolean {
884
+ return method === "execCommandApproval" ||
885
+ method === "applyPatchApproval" ||
886
+ method === "item/commandExecution/requestApproval" ||
887
+ method === "item/fileChange/requestApproval" ||
888
+ method === "item/permissions/requestApproval";
889
+ }
890
+
891
+ // Build the normalized InteractivePrompt for a Codex app-server approval request.
892
+ // reasoningSettled is stamped `true` because handleCodexEvent flushes buffered reasoning +
893
+ // any open narration segment BEFORE this view is emitted as pendingApproval (#435/#723). No
894
+ // `provider` tag — the server reads the typed contract, not provider identity.
895
+ function codexApprovalView(id: string, method: string, params: Record<string, unknown>): InteractivePrompt {
896
+ const command = typeof params.command === "string" ? params.command
897
+ : Array.isArray(params.command) ? params.command.filter((v): v is string => typeof v === "string").join(" ")
898
+ : "";
899
+ const cwd = stringValue(params.cwd) ?? stringValue(params.conversationId) ?? stringValue(params.threadId);
900
+ const reason = stringValue(params.reason);
901
+ const grantRoot = stringValue(params.grantRoot);
902
+ const fileChanges = isRecord(params.fileChanges) ? Object.keys(params.fileChanges) : [];
903
+ const permissions = isRecord(params.permissions) ? params.permissions : undefined;
904
+ const body = [
905
+ command,
906
+ grantRoot ? `Grant write access: ${grantRoot}` : "",
907
+ fileChanges.length ? `Files: ${fileChanges.join(", ")}` : "",
908
+ permissions ? `Permissions: ${JSON.stringify(permissions)}` : "",
909
+ reason ? `Reason: ${reason}` : "",
910
+ cwd ? `Context: ${cwd}` : "",
911
+ ].filter(Boolean).join("\n");
912
+ const isSessionCapable = method !== "item/permissions/requestApproval";
913
+ return {
914
+ id,
915
+ kind: method.includes("file") || method === "applyPatchApproval" ? "file-change" : method.includes("permission") ? "permission" : "command",
916
+ title: method.includes("file") || method === "applyPatchApproval"
917
+ ? "Approve file changes"
918
+ : method.includes("permission")
919
+ ? "Approve extra permissions"
920
+ : "Approve command",
921
+ body: body || "Codex is requesting permission.",
922
+ choices: [
923
+ { id: "approve", label: method.includes("permission") ? "Allow this turn" : "Approve" },
924
+ ...(isSessionCapable ? [{ id: "approve-session", label: "Approve session" }] : [{ id: "approve-session", label: "Allow session" }]),
925
+ { id: "deny", label: "Deny" },
926
+ ],
927
+ reasoningSettled: true,
928
+ };
929
+ }
930
+
931
+ function codexApprovalResponse(method: string, params: Record<string, unknown>, decision: ProviderPermissionDecisionInput["decision"]): Record<string, unknown> {
932
+ if (method === "item/commandExecution/requestApproval") {
933
+ return { decision: decision === "approve-session" ? "acceptForSession" : decision === "deny" ? "decline" : decision === "abort" ? "cancel" : "accept" };
934
+ }
935
+ if (method === "item/fileChange/requestApproval") {
936
+ return { decision: decision === "approve-session" ? "acceptForSession" : decision === "deny" ? "decline" : decision === "abort" ? "cancel" : "accept" };
937
+ }
938
+ if (method === "item/permissions/requestApproval") {
939
+ const permissions = decision === "deny" || decision === "abort" ? {} : isRecord(params.permissions) ? params.permissions : {};
940
+ return { permissions, scope: decision === "approve-session" ? "session" : "turn" };
941
+ }
942
+ return { decision: decision === "approve-session" ? "approved_for_session" : decision === "deny" ? "denied" : decision === "abort" ? "abort" : "approved" };
943
+ }
944
+
945
+ async function ensureCodexThread(process: ManagedProcess): Promise<string> {
946
+ const client = process.meta?.client as CodexAppClient | undefined;
947
+ if (!client) throw new Error("Codex App Server client is unavailable");
948
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
949
+ if (threadId) return threadId;
950
+ // Memoize the in-flight start so concurrent deliveries don't create two threads
951
+ // and orphan the first turn's context.
952
+ const pending = process.meta?.threadStartPromise as Promise<string> | undefined;
953
+ if (pending) return pending;
954
+ const startPromise = client
955
+ .threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() })
956
+ .then((started) => {
957
+ process.meta = { ...(process.meta ?? {}), threadId: started.thread.id, threadStartPromise: undefined };
958
+ return started.thread.id;
959
+ })
960
+ .catch((error) => {
961
+ if (process.meta) process.meta.threadStartPromise = undefined;
962
+ throw error;
963
+ });
964
+ process.meta = { ...(process.meta ?? {}), threadStartPromise: startPromise };
965
+ return startPromise;
966
+ }
967
+
968
+ export function codexDeliveryNotice(messages: Message[], threadId: string): string {
969
+ const ids = messages.map((message) => `#${message.id}`).join(", ");
970
+ const senders = [...new Set(messages.map((message) => message.from))].join(", ");
971
+ return `[agent-relay] received ${messages.length} relay message${messages.length === 1 ? "" : "s"} ${ids} from ${senders}; injecting into Codex thread ${threadId}`;
972
+ }
973
+
974
+ export function codexProviderStateFromThreadStatus(status: unknown, params?: Record<string, unknown>, now = Date.now()): ProviderState | undefined {
975
+ const type = statusType(status);
976
+ const flags = activeFlags(status);
977
+ const threadId = stringValue(params?.threadId) ?? (isRecord(params?.thread) ? stringValue(params.thread.id) : undefined);
978
+ const raw = { ...(threadId ? { threadId } : {}), status: isRecord(status) ? status : { type } };
979
+ const reason = flags[0] ?? type;
980
+
981
+ if (type === "active" && flags.some((flag) => flag.toLowerCase() === "waitingonapproval")) {
982
+ return {
983
+ state: "blocked",
984
+ reason: "waitingOnApproval",
985
+ label: "waiting for Codex approval",
986
+ recommendedAction: "Open the attached TUI to approve, or restart with non-interactive approval.",
987
+ source: "codex",
988
+ raw,
989
+ updatedAt: now,
990
+ };
991
+ }
992
+
993
+ if (type === "active" && flags.some((flag) => flag.toLowerCase().includes("auth"))) {
994
+ return {
995
+ state: "blocked",
996
+ reason: "authRequired",
997
+ label: "Codex authentication required",
998
+ recommendedAction: "Open the provider session and complete authentication, then retry.",
999
+ source: "codex",
1000
+ raw,
1001
+ updatedAt: now,
1002
+ };
1003
+ }
1004
+
1005
+ if (type === "systemError") {
1006
+ return {
1007
+ state: "blocked",
1008
+ reason: "systemError",
1009
+ label: "Codex system error",
1010
+ recommendedAction: "View provider logs, then restart the provider if the error is not transient.",
1011
+ source: "codex",
1012
+ raw,
1013
+ updatedAt: now,
1014
+ };
1015
+ }
1016
+
1017
+ if (type === "active") {
1018
+ return {
1019
+ state: "active",
1020
+ reason,
1021
+ label: flags.length ? `Codex active: ${flags.join(", ")}` : "Codex turn active",
1022
+ source: "codex",
1023
+ raw,
1024
+ updatedAt: now,
1025
+ };
1026
+ }
1027
+
1028
+ return undefined;
1029
+ }
1030
+
1031
+ export function codexTokenUsageContext(params: Record<string, unknown> | undefined, now = Date.now()): ContextState | undefined {
1032
+ const usage = isRecord(params?.tokenUsage) ? params.tokenUsage
1033
+ : isRecord(params?.token_usage) ? params.token_usage
1034
+ : isRecord(params?.usage) ? params.usage
1035
+ : isRecord(params?.info) ? params.info
1036
+ : params;
1037
+ if (!usage) return undefined;
1038
+ const total = isRecord(usage.total) ? usage.total
1039
+ : isRecord(usage.totalTokenUsage) ? usage.totalTokenUsage
1040
+ : isRecord(usage.total_token_usage) ? usage.total_token_usage
1041
+ : undefined;
1042
+ const last = isRecord(usage.last) ? usage.last
1043
+ : isRecord(usage.lastTokenUsage) ? usage.lastTokenUsage
1044
+ : isRecord(usage.last_token_usage) ? usage.last_token_usage
1045
+ : undefined;
1046
+ const tokensUsed = numberValue(last?.totalTokens) ?? numberValue(last?.total_tokens) ??
1047
+ numberValue(total?.totalTokens) ?? numberValue(total?.total_tokens) ??
1048
+ numberValue(usage.totalTokens) ?? numberValue(usage.total_tokens);
1049
+ const tokensMax = numberValue(usage.modelContextWindow) ?? numberValue(usage.model_context_window) ??
1050
+ numberValue(params?.modelContextWindow) ?? numberValue(params?.model_context_window);
1051
+ if (tokensUsed === undefined || tokensMax === undefined || tokensMax <= 0) return undefined;
1052
+ return {
1053
+ utilization: Math.max(0, Math.min(1, tokensUsed / tokensMax)),
1054
+ tokensUsed,
1055
+ tokensMax,
1056
+ lifecycleState: "working",
1057
+ warmTopics: [],
1058
+ activeMemories: [],
1059
+ tasksSinceCompact: 0,
1060
+ lastUpdatedAt: now,
1061
+ source: "api",
1062
+ confidence: "reported",
1063
+ };
1064
+ }
1065
+
1066
+ function latestCodexContext(process: ManagedProcess): ContextState | undefined {
1067
+ const meta = process.meta ?? {};
1068
+ const current = isContextState(meta.context) ? meta.context : undefined;
1069
+ const explicitPath = stringValue(meta.threadPath);
1070
+ const threadId = stringValue(meta.threadId);
1071
+ const rolloutPath = explicitPath ?? (threadId ? findCodexRolloutPath(threadId) : undefined);
1072
+ if (!rolloutPath) return current;
1073
+ const context = readCodexRolloutContext(rolloutPath);
1074
+ if (!context) return current;
1075
+ process.meta = { ...meta, context, threadPath: rolloutPath };
1076
+ return context;
1077
+ }
1078
+
1079
+ function readCodexRolloutContext(path: string): ContextState | undefined {
1080
+ try {
1081
+ return codexRolloutContextFromText(readFileSync(path, "utf8"));
1082
+ } catch {
1083
+ return undefined;
1084
+ }
1085
+ }
1086
+
1087
+ export function codexRolloutContextFromText(input: string): ContextState | undefined {
1088
+ const lines = input.split(/\r?\n/);
1089
+ for (let i = lines.length - 1; i >= 0; i--) {
1090
+ const line = lines[i]?.trim();
1091
+ if (!line) continue;
1092
+ let event: unknown;
1093
+ try {
1094
+ event = JSON.parse(line);
1095
+ } catch {
1096
+ continue;
1097
+ }
1098
+ if (!isRecord(event)) continue;
1099
+ const payload = isRecord(event.payload) ? event.payload : undefined;
1100
+ if (payload?.type !== "token_count") continue;
1101
+ const timestamp = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : Date.now();
1102
+ return codexTokenUsageContext(payload, Number.isFinite(timestamp) ? timestamp : Date.now());
1103
+ }
1104
+ return undefined;
1105
+ }
1106
+
1107
+ function findCodexRolloutPath(threadId: string): string | undefined {
1108
+ const root = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
1109
+ return findFileContaining(root, threadId);
1110
+ }
1111
+
1112
+ function findFileContaining(dir: string, needle: string, depth = 0): string | undefined {
1113
+ if (depth > 8) return undefined;
1114
+ let entries: Array<{ name: string; isFile(): boolean; isDirectory(): boolean }>;
1115
+ try {
1116
+ entries = readdirSync(dir, { withFileTypes: true });
1117
+ } catch {
1118
+ return undefined;
1119
+ }
1120
+ entries.sort((a, b) => b.name.localeCompare(a.name));
1121
+ for (const entry of entries) {
1122
+ const path = join(dir, entry.name);
1123
+ if (entry.isFile() && entry.name.includes(needle) && entry.name.endsWith(".jsonl")) return path;
1124
+ if (entry.isDirectory()) {
1125
+ const found = findFileContaining(path, needle, depth + 1);
1126
+ if (found) return found;
1127
+ }
1128
+ }
1129
+ return undefined;
1130
+ }
1131
+
1132
+ function isSubagentThread(thread: Record<string, unknown> | undefined, params: Record<string, unknown> | undefined): boolean {
1133
+ if (thread?.threadSource === "subagent" || params?.threadSource === "subagent") return true;
1134
+ const source = isRecord(thread?.sessionSource) ? thread.sessionSource : isRecord(params?.sessionSource) ? params.sessionSource : undefined;
1135
+ return isRecord(source?.subagent);
1136
+ }
1137
+
1138
+ function subagentThreadDetails(thread: Record<string, unknown> | undefined, params: Record<string, unknown> | undefined): { label?: string; role?: string; parentId?: string } {
1139
+ const source = isRecord(thread?.sessionSource) ? thread.sessionSource : isRecord(params?.sessionSource) ? params.sessionSource : undefined;
1140
+ const subagent = isRecord(source?.subagent) ? source.subagent : undefined;
1141
+ const threadSpawn = isRecord(subagent?.thread_spawn) ? subagent.thread_spawn : undefined;
1142
+ return {
1143
+ label: stringValue(thread?.agentNickname) ?? stringValue(params?.agentNickname) ?? stringValue(threadSpawn?.agent_nickname),
1144
+ role: stringValue(thread?.agentRole) ?? stringValue(params?.agentRole) ?? stringValue(threadSpawn?.agent_role),
1145
+ parentId: stringValue(threadSpawn?.parent_thread_id) ?? stringValue(params?.parentThreadId),
1146
+ };
1147
+ }
1148
+
1149
+ function statusType(value: unknown): string | undefined {
1150
+ if (typeof value === "string") return value;
1151
+ return isRecord(value) ? stringValue(value.type) : undefined;
1152
+ }
1153
+
1154
+ function activeFlags(value: unknown): string[] {
1155
+ if (!isRecord(value) || !Array.isArray(value.activeFlags)) return [];
1156
+ return value.activeFlags.filter((flag): flag is string => typeof flag === "string" && flag.length > 0);
1157
+ }
1158
+
1159
+ function numberValue(value: unknown): number | undefined {
1160
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1161
+ }
1162
+
1163
+ function isContextState(value: unknown): value is ContextState {
1164
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1165
+ const state = value as Record<string, unknown>;
1166
+ return typeof state.utilization === "number" &&
1167
+ typeof state.lifecycleState === "string" &&
1168
+ typeof state.source === "string" &&
1169
+ typeof state.confidence === "string";
1170
+ }
1171
+
1172
+
1173
+ export function codexModelConfigArgs(model?: string, effort?: string): string[] {
1174
+ const args: string[] = [];
1175
+ if (model) args.push("-c", `model=${tomlString(model)}`);
1176
+ if (effort) args.push("-c", `model_reasoning_effort=${tomlString(effort)}`);
1177
+ return args;
1178
+ }
1179
+
1180
+ export function codexApprovalConfigArgs(approvalMode?: string): string[] {
1181
+ const sandboxMode = approvalMode === "open"
1182
+ ? "danger-full-access"
1183
+ : approvalMode === "read-only"
1184
+ ? "read-only"
1185
+ : "workspace-write";
1186
+ return [
1187
+ "-c",
1188
+ 'approval_policy="never"',
1189
+ "-c",
1190
+ `sandbox_mode=${tomlString(sandboxMode)}`,
1191
+ ];
1192
+ }
1193
+
1194
+ export function codexManagedConfigArgs(): string[] {
1195
+ return ["-c", "check_for_update_on_startup=false"];
1196
+ }
1197
+
1198
+ export function isCodexCliCommand(command: string): boolean {
1199
+ return basename(command) === "codex";
1200
+ }
1201
+
1202
+ export function resolveRealCodexCommand(command: string, pathValue = process.env.PATH || ""): string {
1203
+ const candidates: string[] = [];
1204
+ if (command.includes("/")) candidates.push(command);
1205
+ for (const dir of pathValue.split(":").filter(Boolean)) candidates.push(join(dir, "codex"));
1206
+
1207
+ const seen = new Set<string>();
1208
+ for (const candidate of candidates) {
1209
+ if (seen.has(candidate)) continue;
1210
+ seen.add(candidate);
1211
+ if (!isExecutableFile(candidate)) continue;
1212
+ const real = realPath(candidate);
1213
+ if (isAgentRelayCodexShim(candidate) || isAgentRelayCodexShim(real)) continue;
1214
+ return candidate;
1215
+ }
1216
+ return command;
1217
+ }
1218
+
1219
+ function isExecutableFile(path: string): boolean {
1220
+ try {
1221
+ accessSync(path, constants.X_OK);
1222
+ return true;
1223
+ } catch {
1224
+ return false;
1225
+ }
1226
+ }
1227
+
1228
+ function realPath(path: string): string {
1229
+ try {
1230
+ return realpathSync(path);
1231
+ } catch {
1232
+ return path;
1233
+ }
1234
+ }
1235
+
1236
+ function isAgentRelayCodexShim(path: string): boolean {
1237
+ return path.includes("/.agent-relay/codex/bin/codex");
1238
+ }
1239
+
1240
+ // #557 — bundledCodexSkillDirs + bundledSkillConfigArgs now live in ../launch-assembly
1241
+ // (the single launch-arg home, shared with the assembler); re-exported so existing
1242
+ // codex.ts consumers + tests keep their import path. tomlString likewise from ../relay-mcp.
1243
+ export { bundledCodexSkillDirs, bundledSkillConfigArgs };
1244
+ export { tomlString };
1245
+
1246
+ export function codexAppServerConfigArgs(...argLists: string[][]): string[] {
1247
+ const result: string[] = [];
1248
+ for (const args of argLists) {
1249
+ for (let i = 0; i < args.length; i++) {
1250
+ const arg = args[i];
1251
+ if (arg === "-c" || arg === "--config" || arg === "--enable" || arg === "--disable") {
1252
+ const value = args[i + 1];
1253
+ if (value !== undefined) {
1254
+ result.push(arg, value);
1255
+ i++;
1256
+ }
1257
+ } else if (arg?.startsWith("--config=") || arg?.startsWith("--enable=") || arg?.startsWith("--disable=")) {
1258
+ result.push(arg);
1259
+ }
1260
+ }
1261
+ }
1262
+ return result;
1263
+ }
1264
+
1265
+ export function codexToolOutputTokenLimitConfigArgs(config: Pick<RunnerSpawnConfig, "agentProfile">): string[] {
1266
+ const configured = codexToolOutputTokenLimit(config.agentProfile?.providerOptions);
1267
+ if (configured === null) return [];
1268
+ const limit = configured ?? DEFAULT_CODEX_TOOL_OUTPUT_TOKEN_LIMIT;
1269
+ return ["-c", `tool_output_token_limit=${limit}`];
1270
+ }
1271
+
1272
+ function codexToolOutputTokenLimit(providerOptions: unknown): number | null | undefined {
1273
+ if (!isRecord(providerOptions)) return undefined;
1274
+ const codex = providerOptions.codex;
1275
+ if (!isRecord(codex)) return undefined;
1276
+ const limit = codex.toolOutputTokenLimit;
1277
+ return typeof limit === "number" || limit === null ? limit : undefined;
1278
+ }
1279
+
1280
+
1281
+ async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<void> {
1282
+ // Give the freshly-spawned app-server a moment to bind its socket before the
1283
+ // first attempt, so we don't spend the retry budget on guaranteed refusals.
1284
+ await Bun.sleep(150);
1285
+ let lastError: unknown;
1286
+ for (let i = 0; i < attempts; i++) {
1287
+ try {
1288
+ await client.connect();
1289
+ return;
1290
+ } catch (error) {
1291
+ lastError = error;
1292
+ await Bun.sleep(250);
1293
+ }
1294
+ }
1295
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
1296
+ }
1297
+
1298
+ async function terminateProcessTree(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
1299
+ const processes = [
1300
+ process.meta?.tui as Bun.Subprocess | undefined,
1301
+ process.meta?.appServer as Bun.Subprocess | undefined,
1302
+ process.process,
1303
+ ].filter(Boolean) as Bun.Subprocess[];
1304
+ if (processes.length === 0) return;
1305
+ const rootPids = processes.map((proc) => proc.pid).filter((pid): pid is number => typeof pid === "number" && pid > 0);
1306
+ const pids = await processTreePids(rootPids).catch(() => rootPids);
1307
+ const signal = opts.graceful ? "SIGTERM" : "SIGKILL";
1308
+ try {
1309
+ for (const pid of pids) killPid(pid, signal);
1310
+ } catch {
1311
+ return;
1312
+ }
1313
+ const exited = await waitForPidsExit(pids, opts.timeoutMs);
1314
+ const alive = pids.filter(isPidAlive);
1315
+ if (!exited || alive.length > 0) {
1316
+ try {
1317
+ for (const pid of alive) killPid(pid, "SIGKILL");
1318
+ } catch {}
1319
+ await waitForPidsExit(alive, 1_000);
1320
+ await Promise.race([
1321
+ Promise.all(processes.map((proc) => proc.exited.catch(() => {}))),
1322
+ Bun.sleep(1_000),
1323
+ ]);
1324
+ }
1325
+ }
1326
+
1327
+ function codexRelayContextEnabled(process: ManagedProcess): boolean {
1328
+ const config = process.meta?.config as RunnerSpawnConfig | undefined;
1329
+ return config ? profileAllowsRelayFeature(config, "context") : true;
1330
+ }
1331
+
1332
+ // Exported for integration coverage (#462): Codex has no `--append-system-prompt`
1333
+ // flag — the composed systemPromptAppend rides in as the launch-context message
1334
+ // prepended to the first turn. This is the Codex twin of Claude's append arg.
1335
+ export function codexLaunchContext(process: ManagedProcess): string | undefined {
1336
+ const config = process.meta?.config as RunnerSpawnConfig | undefined;
1337
+ const text = config?.systemPromptAppend?.trim();
1338
+ if (!text || process.meta?.systemPromptAppendSent) return undefined;
1339
+ process.meta = { ...(process.meta ?? {}), systemPromptAppendSent: true };
1340
+ return [
1341
+ "[agent-relay launch context]",
1342
+ "The following context was attached when this provider session was launched. Treat it as background context, not as a user request and not as your own prior actions.",
1343
+ text,
1344
+ ].join("\n");
1345
+ }