From 4b951338bbdd7f25064c2c7c4d0a131431dbc2ad Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 13 Apr 2026 12:27:05 +0800 Subject: [PATCH 1/6] feat: support codex fork across cli hub and web --- README.md | 1 + cli/README.md | 1 + cli/src/api/apiMachine.ts | 3 +- cli/src/codex/appServerTypes.ts | 23 ++++++ cli/src/codex/codexAppServerClient.ts | 10 +++ cli/src/codex/codexLocal.test.ts | 43 +++++++--- cli/src/codex/codexLocal.ts | 27 ++++--- cli/src/codex/codexLocalLauncher.ts | 4 +- cli/src/codex/codexRemoteLauncher.test.ts | 52 ++++++++++++- cli/src/codex/codexRemoteLauncher.ts | 64 ++++++++++++++- cli/src/codex/loop.ts | 2 + cli/src/codex/runCodex.ts | 2 + cli/src/codex/session.ts | 3 + cli/src/codex/utils/appServerConfig.test.ts | 29 ++++++- cli/src/codex/utils/appServerConfig.ts | 32 ++++++++ cli/src/commands/codex.ts | 10 +++ cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/run.ts | 4 +- docs/plans/hapi-feature-codex-fork/design.md | 73 +++++++++++++++++ .../deploy_modules.md | 3 + .../design.md | 16 ++++ hub/src/sync/rpcGateway.ts | 15 +++- hub/src/sync/sessionModel.test.ts | 65 +++++++++++++++- hub/src/sync/syncEngine.ts | 78 +++++++++++++++++++ hub/src/web/routes/sessions.ts | 24 ++++++ web/src/api/client.ts | 8 ++ web/src/components/SessionActionMenu.tsx | 44 +++++++++++ web/src/components/SessionHeader.tsx | 35 ++++++++- web/src/components/SessionList.tsx | 31 +++++++- web/src/components/ToastContainer.tsx | 2 + web/src/components/ui/Toast.tsx | 34 +++++++- web/src/hooks/mutations/useSessionActions.ts | 13 ++++ web/src/lib/locales/en.ts | 5 ++ web/src/lib/locales/zh-CN.ts | 5 ++ web/src/lib/toast-context.tsx | 2 + 35 files changed, 725 insertions(+), 39 deletions(-) create mode 100644 docs/plans/hapi-feature-codex-fork/design.md create mode 100644 docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md create mode 100644 docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md diff --git a/README.md b/README.md index fde776c07..79ed715f1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro ## Features - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. +- **Codex Forking** - Fork an existing Codex conversation into a brand-new session from CLI or web. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. - **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. diff --git a/cli/README.md b/cli/README.md index cd6c2ddd0..f41208f1c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,6 +27,7 @@ Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your ter - `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`. - `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`. - `hapi codex resume ` - Resume existing Codex session. +- `hapi codex fork ` - Fork existing Codex session into a new session. - `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. Local and remote modes supported; remote uses `agent -p` with stream-json. diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 42a25f835..cbf08b13f 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -102,7 +102,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, forkSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -112,6 +112,7 @@ export class ApiMachineClient { directory, sessionId, resumeSessionId, + forkSessionId, machineId, approvedNewDirectoryCreation, agent, diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index fdb7fcf6b..c02d28cb2 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -66,6 +66,29 @@ export interface ThreadResumeResponse { [key: string]: unknown; } +export interface ThreadForkParams { + threadId: string; + path?: string; + model?: string; + modelProvider?: string; + cwd?: string; + approvalPolicy?: ApprovalPolicy; + sandbox?: SandboxMode; + config?: Record; + baseInstructions?: string; + developerInstructions?: string; + ephemeral?: boolean; + persistExtendedHistory: boolean; +} + +export interface ThreadForkResponse { + thread: { + id: string; + }; + model: string; + [key: string]: unknown; +} + export type UserInput = | { type: 'text'; diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index b45b4976b..0c5e1b6f2 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -8,6 +8,8 @@ import type { ThreadStartResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadForkParams, + ThreadForkResponse, TurnStartParams, TurnStartResponse, TurnInterruptParams, @@ -149,6 +151,14 @@ export class CodexAppServerClient { return response as ThreadResumeResponse; } + async forkThread(params: ThreadForkParams, options?: { signal?: AbortSignal }): Promise { + const response = await this.sendRequest('thread/fork', params, { + signal: options?.signal, + timeoutMs: CodexAppServerClient.DEFAULT_TIMEOUT_MS + }); + return response as ThreadForkResponse; + } + async startTurn(params: TurnStartParams, options?: { signal?: AbortSignal }): Promise { const response = await this.sendRequest('turn/start', params, { signal: options?.signal, diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts index 3e718c9c5..4aa48cb4f 100644 --- a/cli/src/codex/codexLocal.test.ts +++ b/cli/src/codex/codexLocal.test.ts @@ -1,36 +1,55 @@ -import { describe, it, expect } from 'vitest'; -import { filterResumeSubcommand } from './codexLocal'; +import { describe, it, expect, vi } from 'vitest'; -describe('filterResumeSubcommand', () => { +vi.mock('@/ui/logger', () => ({ + logger: { + debug: () => {}, + warn: () => {} + } +})); + +import { filterManagedSessionSubcommand } from './codexLocal'; + +describe('filterManagedSessionSubcommand', () => { it('returns empty array unchanged', () => { - expect(filterResumeSubcommand([])).toEqual([]); + expect(filterManagedSessionSubcommand([])).toEqual([]); }); it('passes through args when first arg is not resume', () => { - expect(filterResumeSubcommand(['--model', 'gpt-4'])).toEqual(['--model', 'gpt-4']); - expect(filterResumeSubcommand(['--sandbox', 'read-only'])).toEqual(['--sandbox', 'read-only']); + expect(filterManagedSessionSubcommand(['--model', 'gpt-4'])).toEqual(['--model', 'gpt-4']); + expect(filterManagedSessionSubcommand(['--sandbox', 'read-only'])).toEqual(['--sandbox', 'read-only']); }); it('filters resume subcommand with session ID', () => { - expect(filterResumeSubcommand(['resume', 'abc-123'])).toEqual([]); - expect(filterResumeSubcommand(['resume', 'abc-123', '--model', 'gpt-4'])) + expect(filterManagedSessionSubcommand(['resume', 'abc-123'])).toEqual([]); + expect(filterManagedSessionSubcommand(['resume', 'abc-123', '--model', 'gpt-4'])) .toEqual(['--model', 'gpt-4']); }); it('filters resume subcommand without session ID', () => { - expect(filterResumeSubcommand(['resume'])).toEqual([]); - expect(filterResumeSubcommand(['resume', '--model', 'gpt-4'])) + expect(filterManagedSessionSubcommand(['resume'])).toEqual([]); + expect(filterManagedSessionSubcommand(['resume', '--model', 'gpt-4'])) + .toEqual(['--model', 'gpt-4']); + }); + + it('filters fork subcommand with session ID', () => { + expect(filterManagedSessionSubcommand(['fork', 'abc-123'])).toEqual([]); + expect(filterManagedSessionSubcommand(['fork', 'abc-123', '--model', 'gpt-4'])) .toEqual(['--model', 'gpt-4']); }); it('does not filter resume when it appears as flag value', () => { // --name resume should pass through (resume is value, not subcommand) - expect(filterResumeSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']); + expect(filterManagedSessionSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']); }); it('does not filter resume in middle of args', () => { // If resume appears after flags, it's not the subcommand position - expect(filterResumeSubcommand(['--model', 'gpt-4', 'resume', '123'])) + expect(filterManagedSessionSubcommand(['--model', 'gpt-4', 'resume', '123'])) .toEqual(['--model', 'gpt-4', 'resume', '123']); }); + + it('does not filter fork in middle of args', () => { + expect(filterManagedSessionSubcommand(['--model', 'gpt-4', 'fork', '123'])) + .toEqual(['--model', 'gpt-4', 'fork', '123']); + }); }); diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index c4940b89d..9f9a7fe1f 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -5,27 +5,28 @@ import { buildMcpServerConfigArgs, buildDeveloperInstructionsArg } from './utils import { codexSystemPrompt } from './utils/systemPrompt'; /** - * Filter out 'resume' subcommand which is managed internally by hapi. - * Codex CLI format is `codex resume `, so subcommand is always first. + * Filter out HAPI-managed session subcommands which are handled internally. + * Codex CLI format is `codex `, so the subcommand is always first. */ -export function filterResumeSubcommand(args: string[]): string[] { - if (args.length === 0 || args[0] !== 'resume') { +export function filterManagedSessionSubcommand(args: string[]): string[] { + if (args.length === 0 || (args[0] !== 'resume' && args[0] !== 'fork')) { return args; } - // First arg is 'resume', filter it and optional session ID + // First arg is 'resume' or 'fork'; filter it and optional session ID if (args.length > 1 && !args[1].startsWith('-')) { - logger.debug(`[CodexLocal] Filtered 'resume ${args[1]}' - session managed by hapi`); + logger.debug(`[CodexLocal] Filtered '${args[0]} ${args[1]}' - session managed by hapi`); return args.slice(2); } - logger.debug(`[CodexLocal] Filtered 'resume' - session managed by hapi`); + logger.debug(`[CodexLocal] Filtered '${args[0]}' - session managed by hapi`); return args.slice(1); } export async function codexLocal(opts: { abort: AbortSignal; - sessionId: string | null; + resumeSessionId: string | null; + forkSessionId?: string; path: string; model?: string; sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; @@ -35,9 +36,11 @@ export async function codexLocal(opts: { }): Promise { const args: string[] = []; - if (opts.sessionId) { - args.push('resume', opts.sessionId); - opts.onSessionFound(opts.sessionId); + if (opts.forkSessionId) { + args.push('fork', opts.forkSessionId); + } else if (opts.resumeSessionId) { + args.push('resume', opts.resumeSessionId); + opts.onSessionFound(opts.resumeSessionId); } if (opts.model) { @@ -57,7 +60,7 @@ export async function codexLocal(opts: { args.push(...buildDeveloperInstructionsArg(codexSystemPrompt)); if (opts.codexArgs) { - const safeArgs = filterResumeSubcommand(opts.codexArgs); + const safeArgs = filterManagedSessionSubcommand(opts.codexArgs); args.push(...safeArgs); } diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 9ef714a9c..0e759cfa8 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -10,6 +10,7 @@ import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { const resumeSessionId = session.sessionId; + const forkSessionId = session.forkSessionId; let scanner: Awaited> | null = null; const permissionMode = session.getPermissionMode(); const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo' @@ -41,7 +42,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch launch: async (abortSignal) => { await codexLocal({ path: session.path, - sessionId: resumeSessionId, + resumeSessionId, + forkSessionId, onSessionFound: handleSessionFound, abort: abortSignal, codexArgs, diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570..568c5a230 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -5,7 +5,27 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], - initializeCalls: [] as unknown[] + initializeCalls: [] as unknown[], + forkCalls: [] as unknown[] +})); + +vi.mock('react', () => ({ + default: { + createElement: () => ({}) + } +})); + +vi.mock('ink', () => ({ + render: () => ({ + unmount: () => {} + }) +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: () => {}, + warn: () => {} + } })); vi.mock('./codexAppServerClient', () => { @@ -35,6 +55,11 @@ vi.mock('./codexAppServerClient', () => { return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; } + async forkThread(params: unknown): Promise<{ thread: { id: string }; model: string }> { + harness.forkCalls.push(params); + return { thread: { id: 'thread-forked' }, model: 'gpt-5.4' }; + } + async startTurn(): Promise<{ turn: Record }> { const started = { turn: {} }; harness.notifications.push({ method: 'turn/started', params: started }); @@ -80,7 +105,7 @@ function createMode(): EnhancedMode { }; } -function createSessionStub() { +function createSessionStub(opts?: { forkSessionId?: string | null }) { const queue = new MessageQueue2((mode) => JSON.stringify(mode)); queue.push('hello from launcher test', createMode()); queue.close(); @@ -122,10 +147,14 @@ function createSessionStub() { codexArgs: undefined, codexCliOverrides: undefined, sessionId: null as string | null, + forkSessionId: opts?.forkSessionId ?? undefined, thinking: false, getPermissionMode() { return 'default' as const; }, + getCollaborationMode() { + return 'default' as const; + }, setModel(nextModel: string | null) { currentModel = nextModel; }, @@ -168,6 +197,7 @@ describe('codexRemoteLauncher', () => { harness.notifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; + harness.forkCalls = []; }); it('finishes a turn and emits ready when task lifecycle events omit turn_id', async () => { @@ -198,4 +228,22 @@ describe('codexRemoteLauncher', () => { expect(thinkingChanges).toContain(true); expect(session.thinking).toBe(false); }); + + it('forks the source thread before starting a turn when forkSessionId is provided', async () => { + const { + session, + foundSessionIds + } = createSessionStub({ forkSessionId: 'thread-source' }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.forkCalls).toHaveLength(1); + expect(harness.forkCalls[0]).toMatchObject({ + threadId: 'thread-source', + cwd: '/tmp/hapi-update', + persistExtendedHistory: true + }); + expect(foundSessionIds).toContain('thread-forked'); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..0987fa40d 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -14,7 +14,7 @@ import type { EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; -import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; +import { buildThreadForkParams, buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { RemoteLauncherBase, @@ -530,6 +530,23 @@ class CodexRemoteLauncher extends RemoteLauncherBase { session.sendSessionEvent({ type: 'ready' }); }; + const buildInitialMode = (): EnhancedMode => { + const rawPermissionMode = session.getPermissionMode(); + const permissionMode = rawPermissionMode === 'default' + || rawPermissionMode === 'read-only' + || rawPermissionMode === 'safe-yolo' + || rawPermissionMode === 'yolo' + ? rawPermissionMode + : 'default'; + const rawCollaborationMode = session.getCollaborationMode?.(); + const collaborationMode = rawCollaborationMode === 'plan' ? 'plan' : 'default'; + return { + permissionMode, + model: session.getModel() ?? undefined, + collaborationMode + }; + }; + await appServerClient.connect(); await appServerClient.initialize({ clientInfo: { @@ -544,6 +561,29 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let hasThread = false; let pending: QueuedMessage | null = null; + if (session.forkSessionId) { + const forkResponse = await appServerClient.forkThread(buildThreadForkParams({ + threadId: session.forkSessionId, + cwd: session.path, + mode: buildInitialMode(), + mcpServers, + cliOverrides: session.codexCliOverrides + }), { + signal: this.abortController.signal + }); + const forkRecord = asRecord(forkResponse); + const forkThread = forkRecord ? asRecord(forkRecord.thread) : null; + const forkedThreadId = asString(forkThread?.id); + if (!forkedThreadId) { + throw new Error('app-server thread/fork did not return thread.id'); + } + this.currentThreadId = forkedThreadId; + session.onSessionFound(forkedThreadId); + hasThread = true; + applyResolvedModel(forkRecord?.model); + sendReady(); + } + clearReadyAfterTurnTimer = () => { if (!readyAfterTurnTimer) { return; @@ -599,10 +639,30 @@ class CodexRemoteLauncher extends RemoteLauncherBase { cliOverrides: session.codexCliOverrides }); + const forkCandidate = session.forkSessionId; const resumeCandidate = session.sessionId; let threadId: string | null = null; - if (resumeCandidate) { + if (forkCandidate) { + try { + const forkResponse = await appServerClient.forkThread(buildThreadForkParams({ + threadId: forkCandidate, + cwd: session.path, + mode: message.mode, + mcpServers, + cliOverrides: session.codexCliOverrides + }), { + signal: this.abortController.signal + }); + const forkRecord = asRecord(forkResponse); + const forkThread = forkRecord ? asRecord(forkRecord.thread) : null; + threadId = asString(forkThread?.id); + applyResolvedModel(forkRecord?.model); + logger.debug(`[Codex] Forked app-server thread ${forkCandidate} -> ${threadId ?? 'unknown'}`); + } catch (error) { + logger.warn(`[Codex] Failed to fork app-server thread ${forkCandidate}, starting new thread`, error); + } + } else if (resumeCandidate) { try { const resumeResponse = await appServerClient.resumeThread({ threadId: resumeCandidate, diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 013fd07ce..f84ea6df5 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -32,6 +32,7 @@ interface LoopOptions { model?: string; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; + forkSessionId?: string; onSessionReady?: (session: CodexSession) => void; } @@ -52,6 +53,7 @@ export async function loop(opts: LoopOptions): Promise { startingMode, codexArgs: opts.codexArgs, codexCliOverrides: opts.codexCliOverrides, + forkSessionId: opts.forkSessionId, permissionMode: opts.permissionMode ?? 'default', model: opts.model, collaborationMode: opts.collaborationMode ?? 'default' diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d94286a14..af032625c 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -21,6 +21,7 @@ export async function runCodex(opts: { codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; + forkSessionId?: string; model?: string; modelReasoningEffort?: ReasoningEffort; }): Promise { @@ -177,6 +178,7 @@ export async function runCodex(opts: { model: currentModel, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, + forkSessionId: opts.forkSessionId, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 5fb1c3ea7..add1f7e69 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -14,6 +14,7 @@ type LocalLaunchFailure = { export class CodexSession extends AgentSessionBase { readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; + readonly forkSessionId?: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -31,6 +32,7 @@ export class CodexSession extends AgentSessionBase { startingMode: 'local' | 'remote'; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; + forkSessionId?: string; permissionMode?: PermissionMode; model?: SessionModel; collaborationMode?: EnhancedMode['collaborationMode']; @@ -57,6 +59,7 @@ export class CodexSession extends AgentSessionBase { this.codexArgs = opts.codexArgs; this.codexCliOverrides = opts.codexCliOverrides; + this.forkSessionId = opts.forkSessionId; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; this.permissionMode = opts.permissionMode; diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 0951b4133..7fe7ce914 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildThreadStartParams, buildTurnStartParams } from './appServerConfig'; +import { buildThreadForkParams, buildThreadStartParams, buildTurnStartParams } from './appServerConfig'; import { codexSystemPrompt } from './systemPrompt'; describe('appServerConfig', () => { @@ -97,6 +97,33 @@ describe('appServerConfig', () => { }); }); + it('builds fork params from thread config defaults', () => { + const params = buildThreadForkParams({ + threadId: 'thread-source', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.4', collaborationMode: 'default' }, + mcpServers + }); + + expect(params).toEqual({ + threadId: 'thread-source', + cwd: '/workspace/project', + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + model: 'gpt-5.4', + baseInstructions: codexSystemPrompt, + developerInstructions: codexSystemPrompt, + config: { + 'mcp_servers.hapi': { + command: 'node', + args: ['mcp'] + }, + developer_instructions: codexSystemPrompt + }, + persistExtendedHistory: true + }); + }); + it('builds turn params with mode defaults', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 12565909f..bbf36bd9e 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -6,6 +6,7 @@ import type { ApprovalPolicy, SandboxMode, SandboxPolicy, + ThreadForkParams, ThreadStartParams, TurnStartParams } from '../appServerTypes'; @@ -105,6 +106,37 @@ export function buildThreadStartParams(args: { return params; } +export function buildThreadForkParams(args: { + threadId: string; + cwd: string; + mode: EnhancedMode; + mcpServers: McpServersConfig; + cliOverrides?: CodexCliOverrides; + baseInstructions?: string; + developerInstructions?: string; +}): ThreadForkParams { + const startParams = buildThreadStartParams({ + cwd: args.cwd, + mode: args.mode, + mcpServers: args.mcpServers, + cliOverrides: args.cliOverrides, + baseInstructions: args.baseInstructions, + developerInstructions: args.developerInstructions + }); + + return { + threadId: args.threadId, + cwd: startParams.cwd, + approvalPolicy: startParams.approvalPolicy, + sandbox: startParams.sandbox, + config: startParams.config, + baseInstructions: startParams.baseInstructions, + developerInstructions: startParams.developerInstructions, + model: startParams.model, + persistExtendedHistory: true + }; +} + export function buildTurnStartParams(args: { threadId: string; message: string; diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index f52730630..86b048a91 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -32,6 +32,7 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + forkSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort } = {} @@ -48,6 +49,15 @@ export const codexCommand: CommandDefinition = { i += 1 continue } + if (i === 0 && arg === 'fork') { + const candidate = commandArgs[i + 1] + if (!candidate || candidate.startsWith('-')) { + throw new Error('fork requires a session id') + } + options.forkSessionId = candidate + i += 1 + continue + } if (arg === '--started-by') { options.startedBy = commandArgs[++i] as 'runner' | 'terminal' } else if (arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') { diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 3b1755903..f98a60d19 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -3,6 +3,7 @@ export interface SpawnSessionOptions { directory: string sessionId?: string resumeSessionId?: string + forkSessionId?: string approvedNewDirectoryCreation?: boolean agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' model?: string diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c4e907a1b..540d194b1 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -350,7 +350,9 @@ export async function startRunner(): Promise { ? 'opencode' : 'claude'; const args = [agentCommand]; - if (options.resumeSessionId) { + if (options.forkSessionId && agent === 'codex') { + args.push('fork', options.forkSessionId); + } else if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); } else if (agent === 'cursor') { diff --git a/docs/plans/hapi-feature-codex-fork/design.md b/docs/plans/hapi-feature-codex-fork/design.md new file mode 100644 index 000000000..b76aab5ea --- /dev/null +++ b/docs/plans/hapi-feature-codex-fork/design.md @@ -0,0 +1,73 @@ +# Codex Fork Support Design Document + +## 1. Overview +- Business requirement: HAPI needs first-class Codex fork support instead of only supporting resume. +- Success criteria: + - CLI supports `hapi codex fork ` + - Hub exposes a fork API and spawns a new HAPI session instead of merging into the old one + - Web can trigger fork for Codex sessions and navigate to the new forked session + - Codex remote launcher uses app-server `thread/fork` +- Scope: + - `cli/` Codex launch + runner spawn arguments + - `hub/` session fork orchestration + HTTP route + - `web/` fork action + API client + +## 2. Module Interaction Flow +1. User selects fork in CLI or Web. +2. HAPI passes source Codex thread ID as `forkSessionId`. +3. CLI local mode runs `codex fork `. +4. CLI remote mode calls Codex app-server `thread/fork`. +5. Codex returns a new thread ID; HAPI stores it as the new session metadata `codexSessionId`. +6. Hub returns the new HAPI session id to the Web client. + +## 3. Module Design Details + +### CLI + +#### 0. Metadata +- Reuse existing `metadata.codexSessionId` +- No new persisted schema field required for parent thread tracking in this change set + +#### 1. Interfaces +- Add CLI parse path: `hapi codex fork ` +- Add app-server client method: `forkThread` +- Add thread fork param builder: `buildThreadForkParams` + +#### 2. Local / Remote Launch +- Local mode: + - invoke native `codex fork ` + - avoid pre-binding old thread id as current session id + - session scanner discovers the newly created Codex thread +- Remote mode: + - if `forkSessionId` exists, call `thread/fork` + - otherwise keep existing resume/start behavior + +### Hub + +#### 1. Interfaces +- Add `SyncEngine.forkSession(sessionId, namespace)` +- Add HTTP route: `POST /api/sessions/:id/fork` +- Extend machine spawn RPC payload with `forkSessionId` + +#### 2. Session Semantics +- Fork differs from resume: + - resume reactivates or merges into prior conversation identity + - fork always creates a new HAPI session +- Only Codex sessions are eligible + +### Web + +#### 1. Interfaces +- Add `api.forkSession(sessionId)` +- Add `useSessionActions().forkSession` +- Add session action menu item `Fork` + +#### 2. UX +- User clicks Fork on a Codex session +- Web calls `/fork` +- On success navigate to the new session detail page +- On failure show toast + +## 4. Notes +- Parent/child fork lineage is intentionally out of scope +- Automatic inactive-message send still uses resume; fork remains an explicit action diff --git a/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md new file mode 100644 index 000000000..e4b860607 --- /dev/null +++ b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md @@ -0,0 +1,3 @@ +cli +hub +web diff --git a/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md new file mode 100644 index 000000000..6bd9c2438 --- /dev/null +++ b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md @@ -0,0 +1,16 @@ +# Codex Fork Support Proposal + +## Change Summary +- Wire Codex fork through CLI, runner, hub, and web +- Reuse upstream Codex app-server `thread/fork` +- Keep resume semantics unchanged + +## Affected Modules +- cli +- hub +- web + +## Behavior +- `hapi codex fork ` starts a new session forked from an existing Codex thread +- Web adds a Fork action for Codex sessions +- Hub exposes `POST /api/sessions/:id/fork` diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6d..08806953e 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -115,13 +115,26 @@ export class RpcGateway { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + forkSessionId?: string, effort?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort } + { + type: 'spawn-in-directory', + directory, + agent, + model, + modelReasoningEffort, + yolo, + sessionType, + worktreeName, + resumeSessionId, + forkSessionId, + effort + } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 4d678136c..fe94f7685 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -239,6 +239,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _forkSessionId?: string, effort?: string ) => { capturedModel = model @@ -298,7 +299,8 @@ describe('session model', () => { _yolo?: boolean, _sessionType?: 'simple' | 'worktree', _worktreeName?: string, - resumeSessionId?: string + resumeSessionId?: string, + _forkSessionId?: string ) => { capturedResumeSessionId = resumeSessionId return { type: 'success', sessionId: session.id } @@ -313,4 +315,65 @@ describe('session model', () => { engine.stop() } }) + + it('passes fork session ID to rpc gateway when forking codex session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-fork', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedForkSessionId: string | undefined + let capturedModel: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: 'simple' | 'worktree', + _worktreeName?: string, + _resumeSessionId?: string, + forkSessionId?: string + ) => { + capturedModel = model + capturedForkSessionId = forkSessionId + return { type: 'success', sessionId: 'forked-session' } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: 'forked-session' }) + expect(capturedForkSessionId).toBe('codex-thread-1') + expect(capturedModel).toBe('gpt-5.4') + } finally { + engine.stop() + } + }) }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..4154d8cc4 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -42,6 +42,10 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type ForkSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'fork_unavailable' | 'fork_failed' } + export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache @@ -325,6 +329,7 @@ export class SyncEngine { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + forkSessionId?: string, effort?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( @@ -337,6 +342,7 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + forkSessionId, effort ) } @@ -409,6 +415,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + undefined, session.effort ?? undefined ) @@ -433,6 +440,77 @@ export class SyncEngine { return { type: 'success', sessionId: spawnResult.sessionId } } + async forkSession(sessionId: string, namespace: string): Promise { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', + code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' + } + } + + const session = access.session + const metadata = session.metadata + if (!metadata || typeof metadata.path !== 'string') { + return { type: 'error', message: 'Session metadata missing path', code: 'fork_unavailable' } + } + + if (metadata.flavor !== 'codex') { + return { type: 'error', message: 'Fork is only supported for Codex sessions', code: 'fork_unavailable' } + } + + const forkToken = metadata.codexSessionId + if (!forkToken) { + return { type: 'error', message: 'Fork session ID unavailable', code: 'fork_unavailable' } + } + + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (onlineMachines.length === 0) { + return { type: 'error', message: 'No machine online', code: 'no_machine_online' } + } + + const targetMachine = (() => { + if (metadata.machineId) { + const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) + if (exact) return exact + } + if (metadata.host) { + const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) + if (hostMatch) return hostMatch + } + return null + })() + + if (!targetMachine) { + return { type: 'error', message: 'No machine online', code: 'no_machine_online' } + } + + const spawnResult = await this.rpcGateway.spawnSession( + targetMachine.id, + metadata.path, + 'codex', + session.model ?? undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + forkToken + ) + + if (spawnResult.type !== 'success') { + return { type: 'error', message: spawnResult.message, code: 'fork_failed' } + } + + const becameActive = await this.waitForSessionActive(spawnResult.sessionId) + if (!becameActive) { + return { type: 'error', message: 'Session failed to become active', code: 'fork_failed' } + } + + return { type: 'success', sessionId: spawnResult.sessionId } + } + async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index c0d51203f..24a2c4346 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -115,6 +115,30 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ type: 'success', sessionId: result.sessionId }) }) + app.post('/sessions/:id/fork', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const namespace = c.get('namespace') + const result = await engine.forkSession(sessionResult.sessionId, namespace) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + app.post('/sessions/:id/upload', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..e49e816c0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -274,6 +274,14 @@ export class ApiClient { return response.sessionId } + async forkSession(sessionId: string): Promise { + const response = await this.request<{ sessionId: string }>( + `/api/sessions/${encodeURIComponent(sessionId)}/fork`, + { method: 'POST' } + ) + return response.sessionId + } + async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[]): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 88d6ab97c..1077c94b1 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -13,7 +13,9 @@ type SessionActionMenuProps = { isOpen: boolean onClose: () => void sessionActive: boolean + canFork?: boolean onRename: () => void + onFork?: () => void onArchive: () => void onDelete: () => void anchorPoint: { x: number; y: number } @@ -61,6 +63,29 @@ function ArchiveIcon(props: { className?: string }) { ) } +function ForkIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + function TrashIcon(props: { className?: string }) { return ( { + onClose() + onFork?.() + } + const handleDelete = () => { onClose() onDelete() @@ -239,6 +271,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { {t('session.action.rename')} + {canFork ? ( + + ) : null} + {sessionActive ? (
{title}
{body}
+ {actionLabel ? ( +
+ {actionLabel} + +
+ ) : null}
{onClose ? (