1061 lines
35 KiB
TypeScript
1061 lines
35 KiB
TypeScript
/**
|
|
* Codex backend — real implementation over `codex app-server`.
|
|
*
|
|
* One scoped app-server process owns one persistent Codex thread. The server
|
|
* speaks LF-delimited JSON-RPC over stdio: initialize, thread/start, and
|
|
* turn/start drive runs; v2 item notifications are translated into normalized
|
|
* SubagentEvents. send() queues a follow-up turn while busy, and interrupt uses
|
|
* turn/interrupt with a local deadline so a missing server acknowledgement can
|
|
* never leave the manager stuck in "running".
|
|
*/
|
|
|
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import type { Cause, Scope } from "effect";
|
|
import { Effect, Queue, Stream } from "effect";
|
|
import type { SubagentBackend, SubagentSession } from "../backend.ts";
|
|
import type {
|
|
ReasoningEffort,
|
|
RunOutcome,
|
|
SpawnTask,
|
|
SubagentEvent,
|
|
SubagentMeta,
|
|
TranscriptPart,
|
|
} from "../domain.ts";
|
|
import { SendError, SpawnError } from "../domain.ts";
|
|
|
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
const MODEL_LIST_TIMEOUT_MS = 5_000;
|
|
const INTERRUPT_FALLBACK_MS = 1_500;
|
|
const FORCE_KILL_AFTER_MS = 2_000;
|
|
const PREVIEW_MAX_LENGTH = 1_024;
|
|
/** A protocol line larger than this without a newline means a broken peer. */
|
|
const STDOUT_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
interface PendingRequest {
|
|
readonly resolve: (result: JsonRecord) => void;
|
|
readonly reject: (error: Error) => void;
|
|
readonly timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
interface ToolState {
|
|
readonly name: string;
|
|
output: string;
|
|
}
|
|
|
|
// --- Binary + protocol helpers -----------------------------------------------
|
|
|
|
let cachedCodexBinary: string | null | undefined;
|
|
|
|
function executable(file: string) {
|
|
try {
|
|
fs.accessSync(file, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Resolve once on first use; availability checks after that are allocation-only. */
|
|
function resolveCodexBinary() {
|
|
if (cachedCodexBinary !== undefined) return cachedCodexBinary ?? undefined;
|
|
const names =
|
|
process.platform === "win32" ? ["codex.exe", "codex.cmd"] : ["codex"];
|
|
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
if (!directory) continue;
|
|
for (const name of names) {
|
|
const candidate = path.join(directory, name);
|
|
if (executable(candidate)) {
|
|
cachedCodexBinary = candidate;
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
cachedCodexBinary = null;
|
|
return undefined;
|
|
}
|
|
|
|
function record(value: unknown): JsonRecord | undefined {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? (value as JsonRecord)
|
|
: undefined;
|
|
}
|
|
|
|
function stringValue(value: unknown) {
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function numberValue(value: unknown) {
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function booleanValue(value: unknown) {
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
function records(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.map(record).filter((item): item is JsonRecord => item !== undefined)
|
|
: [];
|
|
}
|
|
|
|
function strings(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
}
|
|
|
|
function safeJson(value: unknown) {
|
|
try {
|
|
const text = JSON.stringify(value);
|
|
return text === undefined ? undefined : text.slice(0, PREVIEW_MAX_LENGTH);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function firstLine(value: unknown) {
|
|
if (typeof value !== "string") return undefined;
|
|
const line = value.split("\n").find((candidate) => candidate.trim());
|
|
return line?.trim().slice(0, PREVIEW_MAX_LENGTH);
|
|
}
|
|
|
|
function boundedError(error: unknown) {
|
|
return (error instanceof Error ? error.message : String(error)).slice(
|
|
0,
|
|
4096,
|
|
);
|
|
}
|
|
|
|
function protocolError(value: unknown) {
|
|
const error = record(value);
|
|
const message = stringValue(error?.message);
|
|
return boundedError(
|
|
message ?? safeJson(value) ?? "Codex app-server request failed",
|
|
);
|
|
}
|
|
|
|
/** 0.144.3 accepts these effort slugs; individual models expose a subset. */
|
|
function preferredCodexEffort(effort: ReasoningEffort | undefined) {
|
|
switch (effort) {
|
|
case "off":
|
|
case "minimal":
|
|
return "minimal";
|
|
case "low":
|
|
case "medium":
|
|
case "high":
|
|
return effort;
|
|
case "xhigh":
|
|
case "max":
|
|
return "xhigh";
|
|
case undefined:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Clamp against model/list because, for example, some models use none instead of minimal. */
|
|
function supportedCodexEffort(
|
|
effort: ReasoningEffort | undefined,
|
|
modelLabel: string | undefined,
|
|
modelList: JsonRecord | undefined,
|
|
) {
|
|
const preferred = preferredCodexEffort(effort);
|
|
if (!preferred) return undefined;
|
|
const models = records(modelList?.data);
|
|
const model =
|
|
models.find(
|
|
(candidate) =>
|
|
stringValue(candidate.id) === modelLabel ||
|
|
stringValue(candidate.model) === modelLabel,
|
|
) ?? models.find((candidate) => candidate.isDefault === true);
|
|
if (!model) return preferred;
|
|
const supported = records(model.supportedReasoningEfforts)
|
|
.map((option) => stringValue(option.reasoningEffort))
|
|
.filter((value): value is string => value !== undefined);
|
|
if (supported.includes(preferred)) return preferred;
|
|
|
|
const scale = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
const target = scale.indexOf(preferred);
|
|
const candidates = supported
|
|
.map((value) => ({
|
|
value,
|
|
index: scale.indexOf(value as (typeof scale)[number]),
|
|
}))
|
|
.filter((candidate) => candidate.index >= 0)
|
|
.sort((a, b) => {
|
|
const distance = Math.abs(a.index - target) - Math.abs(b.index - target);
|
|
if (distance !== 0) return distance;
|
|
// "off" biases down to none; all other values bias toward more reasoning.
|
|
return effort === "off" ? a.index - b.index : b.index - a.index;
|
|
});
|
|
return candidates[0]?.value ?? preferred;
|
|
}
|
|
|
|
function textInput(text: string) {
|
|
return { type: "text", text, text_elements: [] };
|
|
}
|
|
|
|
/**
|
|
* Parse a `thread/tokenUsage/updated` payload into context occupancy.
|
|
* `tokenUsage.total` accumulates every request in the thread (cached prompt
|
|
* tokens re-counted per request), so it is a cumulative spend counter — not
|
|
* occupancy — and treating it as occupancy pinned the gauge at 100%.
|
|
* `tokenUsage.last` is the most recent request, whose totalTokens is what
|
|
* codex-rs itself uses as `tokens_in_context_window()`.
|
|
*/
|
|
export function parseThreadTokenUsage(params: unknown) {
|
|
const usage = record(record(params)?.tokenUsage);
|
|
const last = record(usage?.last);
|
|
return {
|
|
tokens: numberValue(last?.totalTokens),
|
|
contextWindow: numberValue(usage?.modelContextWindow),
|
|
};
|
|
}
|
|
|
|
// --- Item translation --------------------------------------------------------
|
|
|
|
function fileChangePreview(item: JsonRecord) {
|
|
const paths = records(item.changes)
|
|
.map((change) => stringValue(change.path))
|
|
.filter((value): value is string => value !== undefined);
|
|
return paths.length > 0
|
|
? paths.join(", ").slice(0, PREVIEW_MAX_LENGTH)
|
|
: undefined;
|
|
}
|
|
|
|
function toolDescription(
|
|
item: JsonRecord,
|
|
): { id: string; name: string; args?: string } | undefined {
|
|
const id = stringValue(item.id);
|
|
const type = stringValue(item.type);
|
|
if (!id || !type) return undefined;
|
|
switch (type) {
|
|
case "commandExecution":
|
|
return { id, name: "shell", args: firstLine(item.command) };
|
|
case "fileChange":
|
|
return { id, name: "apply_patch", args: fileChangePreview(item) };
|
|
case "webSearch":
|
|
return { id, name: "web_search", args: firstLine(item.query) };
|
|
case "mcpToolCall": {
|
|
const server = stringValue(item.server);
|
|
const tool = stringValue(item.tool) ?? "tool";
|
|
return {
|
|
id,
|
|
name: server ? `${server}/${tool}` : tool,
|
|
args: safeJson(item.arguments),
|
|
};
|
|
}
|
|
case "dynamicToolCall": {
|
|
const namespace = stringValue(item.namespace);
|
|
const tool = stringValue(item.tool) ?? "tool";
|
|
return {
|
|
id,
|
|
name: namespace ? `${namespace}/${tool}` : tool,
|
|
args: safeJson(item.arguments),
|
|
};
|
|
}
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function toolOutput(item: JsonRecord, buffered: string) {
|
|
switch (stringValue(item.type)) {
|
|
case "commandExecution":
|
|
return stringValue(item.aggregatedOutput) ?? buffered;
|
|
case "fileChange":
|
|
return fileChangePreview(item);
|
|
case "webSearch":
|
|
return stringValue(item.query);
|
|
case "mcpToolCall":
|
|
return safeJson(item.result ?? item.error);
|
|
case "dynamicToolCall": {
|
|
const text = records(item.contentItems)
|
|
.map((content) => stringValue(content.text))
|
|
.filter((value): value is string => value !== undefined)
|
|
.join("\n");
|
|
return text || safeJson(item.contentItems);
|
|
}
|
|
default:
|
|
return buffered;
|
|
}
|
|
}
|
|
|
|
function toolFailed(item: JsonRecord) {
|
|
const status = stringValue(item.status);
|
|
const exitCode = numberValue(item.exitCode);
|
|
const success = booleanValue(item.success);
|
|
return (
|
|
(exitCode !== undefined && exitCode !== 0) ||
|
|
success === false ||
|
|
status === "failed" ||
|
|
status === "declined" ||
|
|
status === "cancelled"
|
|
);
|
|
}
|
|
|
|
// --- The session -------------------------------------------------------------
|
|
|
|
const makeCodexSession = (
|
|
task: SpawnTask,
|
|
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
|
|
Effect.gen(function* () {
|
|
const binary = resolveCodexBinary();
|
|
if (!binary) {
|
|
return yield* new SpawnError({
|
|
message: "codex executable was not found on PATH.",
|
|
});
|
|
}
|
|
|
|
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
|
|
const emit = (event: SubagentEvent) => {
|
|
Queue.offerUnsafe(events, event);
|
|
};
|
|
|
|
const child = yield* Effect.try({
|
|
try: () =>
|
|
spawn(binary, ["app-server", "--stdio"], {
|
|
cwd: task.cwd,
|
|
env: process.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
// Own process group on POSIX so teardown can signal the whole
|
|
// tree: a wedged app-server must not orphan a still-running
|
|
// shell command it spawned.
|
|
detached: process.platform !== "win32",
|
|
}),
|
|
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
});
|
|
|
|
const state = {
|
|
closed: false,
|
|
closing: false,
|
|
exited: false,
|
|
activeRun: false,
|
|
dispatching: false,
|
|
interruptRequested: false,
|
|
effort: preferredCodexEffort(task.reasoningEffort),
|
|
runSerial: 0,
|
|
activeTurnId: undefined as string | undefined,
|
|
runError: undefined as string | undefined,
|
|
finalText: "",
|
|
lastAssistantText: "",
|
|
pendingPrompts: [] as string[],
|
|
nextRequestId: 0,
|
|
stderr: "",
|
|
meta: {
|
|
backend: "codex",
|
|
modelLabel: task.model,
|
|
} satisfies SubagentMeta as SubagentMeta,
|
|
interruptTimer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
};
|
|
const pendingRequests = new Map<number, PendingRequest>();
|
|
const tools = new Map<string, ToolState>();
|
|
/** Turns locally settled by the interrupt fallback may still emit late events. */
|
|
const ignoredTurnIds = new Set<string>();
|
|
|
|
const writeMessage = (message: JsonRecord) => {
|
|
if (state.closed || !child.stdin.writable) return false;
|
|
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
return true;
|
|
};
|
|
|
|
const request = (
|
|
method: string,
|
|
params: JsonRecord,
|
|
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
) =>
|
|
new Promise<JsonRecord>((resolve, reject) => {
|
|
if (state.closed) {
|
|
reject(new Error("Codex app-server is closed."));
|
|
return;
|
|
}
|
|
const id = ++state.nextRequestId;
|
|
const timer = setTimeout(() => {
|
|
pendingRequests.delete(id);
|
|
reject(new Error(`Codex app-server request ${method} timed out.`));
|
|
}, timeoutMs);
|
|
pendingRequests.set(id, { resolve, reject, timer });
|
|
if (!writeMessage({ id, method, params })) {
|
|
clearTimeout(timer);
|
|
pendingRequests.delete(id);
|
|
reject(new Error("Codex app-server stdin is closed."));
|
|
}
|
|
});
|
|
|
|
const rejectPending = (message: string) => {
|
|
for (const pending of pendingRequests.values()) {
|
|
clearTimeout(pending.timer);
|
|
pending.reject(new Error(message));
|
|
}
|
|
pendingRequests.clear();
|
|
};
|
|
|
|
const queuedView = () =>
|
|
state.pendingPrompts.map((text) => ({
|
|
text,
|
|
kind: "follow-up" as const,
|
|
}));
|
|
|
|
const startNextQueued = () => {
|
|
if (state.closed || state.activeRun) return;
|
|
const next = state.pendingPrompts.shift();
|
|
if (next === undefined) return;
|
|
emit({ _tag: "QueueChanged", queued: queuedView() });
|
|
startRun(next);
|
|
};
|
|
|
|
const settleRun = (outcome: RunOutcome, serial = state.runSerial) => {
|
|
if (!state.activeRun || serial !== state.runSerial) return;
|
|
if (state.interruptTimer) clearTimeout(state.interruptTimer);
|
|
state.interruptTimer = undefined;
|
|
state.activeRun = false;
|
|
state.dispatching = false;
|
|
// A late turn/completed for this turn must not be misattributed to the
|
|
// next queued run (which starts with no activeTurnId to filter on).
|
|
if (state.activeTurnId) ignoredTurnIds.add(state.activeTurnId);
|
|
state.activeTurnId = undefined;
|
|
state.interruptRequested = false;
|
|
tools.clear();
|
|
emit({ _tag: "RunSettled", outcome });
|
|
queueMicrotask(startNextQueued);
|
|
};
|
|
|
|
const sendInterrupt = (serial: number) => {
|
|
const turnId = state.activeTurnId;
|
|
const threadId = state.meta.nativeSessionId;
|
|
if (
|
|
!turnId ||
|
|
!threadId ||
|
|
!state.activeRun ||
|
|
serial !== state.runSerial
|
|
)
|
|
return;
|
|
void request(
|
|
"turn/interrupt",
|
|
{ threadId, turnId },
|
|
INTERRUPT_FALLBACK_MS,
|
|
).catch((error) => {
|
|
if (state.activeRun && serial === state.runSerial) {
|
|
emit({ _tag: "BackendError", message: boundedError(error) });
|
|
}
|
|
});
|
|
};
|
|
|
|
function startRun(text: string) {
|
|
if (state.closed || state.activeRun) return;
|
|
const threadId = state.meta.nativeSessionId;
|
|
if (!threadId) return;
|
|
const serial = ++state.runSerial;
|
|
state.activeRun = true;
|
|
state.dispatching = true;
|
|
state.interruptRequested = false;
|
|
state.activeTurnId = undefined;
|
|
state.runError = undefined;
|
|
state.finalText = "";
|
|
state.lastAssistantText = "";
|
|
emit({ _tag: "UserMessage", text });
|
|
emit({ _tag: "RunStarted" });
|
|
|
|
const params: JsonRecord = {
|
|
threadId,
|
|
input: [textInput(text)],
|
|
...(state.effort ? { effort: state.effort } : {}),
|
|
};
|
|
void request("turn/start", params).then(
|
|
(result) => {
|
|
const turn = record(result.turn);
|
|
const turnId = stringValue(turn?.id);
|
|
if (!state.activeRun || serial !== state.runSerial) {
|
|
if (turnId) {
|
|
// The run was already settled locally (interrupt fallback or
|
|
// failure), so whatever native turn this response describes is
|
|
// invisible work — stop it unconditionally.
|
|
ignoredTurnIds.add(turnId);
|
|
void request(
|
|
"turn/interrupt",
|
|
{ threadId, turnId },
|
|
INTERRUPT_FALLBACK_MS,
|
|
).catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
state.dispatching = false;
|
|
state.activeTurnId = turnId ?? state.activeTurnId;
|
|
if (state.interruptRequested) sendInterrupt(serial);
|
|
},
|
|
(error) => {
|
|
if (!state.activeRun || serial !== state.runSerial) return;
|
|
const errorText = boundedError(error);
|
|
settleRun(
|
|
state.interruptRequested
|
|
? {
|
|
_tag: "Interrupted",
|
|
partialText: state.finalText || undefined,
|
|
}
|
|
: {
|
|
_tag: "Failed",
|
|
errorText,
|
|
partialText: state.finalText || undefined,
|
|
},
|
|
serial,
|
|
);
|
|
// A timed-out turn/start means a turn may be running that we can
|
|
// never see or interrupt (no turn id). That session cannot be
|
|
// trusted with further work — kill it; the exit handler reports
|
|
// the death. Explicit protocol rejections keep the session alive.
|
|
if (errorText.includes("timed out")) {
|
|
void terminateChild(child, () => state.exited);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
const emitToolStart = (item: JsonRecord) => {
|
|
const tool = toolDescription(item);
|
|
if (!tool || tools.has(tool.id)) return;
|
|
tools.set(tool.id, { name: tool.name, output: "" });
|
|
const toolPart: TranscriptPart = {
|
|
type: "toolCall",
|
|
toolId: tool.id,
|
|
name: tool.name,
|
|
argsPreview: tool.args,
|
|
};
|
|
emit({ _tag: "AssistantMessage", parts: [toolPart] });
|
|
emit({
|
|
_tag: "ToolStart",
|
|
toolId: tool.id,
|
|
name: tool.name,
|
|
argsPreview: tool.args,
|
|
});
|
|
};
|
|
|
|
const emitToolEnd = (item: JsonRecord) => {
|
|
const description = toolDescription(item);
|
|
if (!description) return;
|
|
if (!tools.has(description.id)) emitToolStart(item);
|
|
const live = tools.get(description.id);
|
|
const output = toolOutput(item, live?.output ?? "");
|
|
tools.delete(description.id);
|
|
emit({
|
|
_tag: "ToolEnd",
|
|
toolId: description.id,
|
|
name: live?.name ?? description.name,
|
|
isError: toolFailed(item),
|
|
outputPreview: firstLine(output),
|
|
});
|
|
};
|
|
|
|
const handleItemCompleted = (item: JsonRecord) => {
|
|
const type = stringValue(item.type);
|
|
if (type === "agentMessage") {
|
|
const text = stringValue(item.text) ?? "";
|
|
if (text) {
|
|
emit({ _tag: "AssistantMessage", parts: [{ type: "text", text }] });
|
|
state.lastAssistantText = text;
|
|
if (stringValue(item.phase) === "final_answer")
|
|
state.finalText = text;
|
|
}
|
|
return;
|
|
}
|
|
if (type === "reasoning") {
|
|
const thinking = [
|
|
...strings(item.summary),
|
|
...strings(item.content),
|
|
].join("\n");
|
|
if (thinking) {
|
|
emit({
|
|
_tag: "AssistantMessage",
|
|
parts: [{ type: "thinking", text: thinking }],
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
emitToolEnd(item);
|
|
};
|
|
|
|
const handleNotification = (message: JsonRecord) => {
|
|
if (state.closed) return;
|
|
const method = stringValue(message.method);
|
|
const params = record(message.params) ?? {};
|
|
const notificationTurn = record(params.turn);
|
|
const turnId =
|
|
stringValue(params.turnId) ?? stringValue(notificationTurn?.id);
|
|
if (turnId && ignoredTurnIds.has(turnId)) return;
|
|
const belongsToRun =
|
|
method === "error" ||
|
|
method?.startsWith("turn/") === true ||
|
|
method?.startsWith("item/") === true ||
|
|
method === "thread/tokenUsage/updated";
|
|
if (belongsToRun && !state.activeRun) {
|
|
if (turnId) ignoredTurnIds.add(turnId);
|
|
return;
|
|
}
|
|
if (
|
|
belongsToRun &&
|
|
turnId &&
|
|
state.activeTurnId &&
|
|
turnId !== state.activeTurnId
|
|
) {
|
|
return;
|
|
}
|
|
switch (method) {
|
|
case "thread/started": {
|
|
const thread = record(params.thread);
|
|
const id = stringValue(thread?.id);
|
|
const sessionFilePath = stringValue(thread?.path);
|
|
if (id) state.meta = { ...state.meta, nativeSessionId: id };
|
|
if (sessionFilePath) state.meta = { ...state.meta, sessionFilePath };
|
|
emit({ _tag: "MetaChanged", meta: state.meta });
|
|
break;
|
|
}
|
|
case "thread/settings/updated": {
|
|
const settings = record(params.threadSettings);
|
|
const modelLabel = stringValue(settings?.model);
|
|
if (modelLabel) {
|
|
state.meta = { ...state.meta, modelLabel };
|
|
emit({ _tag: "MetaChanged", meta: { modelLabel } });
|
|
}
|
|
break;
|
|
}
|
|
case "model/rerouted": {
|
|
const modelLabel = stringValue(params.toModel);
|
|
if (modelLabel) {
|
|
state.meta = { ...state.meta, modelLabel };
|
|
emit({ _tag: "MetaChanged", meta: { modelLabel } });
|
|
}
|
|
break;
|
|
}
|
|
case "turn/started": {
|
|
const turn = record(params.turn);
|
|
const startedId = stringValue(turn?.id);
|
|
// Only adopt a turn we are actually waiting on. A stale start from
|
|
// a run the interrupt fallback settled before its turn/start
|
|
// response arrived would otherwise capture activeTurnId and filter
|
|
// out every event of the real next turn.
|
|
if (!state.dispatching && startedId !== state.activeTurnId) {
|
|
if (startedId) ignoredTurnIds.add(startedId);
|
|
break;
|
|
}
|
|
state.activeTurnId = startedId ?? state.activeTurnId;
|
|
state.dispatching = false;
|
|
emit({ _tag: "RunStarted" });
|
|
if (state.interruptRequested) sendInterrupt(state.runSerial);
|
|
break;
|
|
}
|
|
case "item/agentMessage/delta": {
|
|
const delta = stringValue(params.delta);
|
|
if (delta) emit({ _tag: "AssistantDelta", kind: "text", delta });
|
|
break;
|
|
}
|
|
case "item/reasoning/summaryTextDelta":
|
|
case "item/reasoning/textDelta": {
|
|
const delta = stringValue(params.delta);
|
|
if (delta) emit({ _tag: "AssistantDelta", kind: "thinking", delta });
|
|
break;
|
|
}
|
|
case "item/started": {
|
|
const item = record(params.item);
|
|
if (item) emitToolStart(item);
|
|
break;
|
|
}
|
|
case "item/completed": {
|
|
const item = record(params.item);
|
|
if (item) handleItemCompleted(item);
|
|
break;
|
|
}
|
|
case "item/commandExecution/outputDelta":
|
|
case "item/fileChange/outputDelta": {
|
|
const id = stringValue(params.itemId);
|
|
const delta = stringValue(params.delta);
|
|
const tool = id ? tools.get(id) : undefined;
|
|
if (id && tool && delta) {
|
|
tool.output = `${tool.output}${delta}`.slice(-16_384);
|
|
emit({
|
|
_tag: "ToolUpdate",
|
|
toolId: id,
|
|
outputPreview: firstLine(tool.output),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "item/fileChange/patchUpdated": {
|
|
const id = stringValue(params.itemId);
|
|
if (id) {
|
|
emit({
|
|
_tag: "ToolUpdate",
|
|
toolId: id,
|
|
outputPreview: fileChangePreview({ changes: params.changes }),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "item/mcpToolCall/progress": {
|
|
const id = stringValue(params.itemId);
|
|
if (id) {
|
|
emit({
|
|
_tag: "ToolUpdate",
|
|
toolId: id,
|
|
outputPreview: firstLine(params.message),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "thread/tokenUsage/updated": {
|
|
const { tokens, contextWindow } = parseThreadTokenUsage(params);
|
|
if (contextWindow !== undefined) {
|
|
state.meta = { ...state.meta, contextWindow };
|
|
emit({ _tag: "MetaChanged", meta: { contextWindow } });
|
|
}
|
|
emit({ _tag: "UsageChanged", tokens, contextWindow });
|
|
break;
|
|
}
|
|
case "error": {
|
|
const error = record(params.error);
|
|
const messageText = boundedError(
|
|
stringValue(error?.message) ?? "Codex run failed",
|
|
);
|
|
if (params.willRetry !== true) state.runError = messageText;
|
|
emit({ _tag: "BackendError", message: messageText });
|
|
break;
|
|
}
|
|
case "turn/completed": {
|
|
const turn = record(params.turn);
|
|
const status = stringValue(turn?.status);
|
|
const error = record(turn?.error);
|
|
const partialText =
|
|
state.finalText || state.lastAssistantText || undefined;
|
|
if (state.interruptRequested || status === "interrupted") {
|
|
settleRun({ _tag: "Interrupted", partialText });
|
|
} else if (status === "failed") {
|
|
settleRun({
|
|
_tag: "Failed",
|
|
errorText: boundedError(
|
|
state.runError ??
|
|
stringValue(error?.message) ??
|
|
"Codex run failed",
|
|
),
|
|
partialText,
|
|
});
|
|
} else {
|
|
settleRun({
|
|
_tag: "Completed",
|
|
finalText: state.finalText || state.lastAssistantText,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleServerRequest = (message: JsonRecord) => {
|
|
const id = message.id;
|
|
if (typeof id !== "number" && typeof id !== "string") return;
|
|
const method = stringValue(message.method);
|
|
if (
|
|
method === "item/commandExecution/requestApproval" ||
|
|
method === "item/fileChange/requestApproval"
|
|
) {
|
|
writeMessage({ id, result: { decision: "decline" } });
|
|
return;
|
|
}
|
|
writeMessage({
|
|
id,
|
|
error: {
|
|
code: -32601,
|
|
message: `Unsupported headless server request: ${method ?? "unknown"}`,
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleLine = (line: string) => {
|
|
if (!line.trim()) return;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
emit({
|
|
_tag: "BackendError",
|
|
message: `Invalid Codex protocol line: ${line.slice(0, 512)}`,
|
|
});
|
|
return;
|
|
}
|
|
const message = record(parsed);
|
|
if (!message) return;
|
|
const id = numberValue(message.id);
|
|
if (id !== undefined && pendingRequests.has(id)) {
|
|
const pending = pendingRequests.get(id);
|
|
if (!pending) return;
|
|
pendingRequests.delete(id);
|
|
clearTimeout(pending.timer);
|
|
if (message.error !== undefined)
|
|
pending.reject(new Error(protocolError(message.error)));
|
|
else pending.resolve(record(message.result) ?? {});
|
|
return;
|
|
}
|
|
if (message.id !== undefined && message.method !== undefined)
|
|
handleServerRequest(message);
|
|
else if (message.method !== undefined) handleNotification(message);
|
|
};
|
|
|
|
const failForProcessExit = (detail: string) => {
|
|
if (state.exited) return;
|
|
state.exited = true;
|
|
rejectPending(detail);
|
|
if (state.closing) return;
|
|
state.closed = true;
|
|
state.pendingPrompts = [];
|
|
emit({ _tag: "QueueChanged", queued: [] });
|
|
if (state.activeRun) {
|
|
settleRun({
|
|
_tag: "Failed",
|
|
errorText: boundedError(detail),
|
|
partialText: state.finalText || state.lastAssistantText || undefined,
|
|
});
|
|
}
|
|
Queue.endUnsafe(events);
|
|
};
|
|
|
|
let stdoutBuffer = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdoutBuffer += chunk;
|
|
while (true) {
|
|
const newline = stdoutBuffer.indexOf("\n");
|
|
if (newline < 0) break;
|
|
const line = stdoutBuffer.slice(0, newline).replace(/\r$/, "");
|
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
handleLine(line);
|
|
}
|
|
if (stdoutBuffer.length > STDOUT_BUFFER_MAX_BYTES) {
|
|
// A frame this large with no newline is protocol corruption, and an
|
|
// unbounded buffer is a memory leak. Session-fatal: the exit handler
|
|
// settles any active run.
|
|
stdoutBuffer = "";
|
|
void terminateChild(child, () => state.exited);
|
|
}
|
|
});
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk: string) => {
|
|
state.stderr = `${state.stderr}${chunk}`.slice(-4096);
|
|
});
|
|
child.once("error", (error) =>
|
|
failForProcessExit(`Codex app-server failed: ${boundedError(error)}`),
|
|
);
|
|
child.once("exit", (code, signal) => {
|
|
const suffix = firstLine(state.stderr);
|
|
failForProcessExit(
|
|
`Codex app-server exited (${signal ?? `code ${code ?? "unknown"}`})${suffix ? `: ${suffix}` : ""}`,
|
|
);
|
|
});
|
|
|
|
yield* Effect.addFinalizer(() =>
|
|
Effect.promise(async () => {
|
|
if (state.closing) return;
|
|
state.closing = true;
|
|
if (state.interruptTimer) clearTimeout(state.interruptTimer);
|
|
// Settle before marking closed so the run gets the correct
|
|
// "Interrupted" outcome instead of the pump's generic fallback.
|
|
if (state.activeRun) {
|
|
settleRun({
|
|
_tag: "Interrupted",
|
|
partialText:
|
|
state.finalText || state.lastAssistantText || undefined,
|
|
});
|
|
}
|
|
state.closed = true;
|
|
rejectPending("Codex session closed.");
|
|
await terminateChild(child, () => state.exited);
|
|
Queue.endUnsafe(events);
|
|
}),
|
|
);
|
|
|
|
const threadResult = yield* Effect.tryPromise({
|
|
try: async () => {
|
|
await request("initialize", {
|
|
clientInfo: {
|
|
name: "pi-subagents",
|
|
title: "pi subagent",
|
|
version: "2.0.0",
|
|
},
|
|
capabilities: { experimentalApi: true },
|
|
});
|
|
writeMessage({ method: "initialized" });
|
|
// Headless children cannot answer approval prompts. The caller
|
|
// already chose to launch an autonomous subagent, so give the thread
|
|
// full workspace access without interactive approval requests.
|
|
return request("thread/start", {
|
|
cwd: task.cwd,
|
|
approvalPolicy: "never",
|
|
sandbox: "danger-full-access",
|
|
ephemeral: false,
|
|
...(task.model ? { model: task.model } : {}),
|
|
});
|
|
},
|
|
catch: (error) => new SpawnError({ message: boundedError(error) }),
|
|
});
|
|
|
|
const thread = record(threadResult.thread);
|
|
const nativeSessionId = stringValue(thread?.id);
|
|
if (!nativeSessionId) {
|
|
return yield* new SpawnError({
|
|
message: "Codex thread/start returned no thread id.",
|
|
});
|
|
}
|
|
state.meta = {
|
|
backend: "codex",
|
|
modelLabel: stringValue(threadResult.model) ?? task.model,
|
|
sessionFilePath: stringValue(thread?.path),
|
|
nativeSessionId,
|
|
};
|
|
if (task.reasoningEffort) {
|
|
// Optional capability probe: never let a slow/unsupported model/list
|
|
// hold up the spawn (and its concurrency reservation) for the full
|
|
// request timeout; the unclamped preferred effort is a fine fallback.
|
|
const modelList = yield* Effect.tryPromise(() =>
|
|
request("model/list", { includeHidden: true }, MODEL_LIST_TIMEOUT_MS),
|
|
).pipe(Effect.orElseSucceed(() => undefined));
|
|
state.effort = supportedCodexEffort(
|
|
task.reasoningEffort,
|
|
state.meta.modelLabel,
|
|
modelList,
|
|
);
|
|
}
|
|
emit({ _tag: "MetaChanged", meta: state.meta });
|
|
startRun(task.prompt);
|
|
|
|
return {
|
|
meta: Effect.sync(() => state.meta),
|
|
events: Stream.fromQueue(events),
|
|
send: (text) =>
|
|
Effect.suspend((): Effect.Effect<void, SendError> => {
|
|
if (state.closed) {
|
|
return new SendError({ message: "Subagent session is closed." });
|
|
}
|
|
if (state.activeRun) {
|
|
state.pendingPrompts.push(text);
|
|
emit({ _tag: "QueueChanged", queued: queuedView() });
|
|
return Effect.void;
|
|
}
|
|
return Effect.sync(() => startRun(text));
|
|
}),
|
|
interrupt: Effect.promise(async () => {
|
|
if (state.closed || !state.activeRun) return;
|
|
const serial = state.runSerial;
|
|
state.pendingPrompts = [];
|
|
emit({ _tag: "QueueChanged", queued: [] });
|
|
state.interruptRequested = true;
|
|
sendInterrupt(serial);
|
|
if (state.interruptTimer) clearTimeout(state.interruptTimer);
|
|
state.interruptTimer = setTimeout(() => {
|
|
if (state.activeRun && serial === state.runSerial) {
|
|
if (state.activeTurnId) ignoredTurnIds.add(state.activeTurnId);
|
|
settleRun({
|
|
_tag: "Interrupted",
|
|
partialText:
|
|
state.finalText || state.lastAssistantText || undefined,
|
|
});
|
|
// The server never acknowledged the interrupt, so the native
|
|
// turn may still be executing tools. A session that ignores
|
|
// interrupts cannot be trusted — kill it rather than let
|
|
// invisible work continue behind a "settled" run.
|
|
void terminateChild(child, () => state.exited);
|
|
}
|
|
}, INTERRUPT_FALLBACK_MS);
|
|
}),
|
|
} satisfies SubagentSession;
|
|
});
|
|
|
|
/** Signal the whole process group on POSIX so tool descendants (shell
|
|
* commands the app-server spawned) die with it; a wedged or force-killed
|
|
* server must not orphan a still-running command in the workspace. */
|
|
function killTree(
|
|
child: ChildProcessWithoutNullStreams,
|
|
signal: NodeJS.Signals,
|
|
) {
|
|
if (process.platform === "win32" && child.pid) {
|
|
try {
|
|
const killer = spawn(
|
|
"taskkill",
|
|
[
|
|
"/pid",
|
|
String(child.pid),
|
|
"/T",
|
|
...(signal === "SIGKILL" ? ["/F"] : []),
|
|
],
|
|
{ stdio: "ignore", windowsHide: true },
|
|
);
|
|
const killDirect = () => {
|
|
try {
|
|
child.kill(signal);
|
|
} catch {
|
|
// Process may already be gone.
|
|
}
|
|
};
|
|
killer.once("error", killDirect);
|
|
killer.once("exit", (code) => {
|
|
if (code !== 0) killDirect();
|
|
});
|
|
killer.unref();
|
|
return;
|
|
} catch {
|
|
// Fall through to a direct signal when taskkill cannot be launched.
|
|
}
|
|
}
|
|
if (process.platform !== "win32" && child.pid) {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
} catch {
|
|
// Group may already be gone; fall through to the direct signal.
|
|
}
|
|
}
|
|
try {
|
|
child.kill(signal);
|
|
} catch {
|
|
// Process may already be gone.
|
|
}
|
|
}
|
|
|
|
/** SIGTERM is normally enough; the second deadline covers a wedged Rust process. */
|
|
function terminateChild(
|
|
child: ChildProcessWithoutNullStreams,
|
|
exited: () => boolean,
|
|
) {
|
|
if (exited()) return Promise.resolve();
|
|
return new Promise<void>((resolve) => {
|
|
let done = false;
|
|
let forceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let lastTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
if (forceTimer) clearTimeout(forceTimer);
|
|
if (lastTimer) clearTimeout(lastTimer);
|
|
resolve();
|
|
};
|
|
child.once("exit", finish);
|
|
killTree(child, "SIGTERM");
|
|
forceTimer = setTimeout(() => {
|
|
if (!exited()) killTree(child, "SIGKILL");
|
|
}, FORCE_KILL_AFTER_MS);
|
|
lastTimer = setTimeout(finish, FORCE_KILL_AFTER_MS + 500);
|
|
});
|
|
}
|
|
|
|
export const codexBackend: SubagentBackend = {
|
|
name: "codex",
|
|
capabilities: {
|
|
steering: false,
|
|
modelSelection: true,
|
|
reasoningEffort: true,
|
|
},
|
|
available: Effect.sync(() => resolveCodexBinary() !== undefined),
|
|
spawn: makeCodexSession,
|
|
};
|