mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
3071 lines
130 KiB
TypeScript
3071 lines
130 KiB
TypeScript
import React from 'react';
|
||
import { randomUUID } from 'node:crypto';
|
||
|
||
import { CodexAppServerClient } from './codexAppServerClient';
|
||
import { CodexPermissionHandler } from './utils/permissionHandler';
|
||
import { ReasoningProcessor } from './utils/reasoningProcessor';
|
||
import { DiffProcessor } from './utils/diffProcessor';
|
||
import { logger } from '@/ui/logger';
|
||
import { CodexDisplay } from '@/ui/ink/CodexDisplay';
|
||
import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
|
||
import { emitReadyIfIdle } from './utils/emitReadyIfIdle';
|
||
import type { CodexSession } from './session';
|
||
import type { EnhancedMode } from './loop';
|
||
import { hasCodexCliOverrides } from './utils/codexCliOverrides';
|
||
import { AppServerEventConverter } from './utils/appServerEventConverter';
|
||
import { registerGeneratedImage } from '@/modules/common/generatedImages';
|
||
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
|
||
import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig';
|
||
import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes';
|
||
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
|
||
import { parseCodexSpecialCommand } from './codexSpecialCommands';
|
||
import {
|
||
RemoteLauncherBase,
|
||
type RemoteLauncherDisplayContext,
|
||
type RemoteLauncherExitReason
|
||
} from '@/modules/common/remote/RemoteLauncherBase';
|
||
|
||
type HappyServer = Awaited<ReturnType<typeof buildHapiMcpBridge>>['server'];
|
||
type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string };
|
||
type ChildAgentRuntime = {
|
||
reasoningProcessor: ReasoningProcessor;
|
||
diffProcessor: DiffProcessor;
|
||
activeToolsByCallId: Map<string, {
|
||
name: string;
|
||
label: string;
|
||
activity: string;
|
||
activityKind: string;
|
||
}>;
|
||
pendingTitleByCallId: Map<string, string>;
|
||
reasoningPreview: string;
|
||
finalMessage: string | null;
|
||
terminal: boolean;
|
||
blockedNestedAgent: boolean;
|
||
};
|
||
|
||
const AGENT_RUN_UPDATE_THROTTLE_MS = 300;
|
||
const AGENT_RUN_START_TIMEOUT_MS = 30 * 1000;
|
||
const THROTTLED_AGENT_RUN_ACTIVITY_KINDS = new Set(['thinking']);
|
||
const CODEX_SPAWN_AGENT_FULL_HISTORY_ARGUMENT_ERROR =
|
||
'Full-history forked agents inherit the parent agent type, model, and reasoning effort; ' +
|
||
'omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.';
|
||
|
||
const SAME_THREAD_RETRYABLE_ERROR_PATTERNS = [
|
||
'selected model is at capacity',
|
||
'codex thread entered systemerror'
|
||
];
|
||
const CONTEXT_COMPACT_RETRYABLE_ERROR_PATTERNS = [
|
||
'ran out of room in the model',
|
||
'context window',
|
||
'clear earlier history'
|
||
];
|
||
const SAME_THREAD_MAX_RETRIES = 3;
|
||
const SAME_THREAD_MAX_COMPACT_RETRIES = 1;
|
||
const SAME_THREAD_COMPACT_TIMEOUT_MS = 10 * 60 * 1000;
|
||
const CODEX_GOALS_UNSUPPORTED_MESSAGE = 'Codex goals are not supported by this Codex runtime. Upgrade Codex or enable features.goals.';
|
||
const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000;
|
||
|
||
type GoalForwardSignature = {
|
||
objective: string | null;
|
||
status: string | null;
|
||
tokenBudget: number | null;
|
||
tokenBucket: number | null;
|
||
};
|
||
|
||
function goalNumber(value: unknown): number | null {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||
}
|
||
|
||
function goalString(value: unknown): string | null {
|
||
return typeof value === 'string' ? value : null;
|
||
}
|
||
|
||
function buildGoalForwardSignature(goal: Record<string, unknown>): GoalForwardSignature {
|
||
const tokenBudget = goalNumber(goal.tokenBudget ?? goal.token_budget);
|
||
const tokensUsed = goalNumber(goal.tokensUsed ?? goal.tokens_used) ?? 0;
|
||
const tokenBucket = tokenBudget !== null && tokenBudget > 0
|
||
? Math.floor(Math.min(tokensUsed, tokenBudget) / Math.max(1, tokenBudget * 0.05))
|
||
: null;
|
||
|
||
return {
|
||
objective: goalString(goal.objective),
|
||
status: goalString(goal.status),
|
||
tokenBudget,
|
||
tokenBucket
|
||
};
|
||
}
|
||
|
||
function goalForwardSignatureKey(signature: GoalForwardSignature): string {
|
||
return JSON.stringify(signature);
|
||
}
|
||
|
||
function isSameThreadRetryableCodexError(error: string | null): boolean {
|
||
if (!error) {
|
||
return false;
|
||
}
|
||
const normalized = error.toLowerCase();
|
||
return SAME_THREAD_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||
}
|
||
|
||
function isContextCompactRetryableCodexError(error: string | null): boolean {
|
||
if (!error) {
|
||
return false;
|
||
}
|
||
const normalized = error.toLowerCase();
|
||
return CONTEXT_COMPACT_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||
}
|
||
|
||
function formatGoalStatus(status: unknown): string {
|
||
switch (status) {
|
||
case 'active':
|
||
return 'active';
|
||
case 'paused':
|
||
return 'paused';
|
||
case 'budgetLimited':
|
||
return 'limited by budget';
|
||
case 'complete':
|
||
return 'complete';
|
||
default:
|
||
return typeof status === 'string' ? status : 'updated';
|
||
}
|
||
}
|
||
|
||
function formatGoalUsage(goal: ThreadGoal): string {
|
||
const parts: string[] = [`Goal ${formatGoalStatus(goal.status)}`];
|
||
if (goal.tokenBudget !== null && goal.tokenBudget !== undefined) {
|
||
parts.push(`${goal.tokensUsed}/${goal.tokenBudget} tokens`);
|
||
} else if (goal.tokensUsed > 0) {
|
||
parts.push(`${goal.tokensUsed} tokens`);
|
||
}
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function stripAnsi(value: string): string {
|
||
return value.replace(/\u001b\[[0-9;]*m/g, '');
|
||
}
|
||
|
||
class CodexRemoteLauncher extends RemoteLauncherBase {
|
||
private readonly session: CodexSession;
|
||
private readonly appServerClient: CodexAppServerClient;
|
||
private permissionHandler: CodexPermissionHandler | null = null;
|
||
private reasoningProcessor: ReasoningProcessor | null = null;
|
||
private diffProcessor: DiffProcessor | null = null;
|
||
private happyServer: HappyServer | null = null;
|
||
private abortController: AbortController = new AbortController();
|
||
private currentThreadId: string | null = null;
|
||
private currentTurnId: string | null = null;
|
||
private readonly activeChildTurns = new Map<string, string>();
|
||
|
||
constructor(session: CodexSession) {
|
||
super(process.env.DEBUG ? session.logPath : undefined);
|
||
this.session = session;
|
||
this.appServerClient = new CodexAppServerClient();
|
||
}
|
||
|
||
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
|
||
return React.createElement(CodexDisplay, context);
|
||
}
|
||
|
||
private async interruptActiveTurns(reason: string): Promise<void> {
|
||
const turnsToInterrupt = [
|
||
...(this.currentThreadId && this.currentTurnId
|
||
? [{ threadId: this.currentThreadId, turnId: this.currentTurnId, role: 'parent' as const }]
|
||
: []),
|
||
...Array.from(this.activeChildTurns, ([threadId, turnId]) => ({
|
||
threadId,
|
||
turnId,
|
||
role: 'child' as const
|
||
}))
|
||
];
|
||
|
||
if (turnsToInterrupt.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const results = await Promise.allSettled(
|
||
turnsToInterrupt.map((target) => this.appServerClient.interruptTurn({
|
||
threadId: target.threadId,
|
||
turnId: target.turnId
|
||
}))
|
||
);
|
||
|
||
results.forEach((result, index) => {
|
||
const target = turnsToInterrupt[index];
|
||
if (result.status === 'fulfilled') {
|
||
if (target.role === 'child') {
|
||
this.activeChildTurns.delete(target.threadId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
logger.debug(
|
||
`[Codex] Error interrupting ${target.role} app-server turn ` +
|
||
`for ${reason}; threadId=${target.threadId} turnId=${target.turnId}:`,
|
||
result.reason
|
||
);
|
||
});
|
||
}
|
||
|
||
private async handleAbort(): Promise<void> {
|
||
logger.debug('[Codex] Abort requested - stopping current task');
|
||
try {
|
||
await this.interruptActiveTurns('abort');
|
||
this.currentTurnId = null;
|
||
|
||
this.abortController.abort();
|
||
this.session.queue.reset();
|
||
this.permissionHandler?.reset();
|
||
this.reasoningProcessor?.abort();
|
||
this.diffProcessor?.reset();
|
||
logger.debug('[Codex] Abort completed - session remains active');
|
||
} catch (error) {
|
||
logger.debug('[Codex] Error during abort:', error);
|
||
} finally {
|
||
this.abortController = new AbortController();
|
||
}
|
||
}
|
||
|
||
private async handleExitFromUi(): Promise<void> {
|
||
logger.debug('[codex-remote]: Exiting agent via Ctrl-C');
|
||
this.exitReason = 'exit';
|
||
this.shouldExit = true;
|
||
await this.handleAbort();
|
||
}
|
||
|
||
private async handleSwitchFromUi(): Promise<void> {
|
||
logger.debug('[codex-remote]: Switching to local mode via double space');
|
||
this.exitReason = 'switch';
|
||
this.shouldExit = true;
|
||
await this.handleAbort();
|
||
}
|
||
|
||
private async handleSwitchRequest(): Promise<void> {
|
||
this.exitReason = 'switch';
|
||
this.shouldExit = true;
|
||
await this.handleAbort();
|
||
}
|
||
|
||
public async launch(): Promise<RemoteLauncherExitReason> {
|
||
if (this.session.codexArgs && this.session.codexArgs.length > 0) {
|
||
if (hasCodexCliOverrides(this.session.codexCliOverrides)) {
|
||
logger.debug(`[codex-remote] CLI args include sandbox/approval overrides; other args ` +
|
||
`are ignored in remote mode.`);
|
||
} else {
|
||
logger.debug(`[codex-remote] Warning: CLI args [${this.session.codexArgs.join(', ')}] are ignored in remote mode. ` +
|
||
`Remote mode uses message-based configuration (model/sandbox set via web interface).`);
|
||
}
|
||
}
|
||
|
||
return this.start({
|
||
onExit: () => this.handleExitFromUi(),
|
||
onSwitchToLocal: () => this.handleSwitchFromUi()
|
||
});
|
||
}
|
||
|
||
protected async runMainLoop(): Promise<void> {
|
||
const session = this.session;
|
||
const messageBuffer = this.messageBuffer;
|
||
const appServerClient = this.appServerClient;
|
||
const appServerEventConverter = new AppServerEventConverter();
|
||
|
||
const normalizeCommand = (value: unknown): string | undefined => {
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
return trimmed.length > 0 ? trimmed : undefined;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
const joined = value.filter((part): part is string => typeof part === 'string').join(' ');
|
||
return joined.length > 0 ? joined : undefined;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
||
if (!value || typeof value !== 'object') {
|
||
return null;
|
||
}
|
||
return value as Record<string, unknown>;
|
||
};
|
||
|
||
const asString = (value: unknown): string | null => {
|
||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||
};
|
||
|
||
const errorMessage = (error: unknown): string => {
|
||
return error instanceof Error ? error.message : String(error);
|
||
};
|
||
|
||
const extractSpawnAgentStartErrorFromStderr = (text: string): string | null => {
|
||
const cleanText = stripAnsi(text);
|
||
return cleanText.includes(CODEX_SPAWN_AGENT_FULL_HISTORY_ARGUMENT_ERROR)
|
||
? CODEX_SPAWN_AGENT_FULL_HISTORY_ARGUMENT_ERROR
|
||
: null;
|
||
};
|
||
|
||
const isExitPlanModeTool = (toolName: string): boolean => {
|
||
return toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode';
|
||
};
|
||
|
||
const shouldRetryWithoutCollaborationMode = (error: unknown): boolean => {
|
||
const message = errorMessage(error).toLowerCase();
|
||
const mentionsCollaborationMode = message.includes('collaborationmode')
|
||
|| message.includes('collaboration_mode')
|
||
|| message.includes('collaboration mode');
|
||
if (!mentionsCollaborationMode) {
|
||
return false;
|
||
}
|
||
|
||
return message.includes('experimentalapi')
|
||
|| message.includes('unsupported')
|
||
|| message.includes('unknown')
|
||
|| message.includes('unrecognized')
|
||
|| message.includes('unexpected')
|
||
|| message.includes('invalid field');
|
||
};
|
||
|
||
const responseContainsPlanCollaborationMode = (response: unknown): boolean => {
|
||
const record = asRecord(response);
|
||
const candidates = [
|
||
Array.isArray(response) ? response : undefined,
|
||
Array.isArray(record?.data) ? record.data : undefined,
|
||
Array.isArray(record?.modes) ? record.modes : undefined,
|
||
Array.isArray(record?.collaborationModes) ? record.collaborationModes : undefined,
|
||
Array.isArray(record?.items) ? record.items : undefined
|
||
];
|
||
|
||
for (const candidate of candidates) {
|
||
if (!candidate) continue;
|
||
for (const entry of candidate) {
|
||
if (entry === 'plan') {
|
||
return true;
|
||
}
|
||
const entryRecord = asRecord(entry);
|
||
const mode = asString(entryRecord?.mode)
|
||
?? asString(entryRecord?.name)
|
||
?? asString(entryRecord?.id);
|
||
if (mode === 'plan') {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const applyResolvedModel = (value: unknown): string | undefined => {
|
||
const resolvedModel = asString(value) ?? undefined;
|
||
if (!resolvedModel) {
|
||
return undefined;
|
||
}
|
||
session.setModel(resolvedModel);
|
||
logger.debug(`[Codex] Resolved app-server model: ${resolvedModel}`);
|
||
return resolvedModel;
|
||
};
|
||
|
||
const buildMcpToolName = (server: unknown, tool: unknown): string | null => {
|
||
const serverName = asString(server);
|
||
const toolName = asString(tool);
|
||
if (!serverName || !toolName) {
|
||
return null;
|
||
}
|
||
return `mcp__${serverName}__${toolName}`;
|
||
};
|
||
|
||
const isHapiChangeTitleToolName = (toolName: string | null): boolean => {
|
||
return toolName === 'mcp__hapi__change_title';
|
||
};
|
||
|
||
const sendTitleSummary = (title: string): void => {
|
||
session.client.sendClaudeSessionMessage({
|
||
type: 'summary',
|
||
summary: title,
|
||
leafUuid: randomUUID()
|
||
});
|
||
};
|
||
|
||
const formatOutputPreview = (value: unknown): string => {
|
||
if (typeof value === 'string') return value;
|
||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||
if (value === null || value === undefined) return '';
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
};
|
||
|
||
const compactText = (text: string): string => {
|
||
return text.replace(/\s+/g, ' ').trim();
|
||
};
|
||
|
||
const truncateText = (text: string, maxLength: number): string => {
|
||
const compacted = compactText(text);
|
||
if (compacted.length <= maxLength) return compacted;
|
||
return `${compacted.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||
};
|
||
|
||
const previewText = (value: unknown, maxLength = 120): string | null => {
|
||
const text = formatOutputPreview(value);
|
||
const preview = truncateText(text, maxLength);
|
||
return preview.length > 0 ? preview : null;
|
||
};
|
||
|
||
const formatActivity = (verb: string, detail?: string | null, maxLength = 120): string => {
|
||
const normalizedDetail = detail ? truncateText(detail, maxLength) : '';
|
||
return normalizedDetail ? `${verb}: ${normalizedDetail}` : verb;
|
||
};
|
||
|
||
const extractTextItems = (input: unknown): string[] => {
|
||
const record = asRecord(input);
|
||
if (!record || !Array.isArray(record.items)) return [];
|
||
return record.items
|
||
.map((item) => {
|
||
const itemRecord = asRecord(item);
|
||
return asString(itemRecord?.text);
|
||
})
|
||
.filter((text): text is string => Boolean(text));
|
||
};
|
||
|
||
const extractAgentPrompt = (input: unknown): string | null => {
|
||
const record = asRecord(input);
|
||
if (!record) return null;
|
||
|
||
const direct = asString(record.message ?? record.prompt);
|
||
if (direct) return direct;
|
||
|
||
const textItems = extractTextItems(input);
|
||
return textItems.length > 0 ? textItems.join('\n\n') : null;
|
||
};
|
||
|
||
const cleanAgentPromptForSummary = (prompt: string): string => {
|
||
const withoutTags = prompt
|
||
.replace(/<[^>\n]+>/g, ' ')
|
||
.replace(/\r/g, '\n');
|
||
const noisePatterns = [
|
||
/not alone in the codebase/i,
|
||
/do not revert/i,
|
||
/don't revert/i,
|
||
/list the file paths/i,
|
||
/changed files/i,
|
||
/final answer/i,
|
||
/avoid merge conflicts/i,
|
||
/accommodate the changes/i
|
||
];
|
||
const lines = withoutTags
|
||
.split('\n')
|
||
.map((line) => line.trim().replace(/^[-*]\s+/, '').replace(/^#{1,6}\s+/, ''))
|
||
.filter((line) => line.length > 0)
|
||
.filter((line) => !noisePatterns.some((pattern) => pattern.test(line)));
|
||
const candidate = lines.length > 0 ? lines.join(' ') : withoutTags;
|
||
return compactText(candidate)
|
||
.replace(/^(task|your task|request|prompt)\s*[::]\s*/i, '')
|
||
.trim();
|
||
};
|
||
|
||
const summarizeAgentInput = (input: unknown): string | null => {
|
||
const prompt = extractAgentPrompt(input);
|
||
if (prompt) {
|
||
const cleaned = cleanAgentPromptForSummary(prompt);
|
||
if (cleaned.length > 0) {
|
||
return truncateText(cleaned, 80);
|
||
}
|
||
}
|
||
|
||
const record = asRecord(input);
|
||
const agentType = asString(record?.agent_type ?? record?.subagent_type ?? record?.type);
|
||
return agentType ? `${agentType} agent` : null;
|
||
};
|
||
|
||
const getPatchFiles = (changes: unknown): string[] => {
|
||
const record = asRecord(changes);
|
||
if (!record) return [];
|
||
return Object.keys(record).filter((file) => file.length > 0);
|
||
};
|
||
|
||
const summarizeFiles = (files: string[]): string | null => {
|
||
if (files.length === 0) return null;
|
||
const first = files[0] ?? '';
|
||
const basename = first.split('/').filter(Boolean).pop() ?? first;
|
||
return files.length > 1 ? `${basename} (+${files.length - 1})` : basename;
|
||
};
|
||
|
||
const summarizeDiffFiles = (diff: string): string | null => {
|
||
const files: string[] = [];
|
||
for (const line of diff.split('\n')) {
|
||
if (!line.startsWith('+++ ')) continue;
|
||
const file = line.replace(/^\+\+\+ (b\/)?/, '').trim();
|
||
if (file && file !== '/dev/null') files.push(file);
|
||
}
|
||
return summarizeFiles([...new Set(files)]);
|
||
};
|
||
|
||
const displayMcpToolName = (toolName: string): string => {
|
||
const match = toolName.match(/^mcp__(.+?)__(.+)$/);
|
||
if (!match) return toolName;
|
||
return `${match[1]}.${match[2]}`;
|
||
};
|
||
|
||
const permissionHandler = new CodexPermissionHandler(session.client, () => {
|
||
const mode = session.getPermissionMode();
|
||
return mode === 'default' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo'
|
||
? mode
|
||
: undefined;
|
||
}, {
|
||
onRequest: ({ id, toolName, input }) => {
|
||
if (toolName === 'request_user_input') {
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name: 'request_user_input',
|
||
callId: id,
|
||
input,
|
||
id: randomUUID()
|
||
});
|
||
return;
|
||
}
|
||
|
||
const inputRecord = input && typeof input === 'object' ? input as Record<string, unknown> : {};
|
||
const message = typeof inputRecord.message === 'string' ? inputRecord.message : undefined;
|
||
const rawCommand = inputRecord.command;
|
||
const command = Array.isArray(rawCommand)
|
||
? rawCommand.filter((part): part is string => typeof part === 'string').join(' ')
|
||
: typeof rawCommand === 'string'
|
||
? rawCommand
|
||
: undefined;
|
||
const cwdValue = inputRecord.cwd;
|
||
const cwd = typeof cwdValue === 'string' && cwdValue.trim().length > 0 ? cwdValue : undefined;
|
||
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name: 'CodexPermission',
|
||
callId: id,
|
||
input: {
|
||
tool: toolName,
|
||
message,
|
||
command,
|
||
cwd
|
||
},
|
||
id: randomUUID()
|
||
});
|
||
},
|
||
onComplete: ({ id, toolName, decision, reason, approved, answers }) => {
|
||
session.sendAgentMessage({
|
||
type: 'tool-call-result',
|
||
callId: id,
|
||
output: toolName === 'request_user_input'
|
||
? { answers }
|
||
: {
|
||
decision,
|
||
reason
|
||
},
|
||
is_error: !approved,
|
||
id: randomUUID()
|
||
});
|
||
if (approved && isExitPlanModeTool(toolName)) {
|
||
session.setCollaborationMode('default');
|
||
logger.debug('[Codex] exit_plan_mode approved; collaborationMode reset to default');
|
||
}
|
||
}
|
||
});
|
||
const reasoningProcessor = new ReasoningProcessor((message) => {
|
||
session.sendAgentMessage(message);
|
||
});
|
||
const diffProcessor = new DiffProcessor((message) => {
|
||
session.sendAgentMessage(message);
|
||
});
|
||
const mcpTitleByCallId = new Map<string, string>();
|
||
const agentCardByAgentId = new Map<string, string>();
|
||
const agentSummaryByCardId = new Map<string, string>();
|
||
const agentSummaryByAgentId = new Map<string, string>();
|
||
const agentStatusByAgentId = new Map<string, string>();
|
||
const agentStartedAtByCardId = new Map<string, number>();
|
||
const agentStartedAtByAgentId = new Map<string, number>();
|
||
const pendingAgentStartCardIds = new Set<string>();
|
||
const pendingAgentUpdatesByAgentId = new Map<string, Record<string, unknown>[]>();
|
||
const pendingAgentTracesByAgentId = new Map<string, unknown[]>();
|
||
const pendingAgentToolInputByCallId = new Map<string, { name: string; input: unknown }>();
|
||
const childAgentRuntimeById = new Map<string, ChildAgentRuntime>();
|
||
const lastAgentRunUpdateAtByAgentId = new Map<string, number>();
|
||
const lastAgentRunUpdateSignatureByAgentId = new Map<string, string>();
|
||
const pendingThrottledAgentUpdateByAgentId = new Map<string, {
|
||
update: Record<string, unknown>;
|
||
cardIdOverride?: string | null;
|
||
}>();
|
||
const pendingThrottledAgentUpdateTimerByAgentId = new Map<string, ReturnType<typeof setTimeout>>();
|
||
const pendingAgentStartTimersByCardId = new Map<string, ReturnType<typeof setTimeout>>();
|
||
this.permissionHandler = permissionHandler;
|
||
this.reasoningProcessor = reasoningProcessor;
|
||
this.diffProcessor = diffProcessor;
|
||
let readyAfterTurnTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let scheduleReadyAfterTurn: (() => void) | null = null;
|
||
let clearReadyAfterTurnTimer: (() => void) | null = null;
|
||
let turnInFlight = false;
|
||
let allowAnonymousTerminalEvent = false;
|
||
let invalidThreadId: string | null = null;
|
||
let childAgentActivityInCurrentTurn = false;
|
||
|
||
const isCodexAgentToolName = (toolName: string | null): boolean => {
|
||
return toolName === 'spawn_agent'
|
||
|| toolName === 'send_input'
|
||
|| toolName === 'resume_agent'
|
||
|| toolName === 'wait_agent'
|
||
|| toolName === 'close_agent';
|
||
};
|
||
|
||
const isTerminalAgentRunStatus = (status: string | null | undefined): boolean => {
|
||
return status === 'completed'
|
||
|| status === 'failed'
|
||
|| status === 'error'
|
||
|| status === 'canceled'
|
||
|| status === 'cancelled'
|
||
|| status === 'notFound'
|
||
|| status === 'not_found';
|
||
};
|
||
|
||
const isCloseAgentCleanupUpdate = (update: Record<string, unknown>): boolean => {
|
||
const activityKind = asString(update.activityKind ?? update.activity_kind);
|
||
if (activityKind === 'close_agent' || activityKind === 'closed') return true;
|
||
return activityKind === 'canceled'
|
||
&& (asString(update.activity) === 'Closed' || asString(update.statusText ?? update.status_text) === 'Closed');
|
||
};
|
||
|
||
const isScopeSensitiveCodexEvent = (type: string): boolean => {
|
||
return type === 'token_count'
|
||
|| type === 'context_compacted'
|
||
|| type === 'thread_goal_updated'
|
||
|| type === 'thread_goal_cleared';
|
||
};
|
||
|
||
const hasKnownChildAgents = (): boolean => {
|
||
if (childAgentActivityInCurrentTurn) return true;
|
||
if (pendingAgentStartCardIds.size > 0) return true;
|
||
for (const agentId of new Set([...agentCardByAgentId.keys(), ...childAgentRuntimeById.keys()])) {
|
||
const status = agentStatusByAgentId.get(agentId);
|
||
if (!isTerminalAgentRunStatus(status)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const buildCodexEventScope = (
|
||
threadId: string | null,
|
||
role: 'parent' | 'child',
|
||
agentId?: string | null
|
||
): Record<string, unknown> => ({
|
||
role,
|
||
...(threadId ? { threadId, thread_id: threadId } : {}),
|
||
...(this.currentThreadId ? { parentThreadId: this.currentThreadId, parent_thread_id: this.currentThreadId } : {}),
|
||
...(agentId ? { agentId, agent_id: agentId } : {})
|
||
});
|
||
|
||
const addCodexEventScope = (
|
||
event: Record<string, unknown>,
|
||
role: 'parent' | 'child',
|
||
threadId: string | null,
|
||
agentId?: string | null
|
||
): Record<string, unknown> => ({
|
||
...event,
|
||
...(threadId ? { threadId, thread_id: threadId } : {}),
|
||
scopeRole: role,
|
||
scope_role: role,
|
||
scope: buildCodexEventScope(threadId, role, agentId)
|
||
});
|
||
|
||
const extractAgentTargets = (input: unknown): string[] => {
|
||
const record = asRecord(input);
|
||
if (!record) return [];
|
||
const targets = Array.isArray(record.targets)
|
||
? record.targets.filter((target): target is string => typeof target === 'string' && target.length > 0)
|
||
: [];
|
||
if (targets.length > 0) return targets;
|
||
return [record.target, record.agent_id, record.agentId, record.id]
|
||
.filter((target): target is string => typeof target === 'string' && target.length > 0);
|
||
};
|
||
|
||
const emitAgentRunEvent = (event: Record<string, unknown>): void => {
|
||
const agentId = asString(event.agentId ?? event.agent_id);
|
||
session.sendAgentMessage({
|
||
...(agentId ? addCodexEventScope(event, 'child', agentId, agentId) : event),
|
||
id: randomUUID()
|
||
});
|
||
};
|
||
|
||
const clearPendingAgentStart = (cardId: string): void => {
|
||
const timer = pendingAgentStartTimersByCardId.get(cardId);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
pendingAgentStartTimersByCardId.delete(cardId);
|
||
pendingAgentStartCardIds.delete(cardId);
|
||
};
|
||
|
||
let failAgentStartCard = (_cardId: string, _error: unknown): void => {};
|
||
|
||
const emitAgentRunStart = (cardId: string, input: unknown): void => {
|
||
childAgentActivityInCurrentTurn = true;
|
||
const startedAt = Date.now();
|
||
agentStartedAtByCardId.set(cardId, startedAt);
|
||
const summary = summarizeAgentInput(input);
|
||
if (summary) {
|
||
agentSummaryByCardId.set(cardId, summary);
|
||
}
|
||
clearPendingAgentStart(cardId);
|
||
pendingAgentStartCardIds.add(cardId);
|
||
const timer = setTimeout(() => {
|
||
failAgentStartCard(
|
||
cardId,
|
||
`spawn_agent did not return an agent id within ${AGENT_RUN_START_TIMEOUT_MS / 1000}s`
|
||
);
|
||
}, AGENT_RUN_START_TIMEOUT_MS);
|
||
timer.unref?.();
|
||
pendingAgentStartTimersByCardId.set(cardId, timer);
|
||
emitAgentRunEvent({
|
||
type: 'agent-run-start',
|
||
cardId,
|
||
input,
|
||
startedAt,
|
||
status: 'starting',
|
||
statusText: 'Starting',
|
||
activity: 'Starting',
|
||
activityKind: 'starting',
|
||
...(summary ? { summary } : {})
|
||
});
|
||
};
|
||
|
||
const flushPendingAgentTraces = (agentId: string): void => {
|
||
const traces = pendingAgentTracesByAgentId.get(agentId);
|
||
if (!traces || traces.length === 0) return;
|
||
pendingAgentTracesByAgentId.delete(agentId);
|
||
for (const message of traces) {
|
||
emitAgentRunEvent({
|
||
type: 'agent-run-trace',
|
||
agentId,
|
||
cardId: agentCardByAgentId.get(agentId),
|
||
...(agentStartedAtByAgentId.has(agentId) ? { startedAt: agentStartedAtByAgentId.get(agentId) } : {}),
|
||
message
|
||
});
|
||
}
|
||
};
|
||
|
||
const linkAgentToCard = (agentId: string, cardId: string): void => {
|
||
agentCardByAgentId.set(agentId, cardId);
|
||
clearPendingAgentStart(cardId);
|
||
const startedAt = agentStartedAtByCardId.get(cardId) ?? agentStartedAtByAgentId.get(agentId);
|
||
if (startedAt) {
|
||
agentStartedAtByCardId.set(cardId, startedAt);
|
||
agentStartedAtByAgentId.set(agentId, startedAt);
|
||
}
|
||
const summary = agentSummaryByCardId.get(cardId);
|
||
if (summary) {
|
||
agentSummaryByAgentId.set(agentId, summary);
|
||
}
|
||
flushPendingAgentTraces(agentId);
|
||
};
|
||
|
||
const getOnlyPendingAgentStartCardId = (): string | null => {
|
||
if (pendingAgentStartCardIds.size !== 1) return null;
|
||
return pendingAgentStartCardIds.values().next().value ?? null;
|
||
};
|
||
|
||
const linkPendingAgentStartFromChildTask = (agentId: string): void => {
|
||
if (agentCardByAgentId.has(agentId)) {
|
||
return;
|
||
}
|
||
|
||
const cardId = getOnlyPendingAgentStartCardId();
|
||
if (!cardId) {
|
||
if (pendingAgentStartCardIds.size > 1) {
|
||
logger.debug(
|
||
`[Codex] Child task_started while ${pendingAgentStartCardIds.size} spawn_agent cards are pending; ` +
|
||
`not linking automatically; agentId=${agentId}`
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
logger.debug(`[Codex] Linking pending spawn_agent card from child task_started; cardId=${cardId}, agentId=${agentId}`);
|
||
linkAgentToCard(agentId, cardId);
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'running',
|
||
statusText: 'Running',
|
||
activity: 'Started',
|
||
activityKind: 'running'
|
||
}, cardId);
|
||
flushPendingAgentUpdates(agentId);
|
||
};
|
||
|
||
const flushPendingAgentUpdates = (agentId: string): void => {
|
||
const updates = pendingAgentUpdatesByAgentId.get(agentId);
|
||
if (!updates || updates.length === 0) return;
|
||
pendingAgentUpdatesByAgentId.delete(agentId);
|
||
for (const update of updates) {
|
||
emitAgentRunUpdate(agentId, update);
|
||
}
|
||
};
|
||
|
||
const stableStringify = (value: unknown): string => {
|
||
if (value === null || typeof value !== 'object') {
|
||
return JSON.stringify(value);
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return `[${value.map(stableStringify).join(',')}]`;
|
||
}
|
||
const record = value as Record<string, unknown>;
|
||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`;
|
||
};
|
||
|
||
const getAgentRunUpdateSignature = (
|
||
agentId: string,
|
||
update: Record<string, unknown>,
|
||
cardIdOverride?: string | null
|
||
): string => stableStringify({
|
||
agentId,
|
||
cardIdOverride: cardIdOverride ?? null,
|
||
update
|
||
});
|
||
|
||
const cancelPendingThrottledAgentRunUpdate = (agentId: string): void => {
|
||
const timer = pendingThrottledAgentUpdateTimerByAgentId.get(agentId);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
pendingThrottledAgentUpdateTimerByAgentId.delete(agentId);
|
||
pendingThrottledAgentUpdateByAgentId.delete(agentId);
|
||
};
|
||
|
||
const cancelAllPendingThrottledAgentRunUpdates = (): void => {
|
||
for (const agentId of Array.from(pendingThrottledAgentUpdateTimerByAgentId.keys())) {
|
||
cancelPendingThrottledAgentRunUpdate(agentId);
|
||
}
|
||
pendingThrottledAgentUpdateByAgentId.clear();
|
||
};
|
||
|
||
const flushPendingThrottledAgentRunUpdate = (agentId: string): void => {
|
||
const pendingUpdate = pendingThrottledAgentUpdateByAgentId.get(agentId);
|
||
pendingThrottledAgentUpdateTimerByAgentId.delete(agentId);
|
||
pendingThrottledAgentUpdateByAgentId.delete(agentId);
|
||
if (!pendingUpdate) return;
|
||
emitAgentRunUpdateNow(agentId, pendingUpdate.update, pendingUpdate.cardIdOverride);
|
||
};
|
||
|
||
const scheduleThrottledAgentRunUpdate = (
|
||
agentId: string,
|
||
update: Record<string, unknown>,
|
||
cardIdOverride?: string | null
|
||
): void => {
|
||
pendingThrottledAgentUpdateByAgentId.set(agentId, { update, cardIdOverride });
|
||
if (pendingThrottledAgentUpdateTimerByAgentId.has(agentId)) {
|
||
return;
|
||
}
|
||
|
||
const lastAt = lastAgentRunUpdateAtByAgentId.get(agentId) ?? 0;
|
||
const delay = Math.max(AGENT_RUN_UPDATE_THROTTLE_MS - (Date.now() - lastAt), 0);
|
||
const timer = setTimeout(() => {
|
||
flushPendingThrottledAgentRunUpdate(agentId);
|
||
}, delay);
|
||
timer.unref?.();
|
||
pendingThrottledAgentUpdateTimerByAgentId.set(agentId, timer);
|
||
};
|
||
|
||
const emitAgentRunUpdateNow = (
|
||
agentId: string,
|
||
update: Record<string, unknown>,
|
||
cardIdOverride?: string | null
|
||
): void => {
|
||
const knownCardId = agentCardByAgentId.get(agentId);
|
||
if (
|
||
!cardIdOverride
|
||
&& !knownCardId
|
||
&& pendingAgentStartCardIds.size > 0
|
||
&& childAgentRuntimeById.has(agentId)
|
||
) {
|
||
const updates = pendingAgentUpdatesByAgentId.get(agentId) ?? [];
|
||
updates.push(update);
|
||
pendingAgentUpdatesByAgentId.set(agentId, updates);
|
||
return;
|
||
}
|
||
|
||
const cardId = cardIdOverride ?? knownCardId ?? `codex-agent:${agentId}`;
|
||
if (!knownCardId) {
|
||
agentCardByAgentId.set(agentId, cardId);
|
||
}
|
||
const startedAt = agentStartedAtByAgentId.get(agentId)
|
||
?? agentStartedAtByCardId.get(cardId)
|
||
?? Date.now();
|
||
agentStartedAtByAgentId.set(agentId, startedAt);
|
||
agentStartedAtByCardId.set(cardId, startedAt);
|
||
const nextStatus = asString(update.status);
|
||
const currentStatus = agentStatusByAgentId.get(agentId);
|
||
const activityKind = asString(update.activityKind ?? update.activity_kind);
|
||
if (
|
||
isTerminalAgentRunStatus(currentStatus)
|
||
&& !isTerminalAgentRunStatus(nextStatus)
|
||
&& activityKind !== 'send_input'
|
||
&& activityKind !== 'resume_agent'
|
||
) {
|
||
return;
|
||
}
|
||
if (
|
||
childAgentRuntimeById.get(agentId)?.blockedNestedAgent
|
||
&& nextStatus !== 'failed'
|
||
&& nextStatus !== 'error'
|
||
) {
|
||
return;
|
||
}
|
||
if (
|
||
isTerminalAgentRunStatus(currentStatus)
|
||
&& nextStatus !== 'failed'
|
||
&& nextStatus !== 'error'
|
||
&& isCloseAgentCleanupUpdate(update)
|
||
) {
|
||
return;
|
||
}
|
||
const nextSummary = asString(update.summary);
|
||
if (nextSummary) {
|
||
agentSummaryByAgentId.set(agentId, nextSummary);
|
||
agentSummaryByCardId.set(cardId, nextSummary);
|
||
}
|
||
if (nextStatus) {
|
||
agentStatusByAgentId.set(agentId, nextStatus);
|
||
}
|
||
const event = {
|
||
type: 'agent-run-update',
|
||
agentId,
|
||
cardId,
|
||
startedAt,
|
||
...(isTerminalAgentRunStatus(nextStatus) ? { completedAt: Date.now() } : {}),
|
||
...(agentSummaryByAgentId.has(agentId) ? { summary: agentSummaryByAgentId.get(agentId) } : {}),
|
||
...update
|
||
};
|
||
const signature = getAgentRunUpdateSignature(agentId, event, cardIdOverride);
|
||
if (lastAgentRunUpdateSignatureByAgentId.get(agentId) === signature) {
|
||
return;
|
||
}
|
||
lastAgentRunUpdateSignatureByAgentId.set(agentId, signature);
|
||
lastAgentRunUpdateAtByAgentId.set(agentId, Date.now());
|
||
emitAgentRunEvent(event);
|
||
flushPendingAgentTraces(agentId);
|
||
};
|
||
|
||
const emitAgentRunUpdate = (
|
||
agentId: string,
|
||
update: Record<string, unknown>,
|
||
cardIdOverride?: string | null
|
||
): void => {
|
||
const nextStatus = asString(update.status);
|
||
const terminal = isTerminalAgentRunStatus(nextStatus);
|
||
if (terminal) {
|
||
cancelPendingThrottledAgentRunUpdate(agentId);
|
||
emitAgentRunUpdateNow(agentId, update, cardIdOverride);
|
||
return;
|
||
}
|
||
|
||
const activityKind = asString(update.activityKind ?? update.activity_kind);
|
||
if (!activityKind || !THROTTLED_AGENT_RUN_ACTIVITY_KINDS.has(activityKind)) {
|
||
emitAgentRunUpdateNow(agentId, update, cardIdOverride);
|
||
return;
|
||
}
|
||
|
||
const lastAt = lastAgentRunUpdateAtByAgentId.get(agentId);
|
||
if (lastAt === undefined || Date.now() - lastAt >= AGENT_RUN_UPDATE_THROTTLE_MS) {
|
||
emitAgentRunUpdateNow(agentId, update, cardIdOverride);
|
||
return;
|
||
}
|
||
|
||
scheduleThrottledAgentRunUpdate(agentId, update, cardIdOverride);
|
||
};
|
||
|
||
failAgentStartCard = (cardId: string, error: unknown): void => {
|
||
if (!pendingAgentStartCardIds.has(cardId) && !pendingAgentStartTimersByCardId.has(cardId)) {
|
||
return;
|
||
}
|
||
|
||
const agentId = `spawn-error:${cardId}`;
|
||
linkAgentToCard(agentId, cardId);
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'failed',
|
||
statusText: 'Failed to start',
|
||
activity: formatActivity('Failed to start', previewText(error)),
|
||
activityKind: 'failed',
|
||
error
|
||
}, cardId);
|
||
};
|
||
|
||
const failPendingAgentStarts = (error: unknown): void => {
|
||
for (const cardId of Array.from(pendingAgentStartCardIds)) {
|
||
failAgentStartCard(cardId, error);
|
||
}
|
||
};
|
||
|
||
const isPresentSpawnOption = (value: unknown): boolean => {
|
||
if (value === undefined || value === null) return false;
|
||
return typeof value !== 'string' || value.trim().length > 0;
|
||
};
|
||
|
||
const isFullHistorySpawnWithInheritedOverrides = (input: unknown): boolean => {
|
||
const record = asRecord(input);
|
||
if (!record) return false;
|
||
|
||
const forkContext = record.fork_context ?? record.forkContext;
|
||
if (forkContext === false) {
|
||
return false;
|
||
}
|
||
|
||
return [
|
||
record.agent_type,
|
||
record.agentType,
|
||
record.subagent_type,
|
||
record.subagentType,
|
||
record.model,
|
||
record.reasoning_effort,
|
||
record.reasoningEffort
|
||
].some(isPresentSpawnOption);
|
||
};
|
||
|
||
const failPendingAgentStartsForSpawnArgumentError = (error: unknown): void => {
|
||
const matchingCardIds = Array.from(pendingAgentStartCardIds).filter((cardId) => {
|
||
return isFullHistorySpawnWithInheritedOverrides(
|
||
pendingAgentToolInputByCallId.get(cardId)?.input
|
||
);
|
||
});
|
||
|
||
if (matchingCardIds.length > 0) {
|
||
for (const cardId of matchingCardIds) {
|
||
failAgentStartCard(cardId, error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (pendingAgentStartCardIds.size === 1) {
|
||
failPendingAgentStarts(error);
|
||
return;
|
||
}
|
||
|
||
logger.debug(
|
||
`[Codex] Ignoring spawn_agent argument stderr error for ${pendingAgentStartCardIds.size} ` +
|
||
'pending starts because none have detectable inherited-override args'
|
||
);
|
||
};
|
||
|
||
const emitAgentRunTraceMessage = (agentId: string, message: unknown): void => {
|
||
const cardId = agentCardByAgentId.get(agentId);
|
||
if (!cardId) {
|
||
const traces = pendingAgentTracesByAgentId.get(agentId) ?? [];
|
||
traces.push(message);
|
||
pendingAgentTracesByAgentId.set(agentId, traces);
|
||
return;
|
||
}
|
||
emitAgentRunEvent({
|
||
type: 'agent-run-trace',
|
||
agentId,
|
||
cardId,
|
||
...(agentStartedAtByAgentId.has(agentId) ? { startedAt: agentStartedAtByAgentId.get(agentId) } : {}),
|
||
message
|
||
});
|
||
};
|
||
|
||
const getChildRuntime = (agentId: string) => {
|
||
const existing = childAgentRuntimeById.get(agentId);
|
||
if (existing) return existing;
|
||
const runtime = {
|
||
reasoningProcessor: new ReasoningProcessor((message) => {
|
||
emitAgentRunTraceMessage(agentId, message);
|
||
}),
|
||
diffProcessor: new DiffProcessor((message) => {
|
||
emitAgentRunTraceMessage(agentId, message);
|
||
}),
|
||
activeToolsByCallId: new Map(),
|
||
pendingTitleByCallId: new Map(),
|
||
reasoningPreview: '',
|
||
finalMessage: null,
|
||
terminal: false,
|
||
blockedNestedAgent: false
|
||
};
|
||
childAgentRuntimeById.set(agentId, runtime);
|
||
return runtime;
|
||
};
|
||
|
||
const extractAgentStatusMessage = (record: Record<string, unknown>): unknown => {
|
||
const message = asString(record.message);
|
||
if (message) return message;
|
||
|
||
for (const key of ['output', 'result', 'finalMessage', 'final_message'] as const) {
|
||
const value = record[key];
|
||
if (value !== undefined && value !== null) {
|
||
return asString(value) ?? value;
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
const normalizeAgentStateValue = (value: unknown): string | null => {
|
||
return asString(value)?.trim().toLowerCase().replace(/[\s_-]/g, '') ?? null;
|
||
};
|
||
|
||
const hasOwn = (record: Record<string, unknown>, key: string): boolean => {
|
||
return Object.prototype.hasOwnProperty.call(record, key);
|
||
};
|
||
|
||
const fillCompletedAgentUpdateFromRuntime = (
|
||
agentId: string,
|
||
update: Record<string, unknown>
|
||
): Record<string, unknown> => {
|
||
if (asString(update.status) !== 'completed') return update;
|
||
if (hasOwn(update, 'result') || hasOwn(update, 'error')) return update;
|
||
|
||
const result = childAgentRuntimeById.get(agentId)?.finalMessage;
|
||
if (!result) return update;
|
||
|
||
return {
|
||
...update,
|
||
activity: formatActivity('Completed', result),
|
||
result
|
||
};
|
||
};
|
||
|
||
const normalizeAgentStatusUpdate = (value: unknown): Record<string, unknown> => {
|
||
if (typeof value === 'string') {
|
||
const normalized = normalizeAgentStateValue(value);
|
||
if (normalized === 'completed' || normalized === 'complete' || normalized === 'done') {
|
||
return {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity: 'Completed',
|
||
activityKind: 'completed'
|
||
};
|
||
}
|
||
const activity = formatActivity('Completed', previewText(value));
|
||
return {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity,
|
||
activityKind: 'completed',
|
||
result: value
|
||
};
|
||
}
|
||
|
||
const record = asRecord(value);
|
||
if (!record) {
|
||
const activity = formatActivity('Completed', previewText(value));
|
||
return {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity,
|
||
activityKind: 'completed',
|
||
result: value
|
||
};
|
||
}
|
||
|
||
const completed = asString(record.completed);
|
||
if (completed) {
|
||
return {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity: formatActivity('Completed', completed),
|
||
activityKind: 'completed',
|
||
result: completed
|
||
};
|
||
}
|
||
const done = asString(record.done);
|
||
if (done) {
|
||
return {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity: formatActivity('Completed', done),
|
||
activityKind: 'completed',
|
||
result: done
|
||
};
|
||
}
|
||
const failed = asString(record.failed ?? record.error);
|
||
if (failed) {
|
||
return {
|
||
status: 'failed',
|
||
statusText: 'Failed',
|
||
activity: formatActivity('Failed', failed),
|
||
activityKind: 'failed',
|
||
error: failed
|
||
};
|
||
}
|
||
const canceled = asString(record.canceled ?? record.cancelled);
|
||
if (canceled) {
|
||
return {
|
||
status: 'canceled',
|
||
statusText: 'Canceled',
|
||
activity: formatActivity('Canceled', canceled),
|
||
activityKind: 'canceled',
|
||
error: canceled
|
||
};
|
||
}
|
||
|
||
const rawStatus = asString(record.status ?? record.state);
|
||
const normalizedStatus = normalizeAgentStateValue(record.status ?? record.state);
|
||
if (normalizedStatus === 'notfound') {
|
||
const error = record.message ?? record.error ?? value;
|
||
return {
|
||
status: 'failed',
|
||
statusText: 'Not found',
|
||
activity: formatActivity('Agent not found', previewText(error)),
|
||
activityKind: 'not_found',
|
||
error
|
||
};
|
||
}
|
||
if (
|
||
normalizedStatus === 'completed'
|
||
|| normalizedStatus === 'complete'
|
||
|| normalizedStatus === 'done'
|
||
|| record.completed === true
|
||
|| record.done === true
|
||
) {
|
||
const result = extractAgentStatusMessage(record);
|
||
return {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity: formatActivity('Completed', previewText(result)),
|
||
activityKind: 'completed',
|
||
...(result !== undefined && result !== null ? { result } : {})
|
||
};
|
||
}
|
||
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
|
||
const error = extractAgentStatusMessage(record) ?? record.error ?? value;
|
||
return {
|
||
status: 'failed',
|
||
statusText: 'Failed',
|
||
activity: formatActivity('Failed', previewText(error)),
|
||
activityKind: 'failed',
|
||
error
|
||
};
|
||
}
|
||
if (normalizedStatus === 'canceled' || normalizedStatus === 'cancelled') {
|
||
const error = extractAgentStatusMessage(record) ?? record.error ?? value;
|
||
return {
|
||
status: 'canceled',
|
||
statusText: 'Canceled',
|
||
activity: formatActivity('Canceled', previewText(error)),
|
||
activityKind: 'canceled',
|
||
error
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: rawStatus ?? 'running',
|
||
statusText: rawStatus ?? 'Running',
|
||
activity: formatActivity(rawStatus ?? 'Running', previewText(extractAgentStatusMessage(record) ?? value)),
|
||
activityKind: rawStatus ?? 'running',
|
||
result: value
|
||
};
|
||
};
|
||
|
||
const isAgentNotFoundStatusUpdate = (update: Record<string, unknown>): boolean => {
|
||
const status = asString(update.status);
|
||
const activityKind = asString(update.activityKind ?? update.activity_kind);
|
||
return status === 'notFound'
|
||
|| status === 'not_found'
|
||
|| activityKind === 'not_found';
|
||
};
|
||
|
||
const handleAgentToolEnd = (callId: string, name: string, output: unknown, isError: boolean): void => {
|
||
const pending = pendingAgentToolInputByCallId.get(callId);
|
||
pendingAgentToolInputByCallId.delete(callId);
|
||
|
||
if (name === 'spawn_agent') {
|
||
childAgentActivityInCurrentTurn = true;
|
||
const outputRecord = asRecord(output);
|
||
const agentsStates = asRecord(outputRecord?.agentsStates ?? outputRecord?.agents_states);
|
||
const agentIdsFromState = agentsStates ? Object.keys(agentsStates) : [];
|
||
const agentId = asString(outputRecord?.agent_id ?? outputRecord?.agentId ?? outputRecord?.id)
|
||
?? (agentIdsFromState.length === 1 ? agentIdsFromState[0] : null)
|
||
?? extractAgentTargets(pending?.input).at(0);
|
||
if (!agentId) {
|
||
const detail = isError
|
||
? output
|
||
: {
|
||
message: 'spawn_agent completed without returning an agent id',
|
||
output
|
||
};
|
||
failAgentStartCard(callId, detail);
|
||
return;
|
||
}
|
||
linkAgentToCard(agentId, callId);
|
||
emitAgentRunUpdate(agentId, {
|
||
status: isError ? 'failed' : 'running',
|
||
statusText: isError ? 'Failed to start' : 'Running',
|
||
activity: isError ? formatActivity('Failed to start', previewText(output)) : 'Started',
|
||
activityKind: isError ? 'failed' : 'running',
|
||
...(isError ? { error: output } : { spawnResult: output })
|
||
}, callId);
|
||
flushPendingAgentUpdates(agentId);
|
||
return;
|
||
}
|
||
|
||
if (name === 'wait_agent') {
|
||
childAgentActivityInCurrentTurn = true;
|
||
const outputRecord = asRecord(output);
|
||
const statusMap = asRecord(outputRecord?.status) ?? {};
|
||
for (const [agentId, statusValue] of Object.entries(statusMap)) {
|
||
const update = fillCompletedAgentUpdateFromRuntime(
|
||
agentId,
|
||
normalizeAgentStatusUpdate(statusValue)
|
||
);
|
||
if (!agentCardByAgentId.has(agentId) && isAgentNotFoundStatusUpdate(update)) {
|
||
continue;
|
||
}
|
||
if (asString(update.status) === 'completed') {
|
||
const runtime = childAgentRuntimeById.get(agentId);
|
||
if (runtime) {
|
||
runtime.terminal = true;
|
||
}
|
||
}
|
||
emitAgentRunUpdate(agentId, update);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (name === 'send_input' || name === 'resume_agent') {
|
||
childAgentActivityInCurrentTurn = true;
|
||
const outputRecord = asRecord(output);
|
||
const targets = Array.from(new Set([
|
||
...extractAgentTargets(pending?.input),
|
||
...extractAgentTargets(outputRecord)
|
||
]));
|
||
const label = name === 'send_input' ? 'Send input' : 'Resume';
|
||
const successActivity = name === 'send_input' ? 'Input sent' : 'Resumed';
|
||
const errorDetail = asString(outputRecord?.error ?? outputRecord?.message) ?? output;
|
||
|
||
for (const agentId of targets) {
|
||
if (!agentCardByAgentId.has(agentId)) continue;
|
||
emitAgentRunUpdate(agentId, {
|
||
status: isError ? 'failed' : 'running',
|
||
statusText: isError ? `${label} failed` : successActivity,
|
||
activity: isError ? formatActivity(`${label} failed`, previewText(errorDetail)) : successActivity,
|
||
activityKind: isError ? 'failed' : name,
|
||
...(isError ? { error: output } : { result: output })
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (name === 'close_agent') {
|
||
const outputRecord = asRecord(output);
|
||
const agentId = asString(outputRecord?.agent_id ?? outputRecord?.agentId)
|
||
?? extractAgentTargets(pending?.input).at(0);
|
||
if (!agentId) return;
|
||
emitAgentRunUpdate(agentId, {
|
||
status: isError ? 'failed' : 'completed',
|
||
statusText: isError ? 'Close failed' : 'Closed',
|
||
activity: isError ? formatActivity('Close failed', previewText(output)) : 'Closed',
|
||
activityKind: isError ? 'failed' : 'closed',
|
||
...(isError ? { error: output } : { result: output })
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
|
||
const handleChildCodexEvent = (agentId: string, msg: Record<string, unknown>): void => {
|
||
const msgType = asString(msg.type);
|
||
if (!msgType) return;
|
||
childAgentActivityInCurrentTurn = true;
|
||
const runtime = getChildRuntime(agentId);
|
||
const isChildTerminalEvent = msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed';
|
||
if (runtime.blockedNestedAgent && !isChildTerminalEvent) {
|
||
return;
|
||
}
|
||
const updateActivity = (
|
||
activity: string,
|
||
activityKind: string,
|
||
extra?: Record<string, unknown>
|
||
): void => {
|
||
if (runtime.terminal) {
|
||
return;
|
||
}
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'running',
|
||
statusText: activity,
|
||
activity,
|
||
activityKind,
|
||
...extra
|
||
});
|
||
};
|
||
|
||
if (msgType === 'token_count') {
|
||
return;
|
||
}
|
||
|
||
if (msgType === 'context_compacted') {
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'context_compacted',
|
||
id: randomUUID()
|
||
});
|
||
updateActivity('Context compacted', 'compact');
|
||
return;
|
||
}
|
||
|
||
if (msgType === 'task_started') {
|
||
runtime.reasoningPreview = '';
|
||
runtime.finalMessage = null;
|
||
runtime.terminal = false;
|
||
agentStatusByAgentId.delete(agentId);
|
||
updateActivity('Starting task', 'starting');
|
||
return;
|
||
}
|
||
if (msgType === 'agent_reasoning_section_break') {
|
||
runtime.reasoningProcessor.handleSectionBreak();
|
||
runtime.reasoningPreview = '';
|
||
updateActivity('Thinking', 'thinking');
|
||
return;
|
||
}
|
||
if (msgType === 'agent_reasoning_delta') {
|
||
const delta = asString(msg.delta);
|
||
if (delta) {
|
||
runtime.reasoningProcessor.processDelta(delta);
|
||
runtime.reasoningPreview = truncateText(`${runtime.reasoningPreview}${delta}`, 160);
|
||
}
|
||
updateActivity(formatActivity('Thinking', runtime.reasoningPreview || null), 'thinking');
|
||
return;
|
||
}
|
||
if (msgType === 'agent_reasoning') {
|
||
const text = asString(msg.text);
|
||
if (text) {
|
||
runtime.reasoningProcessor.complete(text);
|
||
runtime.reasoningPreview = truncateText(text, 160);
|
||
}
|
||
updateActivity(formatActivity('Thinking', runtime.reasoningPreview || null), 'thinking');
|
||
return;
|
||
}
|
||
if (msgType === 'agent_message') {
|
||
const message = asString(msg.message);
|
||
if (message) {
|
||
runtime.finalMessage = message;
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'message',
|
||
message,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
if (runtime.terminal) {
|
||
if (message) {
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity: formatActivity('Completed', message),
|
||
activityKind: 'completed',
|
||
result: message
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
updateActivity(formatActivity('Writing', message), 'writing');
|
||
return;
|
||
}
|
||
if (msgType === 'exec_command_begin' || msgType === 'exec_approval_request') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const inputs: Record<string, unknown> = { ...msg };
|
||
delete inputs.type;
|
||
delete inputs.call_id;
|
||
delete inputs.callId;
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call',
|
||
name: 'CodexBash',
|
||
callId,
|
||
input: inputs,
|
||
id: randomUUID()
|
||
});
|
||
const command = normalizeCommand(inputs.command) ?? 'command';
|
||
if (!runtime.terminal) {
|
||
runtime.activeToolsByCallId.set(callId, {
|
||
name: 'CodexBash',
|
||
label: command,
|
||
activity: formatActivity('Running command', command),
|
||
activityKind: 'running-command'
|
||
});
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'running',
|
||
statusText: formatActivity('Running command', command),
|
||
activity: formatActivity('Running command', command),
|
||
activityKind: 'running-command'
|
||
});
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'exec_command_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const activeTool = runtime.activeToolsByCallId.get(callId);
|
||
runtime.activeToolsByCallId.delete(callId);
|
||
const output: Record<string, unknown> = { ...msg };
|
||
delete output.type;
|
||
delete output.call_id;
|
||
delete output.callId;
|
||
output.stdout = output.output;
|
||
delete output.output;
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output,
|
||
is_error: Boolean(output.error),
|
||
id: randomUUID()
|
||
});
|
||
const label = activeTool?.label ?? normalizeCommand(output.command) ?? 'command';
|
||
const isError = Boolean(output.error);
|
||
updateActivity(
|
||
formatActivity(isError ? 'Command failed' : 'Command finished', label),
|
||
isError ? 'command-failed' : 'command-completed'
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'patch_apply_begin') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const changes = asRecord(msg.changes) ?? {};
|
||
const files = getPatchFiles(changes);
|
||
const fileSummary = summarizeFiles(files);
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call',
|
||
name: 'CodexPatch',
|
||
callId,
|
||
input: {
|
||
auto_approved: msg.auto_approved ?? msg.autoApproved,
|
||
changes
|
||
},
|
||
id: randomUUID()
|
||
});
|
||
runtime.activeToolsByCallId.set(callId, {
|
||
name: 'CodexPatch',
|
||
label: fileSummary ?? 'files',
|
||
activity: formatActivity('Editing files', fileSummary),
|
||
activityKind: 'editing'
|
||
});
|
||
updateActivity(formatActivity('Editing files', fileSummary), 'editing');
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'patch_apply_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const activeTool = runtime.activeToolsByCallId.get(callId);
|
||
runtime.activeToolsByCallId.delete(callId);
|
||
const stdout = asString(msg.stdout);
|
||
const stderr = asString(msg.stderr);
|
||
const success = Boolean(msg.success);
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output: { stdout, stderr, success },
|
||
is_error: !success,
|
||
id: randomUUID()
|
||
});
|
||
updateActivity(
|
||
formatActivity(success ? 'Files edited' : 'Edit failed', activeTool?.label ?? previewText(stderr ?? stdout)),
|
||
success ? 'edited' : 'edit-failed'
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'mcp_tool_call_begin') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
const invocation = asRecord(msg.invocation) ?? {};
|
||
const name = buildMcpToolName(
|
||
invocation.server ?? invocation.server_name ?? msg.server,
|
||
invocation.tool ?? invocation.tool_name ?? msg.tool
|
||
);
|
||
if (callId && name) {
|
||
const input = invocation.arguments ?? invocation.input ?? msg.arguments ?? msg.input ?? {};
|
||
const inputRecord = asRecord(input);
|
||
const requestedTitle = inputRecord ? asString(inputRecord.title) : null;
|
||
if (isHapiChangeTitleToolName(name) && requestedTitle) {
|
||
runtime.pendingTitleByCallId.set(callId, requestedTitle);
|
||
}
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call',
|
||
name,
|
||
callId,
|
||
input,
|
||
id: randomUUID()
|
||
});
|
||
const label = displayMcpToolName(name);
|
||
runtime.activeToolsByCallId.set(callId, {
|
||
name,
|
||
label,
|
||
activity: formatActivity('Calling tool', label),
|
||
activityKind: 'tool'
|
||
});
|
||
updateActivity(formatActivity('Calling tool', label), 'tool');
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'mcp_tool_call_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const activeTool = runtime.activeToolsByCallId.get(callId);
|
||
runtime.activeToolsByCallId.delete(callId);
|
||
const rawResult = msg.result;
|
||
let output = rawResult;
|
||
let isError = false;
|
||
const resultRecord = asRecord(rawResult);
|
||
if (resultRecord) {
|
||
if (Object.prototype.hasOwnProperty.call(resultRecord, 'Ok')) {
|
||
output = resultRecord.Ok;
|
||
} else if (Object.prototype.hasOwnProperty.call(resultRecord, 'Err')) {
|
||
output = resultRecord.Err;
|
||
isError = true;
|
||
}
|
||
}
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output,
|
||
is_error: isError,
|
||
id: randomUUID()
|
||
});
|
||
const title = runtime.pendingTitleByCallId.get(callId);
|
||
runtime.pendingTitleByCallId.delete(callId);
|
||
updateActivity(
|
||
formatActivity(isError ? 'Tool failed' : 'Tool finished', activeTool?.label ?? displayMcpToolName(asString(msg.tool) ?? 'tool')),
|
||
isError ? 'tool-failed' : 'tool-completed',
|
||
!isError && title ? { summary: title } : undefined
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'codex_tool_call_begin') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
const name = asString(msg.name);
|
||
if (callId && name) {
|
||
if (isCodexAgentToolName(name)) {
|
||
const error = 'Nested agent calls are disabled for child agents.';
|
||
runtime.blockedNestedAgent = true;
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call',
|
||
name,
|
||
callId,
|
||
input: msg.input ?? {},
|
||
id: randomUUID()
|
||
});
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output: error,
|
||
is_error: true,
|
||
id: randomUUID()
|
||
});
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'failed',
|
||
statusText: 'Failed',
|
||
activity: formatActivity('Failed', error),
|
||
activityKind: 'failed',
|
||
error
|
||
});
|
||
return;
|
||
}
|
||
const activity = formatActivity('Running tool', name);
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call',
|
||
name,
|
||
callId,
|
||
input: msg.input ?? {},
|
||
id: randomUUID()
|
||
});
|
||
runtime.activeToolsByCallId.set(callId, {
|
||
name,
|
||
label: name,
|
||
activity,
|
||
activityKind: 'tool'
|
||
});
|
||
updateActivity(activity, 'tool');
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'codex_tool_call_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const activeTool = runtime.activeToolsByCallId.get(callId);
|
||
runtime.activeToolsByCallId.delete(callId);
|
||
const isError = Boolean(msg.is_error ?? msg.isError);
|
||
emitAgentRunTraceMessage(agentId, {
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output: msg.output,
|
||
is_error: isError,
|
||
id: randomUUID()
|
||
});
|
||
updateActivity(
|
||
formatActivity(isError ? 'Tool failed' : 'Tool finished', activeTool?.label ?? 'tool'),
|
||
isError ? 'tool-failed' : 'tool-completed'
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (msgType === 'turn_diff') {
|
||
const diff = asString(msg.unified_diff);
|
||
if (diff) {
|
||
runtime.diffProcessor.processDiff(diff);
|
||
updateActivity(formatActivity('Editing files', summarizeDiffFiles(diff)), 'editing');
|
||
}
|
||
return;
|
||
}
|
||
if (isChildTerminalEvent) {
|
||
runtime.terminal = true;
|
||
runtime.reasoningProcessor.reset();
|
||
runtime.diffProcessor.reset();
|
||
runtime.activeToolsByCallId.clear();
|
||
runtime.pendingTitleByCallId.clear();
|
||
runtime.reasoningPreview = '';
|
||
if (msgType === 'task_failed') {
|
||
const error = asString(msg.error) ?? 'Task failed';
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'failed',
|
||
statusText: 'Failed',
|
||
activity: formatActivity('Failed', error),
|
||
activityKind: 'failed',
|
||
error
|
||
});
|
||
} else if (msgType === 'turn_aborted') {
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'canceled',
|
||
statusText: 'Canceled',
|
||
activity: 'Canceled',
|
||
activityKind: 'canceled'
|
||
});
|
||
} else {
|
||
const result = runtime.finalMessage;
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'completed',
|
||
statusText: 'Completed',
|
||
activity: formatActivity('Completed', result),
|
||
activityKind: 'completed',
|
||
...(result ? { result } : {})
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
let activeMessage: QueuedMessage | null = null;
|
||
let sameThreadRetryAttempt = 0;
|
||
let sameThreadCompactAttempt = 0;
|
||
let recoveryInFlight = false;
|
||
let compactRecovery: {
|
||
threadId: string;
|
||
message: QueuedMessage;
|
||
timeout: ReturnType<typeof setTimeout> | null;
|
||
} | null = null;
|
||
let loopWakeWaiter: (() => void) | null = null;
|
||
|
||
const wakeLoop = () => {
|
||
const waiter = loopWakeWaiter;
|
||
if (!waiter) {
|
||
return;
|
||
}
|
||
loopWakeWaiter = null;
|
||
waiter();
|
||
};
|
||
|
||
const waitForTurnOrRecovery = (signal: AbortSignal): Promise<void> => new Promise((resolve) => {
|
||
if (!turnInFlight && !recoveryInFlight) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const finish = () => {
|
||
if (loopWakeWaiter === finish) {
|
||
loopWakeWaiter = null;
|
||
}
|
||
signal.removeEventListener('abort', finish);
|
||
resolve();
|
||
};
|
||
|
||
loopWakeWaiter = finish;
|
||
signal.addEventListener('abort', finish, { once: true });
|
||
});
|
||
|
||
const clearCompactRecovery = (recovery: typeof compactRecovery) => {
|
||
if (!recovery) {
|
||
return;
|
||
}
|
||
if (recovery.timeout) {
|
||
clearTimeout(recovery.timeout);
|
||
}
|
||
if (compactRecovery === recovery) {
|
||
compactRecovery = null;
|
||
}
|
||
recoveryInFlight = false;
|
||
wakeLoop();
|
||
};
|
||
|
||
const failCompactRecovery = (recovery: typeof compactRecovery, message: string) => {
|
||
if (!recovery || compactRecovery !== recovery) {
|
||
return;
|
||
}
|
||
logger.warn(`[Codex] ${message}`);
|
||
messageBuffer.addMessage(message, 'status');
|
||
session.sendSessionEvent({ type: 'message', message });
|
||
activeMessage = null;
|
||
clearCompactRecovery(recovery);
|
||
};
|
||
|
||
const completeCompactRecovery = (threadId: string | null) => {
|
||
const recovery = compactRecovery;
|
||
if (!recovery) {
|
||
return false;
|
||
}
|
||
if (!threadId || threadId !== recovery.threadId) {
|
||
return false;
|
||
}
|
||
if (!this.shouldExit && this.currentThreadId === recovery.threadId) {
|
||
pending = recovery.message;
|
||
const message = 'Context compacted; retrying same conversation';
|
||
messageBuffer.addMessage(message, 'status');
|
||
session.sendSessionEvent({ type: 'message', message });
|
||
}
|
||
clearCompactRecovery(recovery);
|
||
return true;
|
||
};
|
||
|
||
const beginCompactRecovery = (threadId: string, messageToRetry: QueuedMessage, error: string | null) => {
|
||
sameThreadCompactAttempt += 1;
|
||
recoveryInFlight = true;
|
||
const recovery = {
|
||
threadId,
|
||
message: messageToRetry,
|
||
timeout: null as ReturnType<typeof setTimeout> | null
|
||
};
|
||
compactRecovery = recovery;
|
||
recovery.timeout = setTimeout(() => {
|
||
failCompactRecovery(
|
||
recovery,
|
||
'Task failed: context window overflow and same-conversation compact timed out'
|
||
);
|
||
}, SAME_THREAD_COMPACT_TIMEOUT_MS);
|
||
recovery.timeout.unref?.();
|
||
|
||
logger.debug(
|
||
`[Codex] Compacting retryable context failure on same thread ` +
|
||
`(attempt ${sameThreadCompactAttempt}/${SAME_THREAD_MAX_COMPACT_RETRIES}): ${error ?? 'unknown error'}`
|
||
);
|
||
void appServerClient.compactThread({ threadId }, { signal: this.abortController.signal })
|
||
.catch((compactError) => {
|
||
logger.warn('[Codex] Failed to start app-server thread compact before retry:', compactError);
|
||
failCompactRecovery(
|
||
recovery,
|
||
'Task failed: context window overflow and same-conversation compact failed'
|
||
);
|
||
});
|
||
};
|
||
|
||
const forwardedGoalSignaturesByThreadId = new Map<string, string>();
|
||
|
||
const shouldForwardGoalUpdate = (msg: Record<string, unknown>, threadId: string | null): boolean => {
|
||
const goal = asRecord(msg.goal);
|
||
const scopedThreadId = threadId
|
||
?? asString(goal?.threadId ?? goal?.thread_id)
|
||
?? this.currentThreadId;
|
||
if (!goal || !scopedThreadId) {
|
||
return true;
|
||
}
|
||
|
||
const signature = goalForwardSignatureKey(buildGoalForwardSignature(goal));
|
||
if (forwardedGoalSignaturesByThreadId.get(scopedThreadId) === signature) {
|
||
logger.debug(`[Codex] Suppressing duplicate thread goal update; threadId=${scopedThreadId}`);
|
||
return false;
|
||
}
|
||
|
||
forwardedGoalSignaturesByThreadId.set(scopedThreadId, signature);
|
||
return true;
|
||
};
|
||
|
||
const noteGoalCleared = (threadId: string | null) => {
|
||
if (threadId) {
|
||
forwardedGoalSignaturesByThreadId.delete(threadId);
|
||
}
|
||
};
|
||
|
||
const handleCodexEvent = (msg: Record<string, unknown>) => {
|
||
const msgType = asString(msg.type);
|
||
if (!msgType) return;
|
||
const eventTurnId = asString(msg.turn_id ?? msg.turnId);
|
||
const eventThreadId = asString(msg.thread_id ?? msg.threadId);
|
||
const isTerminalEvent = msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed';
|
||
|
||
if (msgType === 'thread_started') {
|
||
const threadId = asString(msg.thread_id ?? msg.threadId);
|
||
if (threadId) {
|
||
if (!this.currentThreadId || this.currentThreadId === threadId) {
|
||
this.currentThreadId = threadId;
|
||
session.onSessionFound(threadId);
|
||
} else {
|
||
logger.debug(
|
||
`[Codex] Ignoring thread_started for non-active thread; ` +
|
||
`eventThreadId=${threadId}, activeThread=${this.currentThreadId}`
|
||
);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (msgType === 'thread_compacted') {
|
||
completeCompactRecovery(eventThreadId);
|
||
return;
|
||
}
|
||
|
||
if (eventThreadId && this.currentThreadId && eventThreadId !== this.currentThreadId) {
|
||
logger.debug(
|
||
`[Codex] Routing event from non-active thread into agent trace; ` +
|
||
`type=${msgType}, eventThreadId=${eventThreadId}, activeThread=${this.currentThreadId}`
|
||
);
|
||
if (msgType === 'task_started') {
|
||
if (eventTurnId) {
|
||
this.activeChildTurns.set(eventThreadId, eventTurnId);
|
||
} else {
|
||
logger.debug(`[Codex] Child task_started missing turn id; threadId=${eventThreadId}`);
|
||
}
|
||
linkPendingAgentStartFromChildTask(eventThreadId);
|
||
} else if (isTerminalEvent) {
|
||
this.activeChildTurns.delete(eventThreadId);
|
||
}
|
||
handleChildCodexEvent(eventThreadId, msg);
|
||
return;
|
||
}
|
||
|
||
if (!eventThreadId && this.currentThreadId && isScopeSensitiveCodexEvent(msgType) && hasKnownChildAgents()) {
|
||
logger.debug(
|
||
`[Codex] Dropping unscoped scope-sensitive event while child agents are active; ` +
|
||
`type=${msgType}, activeThread=${this.currentThreadId}`
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (msgType === 'thread_goal_updated') {
|
||
if (shouldForwardGoalUpdate(msg, eventThreadId)) {
|
||
session.sendAgentMessage({
|
||
...addCodexEventScope(msg, 'parent', eventThreadId ?? this.currentThreadId),
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (msgType === 'thread_goal_cleared') {
|
||
noteGoalCleared(eventThreadId ?? this.currentThreadId);
|
||
session.sendAgentMessage({
|
||
...addCodexEventScope(msg, 'parent', eventThreadId ?? this.currentThreadId),
|
||
id: randomUUID()
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (msgType === 'task_started') {
|
||
const turnId = eventTurnId;
|
||
if (turnId) {
|
||
this.currentTurnId = turnId;
|
||
allowAnonymousTerminalEvent = false;
|
||
} else if (!this.currentTurnId) {
|
||
allowAnonymousTerminalEvent = true;
|
||
}
|
||
}
|
||
|
||
const isThreadStatusFailure = msgType === 'task_failed' && msg.terminal_source === 'thread_status';
|
||
const error = msgType === 'task_failed' ? asString(msg.error) : null;
|
||
const shouldCompactAndRetrySameThread = msgType === 'task_failed'
|
||
&& isContextCompactRetryableCodexError(error)
|
||
&& Boolean(activeMessage)
|
||
&& Boolean(this.currentThreadId)
|
||
&& sameThreadCompactAttempt < SAME_THREAD_MAX_COMPACT_RETRIES;
|
||
const shouldRetrySameThread = msgType === 'task_failed'
|
||
&& !shouldCompactAndRetrySameThread
|
||
&& isSameThreadRetryableCodexError(error)
|
||
&& Boolean(activeMessage)
|
||
&& Boolean(this.currentThreadId)
|
||
&& sameThreadRetryAttempt < SAME_THREAD_MAX_RETRIES;
|
||
|
||
if (isTerminalEvent) {
|
||
if (shouldIgnoreTerminalEvent({
|
||
eventTurnId,
|
||
currentTurnId: this.currentTurnId,
|
||
turnInFlight,
|
||
allowAnonymousTerminalEvent,
|
||
eventThreadId,
|
||
currentThreadId: this.currentThreadId,
|
||
allowMatchingThreadIdTerminalEvent: msg.terminal_source === 'thread_status'
|
||
})) {
|
||
logger.debug(
|
||
`[Codex] Ignoring terminal event ${msgType} without matching turn context; ` +
|
||
`eventTurnId=${eventTurnId ?? 'none'}, activeTurn=${this.currentTurnId ?? 'none'}, ` +
|
||
`eventThreadId=${eventThreadId ?? 'none'}, activeThread=${this.currentThreadId ?? 'none'}, ` +
|
||
`turnInFlight=${turnInFlight}, allowAnonymous=${allowAnonymousTerminalEvent}`
|
||
);
|
||
return;
|
||
}
|
||
if (shouldCompactAndRetrySameThread) {
|
||
const threadId = this.currentThreadId;
|
||
const messageToRetry = activeMessage;
|
||
if (threadId && messageToRetry) {
|
||
beginCompactRecovery(threadId, messageToRetry, error);
|
||
}
|
||
} else if (shouldRetrySameThread) {
|
||
sameThreadRetryAttempt += 1;
|
||
pending = activeMessage;
|
||
logger.debug(
|
||
`[Codex] Retrying retryable failure on same thread ` +
|
||
`(attempt ${sameThreadRetryAttempt}/${SAME_THREAD_MAX_RETRIES}): ${error ?? 'unknown error'}`
|
||
);
|
||
}
|
||
this.currentTurnId = null;
|
||
allowAnonymousTerminalEvent = false;
|
||
if (isThreadStatusFailure && !shouldRetrySameThread && !shouldCompactAndRetrySameThread) {
|
||
logger.warn(`[Codex] Thread-level failure on ${eventThreadId ?? this.currentThreadId ?? 'unknown thread'}; preserving same conversation`);
|
||
}
|
||
}
|
||
|
||
if (msgType === 'agent_message') {
|
||
const message = asString(msg.message);
|
||
if (message) {
|
||
messageBuffer.addMessage(message, 'assistant');
|
||
}
|
||
} else if (msgType === 'agent_reasoning') {
|
||
const text = asString(msg.text);
|
||
if (text) {
|
||
messageBuffer.addMessage(`[Thinking] ${text.substring(0, 100)}...`, 'system');
|
||
}
|
||
} else if (msgType === 'exec_command_begin') {
|
||
const command = normalizeCommand(msg.command) ?? 'command';
|
||
messageBuffer.addMessage(`Executing: ${command}`, 'tool');
|
||
} else if (msgType === 'exec_command_end') {
|
||
const output = msg.output ?? msg.error ?? 'Command completed';
|
||
const outputText = formatOutputPreview(output);
|
||
const truncatedOutput = outputText.substring(0, 200);
|
||
messageBuffer.addMessage(
|
||
`Result: ${truncatedOutput}${outputText.length > 200 ? '...' : ''}`,
|
||
'result'
|
||
);
|
||
} else if (msgType === 'task_started') {
|
||
messageBuffer.addMessage('Starting task...', 'status');
|
||
} else if (msgType === 'task_complete') {
|
||
messageBuffer.addMessage('Task completed', 'status');
|
||
} else if (msgType === 'turn_aborted') {
|
||
messageBuffer.addMessage('Turn aborted', 'status');
|
||
} else if (msgType === 'task_failed') {
|
||
if (shouldCompactAndRetrySameThread) {
|
||
const retryMessage = error
|
||
? `Task failed: ${error}; compacting same conversation before retry (${sameThreadCompactAttempt}/${SAME_THREAD_MAX_COMPACT_RETRIES})`
|
||
: `Task failed; compacting same conversation before retry (${sameThreadCompactAttempt}/${SAME_THREAD_MAX_COMPACT_RETRIES})`;
|
||
messageBuffer.addMessage(retryMessage, 'status');
|
||
session.sendSessionEvent({ type: 'message', message: retryMessage });
|
||
} else if (shouldRetrySameThread) {
|
||
const retryMessage = error
|
||
? `Task failed: ${error}; retrying same conversation (${sameThreadRetryAttempt}/${SAME_THREAD_MAX_RETRIES})`
|
||
: `Task failed; retrying same conversation (${sameThreadRetryAttempt}/${SAME_THREAD_MAX_RETRIES})`;
|
||
messageBuffer.addMessage(retryMessage, 'status');
|
||
session.sendSessionEvent({ type: 'message', message: retryMessage });
|
||
} else {
|
||
const message = error ? `Task failed: ${error}` : 'Task failed';
|
||
messageBuffer.addMessage(message, 'status');
|
||
session.sendSessionEvent({ type: 'message', message });
|
||
}
|
||
}
|
||
|
||
if (msgType === 'task_started') {
|
||
clearReadyAfterTurnTimer?.();
|
||
turnInFlight = true;
|
||
if (!eventTurnId && !this.currentTurnId) {
|
||
allowAnonymousTerminalEvent = true;
|
||
}
|
||
if (!session.thinking) {
|
||
logger.debug('thinking started');
|
||
session.onThinkingChange(true);
|
||
}
|
||
}
|
||
if (isTerminalEvent) {
|
||
turnInFlight = false;
|
||
allowAnonymousTerminalEvent = false;
|
||
if (session.thinking) {
|
||
logger.debug('thinking completed');
|
||
session.onThinkingChange(false);
|
||
}
|
||
diffProcessor.reset();
|
||
appServerEventConverter.reset();
|
||
mcpTitleByCallId.clear();
|
||
pendingAgentToolInputByCallId.clear();
|
||
childAgentActivityInCurrentTurn = false;
|
||
wakeLoop();
|
||
}
|
||
|
||
if (isTerminalEvent && !turnInFlight) {
|
||
scheduleReadyAfterTurn?.();
|
||
} else if (readyAfterTurnTimer && msgType !== 'task_started') {
|
||
scheduleReadyAfterTurn?.();
|
||
}
|
||
|
||
if (msgType === 'task_complete') {
|
||
sameThreadRetryAttempt = 0;
|
||
sameThreadCompactAttempt = 0;
|
||
recoveryInFlight = false;
|
||
clearCompactRecovery(compactRecovery);
|
||
activeMessage = null;
|
||
}
|
||
|
||
if (msgType === 'agent_reasoning_section_break') {
|
||
reasoningProcessor.handleSectionBreak();
|
||
}
|
||
if (msgType === 'agent_reasoning_delta') {
|
||
const delta = asString(msg.delta);
|
||
if (delta) {
|
||
reasoningProcessor.processDelta(delta);
|
||
}
|
||
}
|
||
if (msgType === 'agent_reasoning') {
|
||
const text = asString(msg.text);
|
||
if (text) {
|
||
reasoningProcessor.complete(text);
|
||
}
|
||
}
|
||
if (msgType === 'agent_message') {
|
||
const message = asString(msg.message);
|
||
if (message) {
|
||
session.sendAgentMessage({
|
||
type: 'message',
|
||
message,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'generated_image') {
|
||
const sourceImageId = asString(msg.image_id ?? msg.imageId ?? msg.id);
|
||
const imageId = randomUUID();
|
||
const savedPath = asString(msg.saved_path ?? msg.savedPath);
|
||
if (savedPath) {
|
||
const image = registerGeneratedImage({
|
||
id: imageId,
|
||
path: savedPath,
|
||
fileName: asString(msg.file_name ?? msg.fileName),
|
||
mimeType: asString(msg.mime_type ?? msg.mimeType)
|
||
});
|
||
messageBuffer.addMessage(`Generated image: ${image.fileName}`, 'assistant');
|
||
session.sendAgentMessage({
|
||
type: 'generated-image',
|
||
imageId: image.id,
|
||
sourceImageId,
|
||
fileName: image.fileName,
|
||
mimeType: image.mimeType,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'exec_command_begin' || msgType === 'exec_approval_request') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const inputs: Record<string, unknown> = { ...msg };
|
||
delete inputs.type;
|
||
delete inputs.call_id;
|
||
delete inputs.callId;
|
||
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name: 'CodexBash',
|
||
callId: callId,
|
||
input: inputs,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'exec_command_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const output: Record<string, unknown> = { ...msg };
|
||
delete output.type;
|
||
delete output.call_id;
|
||
delete output.callId;
|
||
output.stdout = output.output;
|
||
delete output.output;
|
||
|
||
session.sendAgentMessage({
|
||
type: 'tool-call-result',
|
||
callId: callId,
|
||
output,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'token_count') {
|
||
const threadId = eventThreadId ?? this.currentThreadId;
|
||
session.sendAgentMessage({
|
||
...addCodexEventScope(msg, 'parent', threadId),
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
if (msgType === 'context_compacted') {
|
||
const threadId = eventThreadId ?? this.currentThreadId;
|
||
session.sendAgentMessage({
|
||
...addCodexEventScope({
|
||
type: 'context_compacted',
|
||
...(eventTurnId ? { turn_id: eventTurnId } : {})
|
||
}, 'parent', threadId),
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
if (msgType === 'plan_update') {
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name: 'update_plan',
|
||
callId: 'codex-plan-state',
|
||
input: {
|
||
plan: Array.isArray(msg.plan) ? msg.plan : [],
|
||
source: 'codex'
|
||
},
|
||
id: randomUUID()
|
||
});
|
||
session.sendAgentMessage({
|
||
type: 'tool-call-result',
|
||
callId: 'codex-plan-state',
|
||
output: {
|
||
plan: Array.isArray(msg.plan) ? msg.plan : [],
|
||
source: 'codex',
|
||
status: 'updated'
|
||
},
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
if (msgType === 'patch_apply_begin') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const changes = asRecord(msg.changes) ?? {};
|
||
const changeCount = Object.keys(changes).length;
|
||
const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`;
|
||
messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool');
|
||
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name: 'CodexPatch',
|
||
callId: callId,
|
||
input: {
|
||
auto_approved: msg.auto_approved ?? msg.autoApproved,
|
||
changes
|
||
},
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'patch_apply_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
if (callId) {
|
||
const stdout = asString(msg.stdout);
|
||
const stderr = asString(msg.stderr);
|
||
const success = Boolean(msg.success);
|
||
|
||
if (success) {
|
||
const message = stdout || 'Files modified successfully';
|
||
messageBuffer.addMessage(message.substring(0, 200), 'result');
|
||
} else {
|
||
const errorMsg = stderr || 'Failed to modify files';
|
||
messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result');
|
||
}
|
||
|
||
session.sendAgentMessage({
|
||
type: 'tool-call-result',
|
||
callId: callId,
|
||
output: {
|
||
stdout,
|
||
stderr,
|
||
success
|
||
},
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'mcp_tool_call_begin') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
const invocation = asRecord(msg.invocation) ?? {};
|
||
const name = buildMcpToolName(
|
||
invocation.server ?? invocation.server_name ?? msg.server,
|
||
invocation.tool ?? invocation.tool_name ?? msg.tool
|
||
);
|
||
if (callId && name) {
|
||
const input = invocation.arguments ?? invocation.input ?? msg.arguments ?? msg.input ?? {};
|
||
const inputRecord = asRecord(input);
|
||
const requestedTitle = inputRecord ? asString(inputRecord.title) : null;
|
||
if (isHapiChangeTitleToolName(name) && requestedTitle) {
|
||
mcpTitleByCallId.set(callId, requestedTitle);
|
||
}
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name,
|
||
callId,
|
||
input,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'mcp_tool_call_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
const rawResult = msg.result;
|
||
let output = rawResult;
|
||
let isError = false;
|
||
const resultRecord = asRecord(rawResult);
|
||
if (resultRecord) {
|
||
if (Object.prototype.hasOwnProperty.call(resultRecord, 'Ok')) {
|
||
output = resultRecord.Ok;
|
||
} else if (Object.prototype.hasOwnProperty.call(resultRecord, 'Err')) {
|
||
output = resultRecord.Err;
|
||
isError = true;
|
||
}
|
||
}
|
||
|
||
if (callId) {
|
||
const title = mcpTitleByCallId.get(callId);
|
||
mcpTitleByCallId.delete(callId);
|
||
if (!isError && title) {
|
||
sendTitleSummary(title);
|
||
}
|
||
|
||
session.sendAgentMessage({
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output,
|
||
is_error: isError,
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'codex_tool_call_begin') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
const name = asString(msg.name);
|
||
if (callId && name) {
|
||
if (isCodexAgentToolName(name)) {
|
||
const input = msg.input ?? {};
|
||
pendingAgentToolInputByCallId.set(callId, { name, input });
|
||
if (name === 'spawn_agent') {
|
||
emitAgentRunStart(callId, input);
|
||
} else {
|
||
for (const agentId of extractAgentTargets(input)) {
|
||
if (!agentCardByAgentId.has(agentId)) {
|
||
continue;
|
||
}
|
||
const activity = name === 'wait_agent'
|
||
? 'Waiting for agent'
|
||
: name === 'send_input'
|
||
? 'Sending input'
|
||
: name === 'resume_agent'
|
||
? 'Resuming agent'
|
||
: name === 'close_agent'
|
||
? 'Closing agent'
|
||
: 'Running agent tool';
|
||
emitAgentRunUpdate(agentId, {
|
||
status: 'running',
|
||
statusText: activity,
|
||
activity,
|
||
activityKind: name
|
||
});
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
session.sendAgentMessage({
|
||
type: 'tool-call',
|
||
name,
|
||
callId,
|
||
input: msg.input ?? {},
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'codex_tool_call_end') {
|
||
const callId = asString(msg.call_id ?? msg.callId);
|
||
const name = asString(msg.name) ?? pendingAgentToolInputByCallId.get(callId ?? '')?.name ?? null;
|
||
if (callId) {
|
||
if (name && isCodexAgentToolName(name)) {
|
||
handleAgentToolEnd(callId, name, msg.output, Boolean(msg.is_error ?? msg.isError));
|
||
return;
|
||
}
|
||
session.sendAgentMessage({
|
||
type: 'tool-call-result',
|
||
callId,
|
||
output: msg.output,
|
||
is_error: Boolean(msg.is_error ?? msg.isError),
|
||
id: randomUUID()
|
||
});
|
||
}
|
||
}
|
||
if (msgType === 'turn_diff') {
|
||
const diff = asString(msg.unified_diff);
|
||
if (diff) {
|
||
diffProcessor.processDiff(diff);
|
||
}
|
||
}
|
||
};
|
||
|
||
registerAppServerPermissionHandlers({
|
||
client: appServerClient,
|
||
permissionHandler,
|
||
onUserInputRequest: async ({ id, input }) => {
|
||
try {
|
||
const answers = await permissionHandler.handleUserInputRequest(id, input);
|
||
return {
|
||
decision: 'accept',
|
||
answers
|
||
};
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
logger.debug(`[Codex] request_user_input failed: ${message}`);
|
||
return {
|
||
decision: 'cancel'
|
||
};
|
||
}
|
||
}
|
||
});
|
||
|
||
appServerClient.setNotificationHandler((method, params) => {
|
||
const events = appServerEventConverter.handleNotification(method, params);
|
||
for (const event of events) {
|
||
const eventRecord = asRecord(event) ?? { type: undefined };
|
||
handleCodexEvent(eventRecord);
|
||
}
|
||
});
|
||
|
||
appServerClient.setStderrHandler((text) => {
|
||
const spawnAgentError = extractSpawnAgentStartErrorFromStderr(text);
|
||
if (!spawnAgentError || pendingAgentStartCardIds.size === 0) {
|
||
return;
|
||
}
|
||
logger.debug(
|
||
`[Codex] Failing ${pendingAgentStartCardIds.size} pending spawn_agent start(s) ` +
|
||
`from app-server stderr: ${spawnAgentError}`
|
||
);
|
||
failPendingAgentStartsForSpawnArgumentError(spawnAgentError);
|
||
});
|
||
|
||
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, {
|
||
// In app-server/collab mode, child agents share this MCP bridge.
|
||
// If the MCP handler writes the title directly, child title calls
|
||
// leak into the parent HAPI session. Defer the side effect until
|
||
// parent-thread mcp_tool_call_end reaches this launcher; child
|
||
// events are filtered above by thread id.
|
||
emitTitleSummary: false
|
||
});
|
||
this.happyServer = happyServer;
|
||
|
||
this.setupAbortHandlers(session.client.rpcHandlerManager, {
|
||
onAbort: () => this.handleAbort(),
|
||
onSwitch: () => this.handleSwitchRequest()
|
||
});
|
||
|
||
function logActiveHandles(tag: string) {
|
||
if (!process.env.DEBUG) return;
|
||
const anyProc: any = process as any;
|
||
const handles = typeof anyProc._getActiveHandles === 'function' ? anyProc._getActiveHandles() : [];
|
||
const requests = typeof anyProc._getActiveRequests === 'function' ? anyProc._getActiveRequests() : [];
|
||
logger.debug(`[codex][handles] ${tag}: handles=${handles.length} requests=${requests.length}`);
|
||
try {
|
||
const kinds = handles.map((h: any) => (h && h.constructor ? h.constructor.name : typeof h));
|
||
logger.debug(`[codex][handles] kinds=${JSON.stringify(kinds)}`);
|
||
} catch {}
|
||
}
|
||
|
||
const sendReady = () => {
|
||
session.sendSessionEvent({ type: 'ready' });
|
||
};
|
||
|
||
await appServerClient.connect();
|
||
await appServerClient.initialize({
|
||
clientInfo: {
|
||
name: 'hapi-codex-client',
|
||
version: '1.0.0'
|
||
},
|
||
capabilities: {
|
||
experimentalApi: true
|
||
}
|
||
});
|
||
let supportsTurnCollaborationMode = true;
|
||
let supportsGoals = true;
|
||
try {
|
||
await appServerClient.setExperimentalFeatureEnablement({ enablement: { goals: true } });
|
||
logger.debug('[Codex] goals feature enabled');
|
||
} catch (error) {
|
||
logger.debug(`[Codex] failed to enable goals feature: ${errorMessage(error)}; will rely on configured feature state`);
|
||
}
|
||
try {
|
||
const response = await appServerClient.listCollaborationModes();
|
||
const hasPlanMode = responseContainsPlanCollaborationMode(response);
|
||
logger.debug(`[Codex] collaborationMode/list plan=${hasPlanMode}`);
|
||
if (!hasPlanMode) {
|
||
logger.debug('[Codex] collaborationMode/list did not report plan; will still attempt collaborationMode until rejected');
|
||
}
|
||
} catch (error) {
|
||
logger.debug(`[Codex] collaborationMode/list failed: ${errorMessage(error)}`);
|
||
}
|
||
|
||
let hasThread = false;
|
||
let pending: QueuedMessage | null = null;
|
||
|
||
clearReadyAfterTurnTimer = () => {
|
||
if (!readyAfterTurnTimer) {
|
||
return;
|
||
}
|
||
clearTimeout(readyAfterTurnTimer);
|
||
readyAfterTurnTimer = null;
|
||
};
|
||
|
||
scheduleReadyAfterTurn = () => {
|
||
clearReadyAfterTurnTimer?.();
|
||
readyAfterTurnTimer = setTimeout(() => {
|
||
readyAfterTurnTimer = null;
|
||
emitReadyIfIdle({
|
||
pending: pending ?? (recoveryInFlight ? activeMessage : null),
|
||
queueSize: () => session.queue.size(),
|
||
shouldExit: this.shouldExit,
|
||
sendReady
|
||
});
|
||
}, 120);
|
||
readyAfterTurnTimer.unref?.();
|
||
};
|
||
|
||
const sendVisibleStatus = (message: string) => {
|
||
messageBuffer.addMessage(message, 'status');
|
||
session.sendSessionEvent({ type: 'message', message });
|
||
};
|
||
|
||
const sendGoalEvent = (event: Record<string, unknown>) => {
|
||
const threadId = asString(event.thread_id ?? event.threadId) ?? this.currentThreadId;
|
||
if (event.type === 'thread_goal_cleared') {
|
||
noteGoalCleared(threadId);
|
||
} else if (event.type === 'thread_goal_updated' && !shouldForwardGoalUpdate(event, threadId)) {
|
||
return;
|
||
}
|
||
session.sendAgentMessage({
|
||
...addCodexEventScope(event, 'parent', this.currentThreadId),
|
||
id: randomUUID()
|
||
});
|
||
};
|
||
|
||
const resetCurrentTurnState = () => {
|
||
turnInFlight = false;
|
||
allowAnonymousTerminalEvent = false;
|
||
this.currentTurnId = null;
|
||
permissionHandler.reset();
|
||
reasoningProcessor.abort();
|
||
diffProcessor.reset();
|
||
appServerEventConverter.reset();
|
||
session.onThinkingChange(false);
|
||
};
|
||
|
||
const interruptActiveTurn = async () => {
|
||
await this.interruptActiveTurns('slash command');
|
||
};
|
||
|
||
const resumeExistingThreadForCompact = async (mode: EnhancedMode): Promise<string | null> => {
|
||
if (this.currentThreadId && this.currentThreadId !== invalidThreadId) {
|
||
hasThread = true;
|
||
return this.currentThreadId;
|
||
}
|
||
|
||
const resumeCandidate = session.sessionId && session.sessionId !== invalidThreadId
|
||
? session.sessionId
|
||
: null;
|
||
if (!resumeCandidate) {
|
||
return null;
|
||
}
|
||
|
||
const threadParams = buildThreadStartParams({
|
||
cwd: session.path,
|
||
mode,
|
||
mcpServers,
|
||
cliOverrides: session.codexCliOverrides
|
||
});
|
||
|
||
try {
|
||
const resumeResponse = await appServerClient.resumeThread({
|
||
threadId: resumeCandidate,
|
||
...threadParams
|
||
}, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const resumeRecord = asRecord(resumeResponse);
|
||
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
|
||
const threadId = asString(resumeThread?.id) ?? resumeCandidate;
|
||
applyResolvedModel(resumeRecord?.model);
|
||
this.currentThreadId = threadId;
|
||
session.onSessionFound(threadId);
|
||
hasThread = true;
|
||
logger.debug(`[Codex] Resumed app-server thread ${threadId} for /compact`);
|
||
return threadId;
|
||
} catch (error) {
|
||
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate} for /compact`, error);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const parseGoalCommand = (text: string): {
|
||
action: 'show' | 'set' | 'pause' | 'resume' | 'clear';
|
||
objective?: string;
|
||
error?: string;
|
||
} | null => {
|
||
const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(text);
|
||
if (!match) return null;
|
||
const rest = match[1]?.trim() ?? '';
|
||
if (!rest) return { action: 'show' };
|
||
switch (rest.toLowerCase()) {
|
||
case 'clear':
|
||
return { action: 'clear' };
|
||
case 'pause':
|
||
return { action: 'pause' };
|
||
case 'resume':
|
||
return { action: 'resume' };
|
||
default:
|
||
if ([...rest].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
|
||
return { action: 'set', error: `Goal objective must be at most ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} characters.` };
|
||
}
|
||
return { action: 'set', objective: rest };
|
||
}
|
||
};
|
||
|
||
const ensureThreadForGoal = async (mode: EnhancedMode): Promise<string | null> => {
|
||
if (this.currentThreadId && this.currentThreadId !== invalidThreadId) {
|
||
hasThread = true;
|
||
return this.currentThreadId;
|
||
}
|
||
|
||
const resumeCandidate = session.sessionId && session.sessionId !== invalidThreadId
|
||
? session.sessionId
|
||
: null;
|
||
if (resumeCandidate) {
|
||
const threadParams = buildThreadStartParams({
|
||
cwd: session.path,
|
||
mode,
|
||
mcpServers,
|
||
cliOverrides: session.codexCliOverrides
|
||
});
|
||
try {
|
||
const resumeResponse = await appServerClient.resumeThread({
|
||
threadId: resumeCandidate,
|
||
...threadParams
|
||
}, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const resumeRecord = asRecord(resumeResponse);
|
||
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
|
||
const threadId = asString(resumeThread?.id) ?? resumeCandidate;
|
||
applyResolvedModel(resumeRecord?.model);
|
||
this.currentThreadId = threadId;
|
||
session.onSessionFound(threadId);
|
||
hasThread = true;
|
||
return threadId;
|
||
} catch (error) {
|
||
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate} for /goal`, error);
|
||
sendVisibleStatus(`Goal failed: Codex conversation ${resumeCandidate} could not be resumed`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
if (!hasThread) {
|
||
const threadParams = buildThreadStartParams({
|
||
cwd: session.path,
|
||
mode,
|
||
mcpServers,
|
||
cliOverrides: session.codexCliOverrides
|
||
});
|
||
const threadResponse = await appServerClient.startThread(threadParams, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const threadRecord = asRecord(threadResponse);
|
||
const thread = threadRecord ? asRecord(threadRecord.thread) : null;
|
||
const threadId = asString(thread?.id);
|
||
applyResolvedModel(threadRecord?.model);
|
||
if (!threadId) {
|
||
throw new Error('app-server thread/start did not return thread.id');
|
||
}
|
||
this.currentThreadId = threadId;
|
||
session.onSessionFound(threadId);
|
||
hasThread = true;
|
||
return threadId;
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const normalizeGoal = (goal: ThreadGoal): ThreadGoal => ({
|
||
...goal,
|
||
threadId: asString((goal as unknown as Record<string, unknown>).threadId ?? (goal as unknown as Record<string, unknown>).thread_id) ?? goal.threadId,
|
||
tokenBudget: (goal as unknown as Record<string, unknown>).tokenBudget as number | null | undefined
|
||
?? (goal as unknown as Record<string, unknown>).token_budget as number | null | undefined
|
||
?? null,
|
||
tokensUsed: (goal as unknown as Record<string, unknown>).tokensUsed as number | undefined
|
||
?? (goal as unknown as Record<string, unknown>).tokens_used as number | undefined
|
||
?? 0,
|
||
timeUsedSeconds: (goal as unknown as Record<string, unknown>).timeUsedSeconds as number | undefined
|
||
?? (goal as unknown as Record<string, unknown>).time_used_seconds as number | undefined
|
||
?? 0,
|
||
createdAt: (goal as unknown as Record<string, unknown>).createdAt as number | undefined
|
||
?? (goal as unknown as Record<string, unknown>).created_at as number | undefined
|
||
?? 0,
|
||
updatedAt: (goal as unknown as Record<string, unknown>).updatedAt as number | undefined
|
||
?? (goal as unknown as Record<string, unknown>).updated_at as number | undefined
|
||
?? 0
|
||
});
|
||
|
||
const handleGoalCommand = async (message: QueuedMessage): Promise<boolean> => {
|
||
const command = parseGoalCommand(message.message);
|
||
if (!command) {
|
||
return false;
|
||
}
|
||
|
||
await interruptActiveTurn();
|
||
resetCurrentTurnState();
|
||
|
||
if (command.error) {
|
||
sendVisibleStatus(command.error);
|
||
return true;
|
||
}
|
||
|
||
if (!supportsGoals) {
|
||
sendVisibleStatus(CODEX_GOALS_UNSUPPORTED_MESSAGE);
|
||
return true;
|
||
}
|
||
|
||
const threadId = await ensureThreadForGoal(message.mode);
|
||
if (!threadId) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
if (command.action === 'show') {
|
||
const response = await appServerClient.getThreadGoal({ threadId }, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const goal = response.goal ? normalizeGoal(response.goal) : null;
|
||
if (!goal) {
|
||
sendVisibleStatus('Usage: /goal <objective>');
|
||
sendGoalEvent({ type: 'thread_goal_cleared', thread_id: threadId });
|
||
return true;
|
||
}
|
||
sendVisibleStatus(formatGoalUsage(goal));
|
||
sendGoalEvent({ type: 'thread_goal_updated', thread_id: threadId, goal });
|
||
return true;
|
||
}
|
||
|
||
if (command.action === 'clear') {
|
||
const response = await appServerClient.clearThreadGoal({ threadId }, {
|
||
signal: this.abortController.signal
|
||
});
|
||
if (response.cleared) {
|
||
sendVisibleStatus('Goal cleared');
|
||
} else {
|
||
sendVisibleStatus('No goal to clear');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const status: ThreadGoalStatus = command.action === 'pause' ? 'paused' : 'active';
|
||
const response = await appServerClient.setThreadGoal({
|
||
threadId,
|
||
...(command.action === 'set' ? { objective: command.objective } : {}),
|
||
status
|
||
}, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const goal = normalizeGoal(response.goal);
|
||
sendVisibleStatus(formatGoalUsage(goal));
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
if (/goals feature is disabled|unsupported remote app-server request|method not found/i.test(detail)) {
|
||
supportsGoals = false;
|
||
sendVisibleStatus(CODEX_GOALS_UNSUPPORTED_MESSAGE);
|
||
} else {
|
||
sendVisibleStatus(`Goal failed: ${detail}`);
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const handleSpecialCommand = async (message: QueuedMessage): Promise<boolean> => {
|
||
const specialCommand = parseCodexSpecialCommand(message.message);
|
||
if (!specialCommand.type) {
|
||
return false;
|
||
}
|
||
|
||
if (specialCommand.type === 'invalid') {
|
||
await interruptActiveTurn();
|
||
resetCurrentTurnState();
|
||
sendVisibleStatus(specialCommand.message);
|
||
return true;
|
||
}
|
||
|
||
if (specialCommand.type === 'clear') {
|
||
await interruptActiveTurn();
|
||
resetCurrentTurnState();
|
||
this.currentThreadId = null;
|
||
invalidThreadId = null;
|
||
hasThread = false;
|
||
session.resetCodexThread();
|
||
sendVisibleStatus('Context was reset');
|
||
return true;
|
||
}
|
||
|
||
await interruptActiveTurn();
|
||
resetCurrentTurnState();
|
||
const threadId = await resumeExistingThreadForCompact(message.mode);
|
||
if (!threadId) {
|
||
sendVisibleStatus('Nothing to compact');
|
||
return true;
|
||
}
|
||
|
||
sendVisibleStatus('Compaction started');
|
||
try {
|
||
await appServerClient.compactThread({ threadId }, {
|
||
signal: this.abortController.signal
|
||
});
|
||
sendVisibleStatus('Compaction completed');
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
sendVisibleStatus(`Compaction failed: ${detail}`);
|
||
}
|
||
return true;
|
||
};
|
||
|
||
while (!this.shouldExit) {
|
||
logActiveHandles('loop-top');
|
||
if (!pending && (turnInFlight || recoveryInFlight) && session.queue.size() === 0) {
|
||
await waitForTurnOrRecovery(this.abortController.signal);
|
||
if (this.abortController.signal.aborted && !this.shouldExit) {
|
||
logger.debug('[codex]: Internal wait aborted while turn/recovery was active; continuing');
|
||
continue;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let message: QueuedMessage | null = pending;
|
||
const isRetryMessage = Boolean(message);
|
||
pending = null;
|
||
if (!message) {
|
||
sameThreadRetryAttempt = 0;
|
||
sameThreadCompactAttempt = 0;
|
||
activeMessage = null;
|
||
const waitSignal = this.abortController.signal;
|
||
const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal);
|
||
if (!batch) {
|
||
if (waitSignal.aborted && !this.shouldExit) {
|
||
logger.debug('[codex]: Wait aborted while idle; ignoring and continuing');
|
||
continue;
|
||
}
|
||
logger.debug(`[codex]: batch=${!!batch}, shouldExit=${this.shouldExit}`);
|
||
break;
|
||
}
|
||
message = batch;
|
||
}
|
||
|
||
if (!message) {
|
||
break;
|
||
}
|
||
|
||
if (!isRetryMessage) {
|
||
messageBuffer.addMessage(message.message, 'user');
|
||
}
|
||
activeMessage = message;
|
||
|
||
try {
|
||
if (await handleGoalCommand(message)) {
|
||
continue;
|
||
}
|
||
|
||
if (await handleSpecialCommand(message)) {
|
||
continue;
|
||
}
|
||
|
||
if (!hasThread) {
|
||
const threadParams = buildThreadStartParams({
|
||
cwd: session.path,
|
||
mode: message.mode,
|
||
mcpServers,
|
||
cliOverrides: session.codexCliOverrides
|
||
});
|
||
|
||
const resumeCandidate = session.sessionId ?? null;
|
||
let threadId: string | null = null;
|
||
|
||
if (resumeCandidate) {
|
||
try {
|
||
const resumeResponse = await appServerClient.resumeThread({
|
||
threadId: resumeCandidate,
|
||
...threadParams
|
||
}, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const resumeRecord = asRecord(resumeResponse);
|
||
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
|
||
threadId = asString(resumeThread?.id) ?? resumeCandidate;
|
||
applyResolvedModel(resumeRecord?.model);
|
||
logger.debug(`[Codex] Resumed app-server thread ${threadId}`);
|
||
} catch (error) {
|
||
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary`, error);
|
||
const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created`;
|
||
messageBuffer.addMessage(failureMessage, 'status');
|
||
session.sendSessionEvent({ type: 'message', message: failureMessage });
|
||
pending = null;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (!threadId) {
|
||
const threadResponse = await appServerClient.startThread(threadParams, {
|
||
signal: this.abortController.signal
|
||
});
|
||
const threadRecord = asRecord(threadResponse);
|
||
const thread = threadRecord ? asRecord(threadRecord.thread) : null;
|
||
threadId = asString(thread?.id);
|
||
applyResolvedModel(threadRecord?.model);
|
||
if (!threadId) {
|
||
throw new Error('app-server thread/start did not return thread.id');
|
||
}
|
||
}
|
||
|
||
if (!threadId) {
|
||
throw new Error('app-server resume did not return thread.id');
|
||
}
|
||
|
||
this.currentThreadId = threadId;
|
||
session.onSessionFound(threadId);
|
||
hasThread = true;
|
||
} else {
|
||
if (!this.currentThreadId) {
|
||
logger.debug('[Codex] Missing thread id; restarting app-server thread');
|
||
hasThread = false;
|
||
pending = message;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
turnInFlight = true;
|
||
allowAnonymousTerminalEvent = false;
|
||
const mode = {
|
||
...message.mode,
|
||
model: session.getModel() ?? message.mode.model
|
||
};
|
||
const shouldSendCollaborationMode = supportsTurnCollaborationMode
|
||
&& Boolean(mode.collaborationMode);
|
||
const buildParams = (suppressCollaborationMode: boolean) => buildTurnStartParams({
|
||
threadId: this.currentThreadId!,
|
||
message: message.message,
|
||
cwd: session.path,
|
||
mode,
|
||
cliOverrides: session.codexCliOverrides,
|
||
overrides: suppressCollaborationMode
|
||
? { suppressCollaborationMode: true }
|
||
: undefined
|
||
});
|
||
if (
|
||
mode.collaborationMode === 'plan'
|
||
&& !supportsTurnCollaborationMode
|
||
) {
|
||
session.sendSessionEvent({
|
||
type: 'message',
|
||
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
|
||
});
|
||
}
|
||
let turnResponse: unknown;
|
||
try {
|
||
turnResponse = await appServerClient.startTurn(buildParams(!shouldSendCollaborationMode), {
|
||
signal: this.abortController.signal
|
||
});
|
||
} catch (error) {
|
||
if (shouldSendCollaborationMode && shouldRetryWithoutCollaborationMode(error)) {
|
||
supportsTurnCollaborationMode = false;
|
||
if (mode.collaborationMode === 'plan') {
|
||
session.sendSessionEvent({
|
||
type: 'message',
|
||
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
|
||
});
|
||
}
|
||
turnResponse = await appServerClient.startTurn(buildParams(true), {
|
||
signal: this.abortController.signal
|
||
});
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
const turnRecord = asRecord(turnResponse);
|
||
const turn = turnRecord ? asRecord(turnRecord.turn) : null;
|
||
const turnId = asString(turn?.id);
|
||
if (turnInFlight) {
|
||
if (turnId) {
|
||
this.currentTurnId = turnId;
|
||
} else if (!this.currentTurnId) {
|
||
allowAnonymousTerminalEvent = true;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.warn('Error in codex session:', error);
|
||
const isAbortError = error instanceof Error && error.name === 'AbortError';
|
||
turnInFlight = false;
|
||
allowAnonymousTerminalEvent = false;
|
||
this.currentTurnId = null;
|
||
|
||
if (isAbortError) {
|
||
messageBuffer.addMessage('Aborted by user', 'status');
|
||
session.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
|
||
} else {
|
||
messageBuffer.addMessage('Process exited unexpectedly', 'status');
|
||
session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
|
||
this.currentTurnId = null;
|
||
this.currentThreadId = null;
|
||
hasThread = false;
|
||
}
|
||
} finally {
|
||
if (!turnInFlight) {
|
||
permissionHandler.reset();
|
||
reasoningProcessor.abort();
|
||
diffProcessor.reset();
|
||
appServerEventConverter.reset();
|
||
mcpTitleByCallId.clear();
|
||
pendingAgentToolInputByCallId.clear();
|
||
pendingAgentTracesByAgentId.clear();
|
||
cancelAllPendingThrottledAgentRunUpdates();
|
||
childAgentRuntimeById.clear();
|
||
session.onThinkingChange(false);
|
||
clearReadyAfterTurnTimer?.();
|
||
emitReadyIfIdle({
|
||
pending: pending ?? (recoveryInFlight ? activeMessage : null),
|
||
queueSize: () => session.queue.size(),
|
||
shouldExit: this.shouldExit,
|
||
sendReady
|
||
});
|
||
}
|
||
logActiveHandles('after-turn');
|
||
}
|
||
}
|
||
|
||
failPendingAgentStarts('spawn_agent did not return an agent id before the Codex session ended');
|
||
cancelAllPendingThrottledAgentRunUpdates();
|
||
}
|
||
|
||
protected async cleanup(): Promise<void> {
|
||
logger.debug('[codex-remote]: cleanup start');
|
||
this.appServerClient.setStderrHandler(null);
|
||
try {
|
||
await this.appServerClient.disconnect();
|
||
} catch (error) {
|
||
logger.debug('[codex-remote]: Error disconnecting client', error);
|
||
}
|
||
|
||
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
|
||
|
||
if (this.happyServer) {
|
||
this.happyServer.stop();
|
||
this.happyServer = null;
|
||
}
|
||
|
||
this.permissionHandler?.reset();
|
||
this.reasoningProcessor?.abort();
|
||
this.diffProcessor?.reset();
|
||
this.permissionHandler = null;
|
||
this.reasoningProcessor = null;
|
||
this.diffProcessor = null;
|
||
this.activeChildTurns.clear();
|
||
|
||
logger.debug('[codex-remote]: cleanup done');
|
||
}
|
||
}
|
||
|
||
export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
|
||
const launcher = new CodexRemoteLauncher(session);
|
||
return launcher.launch();
|
||
}
|