mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
4254 lines
191 KiB
TypeScript
4254 lines
191 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|||
|
|
import { promises as fs } from "node:fs";
|
||
|
|
import * as os from "node:os";
|
||
|
|
import * as path from "node:path";
|
||
|
|
|
||
|
|
import {
|
||
|
|
CODEX_IPC_METHOD_VERSIONS,
|
||
|
|
CodexIpcClient,
|
||
|
|
CodexIpcClientOptions,
|
||
|
|
ConversationStreamState,
|
||
|
|
ConversationStreamEvent,
|
||
|
|
IpcBroadcast,
|
||
|
|
IpcSubscription,
|
||
|
|
} from "./codexIpc";
|
||
|
|
import {
|
||
|
|
AgentAdapter,
|
||
|
|
AgentEvent,
|
||
|
|
AgentStatusSnapshot,
|
||
|
|
asJsonObject,
|
||
|
|
asJsonValue,
|
||
|
|
Disposable,
|
||
|
|
isJsonRpcId,
|
||
|
|
isRecord,
|
||
|
|
JsonObject,
|
||
|
|
JsonRpcId,
|
||
|
|
JsonValue,
|
||
|
|
Logger,
|
||
|
|
PendingApproval,
|
||
|
|
SessionSnapshot,
|
||
|
|
SessionListEntry,
|
||
|
|
SessionListResult,
|
||
|
|
SubagentSnapshot,
|
||
|
|
jsonRpcIdKey,
|
||
|
|
} from "./protocol";
|
||
|
|
|
||
|
|
const APPROVAL_METHODS = new Set([
|
||
|
|
"item/commandExecution/requestApproval",
|
||
|
|
"item/fileChange/requestApproval",
|
||
|
|
"item/permissions/requestApproval",
|
||
|
|
"applyPatchApproval",
|
||
|
|
"execCommandApproval",
|
||
|
|
]);
|
||
|
|
const INPUT_METHODS = new Set([
|
||
|
|
"item/tool/requestUserInput",
|
||
|
|
"mcpServer/elicitation/request",
|
||
|
|
]);
|
||
|
|
const TERMINAL_TURN_STATES = new Set([
|
||
|
|
"completed",
|
||
|
|
"complete",
|
||
|
|
"failed",
|
||
|
|
"cancelled",
|
||
|
|
"canceled",
|
||
|
|
"interrupted",
|
||
|
|
"error",
|
||
|
|
"done",
|
||
|
|
]);
|
||
|
|
const HISTORY_LOAD_RETRY_DELAY_MS = 50;
|
||
|
|
const VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS = 250;
|
||
|
|
const VSCODE_SESSION_FOLLOW_MAX_ATTEMPTS = 3;
|
||
|
|
/** Keep the relay alive while a user opens/selects the first official panel. */
|
||
|
|
const WAITING_SESSION_DISCOVERY_DELAY_MS = 1_000;
|
||
|
|
/** A waiting poll is a fallback; route broadcasts handle the common fast path. */
|
||
|
|
const WAITING_SESSION_DISCOVERY_MAX_CANDIDATES = 8;
|
||
|
|
|
||
|
|
export interface CodexIpcAgentAdapterOptions extends Omit<CodexIpcClientOptions, "clientType" | "canHandleRequest"> {
|
||
|
|
/** Existing conversation id. Empty/undefined enables local discovery. */
|
||
|
|
threadId?: string;
|
||
|
|
hostId?: string;
|
||
|
|
autoDiscoverThread?: boolean;
|
||
|
|
/** Workspace paths used to rank auto-discovered sessions. */
|
||
|
|
preferredCwds?: string[];
|
||
|
|
/** Ask the owner for older paginated history after the initial snapshot. */
|
||
|
|
loadCompleteHistory?: boolean;
|
||
|
|
/** Follow route changes in the official VS Code Codex panel. */
|
||
|
|
followVscodeSession?: boolean;
|
||
|
|
/** Coalesce the official panel's old=false/new=true route broadcasts. */
|
||
|
|
vscodeSessionFollowDebounceMs?: number;
|
||
|
|
ownerDiscoveryTimeoutMs?: number;
|
||
|
|
followTimeoutMs?: number;
|
||
|
|
maxOutputTailChars?: number;
|
||
|
|
approvalTimeoutMs?: number;
|
||
|
|
logger?: Logger;
|
||
|
|
/** Invoke the existing official VS Code command for a fresh Codex panel. */
|
||
|
|
openNewSession?: () => Promise<JsonValue>;
|
||
|
|
/** Inject a client in tests. The adapter owns it unless disabled below. */
|
||
|
|
client?: CodexIpcClient;
|
||
|
|
disposeClient?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface RequestEntry {
|
||
|
|
requestId: JsonRpcId;
|
||
|
|
method: string;
|
||
|
|
params: JsonObject;
|
||
|
|
threadId?: string;
|
||
|
|
turnId?: string;
|
||
|
|
createdAt: number;
|
||
|
|
expiresAt?: number;
|
||
|
|
approval?: PendingApproval;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface TurnInfo {
|
||
|
|
id?: string;
|
||
|
|
status: string;
|
||
|
|
active: boolean;
|
||
|
|
startedAt?: number;
|
||
|
|
durationMs?: number | null;
|
||
|
|
workedDurationMs?: number | null;
|
||
|
|
firstTurnWorkItemStartedAtMs?: number | null;
|
||
|
|
finalAssistantStartedAtMs?: number | null;
|
||
|
|
completedAtMs?: number | null;
|
||
|
|
error?: JsonValue;
|
||
|
|
/** The raw turn record, used to classify the currently running work item. */
|
||
|
|
raw?: Record<string, unknown>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* AgentAdapter backed by the private IPC follower protocol used by the
|
||
|
|
* official OpenAI Codex VS Code extension. It never starts or kills a codex
|
||
|
|
* process: all work is routed to the owner of an already-open conversation.
|
||
|
|
*/
|
||
|
|
export class CodexIpcAgentAdapter implements AgentAdapter {
|
||
|
|
private readonly options: Required<Pick<
|
||
|
|
CodexIpcAgentAdapterOptions,
|
||
|
|
"hostId" | "autoDiscoverThread" | "followVscodeSession" | "vscodeSessionFollowDebounceMs" | "ownerDiscoveryTimeoutMs" | "followTimeoutMs" | "maxOutputTailChars" | "approvalTimeoutMs" | "disposeClient"
|
||
|
|
>> & CodexIpcAgentAdapterOptions;
|
||
|
|
private readonly client: CodexIpcClient;
|
||
|
|
private readonly listeners = new Set<(event: AgentEvent) => void>();
|
||
|
|
private readonly subscriptions: IpcSubscription[] = [];
|
||
|
|
private readonly pending = new Map<string, RequestEntry>();
|
||
|
|
private readonly pendingTimers = new Map<string, NodeJS.Timeout>();
|
||
|
|
private readonly pendingExpiryAt = new Map<string, number>();
|
||
|
|
private readonly optimisticallyResolved = new Set<string>();
|
||
|
|
private threadId: string | null = null;
|
||
|
|
private ownerClientId: string | null = null;
|
||
|
|
private revision: number | null = null;
|
||
|
|
private conversationState: JsonObject = {};
|
||
|
|
private turnId: string | null = null;
|
||
|
|
private state = "disconnected";
|
||
|
|
private status: AgentStatusSnapshot = {
|
||
|
|
activity: "idle",
|
||
|
|
turnStatus: "idle",
|
||
|
|
activeFlags: [],
|
||
|
|
startedAtMs: null,
|
||
|
|
durationMs: null,
|
||
|
|
workedDurationMs: null,
|
||
|
|
elapsedMs: null,
|
||
|
|
firstTurnWorkItemStartedAtMs: null,
|
||
|
|
finalAssistantStartedAtMs: null,
|
||
|
|
};
|
||
|
|
private started = false;
|
||
|
|
private renderedOutput = "";
|
||
|
|
private renderedOutputLength = 0;
|
||
|
|
private renderedOutputWasTruncated = false;
|
||
|
|
private renderedMessageShape = "";
|
||
|
|
private outputTail = "";
|
||
|
|
private outputMessages: RenderedConversationMessage[] = [];
|
||
|
|
private subagents: SubagentSnapshot[] = [];
|
||
|
|
private renderedSubagentShape = "";
|
||
|
|
/** Fingerprint of the display-safe metadata projection last sent to peers. */
|
||
|
|
private renderedMetadataShape = "";
|
||
|
|
private snapshotSeen = false;
|
||
|
|
private historyComplete?: boolean;
|
||
|
|
private historyLoadRequested = false;
|
||
|
|
private historyLoadAttempts = 0;
|
||
|
|
private historyLoadGeneration = 0;
|
||
|
|
private historyLoadRetryTimer?: NodeJS.Timeout;
|
||
|
|
private disposed = false;
|
||
|
|
/** Serialize session navigation so two browser clicks cannot overlap. */
|
||
|
|
private sessionSwitching = false;
|
||
|
|
/** Invalidates in-flight navigation when dispose/socket close begins. */
|
||
|
|
private sessionLifecycleGeneration = 0;
|
||
|
|
/** IPC client that owns the official Codex panel route being mirrored. */
|
||
|
|
private vscodeRouteClientId: string | null = null;
|
||
|
|
/** Untrusted old=false halves; only a matching same-source true can bind. */
|
||
|
|
private readonly vscodeRouteCandidates = new Map<string, string>();
|
||
|
|
/** Last route that source reported as active, independent of our attachment. */
|
||
|
|
private vscodeRouteActiveThreadId: string | null = null;
|
||
|
|
/** Official routing emits old=false before new=true, even when the picker stays open. */
|
||
|
|
private vscodeRouteAwaitingSelection = false;
|
||
|
|
/** Latest route selected in that official panel, coalesced across rapid clicks. */
|
||
|
|
private pendingVscodeThreadId: string | null = null;
|
||
|
|
private pendingVscodeFollowAttempts = 0;
|
||
|
|
private pendingVscodeFollowGeneration = 0;
|
||
|
|
private vscodeRouteGeneration = 0;
|
||
|
|
private activeVscodeSelection?: { target: string; generation: number };
|
||
|
|
private vscodeSessionFollowTimer?: NodeJS.Timeout;
|
||
|
|
/** Serialize attachability probes with navigation so probe cleanup cannot unfollow a newly selected thread. */
|
||
|
|
private sessionOperationTail: Promise<void> = Promise.resolve();
|
||
|
|
/** True while the IPC/relay host is usable but no conversation is attached. */
|
||
|
|
private waitingForSession = false;
|
||
|
|
private waitingDiscoveryTimer?: NodeJS.Timeout;
|
||
|
|
private waitingDiscoveryInFlight?: Promise<void>;
|
||
|
|
private waitingAttachPromise?: Promise<boolean>;
|
||
|
|
private waitingAttachTarget: string | null = null;
|
||
|
|
private queuedWaitingAttachTarget: string | null = null;
|
||
|
|
private snapshotWaiter?: { threadId: string; ownerClientId?: string; resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout };
|
||
|
|
private revisionWaiter?: { threadId: string; ownerClientId: string; revision: number; resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout };
|
||
|
|
|
||
|
|
constructor(options: CodexIpcAgentAdapterOptions = {}) {
|
||
|
|
this.options = {
|
||
|
|
...options,
|
||
|
|
hostId: options.hostId ?? "local",
|
||
|
|
autoDiscoverThread: options.autoDiscoverThread ?? true,
|
||
|
|
followVscodeSession: options.followVscodeSession ?? true,
|
||
|
|
vscodeSessionFollowDebounceMs: Math.max(0, options.vscodeSessionFollowDebounceMs ?? 150),
|
||
|
|
// Owner discovery for an active local VS Code client normally returns
|
||
|
|
// in a few milliseconds. A short default keeps stale rollout files from
|
||
|
|
// making bridge startup look hung; callers can raise this explicitly.
|
||
|
|
ownerDiscoveryTimeoutMs: options.ownerDiscoveryTimeoutMs ?? 2_500,
|
||
|
|
followTimeoutMs: options.followTimeoutMs ?? 8_000,
|
||
|
|
maxOutputTailChars: options.maxOutputTailChars ?? 32_000,
|
||
|
|
approvalTimeoutMs: options.approvalTimeoutMs ?? 5 * 60_000,
|
||
|
|
disposeClient: options.disposeClient ?? true,
|
||
|
|
};
|
||
|
|
this.client = options.client ?? new CodexIpcClient({
|
||
|
|
...options,
|
||
|
|
clientType: "codex-remote-collab-follower",
|
||
|
|
canHandleRequest: () => false,
|
||
|
|
autoReconnect: false,
|
||
|
|
});
|
||
|
|
this.subscriptions.push(this.client.onBroadcast((frame) => this.handleBroadcast(frame)));
|
||
|
|
this.subscriptions.push(this.client.onStreamEvent((event) => this.handleStreamEvent(event)));
|
||
|
|
this.subscriptions.push(this.client.onError((error) => this.options.logger?.debug?.("Codex IPC error", error.message)));
|
||
|
|
this.subscriptions.push(this.client.onClose((error) => this.handleClose(error)));
|
||
|
|
}
|
||
|
|
|
||
|
|
async start(): Promise<void> {
|
||
|
|
if (this.started) return;
|
||
|
|
if (this.disposed) throw new Error("Codex IPC follower is disposed");
|
||
|
|
this.clearWaitingDiscoveryTimer();
|
||
|
|
this.waitingForSession = false;
|
||
|
|
this.resetHistoryLoading();
|
||
|
|
this.renderedOutput = "";
|
||
|
|
this.renderedOutputLength = 0;
|
||
|
|
this.renderedOutputWasTruncated = false;
|
||
|
|
this.renderedMessageShape = "";
|
||
|
|
this.outputTail = "";
|
||
|
|
this.outputMessages = [];
|
||
|
|
this.subagents = [];
|
||
|
|
this.renderedSubagentShape = "";
|
||
|
|
this.renderedMetadataShape = "";
|
||
|
|
const configured = this.options.threadId?.trim();
|
||
|
|
await this.client.connect();
|
||
|
|
if (configured) {
|
||
|
|
try {
|
||
|
|
await this.attachThread(configured);
|
||
|
|
return;
|
||
|
|
} catch (error) {
|
||
|
|
// A remembered thread can belong to another Codex window (or to a
|
||
|
|
// previous run). When auto-discovery is enabled, keep startup useful
|
||
|
|
// by falling back to the most recent live VS Code owner.
|
||
|
|
if (!isMissingSessionOwnerError(error)) throw error;
|
||
|
|
if (!this.options.autoDiscoverThread) {
|
||
|
|
this.enterWaitingForSession();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.options.logger?.warn?.(`Configured Codex conversation ${configured} could not be followed; trying auto-discovery`, error);
|
||
|
|
const fallback = await this.discoverThreadId(new Set([configured]));
|
||
|
|
if (fallback) {
|
||
|
|
try {
|
||
|
|
await this.attachThread(fallback);
|
||
|
|
return;
|
||
|
|
} catch (fallbackError) {
|
||
|
|
if (!isMissingSessionOwnerError(fallbackError)) throw fallbackError;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.enterWaitingForSession();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedThread = await this.discoverThreadId();
|
||
|
|
if (!selectedThread) {
|
||
|
|
// A fresh VS Code window can have a live IPC socket before the user has
|
||
|
|
// opened a Codex conversation. Keep the follower (and therefore the
|
||
|
|
// relay/WebSocket) alive so the first later panel navigation can attach
|
||
|
|
// without restarting the bridge.
|
||
|
|
this.enterWaitingForSession();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await this.attachThread(selectedThread);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async attachThread(selectedThread: string, options: { fromWaiting?: boolean } = {}): Promise<void> {
|
||
|
|
const fromWaiting = options.fromWaiting === true;
|
||
|
|
const lifecycleGeneration = this.sessionLifecycleGeneration;
|
||
|
|
const routeGeneration = this.vscodeRouteGeneration;
|
||
|
|
const wasStarted = this.started;
|
||
|
|
const wasWaiting = this.waitingForSession;
|
||
|
|
const previousRouteClientId = this.vscodeRouteClientId;
|
||
|
|
const previousRouteActiveThreadId = this.vscodeRouteActiveThreadId;
|
||
|
|
const previousRouteAwaitingSelection = this.vscodeRouteAwaitingSelection;
|
||
|
|
const owner = await this.client.findThreadOwner(selectedThread, this.options.hostId, this.options.ownerDiscoveryTimeoutMs);
|
||
|
|
if (!owner) {
|
||
|
|
throw new Error(`找不到会话 ${selectedThread} 的 VS Code Codex owner。请确认该会话已在官方 Codex 面板打开。`);
|
||
|
|
}
|
||
|
|
this.threadId = selectedThread;
|
||
|
|
this.ownerClientId = owner;
|
||
|
|
// An owner can be Codex Desktop while the visible VS Code webview follows
|
||
|
|
// it, so ownerClientId is not always the route source. Bind the route
|
||
|
|
// source on the first observed old=false/new=true navigation pair instead.
|
||
|
|
if (!this.vscodeRouteActiveThreadId) this.vscodeRouteActiveThreadId = selectedThread;
|
||
|
|
this.state = "syncing";
|
||
|
|
if (!wasStarted) this.started = true;
|
||
|
|
// A normal startup announces the connection before waiting for the first
|
||
|
|
// stream snapshot. A waiting host already announced its IPC connection;
|
||
|
|
// emitting a second `connection.opened` would make the browser reset its
|
||
|
|
// connection indicator unnecessarily.
|
||
|
|
if (!wasStarted) this.emit({ type: "connection.opened", threadId: selectedThread, payload: { mode: "attach", ownerClientId: owner } });
|
||
|
|
// Do not expose a provisional thread as interactive while its first
|
||
|
|
// authoritative snapshot is still in flight.
|
||
|
|
this.waitingForSession = false;
|
||
|
|
try {
|
||
|
|
const waitForSnapshot = this.waitForSnapshot(selectedThread, this.options.followTimeoutMs, owner);
|
||
|
|
void waitForSnapshot.catch(() => undefined);
|
||
|
|
await this.client.followConversation(selectedThread, true, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: [owner],
|
||
|
|
});
|
||
|
|
await waitForSnapshot;
|
||
|
|
this.state = this.deriveSessionState();
|
||
|
|
this.waitingForSession = false;
|
||
|
|
this.clearWaitingDiscoveryTimer();
|
||
|
|
if (this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded();
|
||
|
|
this.options.logger?.info?.(`Attached to existing Codex conversation ${selectedThread}`);
|
||
|
|
} catch (error) {
|
||
|
|
// A failed follow must leave the adapter retryable and must not keep a
|
||
|
|
// stale thread/owner that could receive a later remote command.
|
||
|
|
this.clearSnapshotWaiter(error instanceof Error ? error : new Error(String(error)));
|
||
|
|
try { await this.client.followConversation(selectedThread, false, { hostId: this.options.hostId, targetClientIds: [owner] }); } catch { /* best effort */ }
|
||
|
|
const restoreWaiting = (fromWaiting || wasWaiting)
|
||
|
|
&& !this.disposed
|
||
|
|
&& lifecycleGeneration === this.sessionLifecycleGeneration;
|
||
|
|
// A transient attach attempt made from the waiting state must not tear
|
||
|
|
// down the IPC socket or relay. Restore the waiting projection and let
|
||
|
|
// the discovery loop try again after the official panel is ready.
|
||
|
|
this.started = restoreWaiting ? wasStarted : false;
|
||
|
|
this.state = restoreWaiting ? "waiting_for_host" : "disconnected";
|
||
|
|
this.waitingForSession = restoreWaiting;
|
||
|
|
this.threadId = null;
|
||
|
|
this.ownerClientId = null;
|
||
|
|
if (!restoreWaiting) {
|
||
|
|
this.vscodeRouteClientId = null;
|
||
|
|
this.vscodeRouteActiveThreadId = null;
|
||
|
|
this.vscodeRouteAwaitingSelection = false;
|
||
|
|
} else if (this.vscodeRouteGeneration === routeGeneration) {
|
||
|
|
this.vscodeRouteClientId = previousRouteClientId;
|
||
|
|
this.vscodeRouteActiveThreadId = previousRouteActiveThreadId;
|
||
|
|
this.vscodeRouteAwaitingSelection = previousRouteAwaitingSelection;
|
||
|
|
}
|
||
|
|
this.vscodeRouteCandidates.clear();
|
||
|
|
this.resetConversationProjection();
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Attach mode deliberately has no thread creation operation. */
|
||
|
|
async startThread(): Promise<JsonValue> {
|
||
|
|
throw new Error("attach mode does not create a new thread; open an existing Codex conversation in VS Code");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Open a new conversation through the already-installed VS Code Codex
|
||
|
|
* extension. The callback is injected by the extension entrypoint; this
|
||
|
|
* follower never starts another Codex process.
|
||
|
|
*/
|
||
|
|
async newSession(): Promise<JsonValue> {
|
||
|
|
// Opening the official new-session panel is also the recovery action for
|
||
|
|
// `waiting_for_host`; it does not require an existing conversation owner.
|
||
|
|
this.ensureStarted();
|
||
|
|
if (!this.options.openNewSession) {
|
||
|
|
throw new Error("当前 VS Code Codex 扩展不支持从远程打开新会话");
|
||
|
|
}
|
||
|
|
return asJsonValue(await this.options.openNewSession());
|
||
|
|
}
|
||
|
|
|
||
|
|
async startTurn(params: JsonObject): Promise<JsonValue> {
|
||
|
|
this.ensureInteractiveReady();
|
||
|
|
const input = extractInput(params);
|
||
|
|
const request = pickTurnRequest(params);
|
||
|
|
const result = await this.client.startTurn(this.threadId as string, input, {
|
||
|
|
request,
|
||
|
|
context: pickTurnContext(params),
|
||
|
|
clientUserMessageId: stringValue(params.clientUserMessageId),
|
||
|
|
ownerClientId: this.ownerClientId as string,
|
||
|
|
timeoutMs: this.options.followTimeoutMs,
|
||
|
|
});
|
||
|
|
const unwrapped = unwrapFollowerResult(result);
|
||
|
|
const nextTurn = extractTurnId(unwrapped);
|
||
|
|
if (nextTurn) {
|
||
|
|
this.turnId = nextTurn;
|
||
|
|
this.state = "active";
|
||
|
|
}
|
||
|
|
return asJsonValue(unwrapped);
|
||
|
|
}
|
||
|
|
|
||
|
|
async steerTurn(params: JsonObject): Promise<JsonValue> {
|
||
|
|
this.ensureInteractiveReady();
|
||
|
|
const expected = stringValue(params.expectedTurnId) ?? this.turnId;
|
||
|
|
if (!expected) throw new Error("turn/steer requires an active turn");
|
||
|
|
const result = await this.client.steerTurn(this.threadId as string, extractInput(params), {
|
||
|
|
clientUserMessageId: stringValue(params.clientUserMessageId) ?? randomUUID(),
|
||
|
|
serviceTier: params.serviceTier === null || typeof params.serviceTier === "string" ? params.serviceTier : undefined,
|
||
|
|
attachments: Array.isArray(params.attachments) ? params.attachments : [],
|
||
|
|
additionalContext: isRecord(params.additionalContext) ? asJsonObject(params.additionalContext) : undefined,
|
||
|
|
restoreMessage: params.restoreMessage === null || params.restoreMessage !== undefined ? asJsonValue(params.restoreMessage) : undefined,
|
||
|
|
ownerClientId: this.ownerClientId as string,
|
||
|
|
timeoutMs: this.options.followTimeoutMs,
|
||
|
|
});
|
||
|
|
this.turnId = expected;
|
||
|
|
this.state = "active";
|
||
|
|
return asJsonValue(unwrapFollowerResult(result));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Persist model/reasoning settings on the already-open official thread. */
|
||
|
|
async updateThreadSettings(params: JsonObject): Promise<JsonValue> {
|
||
|
|
this.ensureInteractiveReady();
|
||
|
|
const threadSettings = pickThreadSettingsUpdate(params);
|
||
|
|
const result = await this.client.updateThreadSettings(
|
||
|
|
this.threadId as string,
|
||
|
|
threadSettings,
|
||
|
|
{
|
||
|
|
ownerClientId: this.ownerClientId as string,
|
||
|
|
timeoutMs: this.options.followTimeoutMs,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
return asJsonValue(unwrapFollowerResult(result));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return the local VS Code conversations that this follower can attach to.
|
||
|
|
*
|
||
|
|
* The official extension obtains this list from its app-server client via
|
||
|
|
* `thread/list`. That request is intentionally not exposed by the private
|
||
|
|
* IPC router, so the bridge uses local rollout/index metadata only to find
|
||
|
|
* candidates. A candidate is returned after live owner discovery and, for a
|
||
|
|
* non-active conversation, a matching follower snapshot. Closed, stale, or
|
||
|
|
* desktop-owned rollouts are omitted instead of being shown as selectable
|
||
|
|
* history that attach mode cannot actually open.
|
||
|
|
*/
|
||
|
|
async listSessions(params: JsonObject = {}): Promise<JsonValue> {
|
||
|
|
// Session discovery is useful precisely while no conversation is attached
|
||
|
|
// (for example immediately after a fresh VS Code window opens).
|
||
|
|
this.ensureStarted();
|
||
|
|
const releaseSessionOperation = await this.acquireSessionOperation();
|
||
|
|
try {
|
||
|
|
return await this.listAttachableSessions(params);
|
||
|
|
} finally {
|
||
|
|
releaseSessionOperation();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async listAttachableSessions(params: JsonObject): Promise<JsonValue> {
|
||
|
|
const limitValue = numberValue(params.limit);
|
||
|
|
const limit = Math.max(1, Math.min(100, Number.isInteger(limitValue) ? limitValue as number : 50));
|
||
|
|
const codexHome = resolveCodexHome(this.options);
|
||
|
|
const [candidates, index] = await Promise.all([
|
||
|
|
recentVscodeThreadCandidates(path.join(codexHome, "sessions"), this.options.preferredCwds ?? []),
|
||
|
|
readSessionIndex(path.join(codexHome, "session_index.jsonl")),
|
||
|
|
]);
|
||
|
|
const byId = new Map<string, Candidate>();
|
||
|
|
for (const candidate of candidates) {
|
||
|
|
const indexed = index.get(candidate.id);
|
||
|
|
byId.set(candidate.id, {
|
||
|
|
...candidate,
|
||
|
|
...(indexed?.title && !candidate.title ? { title: indexed.title } : {}),
|
||
|
|
...(indexed?.updatedAtMs !== undefined && indexed.updatedAtMs > (candidate.updatedAtMs ?? 0)
|
||
|
|
? { updatedAtMs: indexed.updatedAtMs } : {}),
|
||
|
|
...(indexed?.cwd && !candidate.cwd ? { cwd: indexed.cwd } : {}),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// A configured/current thread can be valid even while its rollout has
|
||
|
|
// rotated away. Keep it in the picker so the active row is never lost.
|
||
|
|
if (this.threadId && !byId.has(this.threadId)) {
|
||
|
|
const title = stringValue(this.conversationState.title)
|
||
|
|
?? stringValue(this.conversationState.name)
|
||
|
|
?? stringValue(this.conversationState.threadTitle);
|
||
|
|
const cwd = stringValue(this.conversationState.cwd);
|
||
|
|
byId.set(this.threadId, {
|
||
|
|
id: this.threadId,
|
||
|
|
mtime: Date.now(),
|
||
|
|
updatedAtMs: Date.now(),
|
||
|
|
priority: 0,
|
||
|
|
...(title ? { title } : {}),
|
||
|
|
...(cwd ? { cwd } : {}),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const compareCandidates = (a: Candidate, b: Candidate) => (b.updatedAtMs ?? b.mtime) - (a.updatedAtMs ?? a.mtime)
|
||
|
|
|| a.priority - b.priority;
|
||
|
|
const ordered = [...byId.values()]
|
||
|
|
.sort(compareCandidates)
|
||
|
|
.slice(0, limit);
|
||
|
|
// `limit` bounds expensive owner/snapshot probes, but a newer stale
|
||
|
|
// rollout must never consume the only slot and hide the active attachment.
|
||
|
|
const activeCandidate = this.threadId ? byId.get(this.threadId) : undefined;
|
||
|
|
if (activeCandidate && !ordered.some((candidate) => candidate.id === activeCandidate.id)) {
|
||
|
|
if (ordered.length >= limit) ordered[ordered.length - 1] = activeCandidate;
|
||
|
|
else ordered.push(activeCandidate);
|
||
|
|
ordered.sort(compareCandidates);
|
||
|
|
}
|
||
|
|
const sessions: SessionListEntry[] = [];
|
||
|
|
// Owner discovery is deliberately bounded/concurrent: stale rollout files
|
||
|
|
// are common, and one slow stale check must not block all other rows.
|
||
|
|
const ownerTimeout = Math.max(250, Math.min(this.options.ownerDiscoveryTimeoutMs, 750));
|
||
|
|
// A discovery response only proves that *some* client knows the thread.
|
||
|
|
// Require a short, targeted snapshot probe before advertising a non-active
|
||
|
|
// row as selectable; desktop-owned/stale threads can otherwise look live
|
||
|
|
// and leave the browser blank after a failed switch.
|
||
|
|
const snapshotProbeTimeout = Math.max(250, Math.min(this.options.followTimeoutMs, 750));
|
||
|
|
for (let offset = 0; offset < ordered.length; offset += 8) {
|
||
|
|
const batch = ordered.slice(offset, offset + 8);
|
||
|
|
const checked = await Promise.all(batch.map(async (candidate) => {
|
||
|
|
let owner: string | null = candidate.id === this.threadId ? this.ownerClientId : null;
|
||
|
|
if (!owner) {
|
||
|
|
try {
|
||
|
|
owner = await this.client.findThreadOwner(candidate.id, this.options.hostId, ownerTimeout);
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Session owner discovery failed for ${candidate.id}`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let available = false;
|
||
|
|
if (owner) {
|
||
|
|
available = candidate.id === this.threadId
|
||
|
|
|| await this.probeSessionSnapshot(candidate.id, owner, snapshotProbeTimeout);
|
||
|
|
}
|
||
|
|
if (!available) return null;
|
||
|
|
const title = sanitizeSessionTitle(candidate.title) ?? `会话 ${candidate.id.slice(0, 8)}`;
|
||
|
|
return {
|
||
|
|
threadId: candidate.id,
|
||
|
|
title,
|
||
|
|
updatedAtMs: candidate.updatedAtMs ?? candidate.mtime,
|
||
|
|
...(candidate.cwd ? { cwd: redactText(candidate.cwd) } : {}),
|
||
|
|
active: candidate.id === this.threadId,
|
||
|
|
available: true,
|
||
|
|
} satisfies SessionListEntry;
|
||
|
|
}));
|
||
|
|
for (const entry of checked) {
|
||
|
|
if (entry) sessions.push(entry);
|
||
|
|
}
|
||
|
|
// Route navigation has priority over populating more picker rows. The
|
||
|
|
// probes in this batch have already cleaned up their temporary follows;
|
||
|
|
// stop here so selectSession can acquire the shared operation lock
|
||
|
|
// instead of waiting behind dozens of stale rollout candidates.
|
||
|
|
if (this.sessionSwitching || this.pendingVscodeThreadId || this.activeVscodeSelection) break;
|
||
|
|
}
|
||
|
|
const result: SessionListResult = {
|
||
|
|
sessions,
|
||
|
|
activeThreadId: this.threadId,
|
||
|
|
};
|
||
|
|
return asJsonValue(result);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Attach to another already-open VS Code Codex conversation. */
|
||
|
|
async selectSession(params: JsonObject): Promise<JsonValue> {
|
||
|
|
this.ensureStarted();
|
||
|
|
const origin = stringValue(params.origin) === "vscode" ? "vscode" : "web";
|
||
|
|
const expectedRouteGeneration = origin === "vscode"
|
||
|
|
? numberValue(params.vscodeRouteGeneration)
|
||
|
|
: undefined;
|
||
|
|
const target = (stringValue(params.threadId) ?? stringValue(params.conversationId))?.trim();
|
||
|
|
if (!target) throw new Error("session/select requires threadId");
|
||
|
|
if (target === this.threadId) {
|
||
|
|
return asJsonValue({ threadId: target, previousThreadId: target, switched: false, available: true });
|
||
|
|
}
|
||
|
|
if (this.sessionSwitching) throw new Error("a session switch is already in progress");
|
||
|
|
if (this.turnId || this.pending.size) {
|
||
|
|
throw new Error("cannot switch sessions while a turn or approval is active");
|
||
|
|
}
|
||
|
|
const lifecycleGeneration = this.sessionLifecycleGeneration;
|
||
|
|
this.sessionSwitching = true;
|
||
|
|
const releaseSessionOperation = await this.acquireSessionOperation();
|
||
|
|
const previousThreadId = this.threadId;
|
||
|
|
const previousOwnerClientId = this.ownerClientId;
|
||
|
|
const previousState = this.state;
|
||
|
|
// Keep a copy of the last owner-validated old-session state before changing
|
||
|
|
// the active conversation. It is a deterministic fallback if the target
|
||
|
|
// cannot produce a snapshot (for example, an already-running desktop
|
||
|
|
// writer).
|
||
|
|
let previousConversationState: ConversationStreamState | undefined;
|
||
|
|
let owner: string | null = null;
|
||
|
|
let attachmentChanged = false;
|
||
|
|
try {
|
||
|
|
this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
|
||
|
|
owner = await this.client.findThreadOwner(target, this.options.hostId, this.options.ownerDiscoveryTimeoutMs);
|
||
|
|
if (!owner) throw new Error(`找不到会话 ${target} 的 VS Code Codex owner。请确认该会话已在官方 Codex 面板打开。`);
|
||
|
|
this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
|
||
|
|
// A local turn/approval may have appeared while owner discovery was in
|
||
|
|
// flight. Re-check immediately before detaching the old projection.
|
||
|
|
if (this.turnId || this.pending.size) {
|
||
|
|
throw new Error("cannot switch sessions while a turn or approval is active");
|
||
|
|
}
|
||
|
|
// Rollback must use the adapter's last owner-validated projection. The
|
||
|
|
// lower-level IPC cache sees every same-conversation snapshot before
|
||
|
|
// this adapter can reject a stale/unknown owner, so reading that cache
|
||
|
|
// here could resurrect content we deliberately ignored.
|
||
|
|
previousConversationState = previousThreadId && previousOwnerClientId
|
||
|
|
? {
|
||
|
|
conversationId: previousThreadId,
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
ownerClientId: previousOwnerClientId,
|
||
|
|
revision: this.revision ?? 0,
|
||
|
|
conversationState: cloneObject(this.conversationState),
|
||
|
|
}
|
||
|
|
: undefined;
|
||
|
|
this.emit({
|
||
|
|
type: "session.switching",
|
||
|
|
threadId: target,
|
||
|
|
payload: { previousThreadId: previousThreadId ?? null, targetThreadId: target },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Keep the old follow alive until the new owner has supplied an
|
||
|
|
// authoritative snapshot. Events are filtered by `this.threadId`, so a
|
||
|
|
// stale old event cannot overwrite the target projection during attach.
|
||
|
|
this.threadId = target;
|
||
|
|
this.ownerClientId = owner;
|
||
|
|
this.state = "syncing";
|
||
|
|
this.waitingForSession = false;
|
||
|
|
this.resetConversationProjection();
|
||
|
|
attachmentChanged = true;
|
||
|
|
const waitForSnapshot = this.waitForSnapshot(target, this.options.followTimeoutMs, owner);
|
||
|
|
// If follow itself fails, the catch path rejects the waiter. Attach a
|
||
|
|
// handler immediately so that rejection can never become unhandled.
|
||
|
|
void waitForSnapshot.catch(() => undefined);
|
||
|
|
await this.client.followConversation(target, true, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: [owner],
|
||
|
|
});
|
||
|
|
await waitForSnapshot;
|
||
|
|
this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
|
||
|
|
// Revisions are scoped to an owner. A handoff can happen after the first
|
||
|
|
// snapshot, so confirm the owner again before committing/unfollowing A.
|
||
|
|
const confirmedOwner = await this.client.findThreadOwner(
|
||
|
|
target,
|
||
|
|
this.options.hostId,
|
||
|
|
this.options.ownerDiscoveryTimeoutMs,
|
||
|
|
);
|
||
|
|
if (confirmedOwner !== owner) {
|
||
|
|
throw new Error(`Codex conversation ${target} owner changed while switching`);
|
||
|
|
}
|
||
|
|
this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
|
||
|
|
if (previousThreadId && previousOwnerClientId) {
|
||
|
|
try {
|
||
|
|
await this.client.followConversation(previousThreadId, false, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: [previousOwnerClientId],
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Unable to unfollow previous session ${previousThreadId}`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.assertSessionSelectionCurrent(target, origin, lifecycleGeneration, expectedRouteGeneration);
|
||
|
|
this.state = this.deriveSessionState();
|
||
|
|
this.waitingForSession = false;
|
||
|
|
this.clearWaitingDiscoveryTimer();
|
||
|
|
if (this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded();
|
||
|
|
this.emit({
|
||
|
|
type: "session.selected",
|
||
|
|
threadId: target,
|
||
|
|
payload: {
|
||
|
|
threadId: target,
|
||
|
|
activeThreadId: target,
|
||
|
|
previousThreadId: previousThreadId ?? null,
|
||
|
|
switched: true,
|
||
|
|
available: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return asJsonValue({ threadId: target, previousThreadId: previousThreadId ?? null, switched: true, available: true });
|
||
|
|
} catch (error) {
|
||
|
|
this.clearSnapshotWaiter(error instanceof Error ? error : new Error(String(error)));
|
||
|
|
if (!attachmentChanged) throw error;
|
||
|
|
const lifecycleCurrent = lifecycleGeneration === this.sessionLifecycleGeneration
|
||
|
|
&& !this.disposed
|
||
|
|
&& this.started;
|
||
|
|
// Best-effort cleanup of the target subscription, then restore the old
|
||
|
|
// attachment so a failed switch does not strand the bridge disconnected.
|
||
|
|
if (lifecycleCurrent) {
|
||
|
|
try {
|
||
|
|
await this.client.followConversation(target, false, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
...(owner ? { targetClientIds: [owner] } : {}),
|
||
|
|
});
|
||
|
|
} catch { /* best effort */ }
|
||
|
|
}
|
||
|
|
// dispose()/onClose owns the final disconnected state. An interrupted
|
||
|
|
// navigation must never publish rollback snapshots or reconnect after it.
|
||
|
|
if (!lifecycleCurrent) throw error;
|
||
|
|
this.threadId = previousThreadId;
|
||
|
|
this.ownerClientId = previousOwnerClientId;
|
||
|
|
this.state = previousState;
|
||
|
|
this.waitingForSession = !previousThreadId;
|
||
|
|
this.resetConversationProjection();
|
||
|
|
// Move Relay/Web back before re-publishing the old snapshot. Browser
|
||
|
|
// command errors arrive after adapter events, so relying on only the
|
||
|
|
// command envelope would make the restored old projection look like an
|
||
|
|
// out-of-route event and leave the early target snapshot mounted.
|
||
|
|
if (previousThreadId) {
|
||
|
|
this.emit({
|
||
|
|
type: "session.selected",
|
||
|
|
threadId: previousThreadId,
|
||
|
|
payload: {
|
||
|
|
threadId: previousThreadId,
|
||
|
|
activeThreadId: previousThreadId,
|
||
|
|
previousThreadId: target,
|
||
|
|
targetThreadId: target,
|
||
|
|
switched: false,
|
||
|
|
available: true,
|
||
|
|
failed: true,
|
||
|
|
origin,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const restoredFromCache = this.restoreCachedConversationProjection(
|
||
|
|
previousConversationState,
|
||
|
|
previousOwnerClientId,
|
||
|
|
);
|
||
|
|
if (restoredFromCache && previousThreadId) {
|
||
|
|
// Re-assert the old follow without waiting for another snapshot. The
|
||
|
|
// cached state is already authoritative and keeps the bridge usable
|
||
|
|
// even when the owner does not answer a duplicate follow request.
|
||
|
|
try {
|
||
|
|
await this.client.followConversation(previousThreadId, true, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined,
|
||
|
|
});
|
||
|
|
} catch (restoreError) {
|
||
|
|
this.options.logger?.debug?.("Unable to re-follow previous Codex session after switch failure", restoreError);
|
||
|
|
}
|
||
|
|
} else if (previousThreadId && previousOwnerClientId) {
|
||
|
|
try {
|
||
|
|
const restoreWaiter = this.waitForSnapshot(previousThreadId, this.options.followTimeoutMs, previousOwnerClientId);
|
||
|
|
void restoreWaiter.catch(() => undefined);
|
||
|
|
await this.client.followConversation(previousThreadId, true, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: [previousOwnerClientId],
|
||
|
|
});
|
||
|
|
await restoreWaiter;
|
||
|
|
this.state = this.deriveSessionState();
|
||
|
|
} catch (restoreError) {
|
||
|
|
this.options.logger?.warn?.("Unable to restore previous Codex session after switch failure", restoreError);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!previousThreadId && this.started && !this.disposed) this.scheduleWaitingDiscovery();
|
||
|
|
throw error;
|
||
|
|
} finally {
|
||
|
|
releaseSessionOperation();
|
||
|
|
this.sessionSwitching = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Acquire a FIFO lock shared by session probes and attachment changes. */
|
||
|
|
private async acquireSessionOperation(): Promise<() => void> {
|
||
|
|
const previous = this.sessionOperationTail;
|
||
|
|
let release!: () => void;
|
||
|
|
this.sessionOperationTail = new Promise<void>((resolve) => { release = resolve; });
|
||
|
|
await previous;
|
||
|
|
return release;
|
||
|
|
}
|
||
|
|
|
||
|
|
private assertSessionSelectionCurrent(
|
||
|
|
target: string,
|
||
|
|
origin: "web" | "vscode",
|
||
|
|
lifecycleGeneration: number,
|
||
|
|
expectedRouteGeneration?: number,
|
||
|
|
): void {
|
||
|
|
if (lifecycleGeneration !== this.sessionLifecycleGeneration || this.disposed || !this.started) {
|
||
|
|
throw new Error("session selection was cancelled because the IPC session closed");
|
||
|
|
}
|
||
|
|
if (origin === "vscode"
|
||
|
|
&& (expectedRouteGeneration === undefined
|
||
|
|
|| expectedRouteGeneration !== this.vscodeRouteGeneration
|
||
|
|
|| this.vscodeRouteActiveThreadId !== target)) {
|
||
|
|
throw new Error("session selection was superseded by a newer VS Code route");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async interruptTurn(params: JsonObject = {}): Promise<JsonValue> {
|
||
|
|
this.ensureInteractiveReady();
|
||
|
|
const expected = stringValue(params.turnId) ?? stringValue(params.expectedTurnId) ?? this.turnId;
|
||
|
|
if (!expected) throw new Error("turn/interrupt requires an active turn");
|
||
|
|
const mode = stringValue(params.mode) ?? "user-stop";
|
||
|
|
const result = await this.client.interruptTurn(this.threadId as string, {
|
||
|
|
mode,
|
||
|
|
expectedTurnId: expected,
|
||
|
|
ownerClientId: this.ownerClientId as string,
|
||
|
|
timeoutMs: this.options.followTimeoutMs,
|
||
|
|
});
|
||
|
|
this.turnId = null;
|
||
|
|
this.state = "idle";
|
||
|
|
const elapsed = this.status.elapsedMs ?? this.status.durationMs ?? null;
|
||
|
|
this.status = {
|
||
|
|
...this.status,
|
||
|
|
activity: "interrupted",
|
||
|
|
turnStatus: "interrupted",
|
||
|
|
activeFlags: [],
|
||
|
|
durationMs: elapsed,
|
||
|
|
workedDurationMs: this.status.workedDurationMs ?? elapsed,
|
||
|
|
elapsedMs: elapsed,
|
||
|
|
};
|
||
|
|
this.emit({ type: "task.cancelled", threadId: this.threadId ?? undefined, turnId: expected, payload: { mode } });
|
||
|
|
return asJsonValue(unwrapFollowerResult(result));
|
||
|
|
}
|
||
|
|
|
||
|
|
async sendInput(text: string, params: JsonObject = {}): Promise<JsonValue> {
|
||
|
|
const body = { ...params, text };
|
||
|
|
return this.turnId ? this.steerTurn(body) : 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> {
|
||
|
|
return this.resolvePendingResponse(requestId, decision, reason, response, false);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async resolvePendingResponse(
|
||
|
|
requestId: JsonRpcId,
|
||
|
|
decision: "allow" | "deny" | "cancel",
|
||
|
|
reason: string | undefined,
|
||
|
|
response: JsonValue | undefined,
|
||
|
|
allowDuringSessionSwitch: boolean,
|
||
|
|
): Promise<JsonValue> {
|
||
|
|
this.ensureAttached();
|
||
|
|
if (this.sessionSwitching && !allowDuringSessionSwitch) {
|
||
|
|
throw new Error("Codex session switch is still in progress");
|
||
|
|
}
|
||
|
|
const key = jsonRpcIdKey(requestId);
|
||
|
|
const pending = this.pending.get(key);
|
||
|
|
if (!pending) throw new Error(`unknown or already resolved request: ${key}`);
|
||
|
|
const wire = this.toWireResponse(pending, decision, reason, response);
|
||
|
|
// Remove before awaiting the owner so a repeated browser click cannot send
|
||
|
|
// the same approval twice. If the IPC request fails, restore it for retry.
|
||
|
|
this.pending.delete(key);
|
||
|
|
this.clearPendingTimer(key);
|
||
|
|
this.optimisticallyResolved.add(key);
|
||
|
|
let result: JsonValue | undefined;
|
||
|
|
try {
|
||
|
|
result = await this.sendPendingResponse(pending, wire);
|
||
|
|
} catch (error) {
|
||
|
|
this.optimisticallyResolved.delete(key);
|
||
|
|
this.pending.set(key, pending);
|
||
|
|
this.schedulePendingExpiry(key, pending);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
this.emit({
|
||
|
|
type: pending.approval ? "approval.resolved" : "input.resolved",
|
||
|
|
threadId: pending.threadId ?? this.threadId ?? undefined,
|
||
|
|
turnId: pending.turnId,
|
||
|
|
requestId,
|
||
|
|
payload: { requestId: asJsonValue(requestId), method: pending.method, decision },
|
||
|
|
});
|
||
|
|
return asJsonValue(result);
|
||
|
|
}
|
||
|
|
|
||
|
|
async denyPending(reason = "relay disconnected"): Promise<void> {
|
||
|
|
const entries = [...this.pending.values()];
|
||
|
|
await Promise.all(entries.map(async (entry) => {
|
||
|
|
try {
|
||
|
|
await this.resolvePendingResponse(entry.requestId, "deny", reason, undefined, true);
|
||
|
|
} catch { /* fail closed when owner is gone */ }
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
private async sendPendingResponse(entry: RequestEntry, wire: JsonValue): Promise<JsonValue | undefined> {
|
||
|
|
const conversationId = this.threadId as string;
|
||
|
|
const options = { ownerClientId: this.ownerClientId as string, timeoutMs: this.options.followTimeoutMs };
|
||
|
|
if (entry.method === "item/commandExecution/requestApproval" || entry.method === "execCommandApproval") {
|
||
|
|
return this.client.respondCommandApproval(conversationId, entry.requestId, wire, options);
|
||
|
|
}
|
||
|
|
if (entry.method === "item/fileChange/requestApproval" || entry.method === "applyPatchApproval") {
|
||
|
|
return this.client.respondFileApproval(conversationId, entry.requestId, wire, options);
|
||
|
|
}
|
||
|
|
if (entry.method === "item/permissions/requestApproval") {
|
||
|
|
return this.client.respondPermissionsApproval(conversationId, entry.requestId, wire, options);
|
||
|
|
}
|
||
|
|
if (entry.method === "item/tool/requestUserInput") {
|
||
|
|
return this.client.respondUserInput(conversationId, entry.requestId, wire, options);
|
||
|
|
}
|
||
|
|
if (entry.method === "mcpServer/elicitation/request") {
|
||
|
|
return this.client.respondMcpElicitation(conversationId, entry.requestId, wire, options);
|
||
|
|
}
|
||
|
|
throw new Error(`unsupported follower request method: ${entry.method}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
private schedulePendingExpiry(key: string, entry: RequestEntry): void {
|
||
|
|
if (!entry.expiresAt || this.options.approvalTimeoutMs <= 0) {
|
||
|
|
// A later snapshot can omit an expiry that was present in an earlier
|
||
|
|
// request record. Do not leave the old timer alive in that case.
|
||
|
|
this.clearPendingTimer(key);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const existing = this.pendingExpiryAt.get(key);
|
||
|
|
if (existing === entry.expiresAt && this.pendingTimers.has(key)) return;
|
||
|
|
this.clearPendingTimer(key);
|
||
|
|
const delay = Math.max(0, entry.expiresAt - Date.now());
|
||
|
|
this.pendingExpiryAt.set(key, entry.expiresAt);
|
||
|
|
this.pendingTimers.set(key, setTimeout(() => {
|
||
|
|
this.pendingTimers.delete(key);
|
||
|
|
this.pendingExpiryAt.delete(key);
|
||
|
|
void this.expirePending(key);
|
||
|
|
}, delay));
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearPendingTimer(key: string): void {
|
||
|
|
const timer = this.pendingTimers.get(key);
|
||
|
|
if (timer) clearTimeout(timer);
|
||
|
|
this.pendingTimers.delete(key);
|
||
|
|
this.pendingExpiryAt.delete(key);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async expirePending(key: string): Promise<void> {
|
||
|
|
const entry = this.pending.get(key);
|
||
|
|
if (!entry || this.disposed || !this.started) return;
|
||
|
|
this.pending.delete(key);
|
||
|
|
this.optimisticallyResolved.add(key);
|
||
|
|
try {
|
||
|
|
const wire = this.toWireResponse(entry, "deny", "approval expired");
|
||
|
|
await this.sendPendingResponse(entry, wire);
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Unable to send expiry response for ${key}`, error);
|
||
|
|
}
|
||
|
|
this.emit({
|
||
|
|
type: entry.approval ? "approval.expired" : "input.expired",
|
||
|
|
threadId: entry.threadId ?? this.threadId ?? undefined,
|
||
|
|
turnId: entry.turnId,
|
||
|
|
requestId: entry.requestId,
|
||
|
|
payload: { requestId: asJsonValue(entry.requestId), method: entry.method, reason: "approval expired" },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async snapshot(): Promise<SessionSnapshot> {
|
||
|
|
const requests = [...this.pending.values()];
|
||
|
|
return {
|
||
|
|
threadId: this.threadId,
|
||
|
|
turnId: this.turnId,
|
||
|
|
state: this.state,
|
||
|
|
status: { ...this.status, activeFlags: [...this.status.activeFlags] },
|
||
|
|
activity: this.status.activity,
|
||
|
|
turnStatus: this.status.turnStatus,
|
||
|
|
activeFlags: [...this.status.activeFlags],
|
||
|
|
startedAtMs: this.status.startedAtMs ?? null,
|
||
|
|
durationMs: this.status.durationMs ?? null,
|
||
|
|
workedDurationMs: this.status.workedDurationMs ?? null,
|
||
|
|
elapsedMs: this.status.elapsedMs ?? null,
|
||
|
|
pendingApprovals: requests.map((entry) => entry.approval).filter((entry): entry is PendingApproval => Boolean(entry)),
|
||
|
|
pendingRequests: requests.map((entry) => ({
|
||
|
|
requestId: entry.requestId,
|
||
|
|
method: entry.method,
|
||
|
|
params: redactJson(entry.params),
|
||
|
|
...(entry.approval?.commandHash ? { commandHash: entry.approval.commandHash } : {}),
|
||
|
|
...(entry.approval?.risk ? { risk: entry.approval.risk } : {}),
|
||
|
|
...(entry.approval?.summary ? { summary: entry.approval.summary } : {}),
|
||
|
|
createdAt: entry.createdAt,
|
||
|
|
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
|
||
|
|
})),
|
||
|
|
outputTail: this.outputTail,
|
||
|
|
messages: asJsonValue(this.outputMessages) as JsonValue[],
|
||
|
|
subagents: this.subagents.map((subagent) => ({ ...subagent })),
|
||
|
|
metadata: {
|
||
|
|
adapter: "codex-ipc-follower",
|
||
|
|
mode: "attach",
|
||
|
|
privateProtocol: true,
|
||
|
|
socketPath: this.client.socketPath,
|
||
|
|
waitingForSession: this.waitingForSession,
|
||
|
|
attachReady: Boolean(this.threadId && this.ownerClientId && !this.waitingForSession && this.state !== "syncing"),
|
||
|
|
...(this.ownerClientId ? { ownerClientId: this.ownerClientId } : {}),
|
||
|
|
...(this.revision !== null ? { revision: this.revision } : {}),
|
||
|
|
...(typeof this.conversationState.cwd === "string" ? { cwd: this.conversationState.cwd } : {}),
|
||
|
|
...(typeof this.conversationState.title === "string" ? { title: this.conversationState.title } : {}),
|
||
|
|
...(typeof this.conversationState.source === "string" ? { source: this.conversationState.source } : {}),
|
||
|
|
...projectSessionMetadata(this.conversationState),
|
||
|
|
activity: this.status.activity,
|
||
|
|
turnStatus: this.status.turnStatus,
|
||
|
|
activeFlags: asJsonValue(this.status.activeFlags),
|
||
|
|
startedAtMs: this.status.startedAtMs ?? null,
|
||
|
|
durationMs: this.status.durationMs ?? null,
|
||
|
|
workedDurationMs: this.status.workedDurationMs ?? null,
|
||
|
|
elapsedMs: this.status.elapsedMs ?? null,
|
||
|
|
firstTurnWorkItemStartedAtMs: this.status.firstTurnWorkItemStartedAtMs ?? null,
|
||
|
|
finalAssistantStartedAtMs: this.status.finalAssistantStartedAtMs ?? null,
|
||
|
|
historyComplete: !hasIncompleteHistory(this.conversationState),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
onEvent(listener: (event: AgentEvent) => void): Disposable {
|
||
|
|
this.listeners.add(listener);
|
||
|
|
return { dispose: () => this.listeners.delete(listener) };
|
||
|
|
}
|
||
|
|
|
||
|
|
async dispose(): Promise<void> {
|
||
|
|
if (this.disposed) return;
|
||
|
|
this.sessionLifecycleGeneration += 1;
|
||
|
|
this.clearWaitingDiscoveryTimer();
|
||
|
|
this.waitingForSession = false;
|
||
|
|
this.clearSnapshotWaiter(new Error("Codex IPC follower is stopping"));
|
||
|
|
this.clearRevisionWaiter(new Error("Codex IPC follower is stopping"));
|
||
|
|
// While the private IPC socket is still writable, close every outstanding
|
||
|
|
// owner request explicitly so stopping the bridge cannot strand an approval
|
||
|
|
// dialog in the official Codex UI.
|
||
|
|
if (this.started && this.pending.size) await this.denyPending("bridge stopped");
|
||
|
|
this.disposed = true;
|
||
|
|
this.started = false;
|
||
|
|
this.state = "disconnected";
|
||
|
|
this.clearPendingVscodeFollow();
|
||
|
|
this.vscodeRouteActiveThreadId = null;
|
||
|
|
this.vscodeRouteAwaitingSelection = false;
|
||
|
|
this.vscodeRouteClientId = null;
|
||
|
|
this.vscodeRouteCandidates.clear();
|
||
|
|
this.activeVscodeSelection = undefined;
|
||
|
|
this.resetHistoryLoading();
|
||
|
|
this.clearSnapshotWaiter(new Error("Codex IPC follower disposed"));
|
||
|
|
this.clearRevisionWaiter(new Error("Codex IPC follower disposed"));
|
||
|
|
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
|
||
|
|
this.pendingTimers.clear();
|
||
|
|
this.pendingExpiryAt.clear();
|
||
|
|
if (this.threadId) {
|
||
|
|
try {
|
||
|
|
await this.client.followConversation(this.threadId, false, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined,
|
||
|
|
});
|
||
|
|
} catch { /* socket may already be closed */ }
|
||
|
|
}
|
||
|
|
for (const entry of this.pending.values()) {
|
||
|
|
if (entry.approval) this.emit({ type: "approval.expired", threadId: entry.threadId, turnId: entry.turnId, requestId: entry.requestId, payload: { requestId: asJsonValue(entry.requestId), reason: "bridge stopped" } });
|
||
|
|
else this.emit({ type: "input.expired", threadId: entry.threadId, turnId: entry.turnId, requestId: entry.requestId, payload: { requestId: asJsonValue(entry.requestId), reason: "bridge stopped" } });
|
||
|
|
}
|
||
|
|
this.pending.clear();
|
||
|
|
this.optimisticallyResolved.clear();
|
||
|
|
for (const subscription of this.subscriptions.splice(0)) subscription.dispose();
|
||
|
|
if (this.options.disposeClient) await this.client.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async discoverThreadId(excluded = new Set<string>(), maxCandidates = 64): Promise<string | undefined> {
|
||
|
|
if (!this.options.autoDiscoverThread) return undefined;
|
||
|
|
const codexHome = resolveCodexHome(this.options);
|
||
|
|
const candidates = await recentVscodeThreadCandidates(path.join(codexHome, "sessions"), this.options.preferredCwds ?? []);
|
||
|
|
if (!candidates.length) return undefined;
|
||
|
|
// Owner discovery is the authority: a rollout file can remain on disk
|
||
|
|
// after its VS Code owner is gone. An older conversation can still be the
|
||
|
|
// one currently open in the official panel, so inspect enough candidates
|
||
|
|
// to cover its bounded recent-chat view. Eight-way batches with a short
|
||
|
|
// timeout keep the worst-case startup budget no larger than the former
|
||
|
|
// 12-candidate, four-way scan.
|
||
|
|
const limited = candidates.filter((candidate) => !excluded.has(candidate.id)).slice(0, Math.max(1, maxCandidates));
|
||
|
|
const ownerTimeout = Math.max(250, Math.min(this.options.ownerDiscoveryTimeoutMs, 750));
|
||
|
|
for (let offset = 0; offset < limited.length; offset += 8) {
|
||
|
|
const batch = limited.slice(offset, offset + 8);
|
||
|
|
const checks = await Promise.all(batch.map(async (candidate) => {
|
||
|
|
try {
|
||
|
|
const owner = await this.client.findThreadOwner(candidate.id, this.options.hostId, ownerTimeout);
|
||
|
|
return owner ? candidate : undefined;
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Thread owner discovery failed for ${candidate.id}`, error);
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
const selected = checks.find((candidate): candidate is Candidate => Boolean(candidate));
|
||
|
|
if (selected) {
|
||
|
|
this.options.logger?.info?.(`Auto-discovered VS Code Codex conversation ${selected.id}`);
|
||
|
|
return selected.id;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Keep the relay/IPC connection usable while the official panel has no
|
||
|
|
* selected conversation yet. The first route broadcast is handled as a
|
||
|
|
* fast path; this bounded poll covers sessions opened by older extension
|
||
|
|
* builds that do not emit the route notification.
|
||
|
|
*/
|
||
|
|
private enterWaitingForSession(): void {
|
||
|
|
if (this.disposed) return;
|
||
|
|
const wasStarted = this.started;
|
||
|
|
const wasWaiting = this.waitingForSession;
|
||
|
|
this.started = true;
|
||
|
|
this.waitingForSession = true;
|
||
|
|
this.state = "waiting_for_host";
|
||
|
|
this.threadId = null;
|
||
|
|
this.ownerClientId = null;
|
||
|
|
this.vscodeRouteActiveThreadId = null;
|
||
|
|
this.vscodeRouteClientId = null;
|
||
|
|
this.vscodeRouteAwaitingSelection = false;
|
||
|
|
this.vscodeRouteCandidates.clear();
|
||
|
|
this.resetConversationProjection();
|
||
|
|
// RelayHost normally synthesizes this event after adapter.start(). Emit it
|
||
|
|
// here as well so the adapter can be used directly and so a transition
|
||
|
|
// back to waiting clears any provisional target in existing browsers.
|
||
|
|
if (!wasWaiting) {
|
||
|
|
this.emit({ type: "connection.opened", payload: { mode: "attach", waitingForSession: true } });
|
||
|
|
if (wasStarted) void this.emitSnapshot();
|
||
|
|
}
|
||
|
|
this.scheduleWaitingDiscovery();
|
||
|
|
}
|
||
|
|
|
||
|
|
private scheduleWaitingDiscovery(delayMs = WAITING_SESSION_DISCOVERY_DELAY_MS): void {
|
||
|
|
if (this.disposed || !this.started || !this.waitingForSession || this.waitingDiscoveryTimer) return;
|
||
|
|
this.waitingDiscoveryTimer = setTimeout(() => {
|
||
|
|
this.waitingDiscoveryTimer = undefined;
|
||
|
|
void this.runWaitingDiscovery();
|
||
|
|
}, Math.max(0, delayMs));
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearWaitingDiscoveryTimer(): void {
|
||
|
|
if (this.waitingDiscoveryTimer) clearTimeout(this.waitingDiscoveryTimer);
|
||
|
|
this.waitingDiscoveryTimer = undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async runWaitingDiscovery(): Promise<void> {
|
||
|
|
if (this.disposed || !this.started || !this.waitingForSession || this.waitingDiscoveryInFlight) return;
|
||
|
|
const generation = this.sessionLifecycleGeneration;
|
||
|
|
const routeGeneration = this.vscodeRouteGeneration;
|
||
|
|
const officialRouteHasPriority = () => this.options.followVscodeSession
|
||
|
|
&& (this.vscodeRouteGeneration !== routeGeneration
|
||
|
|
|| Boolean(this.vscodeRouteClientId && this.vscodeRouteActiveThreadId));
|
||
|
|
const task = (async () => {
|
||
|
|
// Polling is only a fallback for panels/builds that do not broadcast their
|
||
|
|
// current route. Once the official panel supplies a route, never let a
|
||
|
|
// slower filesystem/owner lookup replace it with an older recent thread.
|
||
|
|
if (officialRouteHasPriority()) return;
|
||
|
|
const configured = this.options.threadId?.trim();
|
||
|
|
if (configured && await this.tryAttachWaitingThread(configured)) return;
|
||
|
|
if (officialRouteHasPriority()) return;
|
||
|
|
if (!this.options.autoDiscoverThread) return;
|
||
|
|
const selected = await this.discoverThreadId(
|
||
|
|
configured ? new Set([configured]) : new Set<string>(),
|
||
|
|
WAITING_SESSION_DISCOVERY_MAX_CANDIDATES,
|
||
|
|
);
|
||
|
|
if (selected && !officialRouteHasPriority()) await this.tryAttachWaitingThread(selected);
|
||
|
|
})();
|
||
|
|
this.waitingDiscoveryInFlight = task;
|
||
|
|
try {
|
||
|
|
await task;
|
||
|
|
} catch (error) {
|
||
|
|
if (this.started && !this.disposed) {
|
||
|
|
this.options.logger?.debug?.("Waiting for a VS Code Codex session", error);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
if (this.waitingDiscoveryInFlight === task) this.waitingDiscoveryInFlight = undefined;
|
||
|
|
// A socket close/dispose may have happened while discovery was in
|
||
|
|
// flight. The generation check prevents a stale completion from
|
||
|
|
// scheduling a new timer on a dead adapter.
|
||
|
|
if (generation === this.sessionLifecycleGeneration && this.started && this.waitingForSession && !this.disposed) {
|
||
|
|
this.scheduleWaitingDiscovery();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async tryAttachWaitingThread(target: string): Promise<boolean> {
|
||
|
|
const normalizedTarget = target.trim();
|
||
|
|
if (!normalizedTarget || this.disposed || !this.started) return false;
|
||
|
|
if (this.waitingAttachPromise) {
|
||
|
|
if (this.waitingAttachTarget !== normalizedTarget) this.queuedWaitingAttachTarget = normalizedTarget;
|
||
|
|
return this.waitingAttachTarget === normalizedTarget ? this.waitingAttachPromise : false;
|
||
|
|
}
|
||
|
|
if (!this.waitingForSession) return false;
|
||
|
|
this.waitingAttachTarget = normalizedTarget;
|
||
|
|
const task = (async () => {
|
||
|
|
const release = await this.acquireSessionOperation();
|
||
|
|
this.sessionSwitching = true;
|
||
|
|
try {
|
||
|
|
if (this.disposed || !this.started || !this.waitingForSession) return false;
|
||
|
|
await this.attachThread(normalizedTarget, { fromWaiting: true });
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
if (this.started && !this.disposed) {
|
||
|
|
this.options.logger?.debug?.(`Unable to attach waiting VS Code session ${normalizedTarget}`, error);
|
||
|
|
// attachThread restores the waiting projection for a failed
|
||
|
|
// fromWaiting attempt. Keep the retry timer alive for the next poll.
|
||
|
|
this.scheduleWaitingDiscovery();
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
} finally {
|
||
|
|
this.sessionSwitching = false;
|
||
|
|
release();
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
this.waitingAttachPromise = task;
|
||
|
|
let attached = false;
|
||
|
|
try {
|
||
|
|
attached = await task;
|
||
|
|
return attached;
|
||
|
|
} finally {
|
||
|
|
if (this.waitingAttachPromise === task) this.waitingAttachPromise = undefined;
|
||
|
|
this.waitingAttachTarget = null;
|
||
|
|
const queued = this.queuedWaitingAttachTarget;
|
||
|
|
this.queuedWaitingAttachTarget = null;
|
||
|
|
if (queued && !this.disposed && this.started) {
|
||
|
|
if (this.waitingForSession) {
|
||
|
|
void this.tryAttachWaitingThread(queued);
|
||
|
|
} else if (queued !== this.threadId) {
|
||
|
|
// The official panel can move A -> B while A's first snapshot is in
|
||
|
|
// flight. Preserve the latest route and reuse the normal deferred
|
||
|
|
// switching path so an active turn on A is never detached early.
|
||
|
|
this.pendingVscodeThreadId = queued;
|
||
|
|
this.pendingVscodeFollowAttempts = 0;
|
||
|
|
this.pendingVscodeFollowGeneration = this.vscodeRouteGeneration;
|
||
|
|
this.schedulePendingVscodeFollow(this.options.vscodeSessionFollowDebounceMs);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify that a discovered owner can actually stream this conversation.
|
||
|
|
* Owner discovery may return a desktop client (or a stale handoff) that
|
||
|
|
* knows the id but cannot provide the follower snapshot needed for attach.
|
||
|
|
* The temporary follow is isolated to a direct stream listener and is always
|
||
|
|
* removed before returning, so probing never changes the active projection.
|
||
|
|
*/
|
||
|
|
private async probeSessionSnapshot(conversationId: string, ownerClientId: string, timeoutMs: number): Promise<boolean> {
|
||
|
|
let timer: NodeJS.Timeout | undefined;
|
||
|
|
let resolveSnapshot!: (available: boolean) => void;
|
||
|
|
const snapshot = new Promise<boolean>((resolve) => {
|
||
|
|
resolveSnapshot = resolve;
|
||
|
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
||
|
|
});
|
||
|
|
const subscription = this.client.onStreamEvent((event) => {
|
||
|
|
if (event.kind === "snapshot"
|
||
|
|
&& event.conversationId === conversationId
|
||
|
|
&& event.ownerClientId === ownerClientId) {
|
||
|
|
resolveSnapshot(true);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
await this.client.followConversation(conversationId, true, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: [ownerClientId],
|
||
|
|
});
|
||
|
|
return await snapshot;
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Session snapshot probe failed for ${conversationId}`, error);
|
||
|
|
return false;
|
||
|
|
} finally {
|
||
|
|
if (timer) clearTimeout(timer);
|
||
|
|
subscription.dispose();
|
||
|
|
// A user may select this row while the probe is waiting. In that case
|
||
|
|
// the temporary follow has become the active attachment; do not tear it
|
||
|
|
// down from the list request's cleanup path.
|
||
|
|
if (!(this.threadId === conversationId && this.ownerClientId === ownerClientId)) {
|
||
|
|
try {
|
||
|
|
await this.client.followConversation(conversationId, false, {
|
||
|
|
hostId: this.options.hostId,
|
||
|
|
targetClientIds: [ownerClientId],
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Unable to clean up session snapshot probe ${conversationId}`, error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Observe the official panel's route without reading its DOM. The official
|
||
|
|
* webview broadcasts old=false followed by new=true from one stable IPC
|
||
|
|
* client. Requiring that pair avoids treating reconnect status replays or a
|
||
|
|
* different Codex window's isolated true broadcast as a user navigation.
|
||
|
|
*/
|
||
|
|
private handleBroadcast(frame: IpcBroadcast): void {
|
||
|
|
if (!this.options.followVscodeSession || !this.started || this.disposed) return;
|
||
|
|
if (frame.method === "client-status-changed") {
|
||
|
|
this.handleRouteClientStatus(frame);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (frame.method !== "thread-stream-following-changed") return;
|
||
|
|
if (this.options.strictVersions !== false
|
||
|
|
&& frame.version !== CODEX_IPC_METHOD_VERSIONS["thread-stream-following-changed"]) return;
|
||
|
|
// Status replies sent to a newly connected follower describe retained
|
||
|
|
// subscriptions, not a fresh route change in the VS Code panel.
|
||
|
|
if (frame.targetClientIds?.length) return;
|
||
|
|
if (!isRecord(frame.params)) return;
|
||
|
|
const conversationId = stringValue(frame.params.conversationId)?.trim();
|
||
|
|
const hostId = stringValue(frame.params.hostId) ?? "local";
|
||
|
|
const sourceClientId = frame.sourceClientId?.trim();
|
||
|
|
if (!conversationId || hostId !== this.options.hostId || !sourceClientId) return;
|
||
|
|
if (sourceClientId === this.client.getClientId()) return;
|
||
|
|
|
||
|
|
// There is no trusted old route while the first conversation is missing.
|
||
|
|
// Treat an untargeted `following:true` as a candidate hint, then verify it
|
||
|
|
// through owner discovery and an authoritative snapshot before attaching.
|
||
|
|
if (this.waitingForSession || this.waitingAttachPromise) {
|
||
|
|
if (frame.params.following !== true) return;
|
||
|
|
if (this.vscodeRouteClientId && sourceClientId !== this.vscodeRouteClientId) return;
|
||
|
|
this.vscodeRouteClientId = sourceClientId;
|
||
|
|
if (this.vscodeRouteActiveThreadId !== conversationId) {
|
||
|
|
this.vscodeRouteActiveThreadId = conversationId;
|
||
|
|
this.vscodeRouteGeneration += 1;
|
||
|
|
}
|
||
|
|
void this.tryAttachWaitingThread(conversationId);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bind only after one source completes old=false -> new=true. A different
|
||
|
|
// remote follower can emit an isolated false while disposing, and that
|
||
|
|
// must not permanently steal the trusted route source.
|
||
|
|
if (!this.vscodeRouteClientId) {
|
||
|
|
if (frame.params.following === false) {
|
||
|
|
if (conversationId === this.vscodeRouteActiveThreadId) {
|
||
|
|
this.vscodeRouteCandidates.set(sourceClientId, conversationId);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (frame.params.following !== true) return;
|
||
|
|
if (this.vscodeRouteCandidates.get(sourceClientId) !== this.vscodeRouteActiveThreadId) return;
|
||
|
|
this.vscodeRouteClientId = sourceClientId;
|
||
|
|
this.vscodeRouteCandidates.clear();
|
||
|
|
this.vscodeRouteActiveThreadId = null;
|
||
|
|
this.vscodeRouteAwaitingSelection = true;
|
||
|
|
this.vscodeRouteGeneration += 1;
|
||
|
|
}
|
||
|
|
if (sourceClientId !== this.vscodeRouteClientId) return;
|
||
|
|
|
||
|
|
if (frame.params.following === false) {
|
||
|
|
if (conversationId !== this.vscodeRouteActiveThreadId) return;
|
||
|
|
this.vscodeRouteGeneration += 1;
|
||
|
|
this.vscodeRouteActiveThreadId = null;
|
||
|
|
this.vscodeRouteAwaitingSelection = true;
|
||
|
|
if (this.activeVscodeSelection?.target === conversationId) {
|
||
|
|
this.clearSnapshotWaiter(new Error(`VS Code moved away from conversation ${conversationId}`));
|
||
|
|
}
|
||
|
|
// A -> B -> C can happen faster than B's snapshot. Cancel the queued B
|
||
|
|
// as soon as the official route reports that B is no longer active.
|
||
|
|
if (this.pendingVscodeThreadId === conversationId) this.clearPendingVscodeFollow();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (frame.params.following !== true) return;
|
||
|
|
|
||
|
|
if (conversationId === this.vscodeRouteActiveThreadId) return;
|
||
|
|
if (!this.vscodeRouteAwaitingSelection) {
|
||
|
|
// Initial/reconnect following status. It may confirm the current route,
|
||
|
|
// but an isolated true is intentionally never treated as navigation.
|
||
|
|
if (conversationId === this.threadId) this.vscodeRouteActiveThreadId = conversationId;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.vscodeRouteAwaitingSelection = false;
|
||
|
|
this.vscodeRouteActiveThreadId = conversationId;
|
||
|
|
this.vscodeRouteGeneration += 1;
|
||
|
|
if (conversationId === this.threadId) {
|
||
|
|
this.clearPendingVscodeFollow();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.pendingVscodeThreadId = conversationId;
|
||
|
|
this.pendingVscodeFollowAttempts = 0;
|
||
|
|
this.pendingVscodeFollowGeneration = this.vscodeRouteGeneration;
|
||
|
|
this.schedulePendingVscodeFollow(this.options.vscodeSessionFollowDebounceMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The official webview gets a new IPC client id after its socket reconnects.
|
||
|
|
* Drop only the trusted route-source binding when that client disconnects;
|
||
|
|
* the next same-source false -> true pair can then establish the replacement.
|
||
|
|
*/
|
||
|
|
private handleRouteClientStatus(frame: IpcBroadcast): void {
|
||
|
|
if (this.options.strictVersions !== false && frame.version !== 0) return;
|
||
|
|
if (!isRecord(frame.params)) return;
|
||
|
|
const clientId = (stringValue(frame.params.clientId) ?? frame.sourceClientId)?.trim();
|
||
|
|
const status = stringValue(frame.params.status)?.trim().toLowerCase();
|
||
|
|
if (!clientId || status !== "disconnected") return;
|
||
|
|
this.vscodeRouteCandidates.delete(clientId);
|
||
|
|
if (clientId !== this.vscodeRouteClientId) return;
|
||
|
|
|
||
|
|
this.options.logger?.debug?.(`VS Code Codex route client ${clientId} disconnected; awaiting a replacement source`);
|
||
|
|
this.vscodeRouteClientId = null;
|
||
|
|
this.vscodeRouteCandidates.clear();
|
||
|
|
this.vscodeRouteAwaitingSelection = false;
|
||
|
|
// Keep the route identity, rather than forcing it back to the currently
|
||
|
|
// committed relay thread. A disconnect may occur between B's true signal
|
||
|
|
// and B's snapshot; the replacement panel will later leave B with false.
|
||
|
|
this.vscodeRouteActiveThreadId = this.activeVscodeSelection?.target
|
||
|
|
?? this.vscodeRouteActiveThreadId
|
||
|
|
?? this.threadId;
|
||
|
|
this.vscodeRouteGeneration += 1;
|
||
|
|
this.clearPendingVscodeFollow();
|
||
|
|
if (this.activeVscodeSelection) {
|
||
|
|
this.clearSnapshotWaiter(new Error("VS Code Codex route client disconnected during session selection"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private schedulePendingVscodeFollow(delayMs: number): void {
|
||
|
|
if (!this.pendingVscodeThreadId || this.disposed || !this.started) return;
|
||
|
|
if (this.vscodeSessionFollowTimer) clearTimeout(this.vscodeSessionFollowTimer);
|
||
|
|
this.vscodeSessionFollowTimer = setTimeout(() => {
|
||
|
|
this.vscodeSessionFollowTimer = undefined;
|
||
|
|
void this.applyPendingVscodeFollow();
|
||
|
|
}, Math.max(0, delayMs));
|
||
|
|
}
|
||
|
|
|
||
|
|
private async applyPendingVscodeFollow(): Promise<void> {
|
||
|
|
const target = this.pendingVscodeThreadId;
|
||
|
|
const routeGeneration = this.pendingVscodeFollowGeneration;
|
||
|
|
if (!target || this.disposed || !this.started) return;
|
||
|
|
if (this.vscodeRouteActiveThreadId !== target || routeGeneration !== this.vscodeRouteGeneration) {
|
||
|
|
if (this.pendingVscodeThreadId === target) this.clearPendingVscodeFollow();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (target === this.threadId) {
|
||
|
|
this.clearPendingVscodeFollow();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Never detach a turn or approval that is still owned by the old thread.
|
||
|
|
// Keep only the latest official target and retry once that state is idle.
|
||
|
|
if (this.sessionSwitching || this.turnId || this.pending.size) {
|
||
|
|
this.schedulePendingVscodeFollow(VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.pendingVscodeThreadId = null;
|
||
|
|
this.pendingVscodeFollowGeneration = 0;
|
||
|
|
this.activeVscodeSelection = { target, generation: routeGeneration };
|
||
|
|
try {
|
||
|
|
await this.selectSession({
|
||
|
|
threadId: target,
|
||
|
|
origin: "vscode",
|
||
|
|
vscodeRouteGeneration: routeGeneration,
|
||
|
|
});
|
||
|
|
this.pendingVscodeFollowAttempts = 0;
|
||
|
|
this.options.logger?.info?.(`Followed VS Code Codex panel to conversation ${target}`);
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.debug?.(`Unable to follow VS Code Codex panel to ${target}`, error);
|
||
|
|
if (!this.disposed
|
||
|
|
&& this.started
|
||
|
|
&& !this.pendingVscodeThreadId
|
||
|
|
&& this.vscodeRouteActiveThreadId === target
|
||
|
|
&& this.vscodeRouteGeneration === routeGeneration
|
||
|
|
&& this.pendingVscodeFollowAttempts < VSCODE_SESSION_FOLLOW_MAX_ATTEMPTS) {
|
||
|
|
this.pendingVscodeFollowAttempts += 1;
|
||
|
|
this.pendingVscodeThreadId = target;
|
||
|
|
this.pendingVscodeFollowGeneration = routeGeneration;
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
if (this.activeVscodeSelection?.target === target
|
||
|
|
&& this.activeVscodeSelection.generation === routeGeneration) {
|
||
|
|
this.activeVscodeSelection = undefined;
|
||
|
|
}
|
||
|
|
if (this.pendingVscodeThreadId) {
|
||
|
|
this.schedulePendingVscodeFollow(VSCODE_SESSION_FOLLOW_RETRY_DELAY_MS);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearPendingVscodeFollow(): void {
|
||
|
|
if (this.vscodeSessionFollowTimer) clearTimeout(this.vscodeSessionFollowTimer);
|
||
|
|
this.vscodeSessionFollowTimer = undefined;
|
||
|
|
this.pendingVscodeThreadId = null;
|
||
|
|
this.pendingVscodeFollowAttempts = 0;
|
||
|
|
this.pendingVscodeFollowGeneration = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleStreamEvent(event: ConversationStreamEvent): void {
|
||
|
|
if (!this.threadId || event.conversationId !== this.threadId) return;
|
||
|
|
// Following is targeted at the owner discovered during attach. The IPC
|
||
|
|
// router normally filters these broadcasts, but a handoff/old socket can
|
||
|
|
// still surface another client's event; applying it would overwrite the
|
||
|
|
// active conversation and could satisfy a revision waiter incorrectly.
|
||
|
|
if (this.ownerClientId && event.ownerClientId && event.ownerClientId !== this.ownerClientId) {
|
||
|
|
this.options.logger?.debug?.(`Ignoring stream event from unexpected Codex owner ${event.ownerClientId}`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (event.kind === "desync") {
|
||
|
|
this.options.logger?.warn?.(`Codex IPC stream desynchronized at revision ${event.receivedBaseRevision}; requesting snapshot`);
|
||
|
|
this.client.followConversation(this.threadId, true, { hostId: this.options.hostId, targetClientIds: this.ownerClientId ? [this.ownerClientId] : undefined }).catch((error) => this.options.logger?.warn?.("Unable to recover IPC snapshot", error));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.ownerClientId = event.ownerClientId || this.ownerClientId;
|
||
|
|
this.revision = event.revision;
|
||
|
|
if (this.revisionWaiter
|
||
|
|
&& this.revisionWaiter.threadId === event.conversationId
|
||
|
|
&& this.revisionWaiter.ownerClientId === event.ownerClientId
|
||
|
|
&& event.revision >= this.revisionWaiter.revision) {
|
||
|
|
this.clearRevisionWaiter();
|
||
|
|
}
|
||
|
|
this.conversationState = cloneObject(event.conversationState);
|
||
|
|
this.processConversationState(event.kind === "snapshot");
|
||
|
|
if (event.kind === "snapshot" && this.options.loadCompleteHistory !== false) void this.loadCompleteHistoryIfNeeded();
|
||
|
|
if (event.kind === "snapshot"
|
||
|
|
&& this.snapshotWaiter?.threadId === event.conversationId
|
||
|
|
&& (!this.snapshotWaiter.ownerClientId || this.snapshotWaiter.ownerClientId === event.ownerClientId)) {
|
||
|
|
this.clearSnapshotWaiter();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private processConversationState(initial: boolean): void {
|
||
|
|
if (initial) this.snapshotSeen = true;
|
||
|
|
const previousTurnId = this.turnId;
|
||
|
|
const previousState = this.state;
|
||
|
|
const previousStatus = this.status;
|
||
|
|
const nextTurn = deriveTurn(this.conversationState);
|
||
|
|
// Request records and runtime flags are part of the same conversation
|
||
|
|
// snapshot. Derive status after extracting them so an approval/input wait
|
||
|
|
// is visible immediately with the corresponding state patch.
|
||
|
|
const nextRequests = extractRequests(this.conversationState, this.options.approvalTimeoutMs);
|
||
|
|
this.status = deriveStatusSnapshot(this.conversationState, nextTurn, nextRequests);
|
||
|
|
this.turnId = nextTurn.active ? nextTurn.id ?? null : null;
|
||
|
|
this.state = nextTurn.active ? "active" : this.deriveSessionState();
|
||
|
|
const nextHistoryComplete = !hasIncompleteHistory(this.conversationState);
|
||
|
|
const historyChanged = this.historyComplete !== undefined && this.historyComplete !== nextHistoryComplete;
|
||
|
|
// Settings updates are broadcast as conversation-state patches and may not
|
||
|
|
// change output, turn status, or pending requests. Fingerprint only the
|
||
|
|
// redacted projection exposed by `snapshot()` so those patches still reach
|
||
|
|
// the relay without leaking opaque/private state or causing per-token
|
||
|
|
// snapshot spam.
|
||
|
|
const metadataShape = stableStringify(projectSessionMetadata(this.conversationState));
|
||
|
|
const metadataChanged = metadataShape !== this.renderedMetadataShape;
|
||
|
|
|
||
|
|
const previousPending = new Map(this.pending);
|
||
|
|
const nextKeys = new Set(nextRequests.map((entry) => jsonRpcIdKey(entry.requestId)));
|
||
|
|
for (const key of this.optimisticallyResolved) {
|
||
|
|
if (!nextKeys.has(key)) this.optimisticallyResolved.delete(key);
|
||
|
|
}
|
||
|
|
const previousKeys = new Set(previousPending.keys());
|
||
|
|
for (const key of this.pendingTimers.keys()) {
|
||
|
|
if (!nextKeys.has(key)) this.clearPendingTimer(key);
|
||
|
|
}
|
||
|
|
this.pending.clear();
|
||
|
|
for (const rawEntry of nextRequests) {
|
||
|
|
const prior = previousPending.get(jsonRpcIdKey(rawEntry.requestId));
|
||
|
|
// Some official request records omit timestamps. Keep the first-seen
|
||
|
|
// deadline across patches instead of extending it on every output delta.
|
||
|
|
const entry = prior
|
||
|
|
? { ...rawEntry, createdAt: prior.createdAt, expiresAt: prior.expiresAt ?? rawEntry.expiresAt }
|
||
|
|
: rawEntry;
|
||
|
|
const key = jsonRpcIdKey(entry.requestId);
|
||
|
|
if (this.optimisticallyResolved.has(key)) continue;
|
||
|
|
this.pending.set(key, entry);
|
||
|
|
this.schedulePendingExpiry(key, entry);
|
||
|
|
if (!previousKeys.has(key)) this.emitRequest(entry);
|
||
|
|
}
|
||
|
|
for (const key of previousKeys) {
|
||
|
|
if (nextKeys.has(key) || this.optimisticallyResolved.has(key)) continue;
|
||
|
|
const old = previousPending.get(key);
|
||
|
|
if (old) this.emit({ type: old.approval ? "approval.resolved" : "input.resolved", threadId: old.threadId ?? this.threadId ?? undefined, turnId: old.turnId, requestId: old.requestId, payload: { requestId: asJsonValue(old.requestId), method: old.method } });
|
||
|
|
}
|
||
|
|
|
||
|
|
const rendered = renderConversationOutput(this.conversationState, this.options.maxOutputTailChars);
|
||
|
|
const output = rendered.text;
|
||
|
|
const messageShape = renderedMessageShape(rendered.messages);
|
||
|
|
const messagesChanged = messageShape !== this.renderedMessageShape;
|
||
|
|
const messagesPatch = messagesChanged
|
||
|
|
? renderedMessagesPatch(this.outputMessages, rendered.messages)
|
||
|
|
: undefined;
|
||
|
|
const subagentShape = renderedSubagentShape(rendered.subagents);
|
||
|
|
const subagentsChanged = subagentShape !== this.renderedSubagentShape;
|
||
|
|
if (output !== this.renderedOutput || rendered.totalLength !== this.renderedOutputLength || messagesChanged || subagentsChanged) {
|
||
|
|
const delta = this.renderedOutput
|
||
|
|
? appendOnlyOutputDelta(
|
||
|
|
this.renderedOutput,
|
||
|
|
this.renderedOutputLength,
|
||
|
|
output,
|
||
|
|
rendered.totalLength,
|
||
|
|
this.renderedOutputWasTruncated,
|
||
|
|
)
|
||
|
|
: undefined;
|
||
|
|
if (!this.renderedOutput || delta === undefined || (!delta && (messagesChanged || subagentsChanged))) {
|
||
|
|
this.emit({ type: "output.snapshot", threadId: this.threadId ?? undefined, turnId: this.turnId ?? undefined, payload: { stream: "codex", text: output, messages: asJsonValue(rendered.messages), subagents: asJsonValue(rendered.subagents), structureChanged: true, encoding: "utf8" } });
|
||
|
|
} else {
|
||
|
|
if (delta || subagentsChanged) this.emit({
|
||
|
|
type: "output.chunk",
|
||
|
|
threadId: this.threadId ?? undefined,
|
||
|
|
turnId: this.turnId ?? undefined,
|
||
|
|
payload: {
|
||
|
|
stream: "codex",
|
||
|
|
text: delta,
|
||
|
|
// Keep the append-only field for older relays, but include the
|
||
|
|
// authoritative projection so a browser can preserve item
|
||
|
|
// boundaries while reasoning/commands/edits stream in.
|
||
|
|
outputTail: output,
|
||
|
|
...(messagesPatch ? { messagesPatch: asJsonValue(messagesPatch) } : {}),
|
||
|
|
subagents: asJsonValue(rendered.subagents),
|
||
|
|
structureChanged: messagesChanged || subagentsChanged,
|
||
|
|
encoding: "utf8",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
this.renderedOutput = output;
|
||
|
|
this.renderedOutputLength = rendered.totalLength;
|
||
|
|
this.renderedOutputWasTruncated = rendered.truncated;
|
||
|
|
this.renderedMessageShape = messageShape;
|
||
|
|
this.renderedSubagentShape = subagentShape;
|
||
|
|
this.outputTail = output;
|
||
|
|
this.outputMessages = rendered.messages;
|
||
|
|
this.subagents = rendered.subagents;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!initial && !previousTurnId && this.turnId) {
|
||
|
|
this.emit({ type: "task.started", threadId: this.threadId ?? undefined, turnId: this.turnId, payload: statusPayload(this.status) });
|
||
|
|
} else if (!initial && previousTurnId && !this.turnId) {
|
||
|
|
const cancelled = new Set(["cancelled", "canceled", "interrupted"]).has(normalizeStatus(nextTurn.status));
|
||
|
|
this.emit({ type: cancelled ? "task.cancelled" : "task.finished", threadId: this.threadId ?? undefined, turnId: previousTurnId, payload: statusPayload(this.status) });
|
||
|
|
} else if (!initial && !sameStatus(previousStatus, this.status)) {
|
||
|
|
// A turn can remain active while moving from reasoning to a command or
|
||
|
|
// file edit, and can enter/leave an approval wait without changing its
|
||
|
|
// id. Publish a dedicated status event so remote viewers do not have to
|
||
|
|
// infer activity from output timing.
|
||
|
|
this.emit({ type: "task.status", threadId: this.threadId ?? undefined, turnId: this.turnId ?? undefined, payload: statusPayload(this.status) });
|
||
|
|
}
|
||
|
|
|
||
|
|
this.historyComplete = nextHistoryComplete;
|
||
|
|
this.renderedMetadataShape = metadataShape;
|
||
|
|
if (initial || metadataChanged || historyChanged || previousTurnId !== this.turnId || previousState !== this.state || !sameStatus(previousStatus, this.status) || nextRequests.length !== previousKeys.size || subagentsChanged) {
|
||
|
|
void this.emitSnapshot();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async loadCompleteHistoryIfNeeded(): Promise<void> {
|
||
|
|
if (this.historyLoadRequested || this.historyLoadRetryTimer || this.historyLoadAttempts >= 2 || !this.threadId || !this.ownerClientId || !hasIncompleteHistory(this.conversationState)) return;
|
||
|
|
this.clearHistoryLoadRetryTimer();
|
||
|
|
// Keep the request target stable. A stream event from another owner can
|
||
|
|
// arrive while the owner is loading history; using the mutable fields
|
||
|
|
// below would otherwise wait for (or acknowledge) the wrong stream.
|
||
|
|
const threadId = this.threadId;
|
||
|
|
const ownerClientId = this.ownerClientId;
|
||
|
|
const generation = this.historyLoadGeneration;
|
||
|
|
this.historyLoadRequested = true;
|
||
|
|
this.historyLoadAttempts += 1;
|
||
|
|
try {
|
||
|
|
const result = await this.client.loadCompleteHistory(threadId, {
|
||
|
|
ownerClientId,
|
||
|
|
timeoutMs: this.options.followTimeoutMs,
|
||
|
|
});
|
||
|
|
// The socket may close (or the owner may hand the conversation to a new
|
||
|
|
// client) while the request is in flight. Do not install a fresh waiter
|
||
|
|
// after dispose/close, where nobody could clear it.
|
||
|
|
if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
|
||
|
|
const requestedRevision = extractRevision(result);
|
||
|
|
// The owner acknowledges the load request before broadcasting its new
|
||
|
|
// state. Wait for that revision so the relay's first snapshot contains
|
||
|
|
// the complete history rather than the old paginated tail.
|
||
|
|
if (requestedRevision !== undefined && (this.revision ?? 0) < requestedRevision) {
|
||
|
|
await this.waitForRevision(threadId, ownerClientId, requestedRevision, this.options.followTimeoutMs);
|
||
|
|
}
|
||
|
|
// A session switch can resolve the old revision waiter while installing
|
||
|
|
// a new history request. Never let that old continuation clear or retry
|
||
|
|
// the new conversation's loading state.
|
||
|
|
if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
|
||
|
|
this.historyLoadRequested = false;
|
||
|
|
if (this.started
|
||
|
|
&& this.threadId === threadId
|
||
|
|
&& this.ownerClientId === ownerClientId
|
||
|
|
&& hasIncompleteHistory(this.conversationState)
|
||
|
|
&& this.historyLoadAttempts < 2) {
|
||
|
|
void this.loadCompleteHistoryIfNeeded();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
// History loading is an optional read enhancement. The live tail remains
|
||
|
|
// usable when an older extension does not implement this request.
|
||
|
|
this.options.logger?.debug?.("Unable to load complete Codex history", error);
|
||
|
|
if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
|
||
|
|
this.historyLoadRequested = false;
|
||
|
|
if (isTransientHistoryLoadError(error)
|
||
|
|
&& this.historyLoadAttempts < 2
|
||
|
|
&& hasIncompleteHistory(this.conversationState)) {
|
||
|
|
this.scheduleHistoryLoadRetry(threadId, ownerClientId, generation);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private scheduleHistoryLoadRetry(threadId: string, ownerClientId: string, generation: number): void {
|
||
|
|
if (this.historyLoadRetryTimer || !this.isCurrentHistoryLoad(threadId, ownerClientId, generation)) return;
|
||
|
|
this.historyLoadRetryTimer = setTimeout(() => {
|
||
|
|
this.historyLoadRetryTimer = undefined;
|
||
|
|
if (!this.isCurrentHistoryLoad(threadId, ownerClientId, generation)
|
||
|
|
|| this.historyLoadRequested
|
||
|
|
|| this.historyLoadAttempts >= 2
|
||
|
|
|| !hasIncompleteHistory(this.conversationState)) return;
|
||
|
|
void this.loadCompleteHistoryIfNeeded();
|
||
|
|
}, HISTORY_LOAD_RETRY_DELAY_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
private isCurrentHistoryLoad(threadId: string, ownerClientId: string, generation: number): boolean {
|
||
|
|
return !this.disposed
|
||
|
|
&& this.started
|
||
|
|
&& this.historyLoadGeneration === generation
|
||
|
|
&& this.threadId === threadId
|
||
|
|
&& this.ownerClientId === ownerClientId;
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearHistoryLoadRetryTimer(): void {
|
||
|
|
if (!this.historyLoadRetryTimer) return;
|
||
|
|
clearTimeout(this.historyLoadRetryTimer);
|
||
|
|
this.historyLoadRetryTimer = undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
private resetHistoryLoading(): void {
|
||
|
|
this.clearHistoryLoadRetryTimer();
|
||
|
|
this.historyLoadRequested = false;
|
||
|
|
this.historyLoadAttempts = 0;
|
||
|
|
this.historyLoadGeneration += 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
private emitRequest(entry: RequestEntry): void {
|
||
|
|
const isInput = INPUT_METHODS.has(entry.method);
|
||
|
|
this.emit({
|
||
|
|
type: isInput ? "input.requested" : APPROVAL_METHODS.has(entry.method) ? "approval.requested" : "server.requested",
|
||
|
|
threadId: entry.threadId ?? this.threadId ?? undefined,
|
||
|
|
turnId: entry.turnId,
|
||
|
|
requestId: entry.requestId,
|
||
|
|
payload: {
|
||
|
|
requestId: asJsonValue(entry.requestId),
|
||
|
|
method: entry.method,
|
||
|
|
params: redactJson(entry.params),
|
||
|
|
...(entry.approval ? {
|
||
|
|
action: entry.approval.action,
|
||
|
|
risk: entry.approval.risk,
|
||
|
|
summary: entry.approval.summary,
|
||
|
|
...(entry.approval.commandHash ? { commandHash: entry.approval.commandHash } : {}),
|
||
|
|
} : {}),
|
||
|
|
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
|
||
|
|
},
|
||
|
|
raw: redactJson({ requestId: entry.requestId, method: entry.method, params: entry.params }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private async emitSnapshot(): Promise<void> {
|
||
|
|
const snapshot = await this.snapshot();
|
||
|
|
this.emit({ type: "session.snapshot", threadId: snapshot.threadId ?? undefined, turnId: snapshot.turnId ?? undefined, payload: asJsonObject(snapshot) });
|
||
|
|
}
|
||
|
|
|
||
|
|
private deriveSessionState(): string {
|
||
|
|
const runtime = this.conversationState.threadRuntimeStatus;
|
||
|
|
if (isRecord(runtime) && typeof runtime.type === "string") {
|
||
|
|
const status = normalizeStatus(runtime.type);
|
||
|
|
if (!TERMINAL_TURN_STATES.has(status) && status !== "idle" && status !== "ready") return runtime.type;
|
||
|
|
}
|
||
|
|
return this.snapshotSeen ? "idle" : "syncing";
|
||
|
|
}
|
||
|
|
|
||
|
|
private waitForSnapshot(threadId: string, timeoutMs: number, ownerClientId?: string): Promise<void> {
|
||
|
|
this.clearSnapshotWaiter();
|
||
|
|
return new Promise<void>((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
if (this.snapshotWaiter?.threadId === threadId
|
||
|
|
&& this.snapshotWaiter.ownerClientId === ownerClientId) this.snapshotWaiter = undefined;
|
||
|
|
reject(new Error(`Timed out waiting for a snapshot from VS Code conversation ${threadId}`));
|
||
|
|
}, timeoutMs);
|
||
|
|
this.snapshotWaiter = { threadId, ownerClientId, resolve, reject, timer };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Rehydrate the adapter's last owner-validated state after failed navigation. */
|
||
|
|
private restoreCachedConversationProjection(
|
||
|
|
cached: ConversationStreamState | undefined,
|
||
|
|
fallbackOwnerClientId: string | null,
|
||
|
|
): boolean {
|
||
|
|
if (!cached || !cached.conversationState) return false;
|
||
|
|
this.ownerClientId = cached.ownerClientId || fallbackOwnerClientId;
|
||
|
|
this.revision = cached.revision;
|
||
|
|
this.conversationState = cloneObject(cached.conversationState);
|
||
|
|
this.processConversationState(true);
|
||
|
|
this.state = this.deriveSessionState();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private waitForRevision(threadId: string, ownerClientId: string, revision: number, timeoutMs: number): Promise<void> {
|
||
|
|
this.clearRevisionWaiter();
|
||
|
|
if (this.threadId === threadId && this.ownerClientId === ownerClientId && (this.revision ?? 0) >= revision) return Promise.resolve();
|
||
|
|
return new Promise<void>((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
if (this.revisionWaiter?.threadId === threadId
|
||
|
|
&& this.revisionWaiter.ownerClientId === ownerClientId
|
||
|
|
&& this.revisionWaiter.revision === revision) this.revisionWaiter = undefined;
|
||
|
|
reject(new Error(`Timed out waiting for Codex conversation revision ${revision}`));
|
||
|
|
}, timeoutMs);
|
||
|
|
this.revisionWaiter = { threadId, ownerClientId, revision, resolve, reject, timer };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearSnapshotWaiter(error?: Error): void {
|
||
|
|
const waiter = this.snapshotWaiter;
|
||
|
|
if (!waiter) return;
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
this.snapshotWaiter = undefined;
|
||
|
|
if (error) waiter.reject(error);
|
||
|
|
else waiter.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
private clearRevisionWaiter(error?: Error): void {
|
||
|
|
const waiter = this.revisionWaiter;
|
||
|
|
if (!waiter) return;
|
||
|
|
clearTimeout(waiter.timer);
|
||
|
|
this.revisionWaiter = undefined;
|
||
|
|
if (error) waiter.reject(error);
|
||
|
|
else waiter.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Clear all projections that belong to the previous conversation. */
|
||
|
|
private resetConversationProjection(): void {
|
||
|
|
this.clearRevisionWaiter();
|
||
|
|
this.revision = null;
|
||
|
|
this.conversationState = {};
|
||
|
|
this.turnId = null;
|
||
|
|
this.status = {
|
||
|
|
activity: "idle",
|
||
|
|
turnStatus: "idle",
|
||
|
|
activeFlags: [],
|
||
|
|
startedAtMs: null,
|
||
|
|
durationMs: null,
|
||
|
|
workedDurationMs: null,
|
||
|
|
elapsedMs: null,
|
||
|
|
firstTurnWorkItemStartedAtMs: null,
|
||
|
|
finalAssistantStartedAtMs: null,
|
||
|
|
};
|
||
|
|
this.renderedOutput = "";
|
||
|
|
this.renderedOutputLength = 0;
|
||
|
|
this.renderedOutputWasTruncated = false;
|
||
|
|
this.renderedMessageShape = "";
|
||
|
|
this.outputTail = "";
|
||
|
|
this.outputMessages = [];
|
||
|
|
this.subagents = [];
|
||
|
|
this.renderedSubagentShape = "";
|
||
|
|
this.renderedMetadataShape = "";
|
||
|
|
this.snapshotSeen = false;
|
||
|
|
this.historyComplete = undefined;
|
||
|
|
this.resetHistoryLoading();
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleClose(error?: Error): void {
|
||
|
|
if (!this.started || this.disposed) return;
|
||
|
|
const closedThreadId = this.threadId;
|
||
|
|
const closedOwnerClientId = this.ownerClientId;
|
||
|
|
this.sessionLifecycleGeneration += 1;
|
||
|
|
this.clearWaitingDiscoveryTimer();
|
||
|
|
this.waitingForSession = false;
|
||
|
|
this.started = false;
|
||
|
|
this.state = "disconnected";
|
||
|
|
this.clearPendingVscodeFollow();
|
||
|
|
this.vscodeRouteActiveThreadId = null;
|
||
|
|
this.vscodeRouteAwaitingSelection = false;
|
||
|
|
this.vscodeRouteClientId = null;
|
||
|
|
this.vscodeRouteCandidates.clear();
|
||
|
|
this.activeVscodeSelection = undefined;
|
||
|
|
this.clearSnapshotWaiter(error ?? new Error("Codex IPC socket closed"));
|
||
|
|
this.clearRevisionWaiter(error ?? new Error("Codex IPC socket closed"));
|
||
|
|
for (const timer of this.pendingTimers.values()) clearTimeout(timer);
|
||
|
|
this.pendingTimers.clear();
|
||
|
|
this.pendingExpiryAt.clear();
|
||
|
|
const pending = [...this.pending.values()];
|
||
|
|
this.pending.clear();
|
||
|
|
this.optimisticallyResolved.clear();
|
||
|
|
// Do not retain a conversation projection after the private IPC owner has
|
||
|
|
// gone away. A later snapshot request must report a disconnected, empty
|
||
|
|
// follower rather than stale messages that can no longer be controlled.
|
||
|
|
this.resetConversationProjection();
|
||
|
|
this.threadId = null;
|
||
|
|
this.ownerClientId = null;
|
||
|
|
this.status = {
|
||
|
|
...this.status,
|
||
|
|
activity: "idle",
|
||
|
|
turnStatus: "disconnected",
|
||
|
|
activeFlags: [],
|
||
|
|
};
|
||
|
|
for (const entry of pending) {
|
||
|
|
this.emit({
|
||
|
|
type: entry.approval ? "approval.expired" : "input.expired",
|
||
|
|
threadId: entry.threadId ?? closedThreadId ?? undefined,
|
||
|
|
turnId: entry.turnId,
|
||
|
|
requestId: entry.requestId,
|
||
|
|
payload: { requestId: asJsonValue(entry.requestId), reason: error?.message ?? "IPC socket closed" },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
this.emit({
|
||
|
|
type: "connection.closed",
|
||
|
|
threadId: closedThreadId ?? undefined,
|
||
|
|
payload: {
|
||
|
|
message: error?.message ?? "IPC socket closed",
|
||
|
|
...(closedOwnerClientId ? { ownerClientId: closedOwnerClientId } : {}),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private ensureAttached(): void {
|
||
|
|
if (!this.started) throw new Error("Codex remote bridge is not attached to a VS Code Codex session");
|
||
|
|
if (this.waitingForSession || this.state === "waiting_for_host") {
|
||
|
|
throw new Error("waiting_for_session: 请先在 VS Code 打开一个 Codex 会话");
|
||
|
|
}
|
||
|
|
if (!this.threadId || !this.ownerClientId) throw new Error("No existing Codex conversation owner is attached");
|
||
|
|
}
|
||
|
|
|
||
|
|
private ensureStarted(): void {
|
||
|
|
if (!this.started) throw new Error("Codex remote bridge is not connected to the VS Code IPC host");
|
||
|
|
}
|
||
|
|
|
||
|
|
private ensureInteractiveReady(): void {
|
||
|
|
this.ensureAttached();
|
||
|
|
if (this.sessionSwitching || this.state === "syncing") throw new Error("Codex session switch is still in progress");
|
||
|
|
}
|
||
|
|
|
||
|
|
private emit(event: AgentEvent): void {
|
||
|
|
// Every relay event carries the latest typed execution projection. Keep
|
||
|
|
// the same values in payload for older consumers that only inspect the
|
||
|
|
// untyped event envelope.
|
||
|
|
const eventStatus = event.status ?? this.status;
|
||
|
|
const normalized: AgentEvent = {
|
||
|
|
...event,
|
||
|
|
status: { ...eventStatus, activeFlags: [...eventStatus.activeFlags] },
|
||
|
|
payload: { ...statusPayload(eventStatus), ...event.payload },
|
||
|
|
};
|
||
|
|
for (const listener of this.listeners) {
|
||
|
|
try {
|
||
|
|
listener(normalized);
|
||
|
|
} catch (error) {
|
||
|
|
this.options.logger?.warn?.("Codex IPC adapter listener failed", error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private toWireResponse(entry: RequestEntry, decision: "allow" | "deny" | "cancel", reason?: string, response?: JsonValue): JsonValue {
|
||
|
|
if (entry.method === "item/permissions/requestApproval") {
|
||
|
|
const supplied = isRecord(response) ? response : {};
|
||
|
|
const requested = isRecord(supplied.permissions) ? supplied.permissions : decision === "allow" && isRecord(entry.params.permissions) ? entry.params.permissions : {};
|
||
|
|
const permissions: JsonObject = {};
|
||
|
|
for (const [key, value] of Object.entries(requested)) if (value !== null && value !== undefined) permissions[key] = asJsonValue(value);
|
||
|
|
return { permissions, scope: supplied.scope === "session" ? "session" : "turn", ...(typeof supplied.strictAutoReview === "boolean" ? { strictAutoReview: supplied.strictAutoReview } : {}) };
|
||
|
|
}
|
||
|
|
if (entry.method === "item/tool/requestUserInput") {
|
||
|
|
return normalizeUserInputResponse(response);
|
||
|
|
}
|
||
|
|
if (entry.method === "mcpServer/elicitation/request") {
|
||
|
|
if (isRecord(response) && typeof response.action === "string") return response;
|
||
|
|
return { action: decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline", content: null, _meta: null };
|
||
|
|
}
|
||
|
|
const suppliedDecision = isRecord(response) && Object.prototype.hasOwnProperty.call(response, "decision") ? response.decision : undefined;
|
||
|
|
if (suppliedDecision !== undefined) return normalizeFollowerDecision(entry.method, suppliedDecision, decision, reason);
|
||
|
|
if (entry.method === "applyPatchApproval" || entry.method === "execCommandApproval") {
|
||
|
|
if (decision === "allow") return "approved";
|
||
|
|
if (decision === "cancel") return "abort";
|
||
|
|
return { denied: { rejection: reason || "Denied remotely" } };
|
||
|
|
}
|
||
|
|
return decision === "allow" ? "accept" : decision === "cancel" ? "cancel" : "decline";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeFollowerDecision(method: string, supplied: unknown, fallback: "allow" | "deny" | "cancel", reason?: string): JsonValue {
|
||
|
|
// The relay accepts compatibility aliases, while the private follower
|
||
|
|
// methods use the app-server's method-specific wire vocabulary.
|
||
|
|
if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
|
||
|
|
if (supplied === "approved" || supplied === "approved_for_session" || supplied === "approved_mcp_policy_amendment") {
|
||
|
|
return supplied === "approved" ? "accept" : supplied === "approved_for_session" ? "acceptForSession" : "accept";
|
||
|
|
}
|
||
|
|
if (supplied === "denied" || supplied === "deny") return "decline";
|
||
|
|
if (supplied === "abort") return "cancel";
|
||
|
|
return asJsonValue(supplied);
|
||
|
|
}
|
||
|
|
if (method === "applyPatchApproval" || method === "execCommandApproval") {
|
||
|
|
if (supplied === "accept") return "approved";
|
||
|
|
if (supplied === "acceptForSession") return "approved_for_session";
|
||
|
|
if (supplied === "decline" || supplied === "deny") return { denied: { rejection: reason || "Denied remotely" } };
|
||
|
|
if (supplied === "cancel") return "abort";
|
||
|
|
return asJsonValue(supplied);
|
||
|
|
}
|
||
|
|
// If a caller supplied a generic wrapper with no method-specific alias,
|
||
|
|
// retain the ordinary fallback selected by the adapter.
|
||
|
|
return asJsonValue(supplied ?? (fallback === "allow" ? "accept" : fallback === "cancel" ? "cancel" : "decline"));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Normalize the official tool-input shape: answers[id].answers is a string array. */
|
||
|
|
function normalizeUserInputResponse(response: JsonValue | undefined): JsonObject {
|
||
|
|
const hasOuterAnswers = isRecord(response) && isRecord(response.answers);
|
||
|
|
const source = hasOuterAnswers ? response.answers as Record<string, unknown> : isRecord(response) ? response : {};
|
||
|
|
const answers: JsonObject = {};
|
||
|
|
for (const [questionId, raw] of Object.entries(source)) {
|
||
|
|
if (isRecord(raw) && Array.isArray(raw.answers)) {
|
||
|
|
answers[questionId] = { answers: raw.answers.map(asJsonValue) };
|
||
|
|
} else if (Array.isArray(raw)) {
|
||
|
|
answers[questionId] = { answers: raw.map(asJsonValue) };
|
||
|
|
} else if (typeof raw === "string") {
|
||
|
|
answers[questionId] = { answers: [raw] };
|
||
|
|
} else if (raw !== undefined && raw !== null) {
|
||
|
|
answers[questionId] = { answers: [asJsonValue(raw)] };
|
||
|
|
} else {
|
||
|
|
answers[questionId] = { answers: [] };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { answers };
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Candidate {
|
||
|
|
id: string;
|
||
|
|
mtime: number;
|
||
|
|
updatedAtMs?: number;
|
||
|
|
cwd?: string;
|
||
|
|
title?: string;
|
||
|
|
priority: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function recentVscodeThreadCandidates(root: string, preferredCwds: string[] = []): Promise<Candidate[]> {
|
||
|
|
const files: Array<{ file: string; mtime: number; id: string }> = [];
|
||
|
|
async function visit(directory: string, depth: number): Promise<void> {
|
||
|
|
if (depth > 3) return;
|
||
|
|
let entries;
|
||
|
|
try { entries = await fs.readdir(directory, { withFileTypes: true }); } catch { return; }
|
||
|
|
await Promise.all(entries.map(async (entry) => {
|
||
|
|
const full = path.join(directory, entry.name);
|
||
|
|
if (entry.isDirectory()) return visit(full, depth + 1);
|
||
|
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return;
|
||
|
|
// Codex has used UUIDv7 rollouts (the current hyphenated form), compact
|
||
|
|
// UUIDs, and 26-character ULIDs across desktop/VS Code builds. Keep the
|
||
|
|
// suffix strict so an arbitrary prompt-like filename cannot become a
|
||
|
|
// selectable session.
|
||
|
|
const match = entry.name.match(/(?:^|-)((?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[0-9a-z]{26}))(?:_[^/]*)?\.jsonl$/i);
|
||
|
|
if (!match) return;
|
||
|
|
try { const stat = await fs.stat(full); files.push({ file: full, mtime: stat.mtimeMs, id: match[1] }); } catch { /* race */ }
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
await visit(root, 0);
|
||
|
|
files.sort((a, b) => b.mtime - a.mtime);
|
||
|
|
const result: Candidate[] = [];
|
||
|
|
// Rollout directories can contain many old sessions. Read metadata from a
|
||
|
|
// generous bounded set so an active VS Code session is not hidden merely
|
||
|
|
// because desktop history was written more recently.
|
||
|
|
for (const file of files.slice(0, 2_000)) {
|
||
|
|
try {
|
||
|
|
const firstLine = await readFirstLine(file.file);
|
||
|
|
let payload: Record<string, unknown> = {};
|
||
|
|
try {
|
||
|
|
const record = JSON.parse(firstLine) as unknown;
|
||
|
|
if (isRecord(record)) {
|
||
|
|
// Current rollouts wrap metadata in `payload`; older writers put the
|
||
|
|
// same fields on the first record itself. Accept only records that
|
||
|
|
// actually contain session identity fields so an arbitrary event
|
||
|
|
// record cannot become a false discovery candidate.
|
||
|
|
const candidate = isRecord(record.payload) ? record.payload : record;
|
||
|
|
if (["originator", "source", "thread_source", "cwd"].some((key) => Object.prototype.hasOwnProperty.call(candidate, key))) {
|
||
|
|
payload = candidate;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Incomplete rollout records are ignored below. A regex fallback is
|
||
|
|
// intentionally omitted so text inside a long prompt cannot identify
|
||
|
|
// an unrelated session as a VS Code conversation.
|
||
|
|
}
|
||
|
|
const originator = stringValue(payload.originator);
|
||
|
|
const source = stringValue(payload.source);
|
||
|
|
const threadSource = stringValue(payload.thread_source);
|
||
|
|
const cwd = stringValue(payload.cwd);
|
||
|
|
const title = stringValue(payload.title)
|
||
|
|
?? stringValue(payload.thread_title)
|
||
|
|
?? stringValue(payload.threadTitle)
|
||
|
|
?? stringValue(payload.name)
|
||
|
|
?? stringValue(payload.thread_name);
|
||
|
|
const updatedAtMs = timestampMs(payload.updated_at_ms)
|
||
|
|
?? timestampMs(payload.updatedAtMs)
|
||
|
|
?? timestampMs(payload.updated_at)
|
||
|
|
?? timestampMs(payload.updatedAt)
|
||
|
|
?? timestampMs(payload.timestamp);
|
||
|
|
// Subagent rollouts can use the same `codex_vscode` originator as their
|
||
|
|
// user-facing parent, but they are not conversations the operator opened.
|
||
|
|
if (threadSource === "subagent") continue;
|
||
|
|
// `source: vscode` is also written by Codex Desktop tasks hosted from a
|
||
|
|
// VS Code-shaped workspace. An explicit non-VS-Code originator must win
|
||
|
|
// so attach mode never follows a desktop task merely because it shares
|
||
|
|
// the same IPC router. Keep compatibility with older official rollouts
|
||
|
|
// that omitted originator altogether.
|
||
|
|
const official = originator === "codex_vscode";
|
||
|
|
const unattributedVscode = !originator && (source === "vscode" || threadSource === "vscode");
|
||
|
|
const isVscode = official || unattributedVscode;
|
||
|
|
if (!isVscode) continue;
|
||
|
|
// Never select a rollout produced by this bridge's legacy spawn mode.
|
||
|
|
if (originator === "codex-remote-collab") continue;
|
||
|
|
const cwdMatch = Boolean(cwd && preferredCwds.some((candidate) => samePath(candidate, cwd)));
|
||
|
|
const id = file.id;
|
||
|
|
// The bridge runs inside a VS Code workspace, so an owner in that
|
||
|
|
// workspace is a stronger signal than the rollout writer's originator.
|
||
|
|
const priority = cwdMatch ? (official ? 0 : 1) : (official ? 2 : 3);
|
||
|
|
if (!result.some((candidate) => candidate.id === id)) {
|
||
|
|
result.push({
|
||
|
|
id,
|
||
|
|
mtime: file.mtime,
|
||
|
|
...(updatedAtMs !== undefined ? { updatedAtMs } : {}),
|
||
|
|
...(cwd ? { cwd } : {}),
|
||
|
|
...(title ? { title } : {}),
|
||
|
|
priority,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch { /* ignore incomplete/rotated rollout files */ }
|
||
|
|
}
|
||
|
|
result.sort((a, b) => a.priority - b.priority || b.mtime - a.mtime);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Read enough of a rollout file to reach its first JSONL record, bounded. */
|
||
|
|
async function readFirstLine(fileName: string, maxBytes = 4 * 1024 * 1024): Promise<string> {
|
||
|
|
const handle = await fs.open(fileName, "r");
|
||
|
|
const parts: Buffer[] = [];
|
||
|
|
let offset = 0;
|
||
|
|
try {
|
||
|
|
while (offset < maxBytes) {
|
||
|
|
const size = Math.min(64 * 1024, maxBytes - offset);
|
||
|
|
const chunk = Buffer.alloc(size);
|
||
|
|
const read = await handle.read(chunk, 0, size, offset);
|
||
|
|
if (!read.bytesRead) break;
|
||
|
|
const piece = chunk.subarray(0, read.bytesRead);
|
||
|
|
const newline = piece.indexOf(0x0a);
|
||
|
|
if (newline >= 0) {
|
||
|
|
parts.push(piece.subarray(0, newline));
|
||
|
|
return Buffer.concat(parts).toString("utf8");
|
||
|
|
}
|
||
|
|
parts.push(piece);
|
||
|
|
offset += read.bytesRead;
|
||
|
|
if (read.bytesRead < size) break;
|
||
|
|
}
|
||
|
|
return Buffer.concat(parts).toString("utf8");
|
||
|
|
} finally {
|
||
|
|
await handle.close();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SessionIndexEntry {
|
||
|
|
title?: string;
|
||
|
|
updatedAtMs?: number;
|
||
|
|
cwd?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Read the bounded local index used by the official Codex recent-chat list. */
|
||
|
|
async function readSessionIndex(fileName: string, maxBytes = 8 * 1024 * 1024): Promise<Map<string, SessionIndexEntry>> {
|
||
|
|
let raw: string;
|
||
|
|
try {
|
||
|
|
raw = await readBoundedText(fileName, maxBytes);
|
||
|
|
} catch {
|
||
|
|
return new Map();
|
||
|
|
}
|
||
|
|
const result = new Map<string, SessionIndexEntry>();
|
||
|
|
for (const line of raw.split(/\r?\n/)) {
|
||
|
|
if (!line.trim()) continue;
|
||
|
|
try {
|
||
|
|
const record = JSON.parse(line) as unknown;
|
||
|
|
if (!isRecord(record)) continue;
|
||
|
|
const id = stringValue(record.id) ?? stringValue(record.session_id) ?? stringValue(record.thread_id);
|
||
|
|
if (!id) continue;
|
||
|
|
const title = stringValue(record.thread_name)
|
||
|
|
?? stringValue(record.title)
|
||
|
|
?? stringValue(record.name)
|
||
|
|
?? stringValue(record.preview);
|
||
|
|
const updatedAtMs = timestampMs(record.updated_at_ms)
|
||
|
|
?? timestampMs(record.updatedAtMs)
|
||
|
|
?? timestampMs(record.updated_at)
|
||
|
|
?? timestampMs(record.updatedAt)
|
||
|
|
?? timestampMs(record.last_updated_at);
|
||
|
|
const cwd = stringValue(record.cwd) ?? stringValue(record.workspace) ?? stringValue(record.workspacePath);
|
||
|
|
result.set(id, {
|
||
|
|
...(title ? { title } : {}),
|
||
|
|
...(updatedAtMs !== undefined ? { updatedAtMs } : {}),
|
||
|
|
...(cwd ? { cwd } : {}),
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
// A partially-written last line must not make the rest of the index
|
||
|
|
// unavailable.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readBoundedText(fileName: string, maxBytes: number): Promise<string> {
|
||
|
|
const handle = await fs.open(fileName, "r");
|
||
|
|
const parts: Buffer[] = [];
|
||
|
|
let offset = 0;
|
||
|
|
try {
|
||
|
|
while (offset < maxBytes) {
|
||
|
|
const size = Math.min(64 * 1024, maxBytes - offset);
|
||
|
|
const chunk = Buffer.alloc(size);
|
||
|
|
const read = await handle.read(chunk, 0, size, offset);
|
||
|
|
if (!read.bytesRead) break;
|
||
|
|
parts.push(chunk.subarray(0, read.bytesRead));
|
||
|
|
offset += read.bytesRead;
|
||
|
|
if (read.bytesRead < size) break;
|
||
|
|
}
|
||
|
|
return Buffer.concat(parts).toString("utf8");
|
||
|
|
} finally {
|
||
|
|
await handle.close();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeSessionTitle(value: string | undefined): string | undefined {
|
||
|
|
if (!value) return undefined;
|
||
|
|
const title = redactText(value).replace(/\s+/g, " ").trim().slice(0, 240);
|
||
|
|
return title || undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function samePath(left: string, right: string): boolean {
|
||
|
|
try { return path.resolve(left) === path.resolve(right); } catch { return left === right; }
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function resolveCodexHome(options: CodexIpcAgentAdapterOptions): string {
|
||
|
|
const env = options.env ?? process.env;
|
||
|
|
const configured = options.codexHome?.trim() || env.CODEX_HOME?.trim() || path.join(options.homeDir ?? os.homedir(), ".codex");
|
||
|
|
if (configured === "~") return options.homeDir ?? os.homedir();
|
||
|
|
if (configured.startsWith("~/")) return path.join(options.homeDir ?? os.homedir(), configured.slice(2));
|
||
|
|
return configured;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMissingSessionOwnerError(error: unknown): boolean {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
return /找不到会话\s+.+\s+的 VS Code Codex owner/.test(message)
|
||
|
|
|| /no existing codex conversation owner is attached/i.test(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractInput(params: JsonObject): string | JsonValue[] {
|
||
|
|
if (typeof params.text === "string") return params.text;
|
||
|
|
if (typeof params.message === "string") return params.message;
|
||
|
|
if (typeof params.prompt === "string") return params.prompt;
|
||
|
|
if (typeof params.input === "string") return params.input;
|
||
|
|
if (Array.isArray(params.input) && params.input.length) return params.input;
|
||
|
|
throw new Error("turn request requires text or a non-empty input array");
|
||
|
|
}
|
||
|
|
|
||
|
|
function pickTurnRequest(params: JsonObject): JsonObject {
|
||
|
|
const request: JsonObject = {};
|
||
|
|
if (Array.isArray(params.attachments)) request.attachments = asJsonValue(params.attachments);
|
||
|
|
// The owner inherits all existing thread settings. Only forward fields that
|
||
|
|
// are part of the app-server turn/start request, never relay UI-only keys.
|
||
|
|
for (const key of ["model", "serviceTier", "effort", "summary", "personality", "collaborationMode", "approvalPolicy", "approvalsReviewer", "permissions", "sandboxPolicy", "runtimeWorkspaceRoots", "cwd", "outputSchema", "multiAgentMode"]) {
|
||
|
|
if (params[key] !== undefined) request[key] = asJsonValue(params[key]);
|
||
|
|
}
|
||
|
|
return request;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The model picker changes durable next-turn settings, not one turn request.
|
||
|
|
* Accept the relay-friendly flat form and the official nested form while
|
||
|
|
* forwarding only the fields required by the picker.
|
||
|
|
*/
|
||
|
|
function pickThreadSettingsUpdate(params: JsonObject): JsonObject {
|
||
|
|
const source = isRecord(params.threadSettings) ? params.threadSettings : params;
|
||
|
|
const settings: JsonObject = {};
|
||
|
|
|
||
|
|
if (Object.prototype.hasOwnProperty.call(source, "model")) {
|
||
|
|
if (typeof source.model !== "string" || !source.model.trim()) {
|
||
|
|
throw new Error("thread settings model must be a non-empty string");
|
||
|
|
}
|
||
|
|
settings.model = source.model.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Object.prototype.hasOwnProperty.call(source, "effort")) {
|
||
|
|
if (source.effort !== null && (typeof source.effort !== "string" || !source.effort.trim())) {
|
||
|
|
throw new Error("thread settings effort must be a non-empty string or null");
|
||
|
|
}
|
||
|
|
settings.effort = typeof source.effort === "string" ? source.effort.trim() : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Object.prototype.hasOwnProperty.call(source, "multiAgentMode")) {
|
||
|
|
if (source.multiAgentMode !== null && typeof source.multiAgentMode !== "string") {
|
||
|
|
throw new Error("thread settings multiAgentMode must be a string or null");
|
||
|
|
}
|
||
|
|
settings.multiAgentMode = asJsonValue(source.multiAgentMode);
|
||
|
|
}
|
||
|
|
|
||
|
|
// The official permissions control updates the same durable thread
|
||
|
|
// settings envelope as the model picker. Keep the projection explicit so a
|
||
|
|
// browser cannot smuggle arbitrary UI state into the owner request, while
|
||
|
|
// retaining object-valued policies used by newer app-server builds.
|
||
|
|
for (const key of ["sandboxPolicy", "approvalPolicy"] 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`);
|
||
|
|
}
|
||
|
|
settings[key] = asJsonValue(value);
|
||
|
|
}
|
||
|
|
if (Object.prototype.hasOwnProperty.call(source, "approvalsReviewer")) {
|
||
|
|
const value = source.approvalsReviewer;
|
||
|
|
if (value !== null && typeof value !== "string") {
|
||
|
|
throw new Error("thread settings approvalsReviewer must be a string or null");
|
||
|
|
}
|
||
|
|
settings.approvalsReviewer = asJsonValue(value);
|
||
|
|
}
|
||
|
|
if (Object.prototype.hasOwnProperty.call(source, "runtimeWorkspaceRoots")) {
|
||
|
|
const value = source.runtimeWorkspaceRoots;
|
||
|
|
if (value !== null && (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))) {
|
||
|
|
throw new Error("thread settings runtimeWorkspaceRoots must be an array of strings or null");
|
||
|
|
}
|
||
|
|
settings.runtimeWorkspaceRoots = asJsonValue(value);
|
||
|
|
}
|
||
|
|
if (Object.prototype.hasOwnProperty.call(source, "permissions")) {
|
||
|
|
const value = source.permissions;
|
||
|
|
if (value !== null && typeof value !== "string" && !isRecord(value)) {
|
||
|
|
throw new Error("thread settings permissions must be a string, object, or null");
|
||
|
|
}
|
||
|
|
settings.permissions = asJsonValue(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Object.keys(settings).length === 0) {
|
||
|
|
throw new Error("thread settings update requires model, effort, multiAgentMode, sandboxPolicy, approvalPolicy, permissions, or approvalsReviewer");
|
||
|
|
}
|
||
|
|
return settings;
|
||
|
|
}
|
||
|
|
|
||
|
|
function pickTurnContext(params: JsonObject): JsonObject {
|
||
|
|
const context = isRecord(params.context) ? asJsonObject(params.context) : {};
|
||
|
|
if (context.inheritThreadSettings === undefined) context.inheritThreadSettings = true;
|
||
|
|
if (Array.isArray(params.commentAttachments)) context.commentAttachments = asJsonValue(params.commentAttachments);
|
||
|
|
if (Array.isArray(params.mcpAppModelContextAttachments)) context.mcpAppModelContextAttachments = asJsonValue(params.mcpAppModelContextAttachments);
|
||
|
|
return context;
|
||
|
|
}
|
||
|
|
|
||
|
|
function unwrapFollowerResult(value: JsonValue | undefined): JsonValue {
|
||
|
|
if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, "result")) return asJsonValue(value.result);
|
||
|
|
return asJsonValue(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractRevision(value: unknown): number | undefined {
|
||
|
|
const direct = numberValue(value);
|
||
|
|
if (direct !== undefined) return direct;
|
||
|
|
if (!isRecord(value)) return undefined;
|
||
|
|
for (const key of ["revision", "streamRevision", "stateRevision"]) {
|
||
|
|
const revision = numberValue(value[key]);
|
||
|
|
if (revision !== undefined) return revision;
|
||
|
|
}
|
||
|
|
return Object.prototype.hasOwnProperty.call(value, "result") ? extractRevision(value.result) : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractTurnId(value: unknown): string | undefined {
|
||
|
|
if (!isRecord(value)) return undefined;
|
||
|
|
if (typeof value.turnId === "string") return value.turnId;
|
||
|
|
if (isRecord(value.turn) && typeof value.turn.id === "string") return value.turn.id;
|
||
|
|
if (isRecord(value.result)) return extractTurnId(value.result);
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function deriveTurn(state: JsonObject): TurnInfo {
|
||
|
|
const candidates: TurnInfo[] = [];
|
||
|
|
const turns = Array.isArray(state.turns) ? state.turns : [];
|
||
|
|
for (const value of turns) if (isRecord(value)) candidates.push(turnInfo(value));
|
||
|
|
const history = isRecord(state.turnHistory) && isRecord(state.turnHistory.history) && isRecord(state.turnHistory.history.entitiesByKey)
|
||
|
|
? Object.values(state.turnHistory.history.entitiesByKey) : [];
|
||
|
|
for (const value of history) if (isRecord(value) && (value.turnId !== undefined || value.status !== undefined || value.items !== undefined)) candidates.push(turnInfo(value));
|
||
|
|
const runtime = isRecord(state.threadRuntimeStatus) ? state.threadRuntimeStatus : undefined;
|
||
|
|
const runtimeType = normalizeStatus(runtime?.type);
|
||
|
|
const active = candidates.filter((entry) => entry.active).sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
||
|
|
if (active[0]) return active[0];
|
||
|
|
const selected = candidates.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0))[0];
|
||
|
|
if (runtime && runtimeType && runtimeType !== "idle" && runtimeType !== "ready" && typeof runtime.turnId === "string") {
|
||
|
|
return { id: runtime.turnId, status: runtimeType, active: true };
|
||
|
|
}
|
||
|
|
return selected ?? { status: runtimeType || "idle", active: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
function turnInfo(value: Record<string, unknown>): TurnInfo {
|
||
|
|
const id = typeof value.id === "string" ? value.id : typeof value.turnId === "string" ? value.turnId : undefined;
|
||
|
|
const status = normalizeStatus(typeof value.status === "string" ? value.status : isRecord(value.status) && typeof value.status.type === "string" ? value.status.type : "unknown");
|
||
|
|
const startedAt = timestampMs(value.turnStartedAtMs)
|
||
|
|
?? timestampMs(value.startedAtMs)
|
||
|
|
?? timestampMs(value.startedAt)
|
||
|
|
?? timestampMs(value.createdAtMs)
|
||
|
|
?? timestampMs(value.createdAt);
|
||
|
|
const durationMs = numberValue(value.durationMs) ?? numberValue(value.duration);
|
||
|
|
const completedAtMs = timestampMs(value.completedAtMs) ?? timestampMs(value.completedAt);
|
||
|
|
const commandStarts = isRecord(value.commandExecutionStartedAtMsById)
|
||
|
|
? value.commandExecutionStartedAtMsById
|
||
|
|
: {};
|
||
|
|
const items = Array.isArray(value.items) ? value.items.filter(isRecord) : [];
|
||
|
|
const firstTurnWorkItemStartedAtMs = timestampMs(value.firstTurnWorkItemStartedAtMs)
|
||
|
|
?? timestampMs(value.firstWorkItemStartedAtMs)
|
||
|
|
?? timestampMs(value.firstTurnWorkItemStartedAt)
|
||
|
|
?? inferFirstWorkItemStartedAtMs(items, commandStarts);
|
||
|
|
const finalAssistantStartedAtMs = timestampMs(value.finalAssistantStartedAtMs)
|
||
|
|
?? timestampMs(value.finalAssistantStartedAt)
|
||
|
|
?? inferFinalAssistantStartedAtMs(items, commandStarts);
|
||
|
|
const active = !TERMINAL_TURN_STATES.has(status) && status !== "idle" && status !== "unknown";
|
||
|
|
const explicitWorkedDurationMs = numberValue(value.workedDurationMs)
|
||
|
|
?? numberValue(value.workDurationMs)
|
||
|
|
?? numberValue(value.workDuration);
|
||
|
|
const workedCompletedAtMs = finalAssistantStartedAtMs
|
||
|
|
?? (!active && completedAtMs !== undefined ? completedAtMs : undefined);
|
||
|
|
const workedDurationMs = explicitWorkedDurationMs
|
||
|
|
?? (firstTurnWorkItemStartedAtMs !== undefined && workedCompletedAtMs !== undefined
|
||
|
|
? Math.max(0, workedCompletedAtMs - firstTurnWorkItemStartedAtMs)
|
||
|
|
: undefined);
|
||
|
|
return {
|
||
|
|
id,
|
||
|
|
status,
|
||
|
|
active,
|
||
|
|
...(startedAt !== undefined ? { startedAt } : {}),
|
||
|
|
...(durationMs !== undefined ? { durationMs } : {}),
|
||
|
|
...(workedDurationMs !== undefined ? { workedDurationMs } : {}),
|
||
|
|
...(completedAtMs !== undefined ? { completedAtMs } : {}),
|
||
|
|
...(firstTurnWorkItemStartedAtMs !== undefined ? { firstTurnWorkItemStartedAtMs } : {}),
|
||
|
|
...(finalAssistantStartedAtMs !== undefined ? { finalAssistantStartedAtMs } : {}),
|
||
|
|
...(value.error !== undefined ? { error: asJsonValue(value.error) } : {}),
|
||
|
|
raw: value,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Infer the timestamps used by the official "worked for" row when an older
|
||
|
|
* conversation snapshot does not carry the denormalized turn fields. Persisted
|
||
|
|
* rollout records use snake_case command metadata, so both spellings are
|
||
|
|
* intentionally accepted here.
|
||
|
|
*/
|
||
|
|
function inferFirstWorkItemStartedAtMs(
|
||
|
|
items: Record<string, unknown>[],
|
||
|
|
commandStarts: Record<string, unknown>,
|
||
|
|
): number | undefined {
|
||
|
|
for (const item of items) {
|
||
|
|
if (isNonWorkItem(item)) continue;
|
||
|
|
const id = stringValue(item.id);
|
||
|
|
const timestamp = itemTimestamp(item)
|
||
|
|
?? (id ? timestampMs(commandStarts[id]) : undefined);
|
||
|
|
if (timestamp !== undefined) return timestamp;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function inferFinalAssistantStartedAtMs(
|
||
|
|
items: Record<string, unknown>[],
|
||
|
|
commandStarts: Record<string, unknown>,
|
||
|
|
): number | undefined {
|
||
|
|
let fallback: number | undefined;
|
||
|
|
for (const item of items) {
|
||
|
|
if (!isAssistantItem(item)) continue;
|
||
|
|
const id = stringValue(item.id);
|
||
|
|
const timestamp = itemTimestamp(item)
|
||
|
|
?? (id ? timestampMs(commandStarts[id]) : undefined);
|
||
|
|
if (timestamp === undefined) continue;
|
||
|
|
fallback = timestamp;
|
||
|
|
const phase = normalizeStatus(item.phase);
|
||
|
|
if (phase === "final_answer" || phase === "finalanswer") return timestamp;
|
||
|
|
}
|
||
|
|
return fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
function itemTimestamp(item: Record<string, unknown>): number | undefined {
|
||
|
|
return timestampMs(item.startedAtMs)
|
||
|
|
?? timestampMs(item.started_at_ms)
|
||
|
|
?? timestampMs(item.startedAt)
|
||
|
|
?? timestampMs(item.started_at)
|
||
|
|
?? timestampMs(item.createdAtMs)
|
||
|
|
?? timestampMs(item.created_at_ms)
|
||
|
|
?? timestampMs(item.createdAt)
|
||
|
|
?? timestampMs(item.created_at);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizedItemType(item: Record<string, unknown>): string {
|
||
|
|
return String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function isAssistantItem(item: Record<string, unknown>): boolean {
|
||
|
|
const type = normalizedItemType(item);
|
||
|
|
return type === "agentmessage" || type === "assistantmessage";
|
||
|
|
}
|
||
|
|
|
||
|
|
function isNonWorkItem(item: Record<string, unknown>): boolean {
|
||
|
|
const type = normalizedItemType(item);
|
||
|
|
return type === "usermessage"
|
||
|
|
|| type === "steeringusermessage"
|
||
|
|
|| type === "realtimetranscript"
|
||
|
|
|| type === "worktreeinit"
|
||
|
|
|| type === "sleep";
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Normalize the official turn/runtime records into a stable status shape for
|
||
|
|
* relay clients. This intentionally tolerates both raw webview state
|
||
|
|
* (`turnStartedAtMs`, `inProgress`) and serialized history summaries
|
||
|
|
* (`startedAt`, `completed`) used by different extension versions.
|
||
|
|
*/
|
||
|
|
function deriveStatusSnapshot(state: JsonObject, turn: TurnInfo, requests: RequestEntry[]): AgentStatusSnapshot {
|
||
|
|
const runtime = isRecord(state.threadRuntimeStatus) ? state.threadRuntimeStatus : undefined;
|
||
|
|
const activeFlags = Array.isArray(runtime?.activeFlags)
|
||
|
|
? runtime.activeFlags.filter((flag): flag is string => typeof flag === "string")
|
||
|
|
: [];
|
||
|
|
const activity = classifyActivity(turn, activeFlags, requests, runtime);
|
||
|
|
const startedAtMs = turn.startedAt ?? null;
|
||
|
|
const durationMs = turn.durationMs
|
||
|
|
?? (!turn.active && turn.completedAtMs !== undefined && turn.completedAtMs !== null && turn.startedAt !== undefined
|
||
|
|
? Math.max(0, turn.completedAtMs - turn.startedAt)
|
||
|
|
: null);
|
||
|
|
// The official worked-for indicator starts at the first actual work item,
|
||
|
|
// not at the user-message/turn start, and ends when the final assistant
|
||
|
|
// message begins. Keep this separate from the broader turn duration.
|
||
|
|
const workStartedAtMs = turn.firstTurnWorkItemStartedAtMs ?? turn.startedAt ?? undefined;
|
||
|
|
const workedCompletedAtMs = turn.finalAssistantStartedAtMs
|
||
|
|
?? (!turn.active ? turn.completedAtMs : undefined)
|
||
|
|
?? undefined;
|
||
|
|
const workedDurationMs = turn.workedDurationMs
|
||
|
|
?? (workStartedAtMs !== undefined && workedCompletedAtMs !== undefined
|
||
|
|
? Math.max(0, workedCompletedAtMs - workStartedAtMs)
|
||
|
|
: turn.active && workStartedAtMs !== undefined
|
||
|
|
? Math.max(0, Date.now() - workStartedAtMs)
|
||
|
|
: null);
|
||
|
|
const elapsedMs = turn.active && workStartedAtMs !== undefined
|
||
|
|
? Math.max(0, Date.now() - workStartedAtMs)
|
||
|
|
: durationMs;
|
||
|
|
const status: AgentStatusSnapshot = {
|
||
|
|
activity,
|
||
|
|
turnStatus: turn.status,
|
||
|
|
activeFlags,
|
||
|
|
startedAtMs,
|
||
|
|
durationMs,
|
||
|
|
workedDurationMs,
|
||
|
|
elapsedMs,
|
||
|
|
firstTurnWorkItemStartedAtMs: turn.firstTurnWorkItemStartedAtMs ?? null,
|
||
|
|
finalAssistantStartedAtMs: turn.finalAssistantStartedAtMs ?? null,
|
||
|
|
...(turn.error !== undefined ? { error: turn.error } : {}),
|
||
|
|
};
|
||
|
|
return status;
|
||
|
|
}
|
||
|
|
|
||
|
|
function classifyActivity(
|
||
|
|
turn: TurnInfo,
|
||
|
|
activeFlags: string[],
|
||
|
|
requests: RequestEntry[],
|
||
|
|
runtime?: Record<string, unknown>,
|
||
|
|
): string {
|
||
|
|
const flags = new Set(activeFlags.map((flag) => normalizeStatus(flag)));
|
||
|
|
if (flags.has("waiting_on_approval") || flags.has("waiting_for_approval") || flags.has("waitingonapproval") || requests.some((entry) => Boolean(entry.approval))) {
|
||
|
|
return "waiting_approval";
|
||
|
|
}
|
||
|
|
if (flags.has("waiting_on_user_input") || flags.has("waiting_for_user_input") || flags.has("waitingonuserinput") || requests.some((entry) => INPUT_METHODS.has(entry.method))) {
|
||
|
|
return "waiting_input";
|
||
|
|
}
|
||
|
|
if (!turn.active) return terminalActivity(turn.status);
|
||
|
|
|
||
|
|
const item = latestActiveWorkItem(turn.raw);
|
||
|
|
if (item) {
|
||
|
|
const type = normalizeStatus(item.type ?? item.kind ?? "");
|
||
|
|
if (type === "read" || type.includes("fileread") || type.includes("readfile") || readPathsFromActions(commandActionsValue(item)).length > 0) return "reading";
|
||
|
|
if (type.includes("filechange") || type.includes("file_change") || type.includes("patch") || type.includes("edit")) return "editing";
|
||
|
|
if (type.includes("reasoning") || type.includes("think")) return "thinking";
|
||
|
|
if (type.includes("command") || type.includes("exec") || type.includes("process") || type.includes("tool")) return "running";
|
||
|
|
const phase = normalizeStatus(item.phase);
|
||
|
|
if (phase.includes("reason") || phase.includes("think")) return "thinking";
|
||
|
|
}
|
||
|
|
|
||
|
|
const runtimeType = normalizeStatus(runtime?.type);
|
||
|
|
if (runtimeType.includes("reason") || runtimeType.includes("think")) return "thinking";
|
||
|
|
if (runtimeType.includes("edit") || runtimeType.includes("patch")) return "editing";
|
||
|
|
return "running";
|
||
|
|
}
|
||
|
|
|
||
|
|
function latestActiveWorkItem(turn?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||
|
|
if (!turn || !Array.isArray(turn.items)) return undefined;
|
||
|
|
for (let index = turn.items.length - 1; index >= 0; index -= 1) {
|
||
|
|
const item = turn.items[index];
|
||
|
|
if (!isRecord(item)) continue;
|
||
|
|
const status = normalizeStatus(isRecord(item.status) ? item.status.type : item.status);
|
||
|
|
if (status && status !== "unknown" && TERMINAL_TURN_STATES.has(status)) continue;
|
||
|
|
return item;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function terminalActivity(status: string): string {
|
||
|
|
const normalized = normalizeStatus(status);
|
||
|
|
if (normalized === "failed" || normalized === "error") return "failed";
|
||
|
|
if (normalized === "cancelled" || normalized === "canceled" || normalized === "interrupted") return "interrupted";
|
||
|
|
if (normalized === "completed" || normalized === "complete" || normalized === "done") return "completed";
|
||
|
|
return normalized === "idle" || normalized === "ready" ? "idle" : "idle";
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusPayload(status: AgentStatusSnapshot): JsonObject {
|
||
|
|
return {
|
||
|
|
// `status` is the legacy scalar alias; `turnStatus` retains the explicit
|
||
|
|
// name so clients can distinguish it from the coarse `activity` value.
|
||
|
|
status: status.turnStatus,
|
||
|
|
turnStatus: status.turnStatus,
|
||
|
|
activity: status.activity,
|
||
|
|
activeFlags: asJsonValue(status.activeFlags),
|
||
|
|
startedAtMs: status.startedAtMs ?? null,
|
||
|
|
durationMs: status.durationMs ?? null,
|
||
|
|
workedDurationMs: status.workedDurationMs ?? null,
|
||
|
|
elapsedMs: status.elapsedMs ?? null,
|
||
|
|
firstTurnWorkItemStartedAtMs: status.firstTurnWorkItemStartedAtMs ?? null,
|
||
|
|
finalAssistantStartedAtMs: status.finalAssistantStartedAtMs ?? null,
|
||
|
|
...(status.error !== undefined ? { error: status.error } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function sameStatus(a: AgentStatusSnapshot, b: AgentStatusSnapshot): boolean {
|
||
|
|
return a.activity === b.activity
|
||
|
|
&& a.turnStatus === b.turnStatus
|
||
|
|
&& JSON.stringify(a.activeFlags) === JSON.stringify(b.activeFlags)
|
||
|
|
&& a.startedAtMs === b.startedAtMs
|
||
|
|
&& a.durationMs === b.durationMs
|
||
|
|
&& a.workedDurationMs === b.workedDurationMs
|
||
|
|
&& a.firstTurnWorkItemStartedAtMs === b.firstTurnWorkItemStartedAtMs
|
||
|
|
&& a.finalAssistantStartedAtMs === b.finalAssistantStartedAtMs
|
||
|
|
&& JSON.stringify(a.error) === JSON.stringify(b.error);
|
||
|
|
}
|
||
|
|
|
||
|
|
function timestampMs(value: unknown): number | undefined {
|
||
|
|
if (typeof value === "string") {
|
||
|
|
const trimmed = value.trim();
|
||
|
|
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(trimmed)) {
|
||
|
|
const number = Number(trimmed);
|
||
|
|
if (Number.isFinite(number)) {
|
||
|
|
return number > 0 && number < 1_000_000_000_000 ? number * 1000 : number;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const parsed = Date.parse(value);
|
||
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||
|
|
}
|
||
|
|
const number = numberValue(value);
|
||
|
|
if (number === undefined) return undefined;
|
||
|
|
// Serialized history in older extension builds uses epoch seconds while
|
||
|
|
// the live turn fields end in `AtMs`. Normalize both to milliseconds.
|
||
|
|
return number > 0 && number < 1_000_000_000_000 ? number * 1000 : number;
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractRequests(state: JsonObject, approvalTimeoutMs: number): RequestEntry[] {
|
||
|
|
const values: Array<{ id?: JsonRpcId; value: Record<string, unknown> }> = [];
|
||
|
|
const seenRecords = new Set<Record<string, unknown>>();
|
||
|
|
const collect = (raw: unknown, hintedId?: JsonRpcId, depth = 0): void => {
|
||
|
|
if (depth > 6 || raw === null || raw === undefined) return;
|
||
|
|
if (Array.isArray(raw)) {
|
||
|
|
for (const value of raw) collect(value, undefined, depth + 1);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!isRecord(raw)) return;
|
||
|
|
if (seenRecords.has(raw)) return;
|
||
|
|
seenRecords.add(raw);
|
||
|
|
const nested = isRecord(raw.request) ? raw.request : raw;
|
||
|
|
const method = requestMethodOf(nested) ?? requestMethodOf(raw);
|
||
|
|
const requestId = requestIdOf(nested) ?? requestIdOf(raw) ?? hintedId;
|
||
|
|
if (method && isJsonRpcId(requestId)) values.push({ id: requestId, value: raw });
|
||
|
|
// Official conversation snapshots can retain pending records inside a
|
||
|
|
// turn item (permission-request/userInput/mcp-server-elicitation), while
|
||
|
|
// older builds expose the same records under `requests`. Traverse only
|
||
|
|
// protocol containers so arbitrary message content is never interpreted as
|
||
|
|
// an approval request.
|
||
|
|
for (const key of ["requests", "pendingRequests", "pendingApprovals", "turns", "turnHistory", "history", "items", "request"]) {
|
||
|
|
const child = raw[key];
|
||
|
|
if (child === undefined) continue;
|
||
|
|
if (isRecord(child) && !Array.isArray(child)) {
|
||
|
|
for (const [childKey, value] of Object.entries(child)) {
|
||
|
|
collect(value, isJsonRpcId(childKey) ? childKey : undefined, depth + 1);
|
||
|
|
}
|
||
|
|
} else collect(child, undefined, depth + 1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
collect(state);
|
||
|
|
const result: RequestEntry[] = [];
|
||
|
|
const seenRequests = new Set<string>();
|
||
|
|
for (const item of values) {
|
||
|
|
const request = isRecord(item.value.request) ? item.value.request : item.value;
|
||
|
|
const requestId = item.id ?? requestIdOf(request);
|
||
|
|
const method = requestMethodOf(request) ?? requestMethodOf(item.value);
|
||
|
|
if (!isJsonRpcId(requestId) || !method) continue;
|
||
|
|
const dedupeKey = `${jsonRpcIdKey(requestId)}\u001f${method}`;
|
||
|
|
if (seenRequests.has(dedupeKey)) continue;
|
||
|
|
seenRequests.add(dedupeKey);
|
||
|
|
const params = asJsonObject(request.params ?? item.value.params ?? (request === item.value ? item.value : {}));
|
||
|
|
// `params.startedAtMs` is the app-server's authoritative timestamp. The
|
||
|
|
// outer fields are compatibility fallbacks for older normalized snapshots
|
||
|
|
// and can represent when a UI record was inserted rather than when the
|
||
|
|
// approval actually started.
|
||
|
|
const createdAt = timestampMs(params.startedAtMs)
|
||
|
|
?? timestampMs(params.started_at_ms)
|
||
|
|
?? timestampMs(params.startedAt)
|
||
|
|
?? timestampMs(params.started_at)
|
||
|
|
?? timestampMs(request.startedAtMs)
|
||
|
|
?? timestampMs(request.started_at_ms)
|
||
|
|
?? timestampMs(request.startedAt)
|
||
|
|
?? timestampMs(request.started_at)
|
||
|
|
?? timestampMs(item.value.startedAtMs)
|
||
|
|
?? timestampMs(item.value.started_at_ms)
|
||
|
|
?? timestampMs(item.value.startedAt)
|
||
|
|
?? timestampMs(item.value.started_at)
|
||
|
|
?? timestampMs(item.value.createdAtMs)
|
||
|
|
?? timestampMs(item.value.created_at_ms)
|
||
|
|
?? timestampMs(item.value.createdAt)
|
||
|
|
?? timestampMs(item.value.created_at)
|
||
|
|
?? Date.now();
|
||
|
|
const expiresAt = timestampMs(item.value.expiresAtMs)
|
||
|
|
?? timestampMs(item.value.expires_at_ms)
|
||
|
|
?? timestampMs(item.value.expiresAt)
|
||
|
|
?? timestampMs(item.value.expires_at)
|
||
|
|
?? timestampMs(request.expiresAtMs)
|
||
|
|
?? timestampMs(request.expires_at_ms)
|
||
|
|
?? timestampMs(request.expiresAt)
|
||
|
|
?? timestampMs(request.expires_at)
|
||
|
|
?? timestampMs(params.expiresAtMs)
|
||
|
|
?? timestampMs(params.expires_at_ms)
|
||
|
|
?? timestampMs(params.expiresAt)
|
||
|
|
?? timestampMs(params.expires_at)
|
||
|
|
?? (approvalTimeoutMs > 0 ? createdAt + approvalTimeoutMs : undefined);
|
||
|
|
const entry: RequestEntry = {
|
||
|
|
requestId,
|
||
|
|
method,
|
||
|
|
params,
|
||
|
|
threadId: stringValue(params.threadId) ?? stringValue(params.conversationId),
|
||
|
|
turnId: stringValue(params.turnId),
|
||
|
|
createdAt,
|
||
|
|
...(expiresAt ? { expiresAt } : {}),
|
||
|
|
};
|
||
|
|
if (APPROVAL_METHODS.has(method)) entry.approval = toPendingApproval(entry);
|
||
|
|
result.push(entry);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function requestMethodOf(value: Record<string, unknown>): string | undefined {
|
||
|
|
if (typeof value.method === "string" && value.method) return value.method;
|
||
|
|
const type = String(value.type ?? value.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase();
|
||
|
|
if (type.includes("permissionrequest")) return "item/permissions/requestApproval";
|
||
|
|
if (type.includes("commandexecutionrequest") || type === "execapproval") return "item/commandExecution/requestApproval";
|
||
|
|
if (type.includes("filechangerequest") || type === "patchapproval") return "item/fileChange/requestApproval";
|
||
|
|
if (type === "exec" && value.approvalRequestId !== undefined && (!isRecord(value.output) || value.output.exitCode === undefined)) return "execCommandApproval";
|
||
|
|
if (type === "patch" && value.approvalRequestId !== undefined && value.success === undefined) return "applyPatchApproval";
|
||
|
|
if (type.includes("userinput") && value.completed !== true) return "item/tool/requestUserInput";
|
||
|
|
if (type.includes("mcpserverelicitation") && value.completed !== true) return "mcpServer/elicitation/request";
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function requestIdOf(value: Record<string, unknown>): JsonRpcId | undefined {
|
||
|
|
const id = value.requestId ?? value.id;
|
||
|
|
return isJsonRpcId(id) ? id : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function toPendingApproval(entry: RequestEntry): PendingApproval {
|
||
|
|
const command = typeof entry.params.command === "string" ? entry.params.command : extractCommand(entry.params);
|
||
|
|
const action = entry.method.includes("fileChange") || entry.method === "applyPatchApproval" ? "file.change" : entry.method.includes("permissions") ? "permissions.grant" : "command.execution";
|
||
|
|
const risk: PendingApproval["risk"] = entry.method.includes("permissions")
|
||
|
|
? "high"
|
||
|
|
: entry.method.includes("command") || entry.method === "execCommandApproval"
|
||
|
|
? (!command || /(?:rm\s+-rf|sudo|curl|wget|ssh|password|token|secret)/i.test(command) ? "high" : "medium")
|
||
|
|
: "medium";
|
||
|
|
const summary = stringValue(entry.params.reason) ?? command ?? `${action} requested by Codex`;
|
||
|
|
return {
|
||
|
|
requestId: entry.requestId,
|
||
|
|
method: entry.method,
|
||
|
|
threadId: entry.threadId,
|
||
|
|
turnId: entry.turnId,
|
||
|
|
itemId: stringValue(entry.params.itemId) ?? stringValue(entry.params.callId),
|
||
|
|
action,
|
||
|
|
risk,
|
||
|
|
summary: redactText(summary),
|
||
|
|
commandHash: hashJson(entry.params),
|
||
|
|
createdAt: entry.createdAt,
|
||
|
|
...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {}),
|
||
|
|
payload: redactJson(entry.params) as JsonObject,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractCommand(params: JsonObject): string | undefined {
|
||
|
|
if (Array.isArray(params.command)) return params.command.filter((value): value is string => typeof value === "string").join(" ");
|
||
|
|
const actions = params.commandActions ?? params.command_actions ?? params.parsedCmd ?? params.parsed_cmd;
|
||
|
|
const actionList = commandActionList(actions);
|
||
|
|
if (actionList.length) {
|
||
|
|
const commands = actionList.map(commandActionText).filter((value): value is string => Boolean(value));
|
||
|
|
return commands.length ? commands.join(" && ") : undefined;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface RenderedConversationMessage {
|
||
|
|
id?: string;
|
||
|
|
turnId?: string;
|
||
|
|
itemId?: string;
|
||
|
|
role: "user" | "assistant" | "reasoning" | "tool" | "error";
|
||
|
|
kind: "user" | "assistant" | "reasoning" | "plan" | "tool" | "edit" | "error";
|
||
|
|
text: string;
|
||
|
|
label?: string;
|
||
|
|
itemType?: string;
|
||
|
|
status?: string;
|
||
|
|
turnStatus?: string;
|
||
|
|
startedAtMs?: number;
|
||
|
|
completedAtMs?: number;
|
||
|
|
durationMs?: number;
|
||
|
|
/** Duration of the official worked-for activity group for this turn. */
|
||
|
|
workedDurationMs?: number;
|
||
|
|
command?: string;
|
||
|
|
/** Parsed command actions emitted by the official command renderer. */
|
||
|
|
commandActions?: JsonValue[];
|
||
|
|
cwd?: string | null;
|
||
|
|
shellName?: string | null;
|
||
|
|
exitCode?: number;
|
||
|
|
phase?: string;
|
||
|
|
breaksPreviousAdjacency?: boolean;
|
||
|
|
/** Official collabAgentToolCall projection. */
|
||
|
|
action?: string;
|
||
|
|
senderThreadId?: string;
|
||
|
|
receiverThreadIds?: string[];
|
||
|
|
/** Official webview compatibility alias. */
|
||
|
|
receiverThreads?: string[];
|
||
|
|
prompt?: string | null;
|
||
|
|
model?: string | null;
|
||
|
|
reasoningEffort?: string | null;
|
||
|
|
agentsStates?: JsonObject;
|
||
|
|
/** Official subAgentActivity projection. */
|
||
|
|
agentThreadId?: string;
|
||
|
|
agentPath?: string;
|
||
|
|
displayName?: string | null;
|
||
|
|
displayStatus?: string;
|
||
|
|
activityKind?: string;
|
||
|
|
/** Semantic name used by the official webview converter. */
|
||
|
|
uiType?: string;
|
||
|
|
/** Friendly paths extracted from a parsed `read` command action. */
|
||
|
|
readPaths?: string[];
|
||
|
|
/** Raw tool/file output kept separate from the compact activity summary. */
|
||
|
|
output?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ItemDisplayProjection {
|
||
|
|
outputText: string;
|
||
|
|
projectionId?: string;
|
||
|
|
startedAtMs?: number;
|
||
|
|
completedAtMs?: number;
|
||
|
|
durationMs?: number;
|
||
|
|
workedDurationMs?: number;
|
||
|
|
role: RenderedConversationMessage["role"];
|
||
|
|
kind: RenderedConversationMessage["kind"];
|
||
|
|
text: string;
|
||
|
|
label?: string;
|
||
|
|
itemType?: string;
|
||
|
|
status?: string;
|
||
|
|
command?: string;
|
||
|
|
commandActions?: JsonValue[];
|
||
|
|
cwd?: string | null;
|
||
|
|
shellName?: string | null;
|
||
|
|
action?: string;
|
||
|
|
senderThreadId?: string;
|
||
|
|
receiverThreadIds?: string[];
|
||
|
|
receiverThreads?: string[];
|
||
|
|
prompt?: string | null;
|
||
|
|
model?: string | null;
|
||
|
|
reasoningEffort?: string | null;
|
||
|
|
agentsStates?: JsonObject;
|
||
|
|
agentThreadId?: string;
|
||
|
|
agentPath?: string;
|
||
|
|
displayName?: string | null;
|
||
|
|
displayStatus?: string;
|
||
|
|
activityKind?: string;
|
||
|
|
uiType?: string;
|
||
|
|
readPaths?: string[];
|
||
|
|
output?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderedMessageShape(messages: RenderedConversationMessage[]): string {
|
||
|
|
return messages.map((message, index) => [
|
||
|
|
message.id ?? `index:${index}`,
|
||
|
|
message.turnId ?? "",
|
||
|
|
message.itemId ?? "",
|
||
|
|
message.role,
|
||
|
|
message.kind,
|
||
|
|
message.itemType ?? "",
|
||
|
|
message.text,
|
||
|
|
message.label ?? "",
|
||
|
|
message.status ?? "",
|
||
|
|
message.turnStatus ?? "",
|
||
|
|
message.startedAtMs ?? "",
|
||
|
|
message.completedAtMs ?? "",
|
||
|
|
message.durationMs ?? "",
|
||
|
|
message.workedDurationMs ?? "",
|
||
|
|
message.command ?? "",
|
||
|
|
JSON.stringify(message.commandActions ?? []),
|
||
|
|
message.cwd ?? "",
|
||
|
|
message.shellName ?? "",
|
||
|
|
message.exitCode ?? "",
|
||
|
|
message.phase ?? "",
|
||
|
|
message.action ?? "",
|
||
|
|
message.senderThreadId ?? "",
|
||
|
|
JSON.stringify(message.receiverThreadIds ?? []),
|
||
|
|
message.prompt ?? "",
|
||
|
|
message.model ?? "",
|
||
|
|
message.reasoningEffort ?? "",
|
||
|
|
JSON.stringify(message.agentsStates ?? {}),
|
||
|
|
message.agentThreadId ?? "",
|
||
|
|
message.agentPath ?? "",
|
||
|
|
message.displayName ?? "",
|
||
|
|
message.displayStatus ?? "",
|
||
|
|
message.activityKind ?? "",
|
||
|
|
message.uiType ?? "",
|
||
|
|
JSON.stringify(message.readPaths ?? []),
|
||
|
|
message.output ?? "",
|
||
|
|
message.breaksPreviousAdjacency ? "break" : "",
|
||
|
|
].join("\u001f")).join("\u001e");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Encode a suffix replacement instead of repeating the complete structured
|
||
|
|
* history on every streaming text patch. Initial/output snapshots still carry
|
||
|
|
* the full projection, so reconnect and late-join hydration stay lossless.
|
||
|
|
*/
|
||
|
|
function renderedMessagesPatch(
|
||
|
|
previous: RenderedConversationMessage[],
|
||
|
|
next: RenderedConversationMessage[],
|
||
|
|
): JsonObject | undefined {
|
||
|
|
const sharedLength = Math.min(previous.length, next.length);
|
||
|
|
let start = 0;
|
||
|
|
while (start < sharedLength
|
||
|
|
&& stableStringify(asJsonValue(previous[start])) === stableStringify(asJsonValue(next[start]))) start += 1;
|
||
|
|
if (start === previous.length && start === next.length) return undefined;
|
||
|
|
return {
|
||
|
|
start,
|
||
|
|
deleteCount: previous.length - start,
|
||
|
|
messages: asJsonValue(next.slice(start)),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderedSubagentShape(subagents: SubagentSnapshot[]): string {
|
||
|
|
return JSON.stringify(subagents);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderConversationOutput(state: JsonObject, maxChars: number): { text: string; totalLength: number; truncated: boolean; messages: RenderedConversationMessage[]; subagents: SubagentSnapshot[] } {
|
||
|
|
const chunks: string[] = [];
|
||
|
|
const messages: RenderedConversationMessage[] = [];
|
||
|
|
const seen = new Map<string, number>();
|
||
|
|
const add = (text: string, id?: string, message?: RenderedConversationMessage): void => {
|
||
|
|
const safe = redactText(text);
|
||
|
|
if (!safe) return;
|
||
|
|
if (id && seen.has(id)) {
|
||
|
|
// The same turn is commonly present in both the canonical history and
|
||
|
|
// the active-page list. Keep the latest item text when a streaming item
|
||
|
|
// was updated, instead of dropping the active-page update entirely.
|
||
|
|
const position = seen.get(id) as number;
|
||
|
|
chunks[position] = safe;
|
||
|
|
if (message) messages[position] = { ...message, text: message.text ? redactText(message.text) : safe };
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (id) seen.set(id, chunks.length);
|
||
|
|
chunks.push(safe);
|
||
|
|
if (message) messages.push({ ...message, text: redactText(message.text || safe) });
|
||
|
|
};
|
||
|
|
const consumeTurn = (turn: Record<string, unknown>): void => {
|
||
|
|
const turnKey = stringValue(turn.id) ?? stringValue(turn.turnId);
|
||
|
|
const turnStatus = statusValue(turn.status);
|
||
|
|
const turnStartedAtMs = timestampMs(turn.turnStartedAtMs)
|
||
|
|
?? timestampMs(turn.startedAtMs)
|
||
|
|
?? timestampMs(turn.startedAt)
|
||
|
|
?? timestampMs(turn.createdAtMs)
|
||
|
|
?? timestampMs(turn.createdAt);
|
||
|
|
const turnDurationMs = numberValue(turn.durationMs) ?? numberValue(turn.duration);
|
||
|
|
const firstTurnWorkItemStartedAtMs = timestampMs(turn.firstTurnWorkItemStartedAtMs)
|
||
|
|
?? timestampMs(turn.firstWorkItemStartedAtMs)
|
||
|
|
?? timestampMs(turn.firstTurnWorkItemStartedAt);
|
||
|
|
const finalAssistantStartedAtMs = timestampMs(turn.finalAssistantStartedAtMs)
|
||
|
|
?? timestampMs(turn.finalAssistantStartedAt);
|
||
|
|
const commandStarts = isRecord(turn.commandExecutionStartedAtMsById)
|
||
|
|
? turn.commandExecutionStartedAtMsById
|
||
|
|
: {};
|
||
|
|
const items = Array.isArray(turn.items) ? turn.items.filter(isRecord) : [];
|
||
|
|
const inferredFirstWorkItemStartedAtMs = firstTurnWorkItemStartedAtMs
|
||
|
|
?? inferFirstWorkItemStartedAtMs(items, commandStarts);
|
||
|
|
const inferredFinalAssistantStartedAtMs = finalAssistantStartedAtMs
|
||
|
|
?? inferFinalAssistantStartedAtMs(items, commandStarts);
|
||
|
|
const workedCompletedAtMs = inferredFinalAssistantStartedAtMs
|
||
|
|
?? (!TERMINAL_TURN_STATES.has(normalizeStatus(turnStatus))
|
||
|
|
? undefined
|
||
|
|
: timestampMs(turn.completedAtMs) ?? timestampMs(turn.completedAt));
|
||
|
|
const workedDurationMs = numberValue(turn.workedDurationMs)
|
||
|
|
?? numberValue(turn.workDurationMs)
|
||
|
|
?? (inferredFirstWorkItemStartedAtMs !== undefined && workedCompletedAtMs !== undefined
|
||
|
|
? Math.max(0, workedCompletedAtMs - inferredFirstWorkItemStartedAtMs)
|
||
|
|
: undefined);
|
||
|
|
// A turn may append bookkeeping/tool records after the final assistant
|
||
|
|
// item. Identify the final assistant from the rendered assistant records,
|
||
|
|
// with an explicit final-answer phase taking precedence over chronology.
|
||
|
|
const itemDisplays = items.map((item) => itemDisplayVariants(item));
|
||
|
|
let finalAssistantIndex = -1;
|
||
|
|
let explicitFinalAssistantIndex = -1;
|
||
|
|
itemDisplays.forEach((displays, index) => {
|
||
|
|
if (!displays.some((display) => display.role === "assistant")) return;
|
||
|
|
finalAssistantIndex = index;
|
||
|
|
const phase = typeof items[index].phase === "string"
|
||
|
|
? normalizeStatus(items[index].phase)
|
||
|
|
: "";
|
||
|
|
if (phase === "final_answer" || phase === "finalanswer") explicitFinalAssistantIndex = index;
|
||
|
|
});
|
||
|
|
if (explicitFinalAssistantIndex >= 0) finalAssistantIndex = explicitFinalAssistantIndex;
|
||
|
|
items.forEach((item, index) => {
|
||
|
|
const displays = itemDisplays[index];
|
||
|
|
if (!displays.length) return;
|
||
|
|
const rawItemId = typeof item.id === "string" ? item.id : undefined;
|
||
|
|
const startedAtMs = timestampMs(item.startedAtMs)
|
||
|
|
?? timestampMs(item.startedAt)
|
||
|
|
?? (rawItemId ? timestampMs(commandStarts[rawItemId]) : undefined);
|
||
|
|
const durationMs = numberValue(item.durationMs) ?? numberValue(item.duration);
|
||
|
|
const completedAtMs = timestampMs(item.completedAtMs)
|
||
|
|
?? timestampMs(item.finishedAtMs)
|
||
|
|
?? timestampMs(item.completedAt)
|
||
|
|
?? (startedAtMs !== undefined && durationMs !== undefined ? startedAtMs + durationMs : undefined);
|
||
|
|
const command = commandText(item);
|
||
|
|
const phase = typeof item.phase === "string" ? item.phase : undefined;
|
||
|
|
displays.forEach((display, displayIndex) => {
|
||
|
|
const projectionId = display.projectionId ?? rawItemId;
|
||
|
|
const itemId = projectionId
|
||
|
|
? `id:${projectionId}`
|
||
|
|
: turnKey
|
||
|
|
? `turn:${turnKey}:${index}:${displayIndex}`
|
||
|
|
: `raw:${JSON.stringify(item)}:${displayIndex}`;
|
||
|
|
const isFinalAssistant = display.role === "assistant"
|
||
|
|
&& (phase === "final_answer" || phase === "final-answer" || index === finalAssistantIndex);
|
||
|
|
// User items do not carry their own timestamp in several official
|
||
|
|
// snapshots. Associate them with the turn start; likewise associate the
|
||
|
|
// final assistant item with the turn's final-answer start and duration.
|
||
|
|
const effectiveStartedAtMs = display.startedAtMs ?? startedAtMs
|
||
|
|
?? (display.role === "user" ? turnStartedAtMs : undefined)
|
||
|
|
?? (display.role === "reasoning" ? inferredFirstWorkItemStartedAtMs : undefined)
|
||
|
|
?? (isFinalAssistant ? inferredFinalAssistantStartedAtMs : undefined);
|
||
|
|
const effectiveDurationMs = display.durationMs ?? durationMs
|
||
|
|
?? (isFinalAssistant ? turnDurationMs : undefined);
|
||
|
|
const effectiveCompletedAtMs = display.completedAtMs ?? completedAtMs
|
||
|
|
?? (effectiveStartedAtMs !== undefined && effectiveDurationMs !== undefined
|
||
|
|
? effectiveStartedAtMs + effectiveDurationMs
|
||
|
|
: undefined);
|
||
|
|
const itemStatus = display.status ?? statusValue(item.status)
|
||
|
|
?? (item.completed === true ? "completed" : item.completed === false ? "in_progress" : undefined);
|
||
|
|
const displayCommand = display.command ?? (displayIndex === 0 ? command : undefined);
|
||
|
|
add(display.outputText, itemId, {
|
||
|
|
id: itemId,
|
||
|
|
...(turnKey ? { turnId: turnKey } : {}),
|
||
|
|
...(projectionId ? { itemId: projectionId } : {}),
|
||
|
|
role: display.role,
|
||
|
|
kind: display.kind,
|
||
|
|
text: display.text,
|
||
|
|
...(display.label ? { label: display.label } : {}),
|
||
|
|
...(display.itemType ? { itemType: display.itemType } : typeof item.type === "string" ? { itemType: item.type } : typeof item.kind === "string" ? { itemType: item.kind } : {}),
|
||
|
|
...(itemStatus ? { status: itemStatus } : {}),
|
||
|
|
...(turnStatus ? { turnStatus } : {}),
|
||
|
|
...(effectiveStartedAtMs !== undefined ? { startedAtMs: effectiveStartedAtMs } : {}),
|
||
|
|
...(effectiveCompletedAtMs !== undefined ? { completedAtMs: effectiveCompletedAtMs } : {}),
|
||
|
|
...(effectiveDurationMs !== undefined ? { durationMs: effectiveDurationMs } : {}),
|
||
|
|
...(workedDurationMs !== undefined ? { workedDurationMs } : {}),
|
||
|
|
...(displayCommand ? { command: displayCommand } : {}),
|
||
|
|
...(display.commandActions?.length ? { commandActions: display.commandActions } : {}),
|
||
|
|
...(display.cwd !== undefined ? { cwd: display.cwd } : {}),
|
||
|
|
...(display.shellName !== undefined ? { shellName: display.shellName } : {}),
|
||
|
|
...(numberValue(item.exitCode) !== undefined && displayIndex === 0 ? { exitCode: numberValue(item.exitCode) as number } : {}),
|
||
|
|
...(phase && displayIndex === 0 ? { phase } : {}),
|
||
|
|
...(display.action ? { action: display.action } : {}),
|
||
|
|
...(display.senderThreadId ? { senderThreadId: display.senderThreadId } : {}),
|
||
|
|
...(display.receiverThreadIds ? { receiverThreadIds: display.receiverThreadIds, receiverThreads: display.receiverThreads ?? display.receiverThreadIds } : {}),
|
||
|
|
...(display.prompt !== undefined ? { prompt: display.prompt } : {}),
|
||
|
|
...(display.model !== undefined ? { model: display.model } : {}),
|
||
|
|
...(display.reasoningEffort !== undefined ? { reasoningEffort: display.reasoningEffort } : {}),
|
||
|
|
...(display.agentsStates ? { agentsStates: display.agentsStates } : {}),
|
||
|
|
...(display.agentThreadId ? { agentThreadId: display.agentThreadId } : {}),
|
||
|
|
...(display.agentPath ? { agentPath: display.agentPath } : {}),
|
||
|
|
...(display.displayName !== undefined ? { displayName: display.displayName } : {}),
|
||
|
|
...(display.displayStatus ? { displayStatus: display.displayStatus } : {}),
|
||
|
|
...(display.activityKind ? { activityKind: display.activityKind } : {}),
|
||
|
|
...(display.uiType ? { uiType: display.uiType } : {}),
|
||
|
|
...(display.readPaths?.length ? { readPaths: display.readPaths } : {}),
|
||
|
|
...(display.output ? { output: redactText(display.output) } : {}),
|
||
|
|
...(item.breaksPreviousAdjacency === true ? { breaksPreviousAdjacency: true } : {}),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
};
|
||
|
|
// Canonical history islands carry the stable chronological order. The
|
||
|
|
// lightweight `turns` list is usually just the active page, so append only
|
||
|
|
// entities that are not already represented there.
|
||
|
|
for (const turn of orderedHistoryTurns(state)) consumeTurn(turn);
|
||
|
|
if (Array.isArray(state.turns)) for (const turn of state.turns) if (isRecord(turn)) consumeTurn(turn);
|
||
|
|
const rendered = chunks.join("\n\n");
|
||
|
|
return {
|
||
|
|
text: rendered.length > maxChars ? rendered.slice(-maxChars) : rendered,
|
||
|
|
totalLength: rendered.length,
|
||
|
|
truncated: rendered.length > maxChars,
|
||
|
|
messages,
|
||
|
|
subagents: collectSubagents(state),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Return only newly appended text. Once the bounded output window starts
|
||
|
|
* sliding, compare the old suffix with the new prefix so a one-character
|
||
|
|
* stream update does not retransmit the entire 32 KB snapshot.
|
||
|
|
*/
|
||
|
|
function appendOnlyOutputDelta(
|
||
|
|
previous: string,
|
||
|
|
previousLength: number,
|
||
|
|
next: string,
|
||
|
|
nextLength: number,
|
||
|
|
previousWasTruncated: boolean,
|
||
|
|
): string | undefined {
|
||
|
|
// A replacement, deletion, or history prepend cannot be represented by an
|
||
|
|
// append-only chunk. Fall back to a bounded snapshot in those cases.
|
||
|
|
if (nextLength < previousLength) return undefined;
|
||
|
|
if (!previousWasTruncated) return next.startsWith(previous) ? next.slice(previous.length) : undefined;
|
||
|
|
if (!previous || !next) return undefined;
|
||
|
|
const dropped = Math.max(0, nextLength - next.length) - Math.max(0, previousLength - previous.length);
|
||
|
|
if (dropped < 0 || dropped > previous.length) return undefined;
|
||
|
|
const retained = previous.slice(dropped);
|
||
|
|
if (retained.length > next.length || next.slice(0, retained.length) !== retained) return undefined;
|
||
|
|
// If the append is larger than the retained tail, the bounded state no
|
||
|
|
// longer contains all newly appended text; a snapshot is the only lossless
|
||
|
|
// representation.
|
||
|
|
const deltaLength = nextLength - previousLength;
|
||
|
|
if (deltaLength !== next.length - retained.length) return undefined;
|
||
|
|
return next.slice(retained.length);
|
||
|
|
}
|
||
|
|
|
||
|
|
function orderedHistoryTurns(state: JsonObject): Record<string, unknown>[] {
|
||
|
|
const turnHistory = isRecord(state.turnHistory) ? state.turnHistory : undefined;
|
||
|
|
const history = turnHistory && isRecord(turnHistory.history) ? turnHistory.history : undefined;
|
||
|
|
const entities = history && isRecord(history.entitiesByKey) ? history.entitiesByKey : undefined;
|
||
|
|
if (!entities) return [];
|
||
|
|
const ordered: Record<string, unknown>[] = [];
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const add = (key: unknown): void => {
|
||
|
|
if (typeof key !== "string" || seen.has(key)) return;
|
||
|
|
const entity = entities[key];
|
||
|
|
if (!isRecord(entity) || !looksLikeTurn(entity)) return;
|
||
|
|
seen.add(key);
|
||
|
|
ordered.push(entity);
|
||
|
|
};
|
||
|
|
if (history && Array.isArray(history.islands)) {
|
||
|
|
for (const island of history.islands) if (isRecord(island) && Array.isArray(island.entries)) {
|
||
|
|
for (const entry of island.entries) {
|
||
|
|
if (isRecord(entry)) add(entry.key ?? entry.value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Include entities not listed by islands for forward compatibility with an
|
||
|
|
// extension that omits island metadata in a snapshot.
|
||
|
|
for (const [key, entity] of Object.entries(entities)) {
|
||
|
|
if (!seen.has(key) && isRecord(entity) && looksLikeTurn(entity)) {
|
||
|
|
seen.add(key);
|
||
|
|
ordered.push(entity);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ordered;
|
||
|
|
}
|
||
|
|
|
||
|
|
function looksLikeTurn(value: Record<string, unknown>): boolean {
|
||
|
|
return Array.isArray(value.items) || value.turnId !== undefined || value.status !== undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isTransientHistoryLoadError(error: unknown): boolean {
|
||
|
|
const code = isRecord(error) && typeof error.code === "string" ? error.code : "";
|
||
|
|
if (["timeout", "connection-closed", "not-connected"].includes(code) || code.startsWith("no-client-found")) return true;
|
||
|
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
||
|
|
return /timed? out|socket (?:is )?closed|not connected|no client found/i.test(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasIncompleteHistory(state: JsonObject): boolean {
|
||
|
|
const turnHistory = isRecord(state.turnHistory) ? state.turnHistory : undefined;
|
||
|
|
const history = turnHistory && isRecord(turnHistory.history) ? turnHistory.history : undefined;
|
||
|
|
if (turnHistory?.kind === "canonical" && !history) return true;
|
||
|
|
const entities = history && isRecord(history.entitiesByKey) ? Object.values(history.entitiesByKey) : [];
|
||
|
|
const turns = [
|
||
|
|
...entities,
|
||
|
|
...(Array.isArray(state.turns) ? state.turns : []),
|
||
|
|
];
|
||
|
|
// Both canonical entities and the legacy turns list expose this per-turn
|
||
|
|
// marker. The official completeness predicate only treats an explicit
|
||
|
|
// `false` as incomplete; missing metadata is compatible with older builds.
|
||
|
|
if (turns.some((turn) => isRecord(turn)
|
||
|
|
&& isRecord(turn.itemsPagination)
|
||
|
|
&& turn.itemsPagination.hasLoadedOldest === false)) return true;
|
||
|
|
|
||
|
|
// Canonical history is complete only after the owner has coalesced it into
|
||
|
|
// one island. Boundary status is deliberately not checked here: the
|
||
|
|
// official webview uses `isComplete` and island count, and some versions
|
||
|
|
// leave boundary objects in a non-exhausted transitional shape.
|
||
|
|
const canonical = Boolean(history && (
|
||
|
|
turnHistory?.kind === "canonical"
|
||
|
|
|| history.isComplete !== undefined
|
||
|
|
|| Array.isArray(history.islands)
|
||
|
|
));
|
||
|
|
if (canonical) {
|
||
|
|
return history?.isComplete !== true
|
||
|
|
|| !Array.isArray(history.islands)
|
||
|
|
|| history.islands.length !== 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Legacy snapshots carry a resume marker. Avoid requesting an unsupported
|
||
|
|
// history operation for old snapshots that expose no pagination metadata at
|
||
|
|
// all, while respecting explicit loading/unfinished states.
|
||
|
|
if (state.resumeState !== undefined && state.resumeState !== "resumed") return true;
|
||
|
|
const turnsPagination = isRecord(state.turnsPagination) ? state.turnsPagination : undefined;
|
||
|
|
return turnsPagination?.hasLoadedOldest === false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function itemDisplay(rawItem: Record<string, unknown>): ItemDisplayProjection | undefined {
|
||
|
|
const normalizedItem = normalizeOfficialItem(rawItem);
|
||
|
|
const type = String(normalizedItem.type ?? normalizedItem.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase();
|
||
|
|
|
||
|
|
if (type === "collabagenttoolcall") {
|
||
|
|
const tool = stringValue(normalizedItem.tool) ?? "collabAgent";
|
||
|
|
// `wait` is an internal synchronization action. The official webview
|
||
|
|
// consumes it for aggregation but intentionally omits it from the
|
||
|
|
// visible transcript.
|
||
|
|
if (tool === "wait") return undefined;
|
||
|
|
const status = statusValue(normalizedItem.status) ?? "inProgress";
|
||
|
|
const receiverThreadIds = stringArray(normalizedItem.receiverThreadIds);
|
||
|
|
const agentsStates = collabAgentStates(normalizedItem.agentsStates);
|
||
|
|
const prompt = redactNullableString(normalizedItem.prompt);
|
||
|
|
const model = redactNullableString(normalizedItem.model);
|
||
|
|
const reasoningEffort = redactNullableString(normalizedItem.reasoningEffort);
|
||
|
|
const senderThreadId = stringValue(normalizedItem.senderThreadId);
|
||
|
|
const label = collabAgentToolLabel(tool);
|
||
|
|
const promptText = prompt?.trim() ? `: ${redactText(prompt.trim())}` : "";
|
||
|
|
const outputText = `${label}${promptText}`;
|
||
|
|
return {
|
||
|
|
outputText,
|
||
|
|
projectionId: stringValue(normalizedItem.id),
|
||
|
|
role: "tool",
|
||
|
|
kind: "tool",
|
||
|
|
text: outputText,
|
||
|
|
label,
|
||
|
|
itemType: "collabAgentToolCall",
|
||
|
|
status,
|
||
|
|
action: tool,
|
||
|
|
...(senderThreadId ? { senderThreadId } : {}),
|
||
|
|
receiverThreadIds,
|
||
|
|
receiverThreads: receiverThreadIds,
|
||
|
|
prompt,
|
||
|
|
model,
|
||
|
|
reasoningEffort,
|
||
|
|
agentsStates: redactJson(agentsStates) as JsonObject,
|
||
|
|
uiType: "multi-agent-action",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (type === "subagentactivity") {
|
||
|
|
const activityKind = stringValue(normalizedItem.kind) ?? "started";
|
||
|
|
const agentThreadId = stringValue(normalizedItem.agentThreadId);
|
||
|
|
if (!agentThreadId) return undefined;
|
||
|
|
const agentPath = redactNullableString(normalizedItem.agentPath);
|
||
|
|
const displayName = formatAgentPath(agentPath ?? undefined);
|
||
|
|
const displayStatus = subagentActivityDisplayStatus(activityKind);
|
||
|
|
const status = statusValue(normalizedItem.status)
|
||
|
|
?? (activityKind === "interrupted" || activityKind === "completed" ? "completed" : "inProgress");
|
||
|
|
const label = displayName ? `子代理 · ${displayName}` : "子代理";
|
||
|
|
const activityText = subagentActivityText(displayName, activityKind);
|
||
|
|
return {
|
||
|
|
outputText: activityText,
|
||
|
|
projectionId: stringValue(normalizedItem.id),
|
||
|
|
role: "tool",
|
||
|
|
kind: "tool",
|
||
|
|
text: activityText,
|
||
|
|
label,
|
||
|
|
itemType: "subAgentActivity",
|
||
|
|
status,
|
||
|
|
agentThreadId,
|
||
|
|
...(agentPath ? { agentPath } : {}),
|
||
|
|
displayName,
|
||
|
|
displayStatus,
|
||
|
|
activityKind,
|
||
|
|
uiType: "subagent-activity",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const item = normalizedItem;
|
||
|
|
// Pending permission/input/elicitation items are rendered by the request
|
||
|
|
// card, not as a second transcript activity. Once the owner marks one
|
||
|
|
// complete it may re-enter history and be displayed normally.
|
||
|
|
const requestItem = type.includes("permissionrequest")
|
||
|
|
|| type.includes("userinput")
|
||
|
|
|| type.includes("mcpserverelicitation");
|
||
|
|
const itemStatus = normalizeStatus(statusValue(item.status));
|
||
|
|
const requestPending = requestItem
|
||
|
|
&& item.completed !== true
|
||
|
|
&& !TERMINAL_TURN_STATES.has(itemStatus);
|
||
|
|
if (requestPending) return undefined;
|
||
|
|
if (["agentmessage", "assistantmessage", "usermessage"].includes(type)) {
|
||
|
|
const text = textFromValue(item.text) ?? textFromValue(item.content);
|
||
|
|
if (!text) return undefined;
|
||
|
|
if (type.startsWith("user")) return { outputText: `> ${text}`, role: "user", kind: "user", text };
|
||
|
|
return { outputText: text, role: "assistant", kind: "assistant", text };
|
||
|
|
}
|
||
|
|
if (type.includes("contextcompaction")) {
|
||
|
|
const text = textFromValue(item.summary) ?? textFromValue(item.content) ?? textFromValue(item.text) ?? "整理上下文";
|
||
|
|
return { outputText: text, role: "reasoning", kind: "reasoning", text, label: "整理上下文" };
|
||
|
|
}
|
||
|
|
if (type.includes("reasoning") || type.includes("approvalreview")) {
|
||
|
|
const text = textFromValue(item.summary) ?? textFromValue(item.content);
|
||
|
|
return text ? { outputText: text, role: "reasoning", kind: "reasoning", text, label: "思考" } : undefined;
|
||
|
|
}
|
||
|
|
if (type.includes("plan") || type.includes("todo")) {
|
||
|
|
const value = item.plan ?? item.steps ?? item.todos ?? item.content ?? item.text;
|
||
|
|
const text = planDisplayText(value);
|
||
|
|
return text ? { outputText: text, role: "reasoning", kind: "plan", text, label: "计划" } : undefined;
|
||
|
|
}
|
||
|
|
const parsedActions = commandActionsValue(item);
|
||
|
|
const readPaths = readPathsFromActions(parsedActions);
|
||
|
|
const directReadItem = type === "read"
|
||
|
|
|| type.includes("fileread")
|
||
|
|
|| type.includes("readfile")
|
||
|
|
|| type.includes("exploration");
|
||
|
|
if (directReadItem || readPaths.length > 0) {
|
||
|
|
const command = commandText(item);
|
||
|
|
const output = textFromValue(item.aggregatedOutput)
|
||
|
|
?? textFromValue(item.output)
|
||
|
|
?? textFromValue(item.stdout)
|
||
|
|
?? textFromValue(item.content)
|
||
|
|
?? textFromValue(item.text);
|
||
|
|
const pathSummary = readPaths.length ? readPaths.join(", ") : readPathFromItem(item);
|
||
|
|
const summary = pathSummary ? `已读取 ${pathSummary}` : "已读取文件";
|
||
|
|
const text = summary;
|
||
|
|
const commandActions = projectCommandActions(parsedActions);
|
||
|
|
const cwd = redactNullableString(item.cwd);
|
||
|
|
const shellName = redactNullableString(item.shellName ?? item.shell);
|
||
|
|
return {
|
||
|
|
outputText: output && output.trim() ? `${summary}\n${output}` : text,
|
||
|
|
role: "tool",
|
||
|
|
kind: "tool",
|
||
|
|
text,
|
||
|
|
label: "已读取文件",
|
||
|
|
itemType: typeof item.type === "string" ? item.type : "fileRead",
|
||
|
|
...(command ? { command } : {}),
|
||
|
|
...(commandActions.length ? { commandActions } : {}),
|
||
|
|
...(cwd !== undefined ? { cwd } : {}),
|
||
|
|
...(shellName !== undefined ? { shellName } : {}),
|
||
|
|
...(readPaths.length ? { readPaths } : {}),
|
||
|
|
...(output && output.trim() ? { output } : {}),
|
||
|
|
activityKind: "read",
|
||
|
|
uiType: "file-read",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (type.includes("filechange") || type.includes("file_change") || type.includes("patch") || type.includes("edit")) {
|
||
|
|
const text = textFromValue(item.diff)
|
||
|
|
?? textFromValue(item.patch)
|
||
|
|
?? fileChangesText(item.changes)
|
||
|
|
?? textFromValue(item.output)
|
||
|
|
?? textFromValue(item.text);
|
||
|
|
return text ? { outputText: text, role: "tool", kind: "edit", text, label: "文件变更" } : undefined;
|
||
|
|
}
|
||
|
|
const hasCommandProjection = isCommandActionValue(item.commandActions)
|
||
|
|
|| isCommandActionValue(item.command_actions)
|
||
|
|
|| isCommandActionValue(item.parsedCmd)
|
||
|
|
|| isCommandActionValue(item.parsed_cmd)
|
||
|
|
|| item.command !== undefined
|
||
|
|
|| item.commandLine !== undefined;
|
||
|
|
if (type.includes("command") || type.includes("exec") || type.includes("process") || hasCommandProjection) {
|
||
|
|
const command = commandText(item);
|
||
|
|
// Some official snapshots expose commandActions before output is flushed
|
||
|
|
// (and may leave aggregatedOutput as an empty string). Keep the command
|
||
|
|
// visible in that state, while avoiding a bare shell bootstrap such as
|
||
|
|
// `/bin/zsh` becoming the displayed command.
|
||
|
|
const output = textFromValue(item.aggregatedOutput)
|
||
|
|
?? textFromValue(item.output)
|
||
|
|
?? textFromValue(item.stdout)
|
||
|
|
?? textFromValue(item.stderr);
|
||
|
|
const text = output || command;
|
||
|
|
const commandActions = projectCommandActions(parsedActions);
|
||
|
|
const cwd = redactNullableString(item.cwd);
|
||
|
|
const shellName = redactNullableString(item.shellName ?? item.shell);
|
||
|
|
return text ? {
|
||
|
|
outputText: text,
|
||
|
|
role: "tool",
|
||
|
|
kind: "tool",
|
||
|
|
text,
|
||
|
|
label: "命令输出",
|
||
|
|
...(commandActions.length ? { commandActions } : {}),
|
||
|
|
...(cwd !== undefined ? { cwd } : {}),
|
||
|
|
...(shellName !== undefined ? { shellName } : {}),
|
||
|
|
} : undefined;
|
||
|
|
}
|
||
|
|
if (type.includes("websearch") || type.includes("mcp") || type.includes("dynamictool")
|
||
|
|
|| type.includes("imageview") || type.includes("imagegeneration") || type.includes("generatedimage")
|
||
|
|
|| type.includes("toolcall") || type.includes("permissionrequest") || type.includes("userinput")) {
|
||
|
|
const text = textFromValue(item.output)
|
||
|
|
?? textFromValue(item.result)
|
||
|
|
?? textFromValue(item.content)
|
||
|
|
?? textFromValue(item.summary)
|
||
|
|
?? textFromValue(item.text)
|
||
|
|
?? textFromValue(item.query)
|
||
|
|
?? textFromValue(item.name);
|
||
|
|
if (!text) return undefined;
|
||
|
|
const label = type.includes("websearch") ? "搜索"
|
||
|
|
: type.includes("image") ? "查看图像"
|
||
|
|
: type.includes("permissionrequest") ? "等待授权"
|
||
|
|
: type.includes("userinput") ? "等待输入"
|
||
|
|
: type.includes("mcp") ? "MCP 工具"
|
||
|
|
: "工具";
|
||
|
|
return { outputText: text, role: "tool", kind: "tool", text, label };
|
||
|
|
}
|
||
|
|
const text = textFromValue(item.text) ?? textFromValue(item.output);
|
||
|
|
return text ? { outputText: text, role: "assistant", kind: "assistant", text } : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Official conversation messages can carry collaboration records in a
|
||
|
|
* metadata envelope instead of exposing them as a top-level `type`. Normalize
|
||
|
|
* direct item names here; metadata variants are added by
|
||
|
|
* `itemDisplayVariants` so the parent message is retained as well.
|
||
|
|
*/
|
||
|
|
function normalizeOfficialItem(item: Record<string, unknown>): Record<string, unknown> {
|
||
|
|
const directType = String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase();
|
||
|
|
if (directType === "collabagenttoolcall") {
|
||
|
|
return { ...item, type: "collabAgentToolCall" };
|
||
|
|
}
|
||
|
|
if (directType === "subagentactivity") {
|
||
|
|
return { ...item, type: "subAgentActivity" };
|
||
|
|
}
|
||
|
|
return item;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Return the normal item plus any collaboration records attached as metadata. */
|
||
|
|
function itemDisplayVariants(item: Record<string, unknown>): ItemDisplayProjection[] {
|
||
|
|
const displays: ItemDisplayProjection[] = [];
|
||
|
|
const base = itemDisplay(item);
|
||
|
|
if (base) displays.push(base);
|
||
|
|
const metadata = parseRecord(item.metadata);
|
||
|
|
const candidates: Array<{ key: string; type: "collabAgentToolCall" | "subAgentActivity" }> = [
|
||
|
|
{ key: "codex_collab_agent_tool_call", type: "collabAgentToolCall" },
|
||
|
|
{ key: "codex_sub_agent_activity", type: "subAgentActivity" },
|
||
|
|
];
|
||
|
|
for (const candidate of candidates) {
|
||
|
|
const value = parseRecord(metadata?.[candidate.key]);
|
||
|
|
if (!value) continue;
|
||
|
|
const normalized: Record<string, unknown> = { ...value, type: candidate.type };
|
||
|
|
// A direct item may carry a copy of its own metadata record. Do not render
|
||
|
|
// that record twice when the ids identify the same official item.
|
||
|
|
const normalizedId = stringValue(normalized.id);
|
||
|
|
if (normalizedId && displays.some((display) => display.projectionId === normalizedId)) continue;
|
||
|
|
const display = itemDisplay(normalized);
|
||
|
|
if (display) displays.push(display);
|
||
|
|
}
|
||
|
|
return displays;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseRecord(value: unknown): Record<string, unknown> | undefined {
|
||
|
|
if (isRecord(value)) return value;
|
||
|
|
if (typeof value !== "string") return undefined;
|
||
|
|
try {
|
||
|
|
const parsed: unknown = JSON.parse(value);
|
||
|
|
return isRecord(parsed) ? parsed : undefined;
|
||
|
|
} catch {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function stringArray(value: unknown): string[] {
|
||
|
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function nullableString(value: unknown): string | null | undefined {
|
||
|
|
return typeof value === "string" ? value : value === null ? null : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function redactNullableString(value: unknown): string | null | undefined {
|
||
|
|
const normalized = nullableString(value);
|
||
|
|
return normalized === undefined || normalized === null ? normalized : redactText(normalized);
|
||
|
|
}
|
||
|
|
|
||
|
|
function collabAgentStates(value: unknown): JsonObject {
|
||
|
|
const record = parseRecord(value);
|
||
|
|
if (!record) return {};
|
||
|
|
const states: JsonObject = {};
|
||
|
|
for (const [threadId, rawState] of Object.entries(record)) {
|
||
|
|
const state = parseRecord(rawState);
|
||
|
|
if (!state || typeof state.status !== "string") continue;
|
||
|
|
states[threadId] = redactJson({
|
||
|
|
status: state.status,
|
||
|
|
...(state.message === null || typeof state.message === "string" ? { message: state.message } : {}),
|
||
|
|
}) as JsonValue;
|
||
|
|
}
|
||
|
|
return states;
|
||
|
|
}
|
||
|
|
|
||
|
|
function collabAgentToolLabel(tool: string): string {
|
||
|
|
switch (tool) {
|
||
|
|
case "spawnAgent": return "启动子代理";
|
||
|
|
case "sendInput": return "向子代理发送输入";
|
||
|
|
case "resumeAgent": return "恢复子代理";
|
||
|
|
case "wait": return "等待子代理";
|
||
|
|
case "closeAgent": return "关闭子代理";
|
||
|
|
default: return "子代理操作";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatAgentPath(agentPath?: string): string | null {
|
||
|
|
if (!agentPath) return null;
|
||
|
|
const leaf = agentPath.split("/").map((part) => part.trim()).filter((part) => part && part !== "root").at(-1);
|
||
|
|
if (!leaf) return null;
|
||
|
|
const normalized = leaf.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
||
|
|
return normalized ? normalized[0].toUpperCase() + normalized.slice(1) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function subagentActivityDisplayStatus(kind: string): string {
|
||
|
|
switch (kind) {
|
||
|
|
case "started": return "active";
|
||
|
|
case "interacted": return "updated";
|
||
|
|
case "interrupted": return "interrupted";
|
||
|
|
case "completed": return "completed";
|
||
|
|
default: return "active";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function subagentActivityText(displayName: string | null, kind: string): string {
|
||
|
|
const subject = displayName ? `子代理 ${displayName}` : "子代理";
|
||
|
|
switch (kind) {
|
||
|
|
case "started": return `${subject} 已开始工作`;
|
||
|
|
case "interacted": return `${subject} 正在工作`;
|
||
|
|
case "interrupted": return `${subject} 已中断`;
|
||
|
|
case "completed": return `${subject} 已完成`;
|
||
|
|
default: return `${subject} ${kind}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SubagentAccumulator extends SubagentSnapshot {
|
||
|
|
lastEventIndex: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Rebuild the official subagent panel model from direct items and metadata. */
|
||
|
|
function collectSubagents(state: JsonObject): SubagentSnapshot[] {
|
||
|
|
const agents = new Map<string, SubagentAccumulator>();
|
||
|
|
const parentThreadId = stringValue(state.id)
|
||
|
|
?? stringValue(state.threadId)
|
||
|
|
?? (isRecord(state.thread) ? stringValue(state.thread.id) : undefined)
|
||
|
|
?? null;
|
||
|
|
let eventIndex = 0;
|
||
|
|
const ensure = (threadId: string): SubagentAccumulator => {
|
||
|
|
const current = agents.get(threadId);
|
||
|
|
if (current) {
|
||
|
|
current.lastEventIndex = eventIndex;
|
||
|
|
return current;
|
||
|
|
}
|
||
|
|
const created: SubagentAccumulator = {
|
||
|
|
threadId,
|
||
|
|
displayName: null,
|
||
|
|
prompt: null,
|
||
|
|
objective: null,
|
||
|
|
status: "working",
|
||
|
|
statusMessage: null,
|
||
|
|
canInteract: false,
|
||
|
|
parentThreadId,
|
||
|
|
lastEventIndex: eventIndex,
|
||
|
|
};
|
||
|
|
agents.set(threadId, created);
|
||
|
|
return created;
|
||
|
|
};
|
||
|
|
const consumeTurn = (turn: Record<string, unknown>): void => {
|
||
|
|
const turnStartedAtMs = timestampMs(turn.turnStartedAtMs)
|
||
|
|
?? timestampMs(turn.startedAtMs)
|
||
|
|
?? timestampMs(turn.startedAt)
|
||
|
|
?? timestampMs(turn.createdAtMs)
|
||
|
|
?? timestampMs(turn.createdAt);
|
||
|
|
const turnCompletedAtMs = timestampMs(turn.completedAtMs)
|
||
|
|
?? timestampMs(turn.completedAt)
|
||
|
|
?? (turnStartedAtMs !== undefined && numberValue(turn.durationMs) !== undefined
|
||
|
|
? turnStartedAtMs + (numberValue(turn.durationMs) as number)
|
||
|
|
: undefined);
|
||
|
|
for (const rawItem of Array.isArray(turn.items) ? turn.items : []) {
|
||
|
|
if (!isRecord(rawItem)) continue;
|
||
|
|
const itemStartedAtMs = timestampMs(rawItem.startedAtMs)
|
||
|
|
?? timestampMs(rawItem.startedAt)
|
||
|
|
?? turnStartedAtMs;
|
||
|
|
const itemCompletedAtMs = timestampMs(rawItem.completedAtMs)
|
||
|
|
?? timestampMs(rawItem.finishedAtMs)
|
||
|
|
?? timestampMs(rawItem.completedAt)
|
||
|
|
?? (itemStartedAtMs !== undefined && numberValue(rawItem.durationMs) !== undefined
|
||
|
|
? itemStartedAtMs + (numberValue(rawItem.durationMs) as number)
|
||
|
|
: turnCompletedAtMs);
|
||
|
|
for (const item of officialSubagentItems(rawItem)) {
|
||
|
|
eventIndex += 1;
|
||
|
|
const type = String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase();
|
||
|
|
if (type === "subagentactivity") {
|
||
|
|
const threadId = stringValue(item.agentThreadId);
|
||
|
|
if (!threadId) continue;
|
||
|
|
const agent = ensure(threadId);
|
||
|
|
const agentPath = stringValue(item.agentPath);
|
||
|
|
const displayName = formatAgentPath(agentPath);
|
||
|
|
const activityKind = stringValue(item.kind) ?? "started";
|
||
|
|
if (displayName) agent.displayName = redactText(displayName);
|
||
|
|
if (agentPath) agent.agentPath = redactText(agentPath);
|
||
|
|
if (agent.startedAtMs == null && itemStartedAtMs !== undefined) agent.startedAtMs = itemStartedAtMs;
|
||
|
|
agent.statusMessage = null;
|
||
|
|
if (activityKind === "interrupted" || activityKind === "completed") {
|
||
|
|
agent.status = "done";
|
||
|
|
if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs;
|
||
|
|
} else {
|
||
|
|
agent.status = "working";
|
||
|
|
agent.completedAtMs = null;
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (type !== "collabagenttoolcall") continue;
|
||
|
|
const tool = stringValue(item.tool) ?? "";
|
||
|
|
const toolStatus = statusValue(item.status) ?? "inProgress";
|
||
|
|
const receivers = stringArray(item.receiverThreadIds ?? item.receiverThreads);
|
||
|
|
const states = parseRecord(item.agentsStates) ?? {};
|
||
|
|
const prompt = nullableString(item.prompt);
|
||
|
|
const model = nullableString(item.model);
|
||
|
|
|
||
|
|
for (const threadId of new Set([...receivers, ...Object.keys(states)])) {
|
||
|
|
if (!threadId) continue;
|
||
|
|
const agent = ensure(threadId);
|
||
|
|
if (agent.startedAtMs == null && itemStartedAtMs !== undefined) agent.startedAtMs = itemStartedAtMs;
|
||
|
|
if (tool === "spawnAgent") {
|
||
|
|
if (prompt?.trim()) {
|
||
|
|
agent.prompt = redactText(prompt.trim());
|
||
|
|
agent.objective = agent.prompt;
|
||
|
|
}
|
||
|
|
if (model !== undefined) agent.model = model === null ? null : redactText(model);
|
||
|
|
agent.canInteract = true;
|
||
|
|
} else if (tool === "sendInput" || tool === "resumeAgent") {
|
||
|
|
agent.canInteract = true;
|
||
|
|
}
|
||
|
|
if (toolStatus === "failed") {
|
||
|
|
agent.status = "failed";
|
||
|
|
if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs;
|
||
|
|
} else if (tool === "spawnAgent" || tool === "sendInput" || tool === "resumeAgent") {
|
||
|
|
agent.status = "working";
|
||
|
|
agent.statusMessage = null;
|
||
|
|
agent.completedAtMs = null;
|
||
|
|
} else if (tool === "closeAgent" && toolStatus === "completed") {
|
||
|
|
agent.status = "done";
|
||
|
|
if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const [threadId, rawState] of Object.entries(states)) {
|
||
|
|
const agentState = parseRecord(rawState);
|
||
|
|
if (!agentState || typeof agentState.status !== "string") continue;
|
||
|
|
const agent = ensure(threadId);
|
||
|
|
agent.status = coarseSubagentStatus(agentState.status);
|
||
|
|
if (agent.status === "waiting" || agent.status === "working") {
|
||
|
|
agent.statusMessage = null;
|
||
|
|
agent.completedAtMs = null;
|
||
|
|
} else {
|
||
|
|
const statusMessage = nullableString(agentState.message);
|
||
|
|
agent.statusMessage = statusMessage?.trim() ? redactText(statusMessage.trim()) : null;
|
||
|
|
if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// A completed wait means the parent observed all currently active
|
||
|
|
// receivers finishing, even if older state records were not patched.
|
||
|
|
if (tool === "wait" && toolStatus === "completed") {
|
||
|
|
for (const agent of agents.values()) {
|
||
|
|
if (agent.status !== "waiting" && agent.status !== "working") continue;
|
||
|
|
agent.status = "done";
|
||
|
|
agent.statusMessage = null;
|
||
|
|
agent.lastEventIndex = eventIndex;
|
||
|
|
if (itemCompletedAtMs !== undefined) agent.completedAtMs = itemCompletedAtMs;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
for (const turn of orderedHistoryTurns(state)) consumeTurn(turn);
|
||
|
|
if (Array.isArray(state.turns)) for (const turn of state.turns) if (isRecord(turn)) consumeTurn(turn);
|
||
|
|
|
||
|
|
// The official UI closes stale active rows when the parent turn is no longer
|
||
|
|
// live. This prevents an old running state from lingering after reconnect.
|
||
|
|
if (!deriveTurn(state).active) {
|
||
|
|
for (const agent of agents.values()) {
|
||
|
|
if (agent.status !== "waiting" && agent.status !== "working") continue;
|
||
|
|
agent.status = "done";
|
||
|
|
agent.statusMessage = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return Array.from(agents.values(), ({ lastEventIndex: _lastEventIndex, ...agent }) => agent);
|
||
|
|
}
|
||
|
|
|
||
|
|
function officialSubagentItems(item: Record<string, unknown>): Record<string, unknown>[] {
|
||
|
|
const result: Record<string, unknown>[] = [];
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const add = (value: Record<string, unknown>, type: "collabAgentToolCall" | "subAgentActivity"): void => {
|
||
|
|
const normalized: Record<string, unknown> = { ...value, type };
|
||
|
|
const id = stringValue(normalized.id);
|
||
|
|
const key = `${type}:${id ?? JSON.stringify(normalized)}`;
|
||
|
|
if (seen.has(key)) return;
|
||
|
|
seen.add(key);
|
||
|
|
result.push(normalized);
|
||
|
|
};
|
||
|
|
const directType = String(item.type ?? item.kind ?? "").replace(/[\s/_-]+/g, "").toLowerCase();
|
||
|
|
if (directType === "collabagenttoolcall") add(item, "collabAgentToolCall");
|
||
|
|
if (directType === "subagentactivity") add(item, "subAgentActivity");
|
||
|
|
const metadata = parseRecord(item.metadata);
|
||
|
|
const collab = parseRecord(metadata?.codex_collab_agent_tool_call);
|
||
|
|
if (collab) add(collab, "collabAgentToolCall");
|
||
|
|
const activity = parseRecord(metadata?.codex_sub_agent_activity);
|
||
|
|
if (activity) add(activity, "subAgentActivity");
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function coarseSubagentStatus(status: string): "waiting" | "working" | "done" | "failed" {
|
||
|
|
switch (normalizeStatus(status)) {
|
||
|
|
case "pendinginit": return "waiting";
|
||
|
|
case "running": return "working";
|
||
|
|
case "completed":
|
||
|
|
case "interrupted":
|
||
|
|
case "shutdown": return "done";
|
||
|
|
case "errored":
|
||
|
|
case "notfound": return "failed";
|
||
|
|
default: return "working";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function planDisplayText(value: unknown): string | undefined {
|
||
|
|
if (typeof value === "string") return value;
|
||
|
|
if (!Array.isArray(value)) {
|
||
|
|
if (isRecord(value)) return planDisplayText(value.plan ?? value.steps ?? value.todos ?? value.text ?? value.content);
|
||
|
|
return textFromValue(value);
|
||
|
|
}
|
||
|
|
const lines = value.map((entry) => {
|
||
|
|
if (!isRecord(entry)) return textFromValue(entry);
|
||
|
|
const status = statusValue(entry.status) ?? "pending";
|
||
|
|
const marker = ["completed", "complete", "done", "success", "succeeded"].includes(status.toLowerCase()) ? "[x]" : "[ ]";
|
||
|
|
const label = stringValue(entry.step) ?? stringValue(entry.text) ?? stringValue(entry.title) ?? stringValue(entry.description);
|
||
|
|
return label ? `${marker} ${label}` : undefined;
|
||
|
|
}).filter((line): line is string => Boolean(line));
|
||
|
|
return lines.length ? lines.join("\n") : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function itemDisplayText(item: Record<string, unknown>): string | undefined {
|
||
|
|
return itemDisplay(item)?.outputText;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Keep the command action shape used by the official webview while making it
|
||
|
|
* safe to send over the relay. Older snapshots use `command`, whereas the
|
||
|
|
* webview's normalized action uses `cmd`; expose both aliases so either
|
||
|
|
* renderer can consume the projection without losing the original fields.
|
||
|
|
*/
|
||
|
|
function projectCommandActions(value: unknown): JsonValue[] {
|
||
|
|
const actions = commandActionList(value);
|
||
|
|
if (!actions.length) return [];
|
||
|
|
const projected: JsonValue[] = [];
|
||
|
|
for (const rawAction of actions) {
|
||
|
|
const action = redactJson(rawAction);
|
||
|
|
if (isRecord(action)) {
|
||
|
|
const command = stringValue(action.command) ?? stringValue(action.cmd);
|
||
|
|
if (command && action.command === undefined) action.command = command;
|
||
|
|
if (command && action.cmd === undefined) action.cmd = command;
|
||
|
|
if (Object.keys(action).length > 0) projected.push(action as JsonObject);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (typeof action === "string" && action.trim()) projected.push(action);
|
||
|
|
}
|
||
|
|
return projected;
|
||
|
|
}
|
||
|
|
|
||
|
|
function commandActionText(value: unknown): string | undefined {
|
||
|
|
if (typeof value === "string") return commandValue(value);
|
||
|
|
if (!isRecord(value)) return undefined;
|
||
|
|
const command = stringValue(value.command) ?? stringValue(value.cmd);
|
||
|
|
return command ? commandValue(command) : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
const SHELL_BOOTSTRAP = /^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)(?:\s|$)/i;
|
||
|
|
|
||
|
|
function isShellBootstrapCommand(value: string): boolean {
|
||
|
|
return SHELL_BOOTSTRAP.test(value.trim());
|
||
|
|
}
|
||
|
|
|
||
|
|
function commandValue(value: unknown): string | undefined {
|
||
|
|
if (typeof value === "string") {
|
||
|
|
const command = value.trim();
|
||
|
|
if (!command) return undefined;
|
||
|
|
// A few IPC versions put the shell wrapper and the user command in one
|
||
|
|
// string instead of an argv array. Strip only the wrapper here; the
|
||
|
|
// frontend remains responsible for presentation-level quote cleanup.
|
||
|
|
const wrapped = command.match(/^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)\s+-(?:l?c|c?l)\s+([\s\S]+)$/i);
|
||
|
|
return (wrapped?.[1] ?? command).trim() || undefined;
|
||
|
|
}
|
||
|
|
if (!Array.isArray(value)) return undefined;
|
||
|
|
const parts = value.filter((part): part is string => typeof part === "string").map((part) => part.trim()).filter(Boolean);
|
||
|
|
if (!parts.length) return undefined;
|
||
|
|
// The IPC snapshot may preserve the process argv (`zsh -lc <command>`)
|
||
|
|
// rather than the command string shown by the official disclosure.
|
||
|
|
if (parts.length >= 3 && SHELL_BOOTSTRAP.test(parts[0]) && /^-(?:l?c|c?l)$/i.test(parts[1])) {
|
||
|
|
return parts.slice(2).join(" ").trim() || undefined;
|
||
|
|
}
|
||
|
|
return parts.join(" ").trim() || undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function commandText(item: Record<string, unknown>): string | undefined {
|
||
|
|
// The official renderer walks actions backwards and displays the last
|
||
|
|
// non-bootstrap command. This matters when the first action is just the
|
||
|
|
// shell wrapper used to launch the process.
|
||
|
|
const actions = commandActionsValue(item);
|
||
|
|
const actionList = commandActionList(actions);
|
||
|
|
for (let index = actionList.length - 1; index >= 0; index -= 1) {
|
||
|
|
const candidate = commandActionText(actionList[index]);
|
||
|
|
if (candidate && !isShellBootstrapCommand(candidate)) return candidate;
|
||
|
|
}
|
||
|
|
const command = commandValue(item.command) ?? stringValue(item.commandLine)?.trim();
|
||
|
|
if (!command || isShellBootstrapCommand(command)) return undefined;
|
||
|
|
return command;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Read the parsed command-action field across live and persisted schemas. */
|
||
|
|
function commandActionsValue(item: Record<string, unknown>): unknown {
|
||
|
|
return item.commandActions
|
||
|
|
?? item.command_actions
|
||
|
|
?? item.parsedCmd
|
||
|
|
?? item.parsed_cmd;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Normalize live/persisted command actions, which may be a single object. */
|
||
|
|
function commandActionList(value: unknown): unknown[] {
|
||
|
|
if (Array.isArray(value)) return value;
|
||
|
|
return isRecord(value) ? [value] : [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function isCommandActionValue(value: unknown): boolean {
|
||
|
|
return Array.isArray(value) || isRecord(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readPathsFromActions(value: unknown): string[] {
|
||
|
|
const actions = commandActionList(value);
|
||
|
|
if (!actions.length) return [];
|
||
|
|
const paths: string[] = [];
|
||
|
|
const seen = new Set<string>();
|
||
|
|
for (const raw of actions) {
|
||
|
|
if (!isRecord(raw)) continue;
|
||
|
|
const type = normalizeStatus(raw.type);
|
||
|
|
if (type !== "read") continue;
|
||
|
|
const path = stringValue(raw.path) ?? stringValue(raw.filePath) ?? stringValue(raw.file_path) ?? stringValue(raw.name);
|
||
|
|
if (!path) continue;
|
||
|
|
const safe = redactText(path.trim());
|
||
|
|
if (!safe || seen.has(safe)) continue;
|
||
|
|
seen.add(safe);
|
||
|
|
paths.push(safe);
|
||
|
|
}
|
||
|
|
return paths.slice(0, 128);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readPathFromItem(item: Record<string, unknown>): string | undefined {
|
||
|
|
const value = item.path ?? item.filePath ?? item.file_path ?? item.file ?? item.name;
|
||
|
|
return typeof value === "string" && value.trim() ? redactText(value.trim()) : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function fileChangesText(value: unknown): string | undefined {
|
||
|
|
if (!Array.isArray(value)) return textFromValue(value);
|
||
|
|
const changes = value.map((change) => {
|
||
|
|
if (!isRecord(change)) return textFromValue(change);
|
||
|
|
const file = stringValue(change.path) ?? stringValue(change.filePath) ?? stringValue(change.file) ?? stringValue(change.name);
|
||
|
|
const kind = statusValue(change.kind ?? change.type ?? change.status);
|
||
|
|
const diff = textFromValue(change.diff) ?? textFromValue(change.patch) ?? textFromValue(change.output) ?? textFromValue(change.text);
|
||
|
|
const heading = [kind ? `[${kind}]` : undefined, file].filter(Boolean).join(" ");
|
||
|
|
return [heading, diff].filter(Boolean).join("\n");
|
||
|
|
}).filter((part): part is string => Boolean(part));
|
||
|
|
return changes.length ? changes.join("\n\n") : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusValue(value: unknown): string | undefined {
|
||
|
|
if (typeof value === "string") return value;
|
||
|
|
return isRecord(value) && typeof value.type === "string" ? value.type : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function textFromValue(value: unknown): string | undefined {
|
||
|
|
if (typeof value === "string") return value;
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
const parts = value.map(textFromValue).filter((part): part is string => Boolean(part));
|
||
|
|
return parts.length ? parts.join("\n") : undefined;
|
||
|
|
}
|
||
|
|
if (isRecord(value)) {
|
||
|
|
for (const key of ["text", "value", "output", "stdout", "stderr", "delta", "summary"]) {
|
||
|
|
const text = textFromValue(value[key]);
|
||
|
|
if (text) return text;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MODEL_CATALOG_ROOT_KEYS = ["availableModels", "models", "modelCatalog", "listModels"] as const;
|
||
|
|
const MODEL_CATALOG_CONTAINER_KEYS = new Set<string>([
|
||
|
|
"data",
|
||
|
|
"items",
|
||
|
|
"models",
|
||
|
|
"availableModels",
|
||
|
|
"modelCatalog",
|
||
|
|
"listModels",
|
||
|
|
]);
|
||
|
|
const MODEL_CATALOG_META_KEYS = new Set<string>([
|
||
|
|
"cursor",
|
||
|
|
"nextCursor",
|
||
|
|
"next_cursor",
|
||
|
|
"hasMore",
|
||
|
|
"has_more",
|
||
|
|
"total",
|
||
|
|
"name",
|
||
|
|
"label",
|
||
|
|
"description",
|
||
|
|
"provider",
|
||
|
|
"status",
|
||
|
|
"type",
|
||
|
|
"message",
|
||
|
|
"error",
|
||
|
|
]);
|
||
|
|
const MODEL_CATALOG_MAX_ENTRIES = 256;
|
||
|
|
const MODEL_CATALOG_MAX_TEXT = 512;
|
||
|
|
const MODEL_CATALOG_MAX_MODEL = 256;
|
||
|
|
const MODEL_CATALOG_MAX_EFFORTS = 32;
|
||
|
|
const MODEL_CATALOG_MAX_SCANNED = 4_096;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Keep the model directory useful to the browser without forwarding opaque
|
||
|
|
* provider records (which may contain credentials, URLs, or internal flags).
|
||
|
|
* The official `model/list` response is normally `{ data: Model[] }`, but
|
||
|
|
* older extension builds have exposed the same data under several state keys.
|
||
|
|
*/
|
||
|
|
function projectAvailableModels(state: JsonObject): JsonValue[] {
|
||
|
|
const sources: unknown[] = [];
|
||
|
|
const addSources = (value: unknown): void => {
|
||
|
|
if (!isRecord(value)) return;
|
||
|
|
for (const key of MODEL_CATALOG_ROOT_KEYS) {
|
||
|
|
if (value[key] !== undefined) sources.push(value[key]);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
addSources(state);
|
||
|
|
for (const key of ["thread", "metadata", "conversation", "session", "latestThreadSettings", "threadSettings", "settings"]) {
|
||
|
|
addSources(state[key]);
|
||
|
|
}
|
||
|
|
|
||
|
|
const projected: JsonObject[] = [];
|
||
|
|
const byModel = new Map<string, JsonObject>();
|
||
|
|
const visited = new Set<object>();
|
||
|
|
let scanned = 0;
|
||
|
|
|
||
|
|
const add = (value: unknown, fallbackModel?: string): void => {
|
||
|
|
const item = projectAvailableModel(value, fallbackModel);
|
||
|
|
if (!item) return;
|
||
|
|
const model = stringValue(item.model);
|
||
|
|
if (!model) return;
|
||
|
|
const key = model.toLowerCase();
|
||
|
|
const existing = byModel.get(key);
|
||
|
|
if (!existing) {
|
||
|
|
byModel.set(key, item);
|
||
|
|
projected.push(item);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// A state patch can first expose a bare model id and later provide the
|
||
|
|
// catalog details. Fill only absent fields so explicit false/null values
|
||
|
|
// from the first projection are not accidentally overwritten.
|
||
|
|
for (const [field, fieldValue] of Object.entries(item)) {
|
||
|
|
if (existing[field] === undefined || (Array.isArray(existing[field]) && (existing[field] as unknown[]).length === 0)) {
|
||
|
|
existing[field] = fieldValue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const collect = (value: unknown, fallbackModel?: string, depth = 0): void => {
|
||
|
|
if (projected.length >= MODEL_CATALOG_MAX_ENTRIES || scanned >= MODEL_CATALOG_MAX_SCANNED || depth > 6 || value === undefined || value === null) return;
|
||
|
|
scanned += 1;
|
||
|
|
if (typeof value === "string") {
|
||
|
|
// Strings at the root are model ids. A string under a map key is a
|
||
|
|
// display label, so retain the key as the canonical id in that case.
|
||
|
|
if (fallbackModel && isPlausibleModelMapKey(fallbackModel)) add({ model: fallbackModel, displayName: value });
|
||
|
|
else add(value);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (Array.isArray(value)) {
|
||
|
|
for (const entry of value) collect(entry, undefined, depth + 1);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!isRecord(value)) return;
|
||
|
|
if (visited.has(value)) return;
|
||
|
|
visited.add(value);
|
||
|
|
|
||
|
|
let hasContainer = false;
|
||
|
|
for (const key of MODEL_CATALOG_CONTAINER_KEYS) {
|
||
|
|
if (value[key] === undefined) continue;
|
||
|
|
hasContainer = true;
|
||
|
|
collect(value[key], undefined, depth + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const strongIdentity = modelCatalogText(value.model, MODEL_CATALOG_MAX_MODEL)
|
||
|
|
?? modelCatalogText(value.id, MODEL_CATALOG_MAX_MODEL)
|
||
|
|
?? modelCatalogText(value.slug, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
const directModel = modelCatalogIdentity(value);
|
||
|
|
const isModelEntry = Boolean(
|
||
|
|
(directModel && (!hasContainer || strongIdentity))
|
||
|
|
|| (fallbackModel && isPlausibleModelMapKey(fallbackModel)),
|
||
|
|
);
|
||
|
|
if (isModelEntry) add(value, fallbackModel);
|
||
|
|
// Once a record has an identity, its scalar fields are model properties,
|
||
|
|
// not additional map entries (for example `displayName: "Sol"`).
|
||
|
|
if (isModelEntry && !hasContainer) return;
|
||
|
|
|
||
|
|
// A map-shaped catalog (`{ "gpt-5": { displayName: ... } }`) is used by
|
||
|
|
// a few pre-model/list extension builds. Ignore pagination metadata and
|
||
|
|
// known envelopes while walking those entries.
|
||
|
|
for (const [key, child] of Object.entries(value)) {
|
||
|
|
if (MODEL_CATALOG_CONTAINER_KEYS.has(key) || MODEL_CATALOG_META_KEYS.has(key)) continue;
|
||
|
|
if (!isPlausibleModelMapKey(key)) continue;
|
||
|
|
if (hasContainer && !isRecord(child) && !Array.isArray(child)) continue;
|
||
|
|
if (isRecord(child) || Array.isArray(child)) collect(child, key, depth + 1);
|
||
|
|
else if (typeof child === "string" && isPlausibleModelMapKey(key)) collect(child, key, depth + 1);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
for (const source of sources) collect(source);
|
||
|
|
return projected;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isPlausibleModelMapKey(value: string): boolean {
|
||
|
|
const key = value.trim();
|
||
|
|
return Boolean(key)
|
||
|
|
&& key.length <= MODEL_CATALOG_MAX_MODEL
|
||
|
|
&& !MODEL_CATALOG_CONTAINER_KEYS.has(key)
|
||
|
|
&& !MODEL_CATALOG_META_KEYS.has(key)
|
||
|
|
&& !/(?:token|secret|password|authorization|api[_-]?key|private[_-]?key|refresh)/i.test(key);
|
||
|
|
}
|
||
|
|
|
||
|
|
function modelCatalogText(value: unknown, maxLength = MODEL_CATALOG_MAX_TEXT): string | undefined {
|
||
|
|
if (typeof value !== "string") return undefined;
|
||
|
|
const text = redactText(value.trim()).slice(0, maxLength).trim();
|
||
|
|
return text || undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function modelCatalogStrongIdentity(value: Record<string, unknown>): string | undefined {
|
||
|
|
return modelCatalogText(value.model, MODEL_CATALOG_MAX_MODEL)
|
||
|
|
?? modelCatalogText(value.id, MODEL_CATALOG_MAX_MODEL)
|
||
|
|
?? modelCatalogText(value.slug, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
}
|
||
|
|
|
||
|
|
function modelCatalogIdentity(value: Record<string, unknown>): string | undefined {
|
||
|
|
return modelCatalogStrongIdentity(value)
|
||
|
|
?? modelCatalogText(value.name, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
}
|
||
|
|
|
||
|
|
function projectAvailableModel(value: unknown, fallbackModel?: string): JsonObject | undefined {
|
||
|
|
if (typeof value === "string") {
|
||
|
|
const model = modelCatalogText(fallbackModel ?? value, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
return model ? { model } : undefined;
|
||
|
|
}
|
||
|
|
if (!isRecord(value)) return undefined;
|
||
|
|
// For map-shaped catalogs the key is the canonical model id and `name` is
|
||
|
|
// commonly only a human-readable label. Prefer explicit model/id/slug,
|
||
|
|
// then the map key, and use `name` as a legacy fallback for standalone rows.
|
||
|
|
const model = modelCatalogStrongIdentity(value)
|
||
|
|
?? (fallbackModel && isPlausibleModelMapKey(fallbackModel) ? modelCatalogText(fallbackModel, MODEL_CATALOG_MAX_MODEL) : undefined)
|
||
|
|
?? modelCatalogText(value.name, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
if (!model) return undefined;
|
||
|
|
|
||
|
|
const result: JsonObject = { model };
|
||
|
|
const id = modelCatalogText(value.id, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
if (id) result.id = id;
|
||
|
|
const displayName = modelCatalogText(value.displayName ?? value.label ?? (fallbackModel ? value.name : undefined));
|
||
|
|
if (displayName) result.displayName = displayName;
|
||
|
|
const description = modelCatalogText(value.description);
|
||
|
|
if (description) result.description = description;
|
||
|
|
const specialty = modelCatalogText(value.modelSpecialty);
|
||
|
|
if (specialty) result.modelSpecialty = specialty;
|
||
|
|
for (const key of ["hidden", "isDefault"] as const) {
|
||
|
|
if (typeof value[key] === "boolean") result[key] = value[key];
|
||
|
|
}
|
||
|
|
const upgrade = modelCatalogText(value.upgrade, MODEL_CATALOG_MAX_MODEL);
|
||
|
|
if (upgrade) result.upgrade = upgrade;
|
||
|
|
const defaultEffort = modelCatalogText(value.defaultReasoningEffort ?? value.defaultEffort, 64);
|
||
|
|
if (defaultEffort) result.defaultReasoningEffort = defaultEffort;
|
||
|
|
else if (value.defaultReasoningEffort === null || value.defaultEffort === null) result.defaultReasoningEffort = null;
|
||
|
|
|
||
|
|
const rawEfforts = Array.isArray(value.supportedReasoningEfforts)
|
||
|
|
? value.supportedReasoningEfforts
|
||
|
|
: Array.isArray(value.reasoningEfforts)
|
||
|
|
? value.reasoningEfforts
|
||
|
|
: Array.isArray(value.efforts) ? value.efforts : undefined;
|
||
|
|
const efforts = projectReasoningEfforts(rawEfforts);
|
||
|
|
if (efforts) result.supportedReasoningEfforts = efforts;
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function projectReasoningEfforts(value: unknown[] | undefined): JsonValue[] | undefined {
|
||
|
|
if (!value) return undefined;
|
||
|
|
const seen = new Set<string>();
|
||
|
|
const projected: JsonValue[] = [];
|
||
|
|
for (const entry of value.slice(0, MODEL_CATALOG_MAX_EFFORTS)) {
|
||
|
|
const effort = typeof entry === "string"
|
||
|
|
? modelCatalogText(entry, 64)
|
||
|
|
: isRecord(entry)
|
||
|
|
? modelCatalogText(entry.reasoningEffort ?? entry.effort, 64)
|
||
|
|
: undefined;
|
||
|
|
if (!effort) continue;
|
||
|
|
const key = effort.toLowerCase();
|
||
|
|
if (seen.has(key)) continue;
|
||
|
|
seen.add(key);
|
||
|
|
const description = isRecord(entry) ? modelCatalogText(entry.description) : undefined;
|
||
|
|
projected.push({
|
||
|
|
reasoningEffort: effort,
|
||
|
|
...(description ? { description } : {}),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return projected.length ? projected : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
const TOKEN_USAGE_FIELDS = [
|
||
|
|
"totalTokens",
|
||
|
|
"inputTokens",
|
||
|
|
"cachedInputTokens",
|
||
|
|
"cacheWriteInputTokens",
|
||
|
|
"outputTokens",
|
||
|
|
"reasoningOutputTokens",
|
||
|
|
] as const;
|
||
|
|
|
||
|
|
/** Project the official thread/tokenUsage payload without forwarding limits or account data. */
|
||
|
|
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 contextWindow = tokenNumber(
|
||
|
|
source.modelContextWindow
|
||
|
|
?? source.model_context_window
|
||
|
|
?? source.contextWindow
|
||
|
|
?? source.context_window,
|
||
|
|
);
|
||
|
|
if (!total && !last && contextWindow === undefined) return undefined;
|
||
|
|
return {
|
||
|
|
...(total ? { total } : {}),
|
||
|
|
...(last ? { last } : {}),
|
||
|
|
...(contextWindow !== undefined ? { modelContextWindow: contextWindow } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Project display-safe thread settings from the opaque IPC state. */
|
||
|
|
function projectSessionMetadata(state: JsonObject): JsonObject {
|
||
|
|
const candidates: unknown[] = [
|
||
|
|
state.latestThreadSettings,
|
||
|
|
state.threadSettings,
|
||
|
|
state.settings,
|
||
|
|
isRecord(state.thread) ? state.thread.latestThreadSettings : undefined,
|
||
|
|
isRecord(state.thread) ? state.thread.settings : undefined,
|
||
|
|
];
|
||
|
|
// Merge compatibility locations from oldest to newest so a partial
|
||
|
|
// `latestThreadSettings` record can still inherit provider/permission
|
||
|
|
// fields exposed by older state shapes, while the official latest record
|
||
|
|
// wins when it contains the same key.
|
||
|
|
const settings: Record<string, unknown> = {};
|
||
|
|
for (const candidate of candidates.slice().reverse()) {
|
||
|
|
if (isRecord(candidate)) Object.assign(settings, candidate);
|
||
|
|
}
|
||
|
|
const result: JsonObject = {};
|
||
|
|
const latestModel = settings.model ?? state.latestModel ?? state.model;
|
||
|
|
const latestReasoningEffort = settings.effort !== undefined
|
||
|
|
? settings.effort
|
||
|
|
: Object.prototype.hasOwnProperty.call(state, "latestReasoningEffort")
|
||
|
|
? state.latestReasoningEffort
|
||
|
|
: state.effort;
|
||
|
|
const values: Record<string, unknown> = {
|
||
|
|
model: latestModel,
|
||
|
|
latestModel,
|
||
|
|
modelProvider: settings.modelProvider ?? state.modelProvider,
|
||
|
|
approvalPolicy: settings.approvalPolicy ?? state.approvalPolicy,
|
||
|
|
approvalsReviewer: settings.approvalsReviewer ?? state.approvalsReviewer,
|
||
|
|
sandboxPolicy: settings.sandboxPolicy ?? settings.sandbox ?? state.sandboxPolicy ?? state.sandbox,
|
||
|
|
permissions: settings.permissions ?? state.permissions,
|
||
|
|
currentPermissions: settings.currentPermissions ?? state.currentPermissions,
|
||
|
|
runtimeWorkspaceRoots: settings.runtimeWorkspaceRoots ?? state.runtimeWorkspaceRoots,
|
||
|
|
cwd: settings.cwd ?? state.cwd,
|
||
|
|
effort: latestReasoningEffort,
|
||
|
|
latestReasoningEffort,
|
||
|
|
summary: settings.summary ?? state.summary,
|
||
|
|
};
|
||
|
|
for (const [key, value] of Object.entries(values)) {
|
||
|
|
if (value !== undefined && value !== null) result[key] = asJsonValue(value);
|
||
|
|
}
|
||
|
|
// Preserve an explicit null effort: the official state uses null when a
|
||
|
|
// model has no selectable reasoning level, and omission would make the
|
||
|
|
// browser retain a stale prior value.
|
||
|
|
if (latestReasoningEffort === null) {
|
||
|
|
result.effort = null;
|
||
|
|
result.latestReasoningEffort = null;
|
||
|
|
}
|
||
|
|
const tokenUsageSource = [
|
||
|
|
state.latestTokenUsageInfo,
|
||
|
|
state.tokenUsage,
|
||
|
|
state.token_usage,
|
||
|
|
settings.latestTokenUsageInfo,
|
||
|
|
settings.tokenUsage,
|
||
|
|
settings.token_usage,
|
||
|
|
].find((candidate) => candidate !== undefined);
|
||
|
|
const tokenUsage = projectTokenUsage(tokenUsageSource);
|
||
|
|
if (tokenUsage) {
|
||
|
|
// Keep both names: `latestTokenUsageInfo` is the official state key while
|
||
|
|
// `tokenUsage` is easier for relay/browser clients to consume.
|
||
|
|
result.tokenUsage = tokenUsage;
|
||
|
|
result.latestTokenUsageInfo = tokenUsage;
|
||
|
|
} else if (tokenUsageSource === null) {
|
||
|
|
result.tokenUsage = null;
|
||
|
|
result.latestTokenUsageInfo = null;
|
||
|
|
}
|
||
|
|
if (!result.title && isRecord(state.thread)) {
|
||
|
|
const title = state.thread.name ?? state.thread.title ?? state.thread.preview;
|
||
|
|
if (title !== undefined && title !== null) result.title = asJsonValue(title);
|
||
|
|
}
|
||
|
|
const availableModels = projectAvailableModels(state);
|
||
|
|
if (availableModels.length) {
|
||
|
|
// `availableModels` is the current relay field; `models` preserves the
|
||
|
|
// name used by older browser clients and by the app-server response.
|
||
|
|
result.availableModels = availableModels;
|
||
|
|
result.models = availableModels;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; }
|
||
|
|
function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
|
||
|
|
function normalizeStatus(value: unknown): string { return typeof value === "string" ? value.replace(/[- ]/g, "_").toLowerCase() : "unknown"; }
|
||
|
|
function cloneObject(value: JsonObject): JsonObject { return JSON.parse(JSON.stringify(value)) as JsonObject; }
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
return typeof value === "string" ? redactText(value) : 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);
|
||
|
|
}
|