Files
Aether/aether-vscodex/vscode-extension/src/codexAgentAdapter.ts
T

1915 lines
79 KiB
TypeScript

import { createHash } from "node:crypto";
import {
AgentAdapter,
AgentEvent,
asJsonObject,
asJsonValue,
approvalDecisionKindForMethod,
Disposable,
hasApprovalDecisionField,
isRecord,
JsonObject,
JsonRpcId,
JsonRpcRequest,
JsonValue,
Logger,
PendingApproval,
SessionSnapshot,
isJsonRpcId,
jsonRpcIdKey,
} from "./protocol";
import { JsonlRpcClient, JsonlRpcClientOptions } from "./jsonlRpc";
const APPROVAL_METHODS = new Set([
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
"item/permissions/requestApproval",
"applyPatchApproval",
"execCommandApproval",
]);
const INPUT_REQUEST_METHODS = new Set([
"item/tool/requestUserInput",
"mcpServer/elicitation/request",
]);
// Paginated threads can contain many thousands of items. Keep hydration
// bounded so one history request cannot exhaust the extension host or exceed
// the relay frame limit, while still covering normal long-running sessions.
const HISTORY_PAGE_SIZE = 100;
const MAX_HISTORY_TURN_PAGES = 100;
const MAX_HISTORY_TURNS = HISTORY_PAGE_SIZE * MAX_HISTORY_TURN_PAGES;
const MAX_HISTORY_ITEM_PAGES = 100;
export interface CodexAgentAdapterOptions extends JsonlRpcClientOptions {
clientName?: string;
clientTitle?: string | null;
clientVersion?: string;
initializeCapabilities?: JsonObject;
defaultCwd?: string;
maxOutputTailChars?: number;
approvalTimeoutMs?: number;
/** Handle non-approval server requests (auth refresh, tool calls, etc.). */
onServerRequest?: (request: JsonRpcRequest) => Promise<JsonValue | undefined>;
autoRejectUnsupportedRequests?: boolean;
}
interface PendingRequest {
request: JsonRpcRequest;
approval?: PendingApproval;
timer?: NodeJS.Timeout;
}
/**
* AgentAdapter implementation backed by a child `codex app-server --stdio`.
*
* It deliberately does not shell out for individual tasks. All task and
* approval operations go through the app-server JSON-RPC channel.
*/
export class CodexAgentAdapter implements AgentAdapter {
readonly rpc: JsonlRpcClient;
private readonly options: Required<
Pick<CodexAgentAdapterOptions, "clientName" | "clientVersion" | "maxOutputTailChars" | "approvalTimeoutMs">
> &
Omit<CodexAgentAdapterOptions, "clientName" | "clientVersion" | "maxOutputTailChars" | "approvalTimeoutMs">;
private readonly listeners = new Set<(event: AgentEvent) => void>();
private readonly pending = new Map<string, PendingRequest>();
private threadId: string | null = null;
private turnId: string | null = null;
private state = "disconnected";
private outputTail = "";
private messages: JsonValue[] = [];
private sessionMetadata: JsonObject = {};
private availableModels: JsonValue[] = [];
private historyComplete = true;
private sessionSwitching = false;
private started = false;
private readonly rpcDisposables: Disposable[];
constructor(options: CodexAgentAdapterOptions = {}, rpc?: JsonlRpcClient) {
this.options = {
clientName: options.clientName ?? "codex-remote-collab",
clientVersion: options.clientVersion ?? "0.4.0",
maxOutputTailChars: options.maxOutputTailChars ?? 32_000,
approvalTimeoutMs: options.approvalTimeoutMs ?? 5 * 60_000,
...options,
};
this.rpc = rpc ?? new JsonlRpcClient(options);
this.rpcDisposables = [
this.rpc.onNotification((message) => this.handleNotification(message.method, message.params)),
this.rpc.onServerRequest((request) => this.handleServerRequest(request)),
this.rpc.onExit((error) => {
this.started = false;
this.state = "disconnected";
// A new app-server process cannot safely reuse ids from the dead
// process. Clear them before publishing the terminal event so a
// reconnect/restart cannot steer or interrupt a stale turn.
this.threadId = null;
this.turnId = null;
this.outputTail = "";
this.messages = [];
this.sessionMetadata = {};
this.historyComplete = true;
this.sessionSwitching = false;
// The child cannot receive a response after exit. Drop every pending
// approval/input and publish an explicit expiry so the relay removes
// its corresponding request instead of retaining a stale request id.
this.dropPendingRequests(error?.message ?? "app-server exited");
this.emit({ type: "connection.closed", payload: error ? { message: error.message } : {} });
}),
];
}
async start(): Promise<void> {
if (this.started) return;
await this.rpc.start();
this.state = "initializing";
const capabilities = {
experimentalApi: true,
requestAttestation: false,
...(this.options.initializeCapabilities ?? {}),
};
await this.rpc.request("initialize", {
clientInfo: {
name: this.options.clientName,
title: this.options.clientTitle ?? null,
version: this.options.clientVersion,
},
capabilities,
});
this.rpc.notify("initialized");
this.started = true;
this.state = "idle";
await this.refreshAvailableModels();
this.emit({ type: "connection.opened", payload: {} });
}
async startThread(params: JsonObject = {}): Promise<JsonValue> {
this.ensureStarted();
this.ensureSessionChangeAllowed();
const requestParams: JsonObject = { ...params };
if (requestParams.cwd === undefined && this.options.defaultCwd) {
requestParams.cwd = this.options.defaultCwd;
}
const result = await this.rpc.request("thread/start", requestParams);
this.historyComplete = true;
this.commitThreadResult(result, true);
await this.publishHistorySnapshot();
return result;
}
async newSession(params: JsonObject = {}): Promise<JsonValue> {
return this.startThread(params);
}
async listSessions(params: JsonObject = {}): Promise<JsonValue> {
this.ensureStarted();
const result = await this.rpc.request("thread/list", normalizeThreadListParams(params));
const threads = isRecord(result) && Array.isArray(result.data)
? result.data.filter(isRecord).map((thread) => asJsonObject(thread))
: [];
const sessions = threads.map((thread) => {
const id = typeof thread.id === "string" ? thread.id : "";
const preview = typeof thread.preview === "string" ? thread.preview : "";
const name = typeof thread.name === "string" ? thread.name : "";
const cwd = typeof thread.cwd === "string" ? redactText(thread.cwd) : undefined;
const updatedAt = finiteNumber(thread.updatedAt) ?? finiteNumber(thread.createdAt);
return {
threadId: id,
title: sessionTitle(name || preview, id),
updatedAtMs: updatedAt === undefined ? null : Math.round(updatedAt * 1_000),
...(cwd ? { cwd } : {}),
active: id === this.threadId,
available: Boolean(id),
...(thread.status !== undefined ? { status: redactJson(thread.status) } : {}),
...(thread.source !== undefined ? { source: redactJson(thread.source) } : {}),
};
}).filter((session) => session.threadId);
return asJsonValue({
sessions,
activeThreadId: this.threadId,
nextCursor: isRecord(result) && typeof result.nextCursor === "string" ? result.nextCursor : null,
backwardsCursor: isRecord(result) && typeof result.backwardsCursor === "string" ? result.backwardsCursor : null,
});
}
async selectSession(params: JsonObject): Promise<JsonValue> {
this.ensureStarted();
const target = (this.stringParam(params, "threadId") ?? this.stringParam(params, "conversationId"))?.trim();
if (!target) throw new Error("session/select requires threadId");
this.ensureSessionChangeAllowed();
if (this.sessionSwitching) throw new Error("a session switch is already in progress");
const previousThreadId = this.threadId;
const previousState = this.state;
this.sessionSwitching = true;
this.state = "syncing";
this.historyComplete = false;
this.emit({
type: "session.switching",
threadId: target,
payload: { previousThreadId, targetThreadId: target },
});
try {
const resumeResult = await this.resumeThreadForSelection(target);
const hydrated = await this.ensureThreadHistory(resumeResult);
this.commitThreadResult(resumeResult, true, hydrated);
const switched = previousThreadId !== target;
this.emit({
type: "session.selected",
threadId: target,
payload: { previousThreadId, threadId: target, switched, available: true },
});
await this.publishHistorySnapshot();
return asJsonValue({
threadId: target,
previousThreadId,
switched,
available: true,
result: redactJson(resumeResult),
});
} catch (error) {
this.state = previousState;
this.historyComplete = true;
throw error;
} finally {
this.sessionSwitching = false;
}
}
async updateThreadSettings(params: JsonObject): Promise<JsonValue> {
this.ensureStarted();
const threadId = this.stringParam(params, "threadId") ?? this.threadId;
if (!threadId) throw new Error("thread/settings/update requires threadId");
if (threadId !== this.threadId) throw new Error("thread/settings/update can only target the selected thread");
const settings = normalizeThreadSettings(params);
const result = await this.rpc.request("thread/settings/update", { threadId, ...settings.wire });
this.mergeThreadSettings(settings.display);
await this.publishAuthoritativeSnapshot();
return result;
}
async startTurn(params: JsonObject): Promise<JsonValue> {
this.ensureStarted();
const requestParams = this.normalizeTurnParams(params);
const result = await this.rpc.request("turn/start", requestParams);
const turn = isRecord(result) && isRecord(result.turn) ? result.turn : undefined;
const nextTurnId = turn && typeof turn.id === "string"
? turn.id
: isRecord(result) && typeof result.turnId === "string"
? result.turnId
: undefined;
if (nextTurnId) this.turnId = nextTurnId;
if (typeof requestParams.threadId === "string") this.threadId = requestParams.threadId;
this.state = "active";
return result;
}
async steerTurn(params: JsonObject): Promise<JsonValue> {
this.ensureStarted();
const requestParams = this.normalizeTurnParams(params, true);
const result = await this.rpc.request("turn/steer", requestParams);
const nextTurnId = isRecord(result) && typeof result.turnId === "string"
? result.turnId
: isRecord(result) && isRecord(result.turn) && typeof result.turn.id === "string"
? result.turn.id
: undefined;
if (nextTurnId) this.turnId = nextTurnId;
if (typeof requestParams.threadId === "string") this.threadId = requestParams.threadId;
this.state = "active";
return result;
}
async interruptTurn(params: JsonObject): Promise<JsonValue> {
this.ensureStarted();
const threadId = this.stringParam(params, "threadId") ?? this.threadId;
const turnId = this.stringParam(params, "turnId") ?? this.turnId;
if (!threadId || !turnId) throw new Error("turn/interrupt requires threadId and turnId");
const result = await this.rpc.request("turn/interrupt", { threadId, turnId });
this.state = "idle";
// Do this eagerly rather than waiting for the asynchronous
// `turn/completed` notification. A caller may submit the next turn as
// soon as the interrupt response resolves.
this.turnId = null;
return result;
}
async sendInput(text: string, params: JsonObject = {}): Promise<JsonValue> {
const body: JsonObject = { ...params, text };
if (this.turnId) return this.steerTurn({ ...body, expectedTurnId: this.turnId });
return this.startTurn(body);
}
async cancel(taskId?: string, params: JsonObject = {}): Promise<JsonValue> {
return this.interruptTurn({ ...params, ...(taskId ? { turnId: taskId } : {}) });
}
async respondApproval(
requestId: JsonRpcId,
decision: "allow" | "deny" | "cancel",
reason?: string,
response?: JsonValue,
): Promise<JsonValue> {
this.ensureStarted();
const key = jsonRpcIdKey(requestId);
const pending = this.pending.get(key);
if (!pending) throw new Error(`unknown or already resolved approval request: ${key}`);
const rawResponse = response === undefined
? this.defaultApprovalResponse(pending.request.method, pending.request.params, decision, reason)
: asJsonValue(response);
validateAdapterResponse(pending.request.method, rawResponse, decision);
const result = normalizeServerResponse(pending.request.method, rawResponse);
if (pending.timer) clearTimeout(pending.timer);
this.pending.delete(key);
try {
this.rpc.respond(pending.request.id, result);
} catch (error) {
// Do not leave a request retryable forever when the child exits between
// the liveness check and the JSON-RPC write.
this.emit({
type: pending.approval ? "approval.expired" : "input.expired",
threadId: pending.approval?.threadId,
turnId: pending.approval?.turnId,
requestId,
payload: { requestId: asJsonValue(requestId), reason: "app-server unavailable" },
});
throw error;
}
this.emit({
type: pending.approval ? "approval.resolved" : "input.resolved",
threadId: pending.approval?.threadId,
turnId: pending.approval?.turnId,
requestId,
payload: {
requestId: asJsonValue(requestId),
decision,
...(reason ? { reason } : {}),
},
});
return result;
}
async denyPending(reason = "relay disconnected"): Promise<void> {
const pendingIds = [...this.pending.values()].map((entry) => entry.request.id);
for (const requestId of pendingIds) {
try {
await this.respondApproval(requestId, "deny", reason);
} catch {
// The app-server may have resolved or exited between the snapshot and
// this fail-closed cleanup pass.
}
}
}
async snapshot(): Promise<SessionSnapshot> {
const status = this.statusSnapshot();
return {
threadId: this.threadId,
turnId: this.turnId,
state: this.state,
pendingApprovals: [...this.pending.values()]
.map((entry) => entry.approval)
.filter((approval): approval is PendingApproval => Boolean(approval)),
pendingRequests: [...this.pending.values()].map((entry) => ({
requestId: entry.request.id,
method: entry.request.method,
params: redactJson(asJsonObject(entry.request.params)),
...(entry.approval?.commandHash ? { commandHash: entry.approval.commandHash } : {}),
...(entry.approval?.risk ? { risk: entry.approval.risk } : {}),
...(entry.approval?.summary ? { summary: entry.approval.summary } : {}),
...(entry.approval?.createdAt ? { createdAt: entry.approval.createdAt } : {}),
...(entry.approval?.expiresAt ? { expiresAt: entry.approval.expiresAt } : {}),
})),
outputTail: this.outputTail,
messages: this.messages.map((message) => asJsonValue(message)),
status,
activity: status.activity,
turnStatus: status.turnStatus,
activeFlags: [...status.activeFlags],
startedAtMs: status.startedAtMs,
durationMs: status.durationMs,
elapsedMs: status.elapsedMs,
metadata: {
adapter: "codex-app-server",
mode: "async",
started: this.started,
historyComplete: this.historyComplete,
...this.sessionMetadata,
availableModels: this.availableModels.map((model) => asJsonValue(model)),
models: this.availableModels.map((model) => asJsonValue(model)),
},
};
}
onEvent(listener: (event: AgentEvent) => void): Disposable {
this.listeners.add(listener);
return { dispose: () => this.listeners.delete(listener) };
}
async dispose(): Promise<void> {
for (const [key, entry] of this.pending) {
if (entry.timer) clearTimeout(entry.timer);
// A disconnected host must never leave a command approval hanging.
try {
this.rpc.respond(entry.request.id, normalizeServerResponse(
entry.request.method,
this.defaultApprovalResponse(entry.request.method, entry.request.params, "deny", "bridge stopped"),
));
} catch {
// The child may already have exited.
}
this.pending.delete(key);
}
for (const disposable of this.rpcDisposables) disposable.dispose();
this.rpc.close();
this.started = false;
this.state = "disconnected";
this.threadId = null;
this.turnId = null;
this.messages = [];
this.outputTail = "";
this.sessionMetadata = {};
this.historyComplete = true;
this.sessionSwitching = false;
}
private ensureStarted(): void {
if (!this.started || !this.rpc.running) throw new Error("Codex app-server is not started");
}
private ensureSessionChangeAllowed(): void {
if (this.turnId || this.pending.size) {
throw new Error("cannot change sessions while a turn or approval is active");
}
}
private async refreshAvailableModels(): Promise<void> {
try {
const models: JsonValue[] = [];
let cursor: string | null = null;
for (let page = 0; page < 10; page += 1) {
const result = await this.rpc.request("model/list", {
limit: 100,
includeHidden: false,
...(cursor ? { cursor } : {}),
});
if (!isRecord(result)) break;
if (Array.isArray(result.data)) {
for (const model of result.data) {
if (isRecord(model) && typeof model.model === "string") models.push(redactJson(model));
}
}
cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : null;
if (!cursor) break;
}
this.availableModels = models;
} catch (error) {
// Older app-server builds may not expose the model catalog. Thread and
// turn control should remain usable with a manually supplied model.
this.options.logger?.debug?.("Unable to load app-server model catalog", error);
}
}
private async resumeThreadForSelection(threadId: string): Promise<JsonValue> {
try {
// Paginated history is the stable protocol for newer app-server builds.
// Request the first page in chronological order so the renderer can use
// one consistent ordering while older pages are appended.
return await this.rpc.request("thread/resume", {
threadId,
excludeTurns: true,
initialTurnsPage: {
limit: HISTORY_PAGE_SIZE,
sortDirection: "asc",
itemsView: "full",
},
});
} catch (error) {
if (isPaginationUnsupportedError(error)) {
// Older app-server versions reject the pagination fields. Retry with
// the legacy full-history shape before giving up.
try {
return await this.rpc.request("thread/resume", { threadId, excludeTurns: false });
} catch (legacyError) {
if (!isActiveWriterError(legacyError)) throw legacyError;
return this.readThreadMetadata(threadId, legacyError);
}
}
if (isActiveWriterError(error)) {
// A thread currently owned by another app-server cannot be resumed by
// this process, but its metadata and paginated history are still
// readable. Keep the conversation view available and report the
// writer limitation through metadata rather than showing an empty
// session after a successful list click.
return this.readThreadMetadata(threadId, error);
}
throw error;
}
}
private async readThreadMetadata(threadId: string, originalError: unknown): Promise<JsonValue> {
try {
return await this.rpc.request("thread/read", { threadId, includeTurns: false });
} catch (error) {
this.options.logger?.debug?.("Unable to read a thread after resume failed", error);
throw originalError instanceof Error ? originalError : error;
}
}
private async ensureThreadHistory(result: JsonValue): Promise<JsonObject | undefined> {
const response = isRecord(result) ? result : {};
const thread = extractThread(result);
if (!thread) throw new Error("thread/resume returned no thread");
const turns = Array.isArray(thread.turns) ? thread.turns : undefined;
const threadId = typeof thread.id === "string" ? thread.id : undefined;
if (!threadId) throw new Error("thread/resume returned a thread without id");
const paginated = thread.historyMode === "paginated" || isRecord(response.initialTurnsPage);
const hasHistoryEvidence = Boolean(
(typeof thread.preview === "string" && thread.preview.trim())
|| ((finiteNumber(thread.updatedAt) ?? 0) > (finiteNumber(thread.createdAt) ?? 0)),
);
if (!paginated && turns && (turns.length > 0 || !hasHistoryEvidence)) {
this.historyComplete = true;
return thread;
}
if (paginated) {
try {
const initialPage = isRecord(response.initialTurnsPage)
? response.initialTurnsPage
: undefined;
const hydratedTurns = await this.loadPaginatedTurns(threadId, initialPage);
return { ...thread, turns: hydratedTurns };
} catch (error) {
// Some transitional server builds advertise paginated threads but do
// not implement one of the page methods. Fall back to the legacy read
// endpoint so the session remains usable instead of appearing blank.
this.options.logger?.debug?.("Paginated thread hydration failed; trying thread/read", error);
try {
const readResult = await this.rpc.request("thread/read", { threadId, includeTurns: true });
const hydrated = extractThread(readResult);
if (hydrated) {
this.historyComplete = true;
return hydrated;
}
} catch (readError) {
this.options.logger?.debug?.("Legacy thread/read fallback failed", readError);
}
this.historyComplete = false;
return { ...thread, turns: turns ?? [] };
}
}
if (turns && turns.length > 0) {
this.historyComplete = true;
return thread;
}
const readResult = await this.rpc.request("thread/read", { threadId, includeTurns: true });
const hydrated = extractThread(readResult);
if (!hydrated) throw new Error("thread/read returned no thread");
this.historyComplete = true;
return hydrated;
}
private async loadPaginatedTurns(threadId: string, initialPage?: JsonObject): Promise<JsonObject[]> {
const byId = new Map<string, JsonObject>();
let page: JsonObject | undefined = initialPage;
let cursor: string | null = null;
let complete = true;
for (let index = 0; index < MAX_HISTORY_TURN_PAGES; index += 1) {
if (!page) {
const response = await this.rpc.request("thread/turns/list", {
threadId,
limit: HISTORY_PAGE_SIZE,
sortDirection: "asc",
itemsView: "full",
...(cursor ? { cursor } : {}),
});
page = isRecord(response) ? response : {};
}
const pageData = Array.isArray(page.data) ? page.data : [];
for (const value of pageData) {
if (!isRecord(value)) continue;
const id = typeof value.id === "string" ? value.id : `turn-${byId.size}`;
byId.set(id, { ...value });
if (byId.size >= MAX_HISTORY_TURNS) {
complete = false;
break;
}
}
if (byId.size >= MAX_HISTORY_TURNS) break;
const next = typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null;
page = undefined;
cursor = next;
if (!cursor) break;
}
if (cursor) complete = false;
const turns = sortHistoryTurns([...byId.values()]);
await this.hydrateTurnItems(threadId, turns, (value) => {
complete = complete && value;
});
this.historyComplete = complete;
return turns;
}
private async hydrateTurnItems(
threadId: string,
turns: JsonObject[],
markComplete: (complete: boolean) => void,
): Promise<void> {
for (const turn of turns) {
if (turn.itemsView === "full" || typeof turn.id !== "string") continue;
const items: JsonObject[] = Array.isArray(turn.items)
? turn.items.filter(isRecord).map((item) => ({ ...(item as JsonObject) }))
: [];
const itemIds = new Set(items.map((item) => typeof item.id === "string" ? item.id : ""));
let cursor: string | null = null;
let complete = true;
try {
for (let pageIndex = 0; pageIndex < MAX_HISTORY_ITEM_PAGES; pageIndex += 1) {
const response = await this.rpc.request("thread/items/list", {
threadId,
turnId: turn.id,
limit: HISTORY_PAGE_SIZE,
sortDirection: "asc",
...(cursor ? { cursor } : {}),
});
const page = isRecord(response) ? response : {};
for (const entry of Array.isArray(page.data) ? page.data : []) {
if (!isRecord(entry) || !isRecord(entry.item)) continue;
const item = { ...entry.item };
const id = typeof item.id === "string" ? item.id : "";
if (!id || !itemIds.has(id)) {
items.push(item);
if (id) itemIds.add(id);
}
}
const next = typeof page.nextCursor === "string" && page.nextCursor ? page.nextCursor : null;
cursor = next;
if (!cursor) break;
if (pageIndex === MAX_HISTORY_ITEM_PAGES - 1) complete = false;
}
} catch (error) {
complete = false;
this.options.logger?.debug?.(`Unable to hydrate items for turn ${turn.id}`, error);
}
turn.items = items;
turn.itemsView = "full";
markComplete(complete);
}
}
private commitThreadResult(result: JsonValue, replaceHistory: boolean, hydratedThread?: JsonObject): void {
const thread = hydratedThread ?? extractThread(result);
if (!thread) throw new Error("app-server thread response did not include a thread");
const nextThreadId = typeof thread.id === "string" ? thread.id : undefined;
if (!nextThreadId) throw new Error("app-server thread response did not include thread.id");
this.threadId = nextThreadId;
if (replaceHistory) {
this.messages = projectThreadMessages(thread);
this.outputTail = outputTailFromMessages(this.messages, this.options.maxOutputTailChars);
}
const activeTurn = latestActiveTurn(thread);
this.turnId = activeTurn && typeof activeTurn.id === "string" ? activeTurn.id : null;
this.state = this.turnId ? "active" : statusToState(thread.status);
if (this.state === "notLoaded" || this.state === "unknown") this.state = "idle";
const response = isRecord(result) ? result : {};
const title = sessionTitle(
typeof thread.name === "string" ? thread.name : typeof thread.preview === "string" ? thread.preview : "",
nextThreadId,
);
const cwd = typeof response.cwd === "string"
? redactText(response.cwd)
: typeof thread.cwd === "string" ? redactText(thread.cwd) : undefined;
const model = typeof response.model === "string" ? response.model : undefined;
const effort = response.reasoningEffort === null || typeof response.reasoningEffort === "string"
? response.reasoningEffort
: undefined;
const threadSettings: JsonObject = {
...(cwd ? { cwd } : {}),
...(model ? { model } : {}),
...(effort !== undefined ? { effort: asJsonValue(effort) } : {}),
...(response.modelProvider !== undefined ? { modelProvider: redactJson(response.modelProvider) } : {}),
...(response.serviceTier !== undefined ? { serviceTier: redactJson(response.serviceTier) } : {}),
...(response.approvalPolicy !== undefined ? { approvalPolicy: redactJson(response.approvalPolicy) } : {}),
...(response.approvalsReviewer !== undefined ? { approvalsReviewer: redactJson(response.approvalsReviewer) } : {}),
...(response.sandbox !== undefined ? { sandboxPolicy: redactJson(response.sandbox) } : {}),
};
this.sessionMetadata = {
thread: threadMetadata(thread),
title,
...(cwd ? { cwd } : {}),
...(model ? { model, latestModel: model } : {}),
...(effort !== undefined ? { effort: asJsonValue(effort), latestReasoningEffort: asJsonValue(effort) } : {}),
...(response.modelProvider !== undefined ? { modelProvider: redactJson(response.modelProvider) } : {}),
...(response.approvalPolicy !== undefined ? { approvalPolicy: redactJson(response.approvalPolicy) } : {}),
...(response.approvalsReviewer !== undefined ? { approvalsReviewer: redactJson(response.approvalsReviewer) } : {}),
...(response.sandbox !== undefined ? { sandboxPolicy: redactJson(response.sandbox) } : {}),
threadSettings,
};
}
private mergeThreadSettings(settings: JsonObject): void {
const current = isRecord(this.sessionMetadata.threadSettings)
? this.sessionMetadata.threadSettings
: {};
const next = { ...current, ...redactJson(settings) as JsonObject };
this.sessionMetadata.threadSettings = next;
for (const key of ["model", "modelProvider", "serviceTier", "approvalPolicy", "approvalsReviewer", "sandboxPolicy", "permissions", "cwd"] as const) {
if (settings[key] !== undefined) this.sessionMetadata[key] = redactJson(settings[key]);
}
if (settings.model !== undefined) this.sessionMetadata.latestModel = redactJson(settings.model);
if (settings.effort !== undefined) {
this.sessionMetadata.effort = redactJson(settings.effort);
this.sessionMetadata.latestReasoningEffort = redactJson(settings.effort);
}
}
private async publishHistorySnapshot(): Promise<void> {
const snapshot = await this.snapshot();
this.emit({
type: "output.snapshot",
threadId: this.threadId ?? undefined,
turnId: this.turnId ?? undefined,
payload: {
stream: "codex",
text: this.outputTail,
messages: this.messages.map((message) => asJsonValue(message)),
structureChanged: true,
historyComplete: this.historyComplete,
encoding: "utf8",
metadata: snapshot.metadata ?? {},
status: snapshot.status ? asJsonValue(snapshot.status) : null,
},
});
await this.publishAuthoritativeSnapshot(snapshot);
}
private async publishAuthoritativeSnapshot(existingSnapshot?: SessionSnapshot): Promise<void> {
const snapshot = existingSnapshot ?? await this.snapshot();
this.emit({
type: "session.snapshot",
threadId: snapshot.threadId ?? undefined,
turnId: snapshot.turnId ?? undefined,
payload: asJsonObject(snapshot),
status: snapshot.status,
});
}
private statusSnapshot(): NonNullable<SessionSnapshot["status"]> {
const currentMessages = this.turnId
? this.messages.filter((message) => isRecord(message) && message.turnId === this.turnId)
: [];
const startedAtValues = currentMessages
.map((message) => isRecord(message) ? finiteNumber(message.startedAtMs) : undefined)
.filter((value): value is number => value !== undefined);
const durationValues = currentMessages
.map((message) => isRecord(message) ? finiteNumber(message.durationMs) : undefined)
.filter((value): value is number => value !== undefined);
const startedAtMs = startedAtValues.length ? Math.min(...startedAtValues) : null;
const durationMs = durationValues.length ? Math.max(...durationValues) : null;
const pendingApprovals = [...this.pending.values()].some((entry) => Boolean(entry.approval));
const pendingInput = [...this.pending.values()].some((entry) => !entry.approval);
const latest = currentMessages.length && isRecord(currentMessages[currentMessages.length - 1])
? currentMessages[currentMessages.length - 1] as JsonObject
: undefined;
let activity = this.turnId ? "thinking" : this.state;
if (pendingApprovals) activity = "waitingOnApproval";
else if (pendingInput) activity = "waitingOnUserInput";
else if (latest?.kind === "edit") activity = "editing";
else if (latest?.itemType === "commandExecution") activity = "running";
else if (latest?.kind === "reasoning" || latest?.kind === "plan") activity = "thinking";
return {
activity,
turnStatus: this.turnId ? "inProgress" : this.state === "idle" ? "completed" : this.state,
activeFlags: [
...(pendingApprovals ? ["waitingOnApproval"] : []),
...(pendingInput ? ["waitingOnUserInput"] : []),
],
startedAtMs,
durationMs,
elapsedMs: this.turnId && startedAtMs !== null ? Math.max(0, Date.now() - startedAtMs) : null,
};
}
private upsertItem(item: JsonObject, turnId?: string, turn?: JsonObject, lifecycle: JsonObject = {}): void {
const projected = projectThreadItem(item, turnId, turn, lifecycle);
const itemId = typeof projected.itemId === "string" ? projected.itemId : undefined;
const index = itemId
? this.messages.findIndex((message) => isRecord(message)
&& message.itemId === itemId
&& (turnId === undefined || message.turnId === turnId))
: -1;
if (index >= 0) this.messages[index] = projected;
else this.messages.push(projected);
}
private appendItemDelta(params: JsonObject, kind: "assistant" | "reasoning" | "plan" | "output", delta: string): void {
const itemId = this.extractString(params, "itemId");
const turnId = this.extractString(params, "turnId");
if (!itemId) return;
let index = this.messages.findIndex((message) => isRecord(message)
&& message.itemId === itemId
&& (turnId === undefined || message.turnId === turnId));
if (index < 0) {
const placeholder: JsonObject = {
id: itemId,
itemId,
...(turnId ? { turnId } : {}),
itemType: kind === "output" ? "commandExecution" : kind === "assistant" ? "agentMessage" : kind,
role: kind === "assistant" ? "assistant" : kind === "reasoning" ? "reasoning" : "tool",
kind: kind === "output" ? "tool" : kind,
text: "",
status: "inProgress",
};
this.messages.push(placeholder);
index = this.messages.length - 1;
}
const current = isRecord(this.messages[index]) ? this.messages[index] as JsonObject : {};
if (kind === "output") current.output = `${typeof current.output === "string" ? current.output : ""}${redactText(delta)}`;
else current.text = `${typeof current.text === "string" ? current.text : ""}${redactText(delta)}`;
this.messages[index] = current;
}
private normalizeTurnParams(input: JsonObject, steering = false): JsonObject {
const params: JsonObject = { ...input };
const threadId = this.stringParam(params, "threadId") ?? this.threadId;
if (!threadId) throw new Error(`${steering ? "turn/steer" : "turn/start"} requires threadId (start a thread first)`);
params.threadId = threadId;
const suppliedInput = params.input;
if (typeof suppliedInput === "string") {
params.input = [this.textInput(suppliedInput)];
} else if (Array.isArray(suppliedInput)) {
params.input = suppliedInput.map((item) => (typeof item === "string" ? this.textInput(item) : asJsonValue(item)));
} else {
const text = this.stringParam(params, "text") ?? this.stringParam(params, "message") ?? this.stringParam(params, "prompt");
if (!text) throw new Error("turn request requires input or text");
params.input = [this.textInput(text)];
}
delete params.text;
delete params.message;
delete params.prompt;
if (steering) {
const expectedTurnId = this.stringParam(params, "expectedTurnId") ?? this.turnId;
if (!expectedTurnId) throw new Error("turn/steer requires expectedTurnId (no active turn)");
params.expectedTurnId = expectedTurnId;
}
return params;
}
private textInput(text: string): JsonObject {
return { type: "text", text, text_elements: [] };
}
private stringParam(params: JsonObject, key: string): string | undefined {
return typeof params[key] === "string" ? (params[key] as string) : undefined;
}
private handleNotification(method: string, rawParams: JsonValue | undefined): void {
const params = asJsonObject(rawParams);
const threadId = this.extractString(params, "threadId") ?? this.extractNestedString(params, "thread", "id");
const turnId = this.extractString(params, "turnId") ?? this.extractNestedString(params, "turn", "id");
if (threadId && this.threadId && threadId !== this.threadId) {
this.options.logger?.debug?.(`Ignored late ${method} notification for non-selected thread ${threadId}`);
return;
}
const activeTurnId = this.turnId;
if (threadId && !this.threadId) this.threadId = threadId;
// A late completion for an earlier turn must not overwrite a newer turn
// that was started while the old completion notification was in flight.
// Token usage is thread telemetry, not a lifecycle transition. It can be
// delivered after `turn/completed`, so do not resurrect an old turn (or
// replace a newer active turn) just because the notification carries a
// turnId.
if (turnId
&& method !== "thread/tokenUsage/updated"
&& (method !== "turn/completed" || !activeTurnId || activeTurnId === turnId)) {
this.turnId = turnId;
}
let type = "app-server.notification";
let payload: JsonObject = { method, params: redactJson(params) as JsonObject };
let outputText: string | undefined;
switch (method) {
case "thread/started":
type = "session.created";
payload = { thread: redactJson(params.thread ?? params) as JsonValue };
if (isRecord(params.thread)) {
try {
this.commitThreadResult({ thread: params.thread }, true);
} catch (error) {
this.options.logger?.debug?.("Unable to hydrate thread/started notification", error);
this.state = "idle";
}
} else {
this.state = "idle";
}
break;
case "thread/name/updated": {
const title = this.extractString(params, "threadName")?.trim();
if (title) this.sessionMetadata.title = redactText(title);
payload = redactJson(params) as JsonObject;
break;
}
case "thread/settings/updated":
payload = redactJson(params) as JsonObject;
if (isRecord(params.threadSettings)) this.mergeThreadSettings(params.threadSettings);
break;
case "thread/tokenUsage/updated": {
const tokenUsage = projectTokenUsage(params.tokenUsage);
// `redactJson` treats every key containing "token" as secret. Keep
// its redacted params for diagnostics, then add the numeric usage
// projection that the browser usage picker and relay snapshot need.
payload = redactJson(params) as JsonObject;
if (tokenUsage) {
this.sessionMetadata.tokenUsage = tokenUsage;
// The official extension names this field latestTokenUsageInfo;
// retain the shorter alias for existing relay/browser clients.
this.sessionMetadata.latestTokenUsageInfo = tokenUsage;
payload.tokenUsage = tokenUsage;
payload.latestTokenUsageInfo = tokenUsage;
// Persist usage through the same authoritative snapshot channel as
// thread settings so a browser reconnect does not fall back to the
// previous context-window value.
void this.publishAuthoritativeSnapshot().catch((error) => {
this.options.logger?.debug?.("Unable to publish token usage snapshot", error);
});
} else {
// Do not let the redaction sentinel for malformed usage data look
// like a real update and clear a previously valid browser value.
delete payload.tokenUsage;
delete payload.latestTokenUsageInfo;
}
break;
}
case "thread/status/changed":
type = "session.state";
payload = redactJson(params) as JsonObject;
this.state = statusToState(params.status);
break;
case "thread/closed":
case "thread/deleted":
type = "session.closed";
payload = redactJson(params) as JsonObject;
this.state = "closed";
this.threadId = null;
this.turnId = null;
this.messages = [];
this.outputTail = "";
this.sessionMetadata = {};
this.historyComplete = true;
break;
case "turn/started":
type = "task.started";
payload = redactJson(params) as JsonObject;
this.state = "active";
if (isRecord(params.turn) && Array.isArray(params.turn.items)) {
const turn = asJsonObject(params.turn);
for (const item of params.turn.items.filter(isRecord)) this.upsertItem(asJsonObject(item), turnId, turn);
}
break;
case "turn/completed": {
const status = this.extractNestedString(params, "turn", "status");
type = status === "interrupted" ? "task.cancelled" : "task.finished";
payload = redactJson(params) as JsonObject;
// Do not let a stale completion transition a newer active turn to
// idle. Notifications are asynchronous and can arrive after the
// caller has already started the next turn.
if (!turnId || !activeTurnId || turnId === activeTurnId) {
this.state = "idle";
this.turnId = null;
}
if (isRecord(params.turn) && Array.isArray(params.turn.items)) {
const turn = asJsonObject(params.turn);
for (const item of params.turn.items.filter(isRecord)) this.upsertItem(asJsonObject(item), turnId, turn);
}
break;
}
case "item/agentMessage/delta":
type = "output.chunk";
outputText = this.extractString(params, "delta");
if (outputText) this.appendItemDelta(params, "assistant", outputText);
payload = { stream: "codex", text: redactText(outputText ?? ""), encoding: "utf8" };
break;
case "item/plan/delta":
type = "output.chunk";
outputText = this.extractString(params, "delta") ?? this.extractString(params, "text");
if (outputText) this.appendItemDelta(params, "plan", outputText);
payload = { stream: "reasoning", text: redactText(outputText ?? ""), encoding: "utf8" };
break;
case "item/reasoning/summaryTextDelta":
case "item/reasoning/textDelta":
type = "output.chunk";
outputText = this.extractString(params, "delta") ?? this.extractString(params, "text");
if (outputText) this.appendItemDelta(params, "reasoning", outputText);
payload = { stream: "reasoning", text: redactText(outputText ?? ""), encoding: "utf8" };
break;
case "command/exec/outputDelta":
case "process/outputDelta":
case "item/commandExecution/outputDelta":
type = "output.chunk";
outputText = decodeOutput(params);
if (method === "item/commandExecution/outputDelta" && outputText) {
this.appendItemDelta(params, "output", outputText);
}
payload = {
stream: outputStream(params),
text: redactText(outputText),
encoding: "utf8",
};
break;
case "item/fileChange/outputDelta":
type = "output.chunk";
outputText = this.extractString(params, "delta");
if (outputText) this.appendItemDelta(params, "output", outputText);
payload = { stream: "codex", text: redactText(outputText ?? ""), encoding: "utf8" };
break;
case "item/started":
type = "item.started";
payload = redactJson(params) as JsonObject;
if (isRecord(params.item)) {
this.upsertItem(params.item, turnId, undefined, {
...(finiteNumber(params.startedAtMs) !== undefined ? { startedAtMs: finiteNumber(params.startedAtMs) as number } : {}),
status: "inProgress",
});
}
break;
case "item/completed":
type = "item.completed";
payload = redactJson(params) as JsonObject;
if (isRecord(params.item)) {
this.upsertItem(params.item, turnId, undefined, {
...(finiteNumber(params.completedAtMs) !== undefined ? { completedAtMs: finiteNumber(params.completedAtMs) as number } : {}),
});
}
break;
case "serverRequest/resolved":
payload = redactJson(params) as JsonObject;
if (isJsonRpcId(params.requestId)) {
const pending = this.pending.get(jsonRpcIdKey(params.requestId));
type = pending?.approval ? "approval.resolved" : pending ? "input.resolved" : "approval.resolved";
if (pending?.timer) clearTimeout(pending.timer);
this.pending.delete(jsonRpcIdKey(params.requestId));
} else {
type = "approval.resolved";
}
break;
case "error":
type = "error";
payload = redactJson(params) as JsonObject;
break;
case "warning":
case "guardianWarning":
type = "warning";
payload = redactJson(params) as JsonObject;
break;
default:
break;
}
// Events and snapshots must expose the same redacted view. Keeping raw
// text in outputTail would leak credentials through `snapshot()` even
// though the corresponding output event was redacted.
if (outputText) this.appendOutput(outputText);
this.emit({
type,
threadId,
turnId,
payload,
raw: redactJson({ method, params }) as JsonValue,
});
}
private handleServerRequest(request: JsonRpcRequest): void {
const params = asJsonObject(request.params);
const requestThreadId = this.extractString(params, "threadId") ?? this.extractString(params, "conversationId");
if (requestThreadId && this.threadId && requestThreadId !== this.threadId) {
// A resumed app-server can finish delivering an old request after the
// browser has selected another thread. Never expose or retain it as an
// approval for the selected conversation.
try {
if (APPROVAL_METHODS.has(request.method) || INPUT_REQUEST_METHODS.has(request.method)) {
this.rpc.respond(request.id, normalizeServerResponse(
request.method,
this.defaultApprovalResponse(request.method, request.params, "deny", "thread is no longer selected"),
));
} else {
this.rpc.respondError(request.id, -32000, "thread is no longer selected");
}
} catch {
// The child may have exited while the stale request was in flight.
}
this.options.logger?.debug?.(`Rejected stale ${request.method} request for non-selected thread ${requestThreadId}`);
return;
}
if (APPROVAL_METHODS.has(request.method)) {
const approval = this.toPendingApproval(request, params);
const entry: PendingRequest = { request, approval };
if (this.options.approvalTimeoutMs > 0) {
entry.timer = setTimeout(() => this.expireApproval(request.id), this.options.approvalTimeoutMs);
approval.expiresAt = Date.now() + this.options.approvalTimeoutMs;
}
this.pending.set(jsonRpcIdKey(request.id), entry);
this.emit({
type: "approval.requested",
threadId: approval.threadId,
turnId: approval.turnId,
requestId: request.id,
payload: {
...approval.payload,
params: approval.payload,
requestId: asJsonValue(request.id),
method: request.method,
action: approval.action,
risk: approval.risk,
summary: approval.summary,
...(approval.commandHash ? { commandHash: approval.commandHash } : {}),
...(approval.expiresAt ? { expiresAt: approval.expiresAt } : {}),
},
raw: redactJson(request) as JsonValue,
});
return;
}
if (INPUT_REQUEST_METHODS.has(request.method)) {
const entry: PendingRequest = { request };
if (this.options.approvalTimeoutMs > 0) {
entry.timer = setTimeout(() => this.expirePendingRequest(request.id), this.options.approvalTimeoutMs);
}
this.pending.set(jsonRpcIdKey(request.id), entry);
this.emit({
type: "input.requested",
threadId: this.extractString(params, "threadId"),
turnId: this.extractString(params, "turnId"),
requestId: request.id,
payload: { requestId: asJsonValue(request.id), method: request.method, params: redactJson(params) as JsonValue },
raw: redactJson(request) as JsonValue,
});
return;
}
this.emit({ type: "server.request", requestId: request.id, payload: { method: request.method, params: redactJson(params) as JsonValue }, raw: redactJson(request) as JsonValue });
void this.resolveServerRequest(request);
}
private async resolveServerRequest(request: JsonRpcRequest): Promise<void> {
try {
const result = await this.options.onServerRequest?.(request);
if (result !== undefined) {
this.rpc.respond(request.id, result);
} else if (this.options.autoRejectUnsupportedRequests !== false) {
this.rpc.respondError(request.id, -32601, `Unsupported app-server request: ${request.method}`);
}
} catch (error) {
this.rpc.respondError(request.id, -32000, error instanceof Error ? error.message : String(error));
}
}
private toPendingApproval(request: JsonRpcRequest, params: JsonObject): PendingApproval {
const threadId = this.extractString(params, "threadId") ?? this.extractString(params, "conversationId");
const turnId = this.extractString(params, "turnId");
const itemId = this.extractString(params, "itemId") ?? this.extractString(params, "callId");
const command = this.extractString(params, "command") ?? this.extractCommand(params);
const reason = this.extractString(params, "reason");
const action = approvalAction(request.method);
const risk = approvalRisk(request.method, command, params.commandActions);
const summary = reason || command || `${action} requested by Codex`;
return {
requestId: request.id,
method: request.method,
threadId,
turnId,
itemId,
action,
risk,
summary: redactText(summary),
commandHash: hashJson(params),
createdAt: Date.now(),
payload: redactJson(params) as JsonObject,
};
}
private async expireApproval(requestId: JsonRpcId): Promise<void> {
return this.expirePendingRequest(requestId);
}
private async expirePendingRequest(requestId: JsonRpcId): Promise<void> {
const key = jsonRpcIdKey(requestId);
const pending = this.pending.get(key);
if (!pending) return;
this.pending.delete(key);
try {
this.rpc.respond(requestId, normalizeServerResponse(
pending.request.method,
this.expiredApprovalResponse(pending.request.method, pending.request.params),
));
} catch {
// The app-server may have exited while the timer was pending.
}
this.emit({
type: pending.approval ? "approval.expired" : "input.expired",
threadId: pending.approval?.threadId,
turnId: pending.approval?.turnId,
requestId,
payload: { requestId: asJsonValue(requestId), reason: "approval expired" },
});
}
private dropPendingRequests(reason: string): void {
const pendingEntries = [...this.pending.values()];
this.pending.clear();
for (const pending of pendingEntries) {
if (pending.timer) clearTimeout(pending.timer);
this.emit({
type: pending.approval ? "approval.expired" : "input.expired",
threadId: pending.approval?.threadId,
turnId: pending.approval?.turnId,
requestId: pending.request.id,
payload: { requestId: asJsonValue(pending.request.id), reason },
});
}
}
private defaultApprovalResponse(method: string, rawParams: JsonValue | undefined, decision: "allow" | "deny" | "cancel", reason?: string): JsonValue {
const params = asJsonObject(rawParams);
if (method === "item/permissions/requestApproval") {
return {
permissions: decision === "allow" ? (params.permissions ?? {}) : {},
scope: "turn",
};
}
if (method === "item/tool/requestUserInput") {
return { answers: {} };
}
if (method === "mcpServer/elicitation/request") {
return { action: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline", content: null, _meta: null };
}
if (method === "applyPatchApproval" || method === "execCommandApproval") {
if (decision === "allow") return { decision: "approved" };
if (decision === "cancel") return { decision: "abort" };
return { decision: { denied: { rejection: reason || "Denied remotely" } } };
}
return { decision: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline" };
}
private expiredApprovalResponse(method: string, rawParams: JsonValue | undefined): JsonValue {
if (method === "applyPatchApproval" || method === "execCommandApproval") {
// Preserve the legacy app-server wire decision for an actual timeout;
// `denied` is reserved for an explicit policy rejection.
return { decision: "timed_out" };
}
return this.defaultApprovalResponse(method, rawParams, "deny", "approval expired");
}
private extractString(params: JsonObject, key: string): string | undefined {
return typeof params[key] === "string" ? (params[key] as string) : undefined;
}
private extractNestedString(params: JsonObject, parent: string, key: string): string | undefined {
const nested = params[parent];
return isRecord(nested) && typeof nested[key] === "string" ? (nested[key] as string) : undefined;
}
private extractCommand(params: JsonObject): string | undefined {
const command = params.command;
if (Array.isArray(command)) return command.filter((item): item is string => typeof item === "string").join(" ");
// Newer command-approval requests may leave `command` null while
// providing parsed actions. Include every action command in the risk
// input so a dangerous subcommand cannot be hidden behind command:null.
if (Array.isArray(params.commandActions)) {
const commands = params.commandActions
.map((action) => isRecord(action) && typeof action.command === "string" ? action.command : undefined)
.filter((item): item is string => Boolean(item));
if (commands.length) return commands.join(" && ");
}
return undefined;
}
private appendOutput(text: string): void {
// Keep this invariant at the storage boundary. New notification handlers
// can append raw text later without creating a snapshot-only secret leak.
const safeText = redactText(text);
this.outputTail = `${this.outputTail}${safeText}`;
if (this.outputTail.length > this.options.maxOutputTailChars) {
this.outputTail = this.outputTail.slice(-this.options.maxOutputTailChars);
}
}
private emit(event: AgentEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch (error) {
this.options.logger?.warn?.("Agent event listener failed", error);
}
}
}
}
const THREAD_SORT_KEYS = new Set(["created_at", "updated_at", "recency_at", "section_position"]);
const THREAD_SOURCE_KINDS = new Set([
"cli", "vscode", "exec", "appServer", "subAgent", "subAgentReview",
"subAgentCompact", "subAgentThreadSpawn", "subAgentOther", "unknown",
]);
function normalizeThreadListParams(params: JsonObject): JsonObject {
const result: JsonObject = {};
if (params.cursor === null || typeof params.cursor === "string") result.cursor = params.cursor;
const limit = finiteNumber(params.limit);
result.limit = Math.max(1, Math.min(100, Number.isInteger(limit) ? limit as number : 50));
const sortKey = typeof params.sortKey === "string" ? params.sortKey : "updated_at";
result.sortKey = THREAD_SORT_KEYS.has(sortKey) ? sortKey : "updated_at";
result.sortDirection = params.sortDirection === "asc" ? "asc" : "desc";
if (typeof params.archived === "boolean") result.archived = params.archived;
if (params.sectionId === null || typeof params.sectionId === "string") result.sectionId = params.sectionId;
if (typeof params.useStateDbOnly === "boolean") result.useStateDbOnly = params.useStateDbOnly;
const searchTerm = typeof params.searchTerm === "string"
? params.searchTerm
: typeof params.query === "string" ? params.query : undefined;
if (searchTerm?.trim()) result.searchTerm = searchTerm.trim();
if (typeof params.cwd === "string") result.cwd = params.cwd;
else if (Array.isArray(params.cwd) && params.cwd.every((value) => typeof value === "string")) {
result.cwd = asJsonValue(params.cwd);
}
if (Array.isArray(params.modelProviders) && params.modelProviders.every((value) => typeof value === "string")) {
result.modelProviders = asJsonValue(params.modelProviders);
}
if (Array.isArray(params.sourceKinds)) {
const sourceKinds = params.sourceKinds.filter((value): value is string => typeof value === "string" && THREAD_SOURCE_KINDS.has(value));
if (sourceKinds.length) result.sourceKinds = sourceKinds;
}
return result;
}
function normalizeThreadSettings(params: JsonObject): { wire: JsonObject; display: JsonObject } {
const source = isRecord(params.threadSettings) ? params.threadSettings : params;
const wire: JsonObject = {};
const display: JsonObject = {};
for (const key of ["model", "cwd", "effort", "serviceTier", "summary", "personality"] as const) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value !== null && (typeof value !== "string" || !value.trim())) {
throw new Error(`thread settings ${key} must be a non-empty string or null`);
}
wire[key] = typeof value === "string" ? value.trim() : null;
display[key] = wire[key];
}
for (const key of ["collaborationMode", "multiAgentMode"] as const) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value !== null && typeof value !== "string" && !isRecord(value)) {
throw new Error(`thread settings ${key} must be a string, object, or null`);
}
wire[key] = asJsonValue(value);
display[key] = wire[key];
}
for (const key of ["approvalPolicy", "approvalsReviewer"] as const) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value !== null && typeof value !== "string") {
throw new Error(`thread settings ${key} must be a string or null`);
}
wire[key] = asJsonValue(value);
display[key] = wire[key];
}
const hasPermissions = Object.prototype.hasOwnProperty.call(source, "permissions");
const hasSandboxPolicy = Object.prototype.hasOwnProperty.call(source, "sandboxPolicy");
if (hasPermissions) {
const value = source.permissions;
if (value !== null && (typeof value !== "string" || !value.trim())) {
throw new Error("thread settings permissions must be a non-empty string or null");
}
wire.permissions = typeof value === "string" ? value.trim() : null;
display.permissions = wire.permissions;
}
if (hasSandboxPolicy) {
const value = source.sandboxPolicy;
if (value !== null && typeof value !== "string" && !isRecord(value)) {
throw new Error("thread settings sandboxPolicy must be a string, object, or null");
}
display.sandboxPolicy = asJsonValue(value);
if (!hasPermissions) {
if (typeof value === "string") {
const permission = LEGACY_SANDBOX_PERMISSIONS[value];
if (!permission) throw new Error(`unsupported legacy sandbox policy: ${value}`);
wire.permissions = permission;
display.permissions = permission;
} else {
wire.sandboxPolicy = asJsonValue(value);
}
}
}
if (!Object.keys(wire).length) throw new Error("thread settings update requires at least one setting");
return { wire, display };
}
const LEGACY_SANDBOX_PERMISSIONS: Record<string, string> = {
"read-only": ":read-only",
"workspace-write": ":workspace",
"danger-full-access": ":danger-full-access",
};
function isActiveWriterError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? "");
return /active\s+writer|writer\s+lock|already\s+has\s+an\s+active\s+writer|thread\s+.*(?:locked|lock)|lock\s+.*thread/i.test(message);
}
function isPaginationUnsupportedError(error: unknown): boolean {
if (isActiveWriterError(error)) return false;
const code = isRecord(error) && typeof error.code === "number" ? error.code : undefined;
if (code === -32602 || code === -32601) return true;
const message = error instanceof Error ? error.message : String(error ?? "");
return /unknown\s+(?:field|parameter|method)|method\s+.*not\s+found|invalid\s+(?:param(?:eter)?s?|field)|unexpected\s+(?:field|property)|unsupported\s+(?:pagination|initialTurnsPage|excludeTurns|itemsView)/i.test(message);
}
function sortHistoryTurns(turns: JsonObject[]): JsonObject[] {
return turns
.map((turn, index) => ({ turn, index }))
.sort((left, right) => {
const leftTime = [left.turn.startedAt, left.turn.createdAt, left.turn.completedAt]
.map(finiteNumber)
.find((value): value is number => value !== undefined);
const rightTime = [right.turn.startedAt, right.turn.createdAt, right.turn.completedAt]
.map(finiteNumber)
.find((value): value is number => value !== undefined);
if (leftTime !== undefined && rightTime !== undefined && leftTime !== rightTime) return leftTime - rightTime;
if (leftTime !== undefined && rightTime === undefined) return -1;
if (leftTime === undefined && rightTime !== undefined) return 1;
const leftId = typeof left.turn.id === "string" ? left.turn.id : "";
const rightId = typeof right.turn.id === "string" ? right.turn.id : "";
return leftId.localeCompare(rightId) || left.index - right.index;
})
.map(({ turn }) => turn);
}
function extractThread(result: JsonValue): JsonObject | undefined {
if (!isRecord(result)) return undefined;
if (isRecord(result.thread)) return result.thread;
return typeof result.id === "string" ? result : undefined;
}
function threadMetadata(thread: JsonObject): JsonObject {
const result = redactJson(thread);
if (!isRecord(result)) return {};
return { ...result, turns: [] };
}
const TOKEN_USAGE_FIELDS = [
"totalTokens",
"inputTokens",
"cachedInputTokens",
"cacheWriteInputTokens",
"outputTokens",
"reasoningOutputTokens",
] as const;
/** Keep only the numeric portion of the official token-usage projection. */
function projectTokenUsage(value: unknown): JsonObject | undefined {
if (!isRecord(value)) return undefined;
const source = isRecord(value.info)
? value.info
: isRecord(value.tokenUsage)
? value.tokenUsage
: isRecord(value.token_usage)
? value.token_usage
: value;
const total = projectTokenUsageBreakdown(
source.total
?? source.total_token_usage
?? source.totalTokenUsage,
);
const last = projectTokenUsageBreakdown(
source.last
?? source.last_token_usage
?? source.lastTokenUsage,
);
const modelContextWindow = tokenNumber(
source.modelContextWindow
?? source.model_context_window
?? source.contextWindow
?? source.context_window,
);
if (!total && !last && modelContextWindow === undefined) return undefined;
return {
...(total ? { total } : {}),
...(last ? { last } : {}),
...(modelContextWindow !== undefined ? { modelContextWindow } : {}),
};
}
function projectTokenUsageBreakdown(value: unknown): JsonObject | undefined {
if (!isRecord(value)) return undefined;
const aliases: Record<(typeof TOKEN_USAGE_FIELDS)[number], string[]> = {
totalTokens: ["totalTokens", "total_tokens"],
inputTokens: ["inputTokens", "input_tokens"],
cachedInputTokens: ["cachedInputTokens", "cached_input_tokens"],
cacheWriteInputTokens: ["cacheWriteInputTokens", "cache_write_input_tokens"],
outputTokens: ["outputTokens", "output_tokens"],
reasoningOutputTokens: ["reasoningOutputTokens", "reasoning_output_tokens"],
};
const result: JsonObject = {};
for (const field of TOKEN_USAGE_FIELDS) {
for (const alias of aliases[field]) {
const number = tokenNumber(value[alias]);
if (number === undefined) continue;
result[field] = number;
break;
}
}
return Object.keys(result).length ? result : undefined;
}
function tokenNumber(value: unknown): number | undefined {
if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined;
if (typeof value !== "string" || !/^\d+(?:\.\d+)?$/.test(value.trim())) return undefined;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : undefined;
}
function latestActiveTurn(thread: JsonObject): JsonObject | undefined {
if (!Array.isArray(thread.turns)) return undefined;
for (let index = thread.turns.length - 1; index >= 0; index -= 1) {
const turn = thread.turns[index];
if (isRecord(turn) && turn.status === "inProgress") return turn;
}
return undefined;
}
function projectThreadMessages(thread: JsonObject): JsonValue[] {
if (!Array.isArray(thread.turns)) return [];
const messages: JsonValue[] = [];
for (const turn of thread.turns) {
if (!isRecord(turn) || !Array.isArray(turn.items)) continue;
const turnId = typeof turn.id === "string" ? turn.id : undefined;
for (const item of turn.items) {
if (isRecord(item)) messages.push(projectThreadItem(item, turnId, turn));
}
}
return messages;
}
function projectThreadItem(
item: JsonObject,
turnId?: string,
turn?: JsonObject,
lifecycle: JsonObject = {},
): JsonObject {
const safeItem = redactJson(item);
const projected: JsonObject = isRecord(safeItem) ? { ...safeItem } : {};
const itemType = typeof item.type === "string" ? item.type : "unknown";
const itemId = typeof item.id === "string" ? item.id : undefined;
const turnStatus = typeof turn?.status === "string" ? turn.status : undefined;
const startedAtMs = finiteNumber(lifecycle.startedAtMs) ?? secondsToMs(turn?.startedAt);
const completedAtMs = finiteNumber(lifecycle.completedAtMs) ?? secondsToMs(turn?.completedAt);
const durationMs = finiteNumber(item.durationMs) ?? finiteNumber(turn?.durationMs);
const status = typeof lifecycle.status === "string"
? lifecycle.status
: typeof item.status === "string" ? item.status : undefined;
Object.assign(projected, {
...(itemId ? { id: itemId, itemId } : {}),
...(turnId ? { turnId } : {}),
itemType,
...(status ? { status } : {}),
...(turnStatus ? { turnStatus } : {}),
...(startedAtMs !== undefined ? { startedAtMs } : {}),
...(completedAtMs !== undefined ? { completedAtMs } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
});
switch (itemType) {
case "userMessage":
projected.role = "user";
projected.kind = "user";
projected.text = userInputText(item.content);
break;
case "agentMessage":
projected.role = "assistant";
projected.kind = "assistant";
projected.text = typeof item.text === "string" ? redactText(item.text) : "";
break;
case "reasoning":
projected.role = "reasoning";
projected.kind = "reasoning";
projected.text = stringArrayText(item.summary) || stringArrayText(item.content);
break;
case "plan":
projected.role = "reasoning";
projected.kind = "plan";
projected.text = typeof item.text === "string" ? redactText(item.text) : "";
break;
case "commandExecution":
projected.role = "tool";
projected.kind = "tool";
projected.command = typeof item.command === "string" ? redactText(item.command) : "";
projected.text = typeof item.command === "string" ? redactText(item.command) : "";
projected.output = typeof item.aggregatedOutput === "string" ? redactText(item.aggregatedOutput) : "";
projected.label = "Command";
projected.uiType = "commandExecution";
break;
case "fileChange": {
projected.role = "tool";
projected.kind = "edit";
const paths = Array.isArray(item.changes)
? item.changes.filter(isRecord).map((change) => {
const value = asJsonObject(change);
return typeof value.path === "string" ? redactText(value.path) : "";
}).filter(Boolean)
: [];
projected.text = paths.join("\n");
projected.label = "File changes";
projected.uiType = "fileChange";
break;
}
case "collabAgentToolCall":
projected.role = "tool";
projected.kind = "tool";
projected.text = typeof item.prompt === "string" ? redactText(item.prompt) : typeof item.tool === "string" ? item.tool : "";
projected.action = typeof item.tool === "string" ? item.tool : "";
projected.uiType = "collabAgentToolCall";
break;
case "subAgentActivity":
projected.role = "tool";
projected.kind = "tool";
projected.text = typeof item.agentPath === "string" ? redactText(item.agentPath) : "";
projected.activityKind = typeof item.kind === "string" ? item.kind : "";
projected.uiType = "subAgentActivity";
break;
case "webSearch":
projected.role = "tool";
projected.kind = "tool";
projected.text = typeof item.query === "string" ? redactText(item.query) : "";
projected.label = "Web search";
projected.uiType = "webSearch";
break;
case "imageView":
projected.role = "tool";
projected.kind = "tool";
projected.text = typeof item.path === "string" ? redactText(item.path) : "";
projected.label = "Image";
break;
case "contextCompaction":
projected.role = "tool";
projected.kind = "tool";
projected.text = "Context compacted";
projected.uiType = "contextCompaction";
break;
default:
projected.role = "tool";
projected.kind = "tool";
projected.text = genericItemText(item);
break;
}
return projected;
}
function userInputText(value: unknown): string {
if (!Array.isArray(value)) return "";
return value.map((input) => {
if (!isRecord(input)) return "";
if (input.type === "text" && typeof input.text === "string") return redactText(input.text);
if (input.type === "skill" && typeof input.name === "string") return `$${redactText(input.name)}`;
if (input.type === "mention" && typeof input.name === "string") return `@${redactText(input.name)}`;
if ((input.type === "image" || input.type === "audio") && typeof input.url === "string") return redactText(input.url);
if ((input.type === "localImage" || input.type === "localAudio") && typeof input.path === "string") return redactText(input.path);
return "";
}).filter(Boolean).join("\n");
}
function stringArrayText(value: unknown): string {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string").map(redactText).join("\n")
: "";
}
function genericItemText(item: JsonObject): string {
for (const key of ["text", "query", "command", "name", "tool"] as const) {
if (typeof item[key] === "string") return redactText(item[key] as string);
}
if (item.output !== undefined) return redactText(stableStringify(redactJson(item.output)));
if (item.result !== undefined) return redactText(stableStringify(redactJson(item.result)));
return "";
}
function outputTailFromMessages(messages: JsonValue[], maxChars: number): string {
const chunks: string[] = [];
for (const message of messages) {
if (!isRecord(message)) continue;
if (typeof message.text === "string" && message.text) chunks.push(message.text);
if (typeof message.output === "string" && message.output) chunks.push(message.output);
}
const output = redactText(chunks.join("\n\n"));
return output.length > maxChars ? output.slice(-maxChars) : output;
}
function sessionTitle(value: string, threadId: string): string {
const title = redactText(value).replace(/\s+/g, " ").trim();
if (title) return title.slice(0, 160);
return `Session ${threadId.slice(0, 8)}`;
}
function secondsToMs(value: unknown): number | undefined {
const seconds = finiteNumber(value);
return seconds === undefined ? undefined : Math.round(seconds * 1_000);
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Keep browser/embedding responses aligned with app-server response schemas. */
function normalizeServerResponse(method: string, response: JsonValue): JsonValue {
if (method === "item/permissions/requestApproval") {
const source = isRecord(response) ? response : {};
const requested = isRecord(source.permissions) ? source.permissions : {};
const permissions: JsonObject = {};
for (const [key, value] of Object.entries(requested)) {
// Request profiles use null to mean "not requested"; granted profiles
// omit those fields instead of sending an invalid explicit null.
if (value !== null && value !== undefined) permissions[key] = asJsonValue(value);
}
const normalized: JsonObject = {
permissions,
scope: source.scope === "session" ? "session" : "turn",
};
if (typeof source.strictAutoReview === "boolean") normalized.strictAutoReview = source.strictAutoReview;
return normalized;
}
// Tool user-input responses are wrapped in an `answers` object. MCP
// elicitation has a different schema (`action`, `content`, `_meta`) and
// must be forwarded unchanged; wrapping it would make app-server reject
// an otherwise valid approval response.
if (method === "mcpServer/elicitation/request") return response;
if (method === "item/tool/requestUserInput") {
if (isRecord(response) && Object.prototype.hasOwnProperty.call(response, "answers")) return response;
return { answers: isRecord(response) ? response : {} };
}
return response;
}
/** Validate a response immediately before it crosses the app-server boundary. */
function validateAdapterResponse(
method: string,
response: JsonValue,
decision: "allow" | "deny" | "cancel",
): void {
if (!isRecord(response)) throw new Error("app-server response must be a JSON object");
if (method === "item/permissions/requestApproval") {
if (!isRecord(response.permissions)
|| (response.scope !== "turn" && response.scope !== "session")
|| (response.strictAutoReview !== undefined && typeof response.strictAutoReview !== "boolean")) {
throw new Error("invalid permissions approval response");
}
return;
}
if (method === "item/tool/requestUserInput") {
const answers = response.answers;
if (!isRecord(answers)) throw new Error("invalid tool input response");
return;
}
if (method === "mcpServer/elicitation/request") {
if (!Object.prototype.hasOwnProperty.call(response, "action")) {
throw new Error("MCP elicitation response requires action");
}
const action = approvalDecisionKindForMethod(response.action, method);
if (!action || action !== decision) throw new Error("MCP elicitation action conflicts with decision");
return;
}
// Approval callbacks all use a `decision` field. Unknown or mixed tagged
// objects are rejected by the method-aware classifier before write.
if (!hasApprovalDecisionField(response) || !Object.prototype.hasOwnProperty.call(response, "decision")) {
throw new Error("approval response requires decision");
}
const responseDecision = approvalDecisionKindForMethod(response.decision, method);
if (!responseDecision) throw new Error("unsupported approval response decision");
if (responseDecision !== decision) throw new Error(`approval response implies ${responseDecision}, but decision is ${decision}`);
}
function statusToState(status: unknown): string {
if (typeof status === "string") return status;
if (isRecord(status) && typeof status.type === "string") return status.type;
return "unknown";
}
function approvalAction(method: string): string {
switch (method) {
case "item/commandExecution/requestApproval":
case "execCommandApproval":
return "command.execution";
case "item/fileChange/requestApproval":
case "applyPatchApproval":
return "file.change";
case "item/permissions/requestApproval":
return "permissions.grant";
default:
return "approval";
}
}
function approvalRisk(method: string, command?: string, commandActions?: JsonValue): PendingApproval["risk"] {
// A permission profile can expand filesystem or network access for the
// current turn/session, so treat it like an explicit high-impact command.
if (method.includes("permissions")) return "high";
if (method.includes("command") || method === "execCommandApproval") {
const suspicious = /(?:rm\s+-rf|sudo|curl|wget|ssh|password|token|secret)/i;
if (Array.isArray(commandActions)) {
// `commandActions` is parsed display data, not a proof of safety. Keep
// every request carrying it high-risk, scan all extracted commands, and
// treat unknown/malformed actions as high-risk as well. This prevents a
// dangerous subcommand from being hidden behind command:null.
const actions = commandActions;
const actionCommands = actions
.map((action) => isRecord(action) && typeof action.command === "string" ? action.command : undefined)
.filter((item): item is string => Boolean(item));
const malformed = actions.some((action) => {
if (!isRecord(action) || typeof action.command !== "string") return true;
return action.type !== "read"
&& action.type !== "listFiles"
&& action.type !== "search"
&& action.type !== "unknown";
});
const combined = [command, ...actionCommands].filter((item): item is string => Boolean(item)).join(" && ");
if (malformed || !actionCommands.length || suspicious.test(combined) || actions.some((action) => isRecord(action) && action.type === "unknown")) {
return "high";
}
return "high";
}
if (command && suspicious.test(command)) return "high";
// An unparseable command approval is fail-closed. A missing command can
// otherwise be misclassified as medium and approved by the default host
// capability policy.
if (!command) return "high";
return "medium";
}
if (method.includes("fileChange") || method === "applyPatchApproval") return "medium";
return "unknown";
}
function outputStream(params: JsonObject): string {
const stream = params.stream;
if (stream === "stderr" || stream === "stdout" || stream === "codex") return stream;
return "stdout";
}
function decodeOutput(params: JsonObject): string {
if (typeof params.delta === "string") return params.delta;
if (typeof params.deltaBase64 === "string") {
try {
return Buffer.from(params.deltaBase64, "base64").toString("utf8");
} catch {
return "[invalid base64 output]";
}
}
return "";
}
const SECRET_KEY = /(?:token|secret|password|authorization|api[_-]?key|private[_-]?key|refresh)/i;
const SECRET_VALUE = /(?:Bearer\s+)[A-Za-z0-9._~+\-/]+=*|(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,})/g;
function redactText(text: string): string {
return text
.replace(SECRET_VALUE, "[REDACTED]")
.replace(/([?&](?:token|key|secret|password|api[_-]?key)=)[^&\s]+/gi, "$1[REDACTED]")
.replace(/((?:token|secret|password|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
}
function redactJson(value: unknown): JsonValue {
if (Array.isArray(value)) return value.map((item) => redactJson(item));
if (isRecord(value)) {
const result: JsonObject = {};
for (const [key, child] of Object.entries(value)) {
result[key] = SECRET_KEY.test(key) ? "[REDACTED]" : redactJson(child);
}
return result;
}
if (typeof value === "string") return redactText(value);
return asJsonValue(value);
}
function hashJson(value: JsonValue): string {
return createHash("sha256").update(stableStringify(value)).digest("hex");
}
function stableStringify(value: JsonValue): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value !== null && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key] ?? null)}`).join(",")}}`;
}
return JSON.stringify(value);
}