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.
- package/docs/openapi.json +1 -1
- package/package.json +6 -6
- package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
- package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
- package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
- package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
- package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
- package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
- package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
- package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
- package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
- package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
- package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
- package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
- package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
- package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
- package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
- package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
- package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
- package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
- package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
- package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
- package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
- package/public/assets/display-CJzSoTMq.js +3 -0
- package/public/assets/display-CJzSoTMq.js.map +1 -0
- package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
- package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
- package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
- package/public/assets/index-D0xMQQb_.js +25 -0
- package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
- package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
- package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
- package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
- package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
- package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
- package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
- package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
- package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
- package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
- package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
- package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
- package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
- package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
- package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
- package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
- package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
- package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
- package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
- package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
- package/public/assets/projects-Dzdf-gkK.js +2 -0
- package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
- package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
- package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
- package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
- package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
- package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
- package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
- package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
- package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
- package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
- package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
- package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
- package/public/assets/store-BPrVuKiv.js.map +1 -0
- package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
- package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
- package/public/assets/teams-DWv6UwFV.js +2 -0
- package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
- package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
- package/public/assets/types-BeJUSfDj.js.map +1 -0
- package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
- package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
- package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
- package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
- package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
- package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
- package/runner/src/adapter.ts +53 -18
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +672 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/config.ts +1 -4
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +508 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +43 -0
- package/src/agent-card-projection.ts +5 -1
- package/src/agent-command-activity.ts +39 -0
- package/src/automations/reconcile.ts +2 -2
- package/src/cli/workspace.ts +3 -1
- package/src/config.ts +10 -1
- package/src/db/agents.ts +118 -37
- package/src/db/message-reads.ts +50 -0
- package/src/db/messages.ts +48 -9
- package/src/db/migrations.ts +54 -0
- package/src/db/provider-quota-burn.ts +96 -67
- package/src/db/provider-quotas.ts +29 -7
- package/src/db/scheduled-timers.ts +21 -0
- package/src/db/schema.ts +1 -0
- package/src/db/tasks.ts +33 -3
- package/src/db/work-evidence.ts +10 -7
- package/src/event-loop-lag.ts +15 -0
- package/src/gate-resolver.ts +19 -1
- package/src/maintenance/jobs.ts +23 -6
- package/src/managed-policy.ts +5 -12
- package/src/mcp-plan-tools.ts +13 -1
- package/src/project-instructions.ts +36 -2
- package/src/quota-advisory.ts +6 -4
- package/src/quota-band-transition.ts +23 -14
- package/src/routes/agent-sessions.ts +39 -7
- package/src/routes/agents.ts +3 -2
- package/src/routes/tasks.ts +16 -4
- package/src/routes/workspaces.ts +35 -10
- package/src/services/plan-graph.ts +27 -9
- package/src/services/register-agent.ts +12 -12
- package/src/services/send-message.ts +43 -15
- package/src/services/spawn-agent.ts +3 -0
- package/src/spawn-command.ts +8 -0
- package/src/spawn-targets.ts +19 -34
- package/src/sse.ts +25 -3
- package/src/workspace-merge.ts +25 -5
- package/src/workspace-orphans.ts +13 -6
- package/public/assets/display-exQDWum3.js +0 -3
- package/public/assets/display-exQDWum3.js.map +0 -1
- package/public/assets/index-D9OogaB5.js +0 -25
- package/public/assets/projects-DdPNPFJI.js +0 -2
- package/public/assets/prompt-settings-BpZse7-X.js +0 -2
- package/public/assets/store-DKTLubBT.js.map +0 -1
- package/public/assets/teams-B8f2pGJe.js +0 -2
- 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
|
+
}
|