feat(codex): share sessions across terminal and web

Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
weishu
2026-09-12 10:59:17 +08:00
parent 25af3f8e13
commit 0c4abcb3d1
111 changed files with 4684 additions and 960 deletions
-2
View File
@@ -125,8 +125,6 @@ describe('buildCliArgs', () => {
'codex',
'resume',
'codex-thread-1',
'--hapi-starting-mode',
'remote',
'--started-by',
'runner',
'--existing-session-id',
+43 -2
View File
@@ -28,6 +28,7 @@ import { join } from 'path';
import { buildMachineMetadata } from '@/agent/sessionFactory';
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity';
import { readRuntimes, runtimeMayBeAlive, runtimeAuthHash } from '@/codex/shared/registry';
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree';
import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability';
@@ -414,7 +415,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
};
// Helper functions
const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
const getCurrentChildren = () => Array.from(pidToTrackedSession.values()).flatMap(session => session.sharedSessions
? Object.entries(session.sharedSessions).map(([happySessionId, metadata]) => ({ ...session, happySessionId, happySessionMetadataFromLocalWebhook: metadata }))
: [session]);
// Handle webhook from HAPI session reporting itself
const onHappySessionWebhook = (sessionId: string, sessionMetadata: Metadata) => {
@@ -432,6 +435,18 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
// Check if we already have this PID (runner-spawned)
const existingSession = pidToTrackedSession.get(pid);
if (existingSession && sessionMetadata.capabilities?.concurrentClients) {
existingSession.sharedSessions ??= {};
if (sessionMetadata.lifecycleState === 'archived') {
delete existingSession.sharedSessions[sessionId];
return;
}
existingSession.sharedSessions[sessionId] = sessionMetadata;
invalidateVerifiedExit(sessionId);
// Native /new or /fork cannot replace the primary spawn confirmation.
if (existingSession.happySessionId && existingSession.happySessionId !== sessionId) return;
}
if (existingSession && existingSession.startedBy === 'runner') {
// Update runner-spawned session with reported data
invalidateVerifiedExit(sessionId);
@@ -466,6 +481,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
// anything claiming `'runner'` here must be the second case and
// should be ignored + terminated instead of silently promoted.
if (sessionMetadata.startedBy === 'runner') {
// A shared root can report /new after a Runner restart. Unknown is
// not proof of an orphan: never kill its sibling roots. Known spawn
// timeouts already terminate their ChildProcess tree at the source.
// No registry scan/adoption lifecycle is needed for live attachment.
if (sessionMetadata.capabilities?.concurrentClients) return;
logger.debug(
`[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.`
);
@@ -482,6 +502,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
// New session started externally (terminal)
const trackedSession: TrackedSession = {
...(sessionMetadata.capabilities?.concurrentClients ? { sharedSessions: { [sessionId]: sessionMetadata } } : {}),
startedBy: 'hapi directly - likely by user from terminal',
happySessionId: sessionId,
happySessionMetadataFromLocalWebhook: sessionMetadata,
@@ -968,6 +989,22 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
const stopSession = async (sessionId: string): Promise<'stopped' | 'already_gone' | 'still_alive'> => {
logger.debug(`[RUNNER RUN] Attempting to stop session ${sessionId}`);
const { findRuntime } = await import('@/codex/shared/registry');
const sharedRuntime = await findRuntime(sessionId);
if (sharedRuntime) {
try {
const { runtimeControl } = await import('@/codex/shared/frontend');
await runtimeControl(sharedRuntime, 'hapi/stopSession', sessionId);
const tracked = pidToTrackedSession.get(sharedRuntime.pid);
if (tracked?.sharedSessions) delete tracked.sharedSessions[sessionId];
return 'stopped';
} catch { return 'still_alive'; }
}
if ((await readRuntimes()).some(runtime => runtime.hub === configuration.apiUrl && runtime.authHash === runtimeAuthHash()
&& runtime.sessions[sessionId]?.active && runtimeMayBeAlive(runtime))) return 'still_alive';
// Missing registry is not permission to kill siblings in a live execution.
if ([...pidToTrackedSession.values()].some(session => session.sharedSessions?.[sessionId])) return 'still_alive';
// Try to find by sessionId first
for (const [pid, session] of pidToTrackedSession.entries()) {
if (session.happySessionId === sessionId ||
@@ -1081,6 +1118,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
// Handle child process exit
const onChildExited = (pid: number) => {
const session = pidToTrackedSession.get(pid);
for (const id of Object.keys(session?.sharedSessions ?? {})) rememberVerifiedExit(id);
const requestedSessionId = session?.requestedHappySessionId ?? pidToRequestedSessionId.get(pid);
if (requestedSessionId) rememberVerifiedExit(requestedSessionId);
const confirmedSessionId = session?.happySessionId ?? pidToConfirmedSessionId.get(pid);
@@ -1145,6 +1183,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
// Write initial runner state (no lock needed for state file)
const fileState: RunnerLocallyPersistedState = {
sharedCodexRuntime: true,
pid: process.pid,
httpPort: controlPort,
startTime: new Date().toLocaleString(),
@@ -1567,7 +1606,9 @@ export function buildCliArgs(
args.push('--fork-session');
}
const startingMode = options.startingMode || 'remote';
args.push('--hapi-starting-mode', startingMode, '--started-by', 'runner');
// Codex shares one engine; Runner owns the wrapper, not a remote mode.
if (agent !== 'codex') args.push('--hapi-starting-mode', startingMode);
args.push('--started-by', 'runner');
// Codex, Cursor ACP, OpenCode, Pi native resume, and Claude message-level
// forks reuse the original HAPI row via --existing-session-id.
if (agent === 'codex' || agent === 'cursor' || agent === 'pi'
+2
View File
@@ -9,6 +9,8 @@ import { ChildProcess } from 'child_process';
* Session tracking for runner
*/
export interface TrackedSession {
/** All immutable HAPI roots in this shared execution; primary ID remains the spawn ACK. */
sharedSessions?: Record<string, Metadata>;
startedBy: 'runner' | string;
happySessionId?: string;
/** HAPI row requested for this process generation before its webhook arrives. */