mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* feat(codex): allow historical forks while a turn is running * fix(web): fork user-only completed turns during generation
631 lines
41 KiB
TypeScript
631 lines
41 KiB
TypeScript
import { join } from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { z } from 'zod';
|
|
import type { SessionBootstrapResult } from '@/agent/sessionFactory';
|
|
import type { ApiSessionClient } from '@/api/apiSession';
|
|
import { logger } from '@/ui/logger';
|
|
import { listSlashCommands } from '@/modules/common/slashCommands';
|
|
import { normalizeCodexModel } from '@/modules/common/codexModels';
|
|
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
|
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
|
import { ImplementCodexPlanRequestSchema, type ImplementCodexPlanResult } from '@hapi/protocol/apiTypes';
|
|
import { CodexAppServerClient, isIndeterminateError } from '../codexAppServerClient';
|
|
import { buildHapiMcpBridge, type HapiMcpBridge } from '../utils/buildHapiMcpBridge';
|
|
import { buildUserInputFromMessage } from '../utils/appServerConfig';
|
|
import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig';
|
|
import { resolveCodexSlashCommand } from '../utils/slashCommands';
|
|
import { parseReasoningEffortValue } from '../utils/reasoningEffort';
|
|
import { SharedCodexPermissions } from './permissions';
|
|
import { SharedCodexQueue } from './queue';
|
|
import { SharedCodexProjection, inputText } from './projection';
|
|
import { getCodexSystemPrompt } from '../utils/systemPrompt';
|
|
import { record, string } from './gateway';
|
|
import { initializeSharedClient, type SharedLaunchOptions } from './launch';
|
|
import { inheritedSandbox, settingsMatch } from './settings';
|
|
import { planImplementationMessageId, planProposalForItem, planProposalForTurn } from './plan';
|
|
|
|
type RuntimeSettings = NonNullable<Parameters<ApiSessionClient['keepAlive']>[2]>;
|
|
export type RootHost = {
|
|
directory: string; generation: string; endpoint: string; token?: string;
|
|
settingsFor(threadId: string): Record<string, unknown> | undefined;
|
|
create(method: 'thread/start' | 'thread/fork', params: Record<string, unknown>, parent?: SharedCodexRoot): Promise<SharedCodexRoot>;
|
|
end(root: SharedCodexRoot, nativeArchive?: boolean): Promise<void>;
|
|
};
|
|
const SettingsSchema = z.object({
|
|
permissionMode: z.enum(['default', 'read-only', 'autoReview', 'yolo']).optional(),
|
|
model: z.string().min(1).optional(), modelReasoningEffort: z.string().nullable().optional(),
|
|
collaborationMode: z.enum(['default', 'plan']).optional(),
|
|
serviceTier: z.enum(['fast', 'standard']).nullable().optional(),
|
|
personality: z.enum(['friendly', 'pragmatic', 'none']).optional()
|
|
});
|
|
|
|
/** One immutable HAPI identity + independent subscription for one root. */
|
|
export class SharedCodexRoot {
|
|
readonly client: CodexAppServerClient;
|
|
readonly session: ApiSessionClient;
|
|
bridge!: HapiMcpBridge;
|
|
threadId = '';
|
|
private permissions!: SharedCodexPermissions;
|
|
private queue!: SharedCodexQueue;
|
|
private projection!: SharedCodexProjection;
|
|
private readonly children = new Map<string, SharedCodexProjection>();
|
|
private readonly ancestry = new Map<string, string | null>();
|
|
private heartbeat?: ReturnType<typeof setInterval>;
|
|
private work: Promise<unknown> = Promise.resolve();
|
|
private notifications = Promise.resolve();
|
|
private currentTurn: string | undefined;
|
|
// Ephemeral observations only: dequeue/compaction never prove acceptance.
|
|
private readonly dequeuedInputs = new Set<string>();
|
|
private deliveryTurn?: string;
|
|
private compactingTurn?: string;
|
|
private publishedCompactionIds?: string;
|
|
private latestTurn?: { id: string; status: string; planId?: string };
|
|
private publishedPlanId: string | null | undefined;
|
|
private submittingPlan = false;
|
|
private steeringActive: boolean | undefined;
|
|
private turnRevision = 0;
|
|
private settingsRevision = 0;
|
|
private refreshing?: Promise<void>;
|
|
private interrupted = false;
|
|
private closed = false;
|
|
private stopping = false;
|
|
private closing?: Promise<void>;
|
|
private readonly controls = new Set<Promise<unknown>>();
|
|
private reconnecting = false;
|
|
private settings: RuntimeSettings = {};
|
|
private settingsNative: Record<string, unknown> = {};
|
|
private settingsNotification?: Record<string, unknown>;
|
|
private readonly settingsListeners = new Set<() => void>();
|
|
private ready!: () => void;
|
|
private readonly bound = new Promise<void>(resolve => { this.ready = resolve; });
|
|
|
|
constructor(readonly bootstrap: SessionBootstrapResult, private readonly host: RootHost) {
|
|
this.session = bootstrap.session;
|
|
this.client = new CodexAppServerClient({ endpoint: host.endpoint, token: host.token, cwd: bootstrap.workingDirectory });
|
|
this.client.setNotificationHandler((method, params) => {
|
|
if (method === 'serverRequest/resolved') {
|
|
this.permissions?.resolved(string(record(params).threadId) ?? '', record(params).requestId); return;
|
|
}
|
|
if (method === 'thread/settings/updated' && record(params).threadId === this.threadId) this.acceptSettings(record(record(params).threadSettings));
|
|
if (record(params).threadId === this.threadId) {
|
|
if (method === 'turn/started') {
|
|
this.turnRevision++; this.currentTurn = string(record(record(params).turn).id); this.interrupted = false;
|
|
if (this.currentTurn) this.latestTurn = { id: this.currentTurn, status: 'inProgress' };
|
|
this.publishSteering();
|
|
}
|
|
if (method === 'turn/completed' && (!this.currentTurn || record(record(params).turn).id === this.currentTurn)) {
|
|
this.turnRevision++; this.currentTurn = undefined; this.interrupted = record(record(params).turn).status === 'interrupted';
|
|
this.publishSteering();
|
|
}
|
|
const p = record(params);
|
|
if (method === 'item/completed' && this.latestTurn && this.latestTurn.id === p.turnId) {
|
|
const planId = planProposalForItem(this.threadId, this.latestTurn.id, p.item);
|
|
if (planId) { this.turnRevision++; this.latestTurn.planId = planId; }
|
|
}
|
|
if (method === 'turn/completed') {
|
|
const turn = record(p.turn);
|
|
const id = string(turn.id);
|
|
if (id && (!this.latestTurn || this.latestTurn.id === id)) {
|
|
this.latestTurn = { id, status: string(turn.status) ?? 'unknown',
|
|
planId: planProposalForTurn(this.threadId, turn) ?? this.latestTurn?.planId };
|
|
}
|
|
}
|
|
this.publishPlan();
|
|
}
|
|
const receivedAt = Date.now();
|
|
const modelAtReceipt = record(params).threadId === this.threadId ? this.settings.model ?? undefined : undefined;
|
|
this.notifications = this.notifications.then(() => this.notification(method, params, modelAtReceipt, receivedAt)).catch(error => logger.debug('[Codex shared] projection', error));
|
|
});
|
|
this.client.setTransportAbandonedHandler(() => { void this.reconnect(); });
|
|
this.session.onUserMessage((message, localId) => {
|
|
this.work = this.work.catch(() => {}).then(async () => {
|
|
await this.bound;
|
|
if (this.closed || this.stopping) return;
|
|
const id = localId ?? randomUUID();
|
|
const text = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
|
const resolved = text.trim().startsWith('/') ? await this.queue.command(id, () => this.command(text)) : text;
|
|
if (resolved === null) { this.session.emitMessagesConsumed([id], { clearQueuedThinkingGrace: true }); return; }
|
|
await this.queue.enqueue(id, buildUserInputFromMessage(resolved), this.interrupted);
|
|
}).catch(error => this.notice(`Message not confirmed: ${error instanceof Error ? error.message : error}. Inspect the queue before retrying.`));
|
|
});
|
|
this.session.onCancelQueuedMessage(async id => { await this.bound; return this.stopping ? 'indeterminate' : this.queue.cancel(id); });
|
|
// Absence from history is NOT proof an uncertain mutation failed.
|
|
this.session.onRetryQueuedMessage(async id => {
|
|
await this.bound;
|
|
if (this.stopping) return false;
|
|
await this.refresh();
|
|
return ['rejected', 'canceled', 'released'].includes(this.queue.state(id) ?? '');
|
|
});
|
|
this.session.onReconnect(() => {
|
|
if (!this.threadId || this.closed || this.stopping) return;
|
|
// An emitted socket packet is not a durable hub acknowledgement.
|
|
// Replay final native history with stable IDs after every reconnect.
|
|
this.projection?.reset();
|
|
this.steeringActive = undefined;
|
|
this.publishedPlanId = undefined;
|
|
this.publishedCompactionIds = undefined;
|
|
void this.refresh().then(() => this.refreshChildren(false)).then(() => this.queue.replay())
|
|
.catch(error => logger.debug('[Codex shared] hub resync', error));
|
|
});
|
|
}
|
|
|
|
async prepare(): Promise<void> {
|
|
// Descendants share this bridge. The projection applies successful
|
|
// root title calls so a child cannot rename the parent session.
|
|
this.bridge = await buildHapiMcpBridge(this.session, { exportSessionEnv: false, emitTitleSummary: false,
|
|
skillLookup: { workingDirectory: this.bootstrap.workingDirectory, flavor: 'codex' } });
|
|
await initializeSharedClient(this.client);
|
|
this.permissions = new SharedCodexPermissions(this.session, this.client, this.host.generation);
|
|
this.client.setServerRequestHandler(request => { void this.receiveRequest(request); });
|
|
}
|
|
private async receiveRequest(request: { id: string | number; method: string; params: unknown }): Promise<void> {
|
|
await this.bound;
|
|
if (this.closed || this.stopping) return;
|
|
const threadId = string(record(request.params).threadId);
|
|
// App-server automatically subscribes every initialized connection to
|
|
// newly created threads, including siblings. Never answer their calls.
|
|
if (threadId && await this.ownsThread(threadId)) this.permissions.receive(request);
|
|
}
|
|
private async ownsThread(threadId: string): Promise<boolean> {
|
|
const visited = new Set<string>();
|
|
let candidate: string | null = threadId;
|
|
while (candidate && !visited.has(candidate)) {
|
|
if (candidate === this.threadId) return true;
|
|
visited.add(candidate);
|
|
if (!this.ancestry.has(candidate)) {
|
|
try {
|
|
const thread = record(record(await this.client.request('thread/read', { threadId: candidate, includeTurns: false })).thread);
|
|
this.ancestry.set(candidate, string(thread.parentThreadId) ?? null);
|
|
} catch { return false; }
|
|
}
|
|
candidate = this.ancestry.get(candidate) ?? null;
|
|
}
|
|
return false;
|
|
}
|
|
config(params: Record<string, unknown>): Record<string, unknown> {
|
|
return { ...params, cwd: params.cwd ?? this.bootstrap.workingDirectory,
|
|
developerInstructions: params.developerInstructions ?? getCodexSystemPrompt(), config: {
|
|
...record(params.config), 'mcp_servers.hapi': this.bridge.mcpServers.hapi,
|
|
'shell_environment_policy.set.HAPI_SESSION_ID': this.session.sessionId
|
|
} };
|
|
}
|
|
async bind(threadId: string, response: Record<string, unknown>, subscribe: boolean): Promise<void> {
|
|
if (this.threadId && this.threadId !== threadId) throw new Error('Cannot retarget a shared HAPI session');
|
|
this.threadId = threadId;
|
|
this.queue = new SharedCodexQueue(this.client, threadId, join(this.host.directory, `${this.session.sessionId}.queue.json`),
|
|
(ids, steered) => {
|
|
this.session.emitMessagesConsumed(ids, { steered });
|
|
for (const id of ids) this.dequeuedInputs.delete(id);
|
|
this.publishCompaction();
|
|
}, ids => this.session.emitSteerIndeterminate(ids),
|
|
(id, input) => this.session.syncNativeQueuedMessage(id, input === null ? null : inputText(input)),
|
|
ids => this.session.setSteerDeliveryState(ids, 'queued'));
|
|
await this.queue.load();
|
|
this.projection = new SharedCodexProjection(this.session, threadId, id => this.queue.committed(id));
|
|
this.session.updateMetadata(metadata => ({ ...metadata, codexSessionId: threadId, capabilities: {
|
|
...metadata.capabilities, concurrentClients: true, terminal: true,
|
|
// In-place rewind needs a native + hub commit barrier. Do not
|
|
// advertise a destructive operation we cannot make atomic yet.
|
|
conversationHistory: { forkCurrent: true, forkAtMessage: true, rewindToMessage: false }
|
|
} }));
|
|
this.session.updateAgentState(state => ({ ...state, controlledByUser: false, startingMode: undefined, codexPlanProposalId: null, codexCompactionPendingMessageIds: [], requests: {},
|
|
completedRequests: { ...state.completedRequests, ...Object.fromEntries(Object.entries(state.requests ?? {}).map(([id, request]) =>
|
|
[id, { ...request, completedAt: Date.now(), status: 'canceled' as const }])) }
|
|
}));
|
|
if (subscribe) response = record(await this.client.request('thread/resume', { threadId }));
|
|
this.acceptSettings(response); this.acceptSettings(this.host.settingsFor(threadId) ?? {});
|
|
await this.projection.history(response.thread); await this.refresh(); await this.refreshChildren(true);
|
|
}
|
|
async activate(options: SharedLaunchOptions = {}): Promise<void> {
|
|
// Restored input cannot run before the cold-resume settings are applied.
|
|
await this.initialSettings(options);
|
|
if (this.stopping) throw new Error('Codex execution is stopping');
|
|
this.registerControls(); this.ready();
|
|
this.heartbeat = setInterval(() => this.alive(), 2_000); this.alive(); this.session.emitSessionReady();
|
|
}
|
|
private publishSteering(): void {
|
|
this.publishCompaction();
|
|
const active = Boolean(this.currentTurn) && !this.stopping && !this.closed && !this.reconnecting && this.client.isInitialized();
|
|
if (this.steeringActive === active) return;
|
|
this.steeringActive = active;
|
|
this.session.updateAgentState(state => ({ ...state, steeringActive: active }));
|
|
}
|
|
private publishCompaction(): void {
|
|
const active = this.compactingTurn && this.compactingTurn === this.currentTurn
|
|
&& !this.closed && !this.stopping && !this.reconnecting && this.client.isInitialized();
|
|
const ids = active ? [...this.dequeuedInputs].filter(id => this.queue?.state(id) === 'unknown').sort() : [];
|
|
const key = JSON.stringify(ids);
|
|
if (key === this.publishedCompactionIds) return;
|
|
this.publishedCompactionIds = key;
|
|
this.session.updateAgentState(state => ({ ...state, codexCompactionPendingMessageIds: ids }));
|
|
}
|
|
private alive(): void {
|
|
this.publishSteering();
|
|
this.publishPlan();
|
|
if (!this.closed) this.session.keepAlive(Boolean(this.currentTurn), undefined, this.settings);
|
|
}
|
|
private availablePlanId(): string | null {
|
|
if (this.currentTurn || this.latestTurn?.status !== 'completed' || this.settings.collaborationMode !== 'plan'
|
|
|| this.stopping || this.closed || this.reconnecting || this.submittingPlan || !this.client.isInitialized()) return null;
|
|
const id = this.latestTurn.planId;
|
|
// An uncertain/accepted submission must never become a fresh Execute button after reconnect.
|
|
return id && !this.queue?.owns(planImplementationMessageId(id)) ? id : null;
|
|
}
|
|
private publishPlan(): void {
|
|
const id = this.availablePlanId();
|
|
if (id === this.publishedPlanId) return;
|
|
this.publishedPlanId = id;
|
|
this.session.updateAgentState(state => ({ ...state, codexPlanProposalId: id }));
|
|
}
|
|
acceptSettings(value: Record<string, unknown>): void {
|
|
if (typeof value.model !== 'string') return;
|
|
this.settingsRevision++;
|
|
if ('collaborationMode' in value && 'sandboxPolicy' in value) this.settingsNotification = value;
|
|
this.settingsNative = { ...this.settingsNative, ...value };
|
|
const sandbox = record(value.sandboxPolicy ?? value.sandbox).type;
|
|
this.settings = { ...this.settings, model: value.model,
|
|
modelReasoningEffort: string(value.effort ?? value.reasoningEffort) ?? null,
|
|
...(sandbox ? { permissionMode: sandbox === 'dangerFullAccess' ? 'yolo' : sandbox === 'readOnly' ? 'read-only'
|
|
: this.settingsNative.approvalsReviewer === 'auto_review' ? 'autoReview' : 'default' } : {}),
|
|
...('serviceTier' in value ? { serviceTier: value.serviceTier === 'priority' ? 'fast' : 'standard' } : {}),
|
|
...('collaborationMode' in value ? { collaborationMode: record(value.collaborationMode).mode === 'plan' ? 'plan' as const : 'default' as const } : {})
|
|
};
|
|
for (const listener of this.settingsListeners) listener();
|
|
this.publishPlan();
|
|
}
|
|
private async notification(method: string, params: unknown, modelAtReceipt?: string, receivedAt = Date.now()): Promise<void> {
|
|
if (!this.threadId || this.closed) return;
|
|
const p = record(params);
|
|
if (method === 'thread/started') {
|
|
const thread = record(p.thread); const id = string(thread.id);
|
|
if (id) this.ancestry.set(id, string(thread.parentThreadId) ?? null);
|
|
}
|
|
const eventThread = string(p.threadId) ?? string(record(p.thread).id);
|
|
if (eventThread && eventThread !== this.threadId) {
|
|
if (!await this.ownsThread(eventThread)) return;
|
|
let projection = this.children.get(eventThread);
|
|
if (!projection) {
|
|
projection = new SharedCodexProjection(this.session, eventThread, async () => {}, this.threadId);
|
|
this.children.set(eventThread, projection);
|
|
}
|
|
await projection.notification(method, params); return;
|
|
}
|
|
if (method === 'turn/completed') this.session.sendSessionEvent({ type: 'ready' });
|
|
if (method === 'thread/name/updated' && (typeof p.threadName === 'string' || p.threadName === null)) {
|
|
const name = p.threadName ?? undefined; this.session.updateMetadata(metadata => ({ ...metadata, name }));
|
|
}
|
|
if (method === 'thread/archived') { await this.host.end(this, false); return; }
|
|
// Process these in notification order: queue removal can precede turn/started.
|
|
// Older unknown input must not acquire a later turn's compaction status.
|
|
if (method === 'turn/started') {
|
|
const id = string(record(p.turn).id);
|
|
if (this.deliveryTurn && this.deliveryTurn !== id) this.dequeuedInputs.clear();
|
|
this.deliveryTurn = id;
|
|
this.compactingTurn = undefined;
|
|
}
|
|
if (method === 'turn/completed' && record(p.turn).id === this.deliveryTurn) {
|
|
this.dequeuedInputs.clear(); this.deliveryTurn = undefined; this.compactingTurn = undefined;
|
|
}
|
|
if (p.turnId === this.currentTurn && record(p.item).type === 'contextCompaction') {
|
|
if (method === 'item/started') this.compactingTurn = string(p.turnId);
|
|
if (method === 'item/completed') { this.compactingTurn = undefined; this.dequeuedInputs.clear(); }
|
|
}
|
|
if (method === 'thread/queue/changed') {
|
|
for (const id of await this.queue.reconcile()) this.dequeuedInputs.add(id);
|
|
}
|
|
await this.projection.notification(method, params, modelAtReceipt, receivedAt); this.publishCompaction(); this.alive();
|
|
}
|
|
async readThread(threadId = this.threadId): Promise<Record<string, unknown>> {
|
|
let thread = record(record(await this.client.request('thread/read', { threadId, includeTurns: false })).thread);
|
|
if (thread.historyMode === 'paginated') {
|
|
const turns: unknown[] = []; let cursor: string | undefined;
|
|
do {
|
|
const page = record(await this.client.request('thread/turns/list', { threadId, cursor, sortDirection: 'asc', itemsView: 'full' }));
|
|
if (!Array.isArray(page.data)) throw new Error('Invalid Codex history');
|
|
turns.push(...page.data); cursor = string(page.nextCursor);
|
|
} while (cursor);
|
|
thread.turns = turns;
|
|
} else thread = record(record(await this.client.request('thread/read', { threadId, includeTurns: true })).thread);
|
|
return thread;
|
|
}
|
|
refresh(): Promise<void> {
|
|
return this.refreshing ??= this.refreshNow().finally(() => { this.refreshing = undefined; });
|
|
}
|
|
private async refreshNow(): Promise<void> {
|
|
if (!this.threadId || this.closed || !this.client.isInitialized()) return;
|
|
const revision = this.turnRevision;
|
|
const thread = await this.readThread();
|
|
const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : [];
|
|
if (revision === this.turnRevision) {
|
|
this.currentTurn = string(turns.find(turn => turn.status === 'inProgress')?.id);
|
|
this.interrupted = turns.at(-1)?.status === 'interrupted';
|
|
const last = turns.at(-1);
|
|
const id = string(last?.id);
|
|
this.latestTurn = id && last ? { id, status: string(last.status) ?? 'unknown', planId: planProposalForTurn(this.threadId, last) } : undefined;
|
|
}
|
|
await this.projection.history(thread); await this.queue.reconcile(); this.alive();
|
|
}
|
|
private async refreshChildren(subscribe: boolean): Promise<void> {
|
|
let cursor: string | undefined;
|
|
do {
|
|
const page = record(await this.client.request('thread/list', { ancestorThreadId: this.threadId, cursor,
|
|
sourceKinds: ['subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther'] }));
|
|
for (const value of Array.isArray(page.data) ? page.data : []) {
|
|
const thread = record(value); const id = string(thread.id);
|
|
if (id && id !== this.threadId) this.ancestry.set(id, string(thread.parentThreadId) ?? null);
|
|
if (id && id !== this.threadId && await this.ownsThread(id) && !this.children.has(id)) {
|
|
this.children.set(id, new SharedCodexProjection(this.session, id, async () => {}, this.threadId));
|
|
}
|
|
}
|
|
cursor = string(page.nextCursor);
|
|
} while (cursor);
|
|
const loaded = new Set<string>(); cursor = undefined;
|
|
if (subscribe && this.children.size) do {
|
|
const page = record(await this.client.request('thread/loaded/list', { cursor }));
|
|
for (const id of Array.isArray(page.data) ? page.data : []) if (typeof id === 'string') loaded.add(id);
|
|
cursor = string(page.nextCursor);
|
|
} while (cursor);
|
|
for (const [id, projection] of this.children) {
|
|
// Replaying a completed/unloaded child must not start its engine.
|
|
try {
|
|
if (subscribe && loaded.has(id)) await this.client.request('thread/resume', { threadId: id });
|
|
projection.reset(); await projection.history(await this.readThread(id));
|
|
} catch (error) { logger.debug('[Codex shared] child history unavailable', { id, error }); }
|
|
}
|
|
}
|
|
nativeQueueDeleted(nativeId: string): Promise<void> { return this.queue.deleted(nativeId); }
|
|
replaySettings(): Array<{ method: string; params: unknown }> {
|
|
return this.settingsNotification ? [{ method: 'thread/settings/updated', params: {
|
|
threadId: this.threadId, threadSettings: this.settingsNotification
|
|
} }] : [];
|
|
}
|
|
private async reconnect(): Promise<void> {
|
|
if (this.closed || this.stopping || this.reconnecting || !this.threadId) return;
|
|
this.reconnecting = true;
|
|
this.dequeuedInputs.clear(); this.deliveryTurn = undefined; this.compactingTurn = undefined;
|
|
this.publishSteering(); this.publishPlan(); this.permissions.close();
|
|
try {
|
|
while (!this.closed && !this.stopping) {
|
|
try {
|
|
await initializeSharedClient(this.client);
|
|
if (this.stopping) return;
|
|
this.permissions = new SharedCodexPermissions(this.session, this.client, `${this.host.generation}:${randomUUID()}`);
|
|
this.client.setServerRequestHandler(request => { void this.receiveRequest(request); });
|
|
const settingsRevision = this.settingsRevision;
|
|
const response = record(await this.client.request('thread/resume', { threadId: this.threadId }));
|
|
const observedSettings = this.settingsRevision !== settingsRevision;
|
|
this.acceptSettings(response);
|
|
if (!observedSettings) this.acceptSettings(this.host.settingsFor(this.threadId) ?? {});
|
|
this.projection.reset(); await this.refresh(); this.queue.replay(); await this.refreshChildren(true);
|
|
return;
|
|
} catch (error) {
|
|
this.permissions.close();
|
|
logger.debug('[Codex shared] reconnect', error); await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
}
|
|
}
|
|
} finally { this.reconnecting = false; this.publishSteering(); this.publishPlan(); }
|
|
}
|
|
async applySettings(raw: unknown): Promise<{ applied: RuntimeSettings }> {
|
|
const config = SettingsSchema.parse(raw);
|
|
if (Object.keys(config).length === 0) return { applied: this.settings };
|
|
if (typeof config.modelReasoningEffort === 'string') config.modelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort);
|
|
if (config.modelReasoningEffort === null) {
|
|
// Native public null means "leave effort alone", not reset. Resolve
|
|
// the selected model's real default instead of publishing a false reset.
|
|
let cursor: string | undefined;
|
|
do {
|
|
const page = await this.client.listModels({ cursor, includeHidden: true });
|
|
const model = page.data?.find(model => (model.model ?? model.id) === (config.model ?? this.settingsNative.model));
|
|
if (model?.defaultReasoningEffort) { config.modelReasoningEffort = model.defaultReasoningEffort; break; }
|
|
cursor = page.nextCursor ?? undefined;
|
|
} while (cursor);
|
|
if (config.modelReasoningEffort === null) throw new Error('Codex did not report a default reasoning effort; choose an explicit effort');
|
|
}
|
|
const revision = this.settingsRevision;
|
|
const permission = config.permissionMode && resolveCodexPermissionModeConfig(config.permissionMode);
|
|
const params = { threadId: this.threadId,
|
|
...(config.model ? { model: config.model } : {}),
|
|
...(config.personality ? { personality: config.personality } : {}),
|
|
...(config.modelReasoningEffort !== undefined ? { effort: config.modelReasoningEffort } : {}),
|
|
...(config.serviceTier !== undefined ? { serviceTier: config.serviceTier === 'fast' ? 'priority' : null } : {}),
|
|
...(permission ? { approvalPolicy: permission.approvalPolicy, sandboxPolicy: permission.sandboxPolicy,
|
|
approvalsReviewer: config.permissionMode === 'autoReview' ? 'auto_review' : 'user' } : {}),
|
|
...(config.collaborationMode ? { collaborationMode: { mode: config.collaborationMode, settings: {
|
|
model: config.model ?? this.settings.model, reasoning_effort: config.modelReasoningEffort ?? this.settings.modelReasoningEffort,
|
|
developer_instructions: null
|
|
} } } : {})
|
|
};
|
|
let changed!: () => void;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const accepted = new Promise<void>((resolve, reject) => {
|
|
changed = () => { if (this.settingsRevision > revision && settingsMatch(this.settingsNative, params)) resolve(); };
|
|
this.settingsListeners.add(changed);
|
|
timer = setTimeout(() => reject(new Error('Native settings update is not confirmed; reconnect before retrying')), 15_000);
|
|
});
|
|
void accepted.catch(() => {});
|
|
try {
|
|
await this.client.request('thread/settings/update', params);
|
|
// The RPC only acknowledges queueing the settings operation. The
|
|
// native notification, not our submitted candidate, establishes state.
|
|
await accepted; this.alive(); return { applied: this.settings };
|
|
} finally { clearTimeout(timer); this.settingsListeners.delete(changed); }
|
|
}
|
|
async initialSettings(options: SharedLaunchOptions): Promise<void> {
|
|
if (options.collaborationMode) await this.applySettings({ collaborationMode: options.collaborationMode });
|
|
}
|
|
private async implementPlan(planId: string): Promise<ImplementCodexPlanResult> {
|
|
const unavailable = (): ImplementCodexPlanResult => ({ ok: false, code: 'unavailable', error: 'Codex is disconnected or stopping. Reconnect before implementing the plan.' });
|
|
const stale = (): ImplementCodexPlanResult => ({ ok: false, code: 'stale_plan', error: 'This plan is no longer actionable. Refresh the conversation.' });
|
|
const localId = planImplementationMessageId(planId);
|
|
if (this.closed || this.stopping || this.reconnecting || !this.client.isInitialized()) return unavailable();
|
|
try {
|
|
// A reconnect snapshot may have started before this action arrived.
|
|
await this.refreshing;
|
|
await this.refresh();
|
|
const prior = this.queue.state(localId);
|
|
if (prior === 'queued' || prior === 'consumed') return { ok: true };
|
|
if (prior) return { ok: false, code: 'indeterminate', error: 'This implementation was already submitted. Check the queue and conversation before sending again.' };
|
|
const before = await this.queue.list();
|
|
if (this.availablePlanId() !== planId || before.length) return stale();
|
|
const revision = this.turnRevision;
|
|
this.submittingPlan = true; this.publishPlan();
|
|
await this.applySettings({ collaborationMode: 'default' });
|
|
const queued = await this.queue.list();
|
|
if (this.closed || this.stopping || this.reconnecting || !this.client.isInitialized()) return unavailable();
|
|
if (this.turnRevision !== revision || this.currentTurn || this.latestTurn?.planId !== planId
|
|
|| this.settings.collaborationMode !== 'default' || queued.length) return stale();
|
|
await this.queue.enqueue(localId, buildUserInputFromMessage('Implement the plan.'));
|
|
return { ok: true };
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
return { ok: false, code: isIndeterminateError(error) || this.queue.state(localId) === 'unknown' ? 'indeterminate' : 'failed',
|
|
error: `Plan implementation was not confirmed: ${detail}. Check the mode, queue and conversation before retrying.` };
|
|
} finally { this.submittingPlan = false; this.publishPlan(); }
|
|
}
|
|
private registerControls(): void {
|
|
const rpc = { registerHandler: (method: string, handler: (raw: unknown) => Promise<unknown>) => {
|
|
this.session.rpcHandlerManager.registerHandler(method, async (raw: unknown) => {
|
|
if (this.stopping) throw new Error('Codex execution is stopping; resume when inactive');
|
|
const pending = handler(raw); this.controls.add(pending);
|
|
try { return await pending; } finally { this.controls.delete(pending); }
|
|
});
|
|
} };
|
|
rpc.registerHandler(RPC_METHODS.ListCodexModels, async raw => {
|
|
const models = []; let cursor: string | undefined;
|
|
do {
|
|
const response = await this.client.listModels({ includeHidden: record(raw).includeHidden === true, cursor });
|
|
models.push(...(response.data ?? []).map(normalizeCodexModel).filter(model => model !== null)); cursor = response.nextCursor ?? undefined;
|
|
} while (cursor);
|
|
return { success: true, models };
|
|
});
|
|
rpc.registerHandler(RPC_METHODS.Switch, async () => { throw new Error('control_mode_not_applicable'); });
|
|
rpc.registerHandler(RPC_METHODS.HandoffLocal, async () => { throw new Error('control_mode_not_applicable'); });
|
|
rpc.registerHandler(RPC_METHODS.Abort, async () => {
|
|
const turnId = this.currentTurn;
|
|
if (turnId) await this.client.request('turn/interrupt', { threadId: this.threadId, turnId });
|
|
});
|
|
rpc.registerHandler(RPC_METHODS.KillSession, async () => { await this.host.end(this); return { success: true }; });
|
|
// #1911 C2 / AC6: Codex owns KillSession here and never calls
|
|
// registerKillSessionHandler — still must EXIT on hub-archived metadata
|
|
// (KillSession unreachable / map-miss). Latch covers bootstrap write
|
|
// before bind/activate registers controls; B2 refuses tree-kill of the
|
|
// shared wrapper so this subscribe is load-bearing.
|
|
const exitFromHubArchive = () => {
|
|
void this.host.end(this).catch(error => logger.debug('[CODEX] hub-archived end failed:', error));
|
|
};
|
|
if (this.session.hubArchived) {
|
|
exitFromHubArchive();
|
|
}
|
|
this.session.on('hub-archived', exitFromHubArchive);
|
|
rpc.registerHandler(RPC_METHODS.SetSessionConfig, raw => this.applySettings(raw));
|
|
rpc.registerHandler(RPC_METHODS.ImplementCodexPlan, raw => {
|
|
const { planId } = ImplementCodexPlanRequestSchema.parse(raw);
|
|
const work = this.work.catch(() => {}).then(() => this.implementPlan(planId));
|
|
this.work = work;
|
|
return work;
|
|
});
|
|
rpc.registerHandler(RPC_METHODS.SteerQueuedMessage, async raw => {
|
|
const { localId } = z.object({ localId: z.string().min(1) }).parse(raw);
|
|
const expectedTurnId = this.currentTurn;
|
|
if (!expectedTurnId) return { steered: false, error: 'No active turn' };
|
|
return this.queue.steer(localId, expectedTurnId);
|
|
});
|
|
rpc.registerHandler(RPC_METHODS.ClearConversation, async () => {
|
|
const child = await this.newConversation(); return { sessionId: child.session.sessionId };
|
|
});
|
|
rpc.registerHandler(RPC_METHODS.ForkConversation, async raw => {
|
|
const { messageLocalId } = z.object({ messageLocalId: z.string().optional() }).parse(raw);
|
|
if (this.currentTurn && !messageLocalId) throw new Error('Session is busy');
|
|
const beforeTurnId = messageLocalId ? this.projection.turnFor(messageLocalId) : undefined;
|
|
if (messageLocalId && !beforeTurnId) throw new Error('No native history point for this message');
|
|
if (messageLocalId) {
|
|
const thread = await this.assertBoundary(messageLocalId, beforeTurnId!);
|
|
const turns = (Array.isArray(thread.turns) ? thread.turns : []).map(record);
|
|
const index = turns.findIndex(turn => turn.id === beforeTurnId);
|
|
if (index < 0 || turns.slice(0, index + 1).some(turn =>
|
|
!['completed', 'interrupted', 'failed'].includes(string(turn.status) ?? ''))) {
|
|
throw new Error('Historical fork requires a completed turn and history prefix');
|
|
}
|
|
}
|
|
const child = await this.host.create('thread/fork', { ...this.freshParams(), threadId: this.threadId,
|
|
...(beforeTurnId ? { beforeTurnId } : {}) }, this);
|
|
await child.initialSettings({ collaborationMode: this.settings.collaborationMode });
|
|
return { nativeSessionId: child.threadId, sessionId: child.session.sessionId };
|
|
});
|
|
rpc.registerHandler(RPC_METHODS.RewindConversation, async () => {
|
|
throw new Error('In-place rewind is not supported for concurrent Codex clients; fork at the message instead');
|
|
});
|
|
}
|
|
private async assertBoundary(localId: string, turnId: string): Promise<Record<string, unknown>> {
|
|
const thread = await this.readThread();
|
|
const turn = (Array.isArray(thread.turns) ? thread.turns.map(record) : []).find(turn => turn.id === turnId);
|
|
const first = (Array.isArray(turn?.items) ? turn.items.map(record) : []).find(item => item.type === 'userMessage');
|
|
const firstId = string(first?.clientId ?? first?.clientUserMessageId) ?? (first?.id ? `codex:${this.threadId}:user:${first.id}` : undefined);
|
|
if (firstId !== localId) throw new Error('Cannot cut inside a steered native turn. Select its first message.');
|
|
return thread;
|
|
}
|
|
freshParams(): Record<string, unknown> {
|
|
const sandbox = inheritedSandbox(this.settingsNative);
|
|
return { ...sandbox, cwd: this.settingsNative.cwd ?? this.bootstrap.workingDirectory, model: this.settingsNative.model, modelProvider: this.settingsNative.modelProvider,
|
|
approvalPolicy: this.settingsNative.approvalPolicy, serviceTier: this.settingsNative.serviceTier,
|
|
approvalsReviewer: this.settingsNative.approvalsReviewer, personality: this.settingsNative.personality,
|
|
developerInstructions: getCodexSystemPrompt(),
|
|
config: { ...record(sandbox.config), model_reasoning_effort: this.settings.modelReasoningEffort ?? undefined } };
|
|
}
|
|
private notice(message: string): void { this.session.sendSessionEvent({ type: 'message', message }); }
|
|
private async newConversation(): Promise<SharedCodexRoot> {
|
|
const child = await this.host.create('thread/start', this.freshParams());
|
|
await child.initialSettings({ collaborationMode: this.settings.collaborationMode }); return child;
|
|
}
|
|
private async command(text: string): Promise<string | null> {
|
|
const command = text.trim();
|
|
if (command === '/clear' || command === '/new') {
|
|
const child = await this.newConversation(); this.notice(`New conversation: /sessions/${child.session.sessionId}`); return null;
|
|
}
|
|
if (command === '/compact') { await this.client.request('thread/compact/start', { threadId: this.threadId }); return null; }
|
|
if (command === '/exit') { this.notice('/exit closes a native terminal, not this Web view. Use End session to archive this conversation.'); return null; }
|
|
if (!command.startsWith('/')) return text;
|
|
const slash = resolveCodexSlashCommand(text, { commands: await listSlashCommands('codex', this.bootstrap.workingDirectory),
|
|
permissionMode: this.settings.permissionMode === 'yolo' || this.settings.permissionMode === 'read-only' || this.settings.permissionMode === 'autoReview' ? this.settings.permissionMode : 'default',
|
|
collaborationMode: this.settings.collaborationMode ?? 'default', model: this.settings.model ?? undefined,
|
|
modelReasoningEffort: parseReasoningEffortValue(this.settings.modelReasoningEffort), serviceTier: this.settings.serviceTier });
|
|
if (slash.kind === 'passthrough') return text;
|
|
if (slash.kind === 'goal') {
|
|
const params = { threadId: this.threadId };
|
|
const response = slash.action === 'show' ? await this.client.request('thread/goal/get', params)
|
|
: slash.action === 'clear' ? await this.client.request('thread/goal/clear', params)
|
|
: await this.client.request('thread/goal/set', { ...params, ...(slash.action === 'set' ? { objective: slash.objective } : { status: slash.action === 'pause' ? 'paused' : 'active' }) });
|
|
this.notice(JSON.stringify(response)); return null;
|
|
}
|
|
if (slash.updates?.proactiveMultiAgent !== undefined) throw new Error('This Codex version uses Ultra reasoning effort instead of a multi-agent toggle');
|
|
if (slash.updates?.model === null) throw new Error('Choose an explicit model in a shared thread');
|
|
if (slash.updates) await this.applySettings(slash.updates);
|
|
if (slash.message) this.notice(slash.message);
|
|
return slash.kind === 'replace' ? slash.text : null;
|
|
}
|
|
stopAccepting(): void {
|
|
this.stopping = true; this.ready();
|
|
this.publishSteering();
|
|
this.publishPlan();
|
|
this.session.onReconnect(null); this.client.setTransportAbandonedHandler(null);
|
|
}
|
|
async suspend(): Promise<void> {
|
|
this.stopAccepting();
|
|
await this.work.catch(() => {}); await Promise.allSettled([...this.controls]);
|
|
await this.queue?.suspend();
|
|
// Capture any dequeue/turn that won the race with native deletion.
|
|
if (this.threadId && this.client.isInitialized()) await this.refresh();
|
|
}
|
|
close(archived: boolean): Promise<void> {
|
|
return this.closing ??= (async () => {
|
|
this.stopAccepting(); this.closed = true; clearInterval(this.heartbeat);
|
|
this.permissions?.close();
|
|
await this.client.disconnect(); await this.notifications; await this.queue?.flush(); this.bridge?.server.stop();
|
|
if (archived) this.session.updateMetadata(metadata => ({ ...metadata, lifecycleState: 'archived', lifecycleStateSince: Date.now() }));
|
|
// Inactive is resumable; uploads referenced by pending input must survive.
|
|
this.session.sendSessionDeath(undefined, { preserveUploads: !archived });
|
|
await this.session.flush(); this.session.close();
|
|
})();
|
|
}
|
|
}
|