Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
abf2049
feat: support codex fork across cli hub and web
pppobear Apr 13, 2026
433eb2c
fix: preserve fork history and toast layout
pppobear Apr 9, 2026
b04520b
fix: preserve custom title on fork
pppobear Apr 9, 2026
de8f3c4
refactor: unify fork feedback in web
pppobear Apr 9, 2026
f99a1df
fix: preserve derived title on fork
pppobear Apr 9, 2026
ddd0da8
fix: preserve fork title across metadata updates
pppobear Apr 10, 2026
bc14845
feat: support precise codex history forks
pppobear May 3, 2026
1969755
feat: fork from assistant responses
pppobear May 3, 2026
181fd8a
fix: keep assistant fork actions visible
pppobear May 3, 2026
955eb7e
fix: preserve assistant seq during reconcile
pppobear May 3, 2026
637b3d2
fix: enlarge assistant fork action
pppobear May 3, 2026
da1a9ac
fix: keep current session after fork
pppobear May 3, 2026
98e79f7
fix: prevent fork toast navigation
pppobear May 3, 2026
b5d8b42
fix: keep all fork actions on current session
pppobear May 3, 2026
2b7f884
fix: auto-apply web app updates
pppobear May 3, 2026
4e58984
fix: suppress fork ready toast
pppobear May 3, 2026
6d929c2
fix: suppress ready toast after forking
pppobear May 3, 2026
71d9d91
fix: suppress fork bootstrap ready toast
pppobear May 3, 2026
0f8fea0
fix: harden codex fork flow
pppobear May 3, 2026
06e2fc5
fix: handle unavailable codex forks
pppobear May 3, 2026
081b68c
fix: preserve codex fork reasoning effort
pppobear May 3, 2026
dfb3879
fix: make user fork cut point precede turn
pppobear May 3, 2026
b4023e3
fix: avoid deleting fork history directory
pppobear May 3, 2026
6fef047
fix: clone codex history for forked sessions
pppobear May 3, 2026
72b9feb
fix: move codex history during session merge
pppobear May 3, 2026
3cd5054
fix: preserve moved codex history collisions
pppobear May 3, 2026
44a32b5
fix: preserve cloned codex history collisions
pppobear May 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sessionId>` - Resume existing Codex session.
- `hapi codex fork <sessionId>` - Fork existing Codex session into a new session.
- `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`.
Supports `hapi cursor resume <chatId>`, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`.
Local and remote modes supported; remote uses `agent -p` with stream-json.
Expand Down
3 changes: 2 additions & 1 deletion cli/src/agent/runners/runAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export async function runAgentSession(opts: {

const messageQueue = new MessageQueue2<Record<string, never>>(() => hashObject({}));

session.onUserMessage((message, localId) => {
session.onUserMessage((message, meta) => {
const localId = meta.localId
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
messageQueue.push(formattedText, {}, localId);
});
Expand Down
4 changes: 3 additions & 1 deletion cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,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, permissionMode, token, sessionType, worktreeName } = params || {}
const { directory, sessionId, resumeSessionId, forkSessionId, forkHistory, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {}

if (!directory) {
throw new Error('Directory is required')
Expand All @@ -291,6 +291,8 @@ export class ApiMachineClient {
directory,
sessionId,
resumeSessionId,
forkSessionId,
forkHistory,
machineId,
approvedNewDirectoryCreation,
agent,
Expand Down
38 changes: 30 additions & 8 deletions cli/src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ const SYSTEM_INJECTION_PREFIXES = [
'<system-reminder>',
]

export type UserMessageMeta = {
localId?: string
seq?: number | null
}

/**
* Returns true if a JSONL message should be classified as a user-role message
* (i.e., text typed by a real human) rather than an agent-role message.
Expand Down Expand Up @@ -79,8 +84,8 @@ export class ApiSessionClient extends EventEmitter {
private agentState: AgentState | null
private agentStateVersion: number
private readonly socket: Socket<ServerToClientEvents, ClientToServerEvents>
private pendingMessages: { message: UserMessage; localId?: string }[] = []
private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null
private pendingMessages: { message: UserMessage; meta: UserMessageMeta }[] = []
private pendingMessageCallback: ((message: UserMessage, meta: UserMessageMeta) => void) | null = null
private lastSeenMessageSeq: number | null = null
private backfillInFlight: Promise<void> | null = null
private needsBackfill = false
Expand Down Expand Up @@ -245,19 +250,19 @@ export class ApiSessionClient extends EventEmitter {
this.socket.connect()
}

onUserMessage(callback: (data: UserMessage, localId?: string) => void): void {
onUserMessage(callback: (data: UserMessage, meta: UserMessageMeta) => void): void {
this.pendingMessageCallback = callback
while (this.pendingMessages.length > 0) {
const pending = this.pendingMessages.shift()!
callback(pending.message, pending.localId)
callback(pending.message, pending.meta)
}
}

private enqueueUserMessage(message: UserMessage, localId?: string): void {
private enqueueUserMessage(message: UserMessage, meta: UserMessageMeta): void {
if (this.pendingMessageCallback) {
this.pendingMessageCallback(message, localId)
this.pendingMessageCallback(message, meta)
} else {
this.pendingMessages.push({ message, localId })
this.pendingMessages.push({ message, meta })
}
}

Expand All @@ -272,7 +277,10 @@ export class ApiSessionClient extends EventEmitter {

const userResult = UserMessageSchema.safeParse(message.content)
if (userResult.success) {
this.enqueueUserMessage(userResult.data, message.localId ?? undefined)
this.enqueueUserMessage(userResult.data, {
localId: message.localId ?? undefined,
seq
})
return
}

Expand Down Expand Up @@ -500,6 +508,20 @@ export class ApiSessionClient extends EventEmitter {
this.socket.emit('messages-consumed', { sid: this.sessionId, localIds })
}

sendCodexHistoryItem(item: {
codexThreadId: string
turnId?: string | null
itemId: string
itemKind: 'user' | 'assistant' | 'tool' | 'event' | 'unknown'
messageSeq?: number | null
rawItem: unknown
}): void {
this.socket.emit('codex-history-item', {
sid: this.sessionId,
...item
})
}

sendSessionDeath(reason?: SessionEndReason): void {
void cleanupUploadDir(this.sessionId)
this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason })
Expand Down
3 changes: 2 additions & 1 deletion cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
sessionInstance.setEffort(currentEffort);
logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, effort=${currentEffort ?? 'auto'}`);
};
session.onUserMessage((message, localId) => {
session.onUserMessage((message, meta) => {
const localId = meta.localId
const sessionPermissionMode = currentSessionRef.current?.getPermissionMode();
if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) {
currentPermissionMode = sessionPermissionMode as PermissionMode;
Expand Down
23 changes: 23 additions & 0 deletions cli/src/codex/appServerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,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<string, unknown>;
baseInstructions?: string;
developerInstructions?: string;
ephemeral?: boolean;
persistExtendedHistory: boolean;
}

export interface ThreadForkResponse {
thread: {
id: string;
};
model: string;
[key: string]: unknown;
}

export type UserInput =
| {
type: 'text';
Expand Down
10 changes: 10 additions & 0 deletions cli/src/codex/codexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
ThreadStartResponse,
ThreadResumeParams,
ThreadResumeResponse,
ThreadForkParams,
ThreadForkResponse,
TurnStartParams,
TurnStartResponse,
TurnInterruptParams,
Expand Down Expand Up @@ -160,6 +162,14 @@ export class CodexAppServerClient {
return response as ThreadResumeResponse;
}

async forkThread(params: ThreadForkParams, options?: { signal?: AbortSignal }): Promise<ThreadForkResponse> {
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<TurnStartResponse> {
const response = await this.sendRequest('turn/start', params, {
signal: options?.signal,
Expand Down
38 changes: 25 additions & 13 deletions cli/src/codex/codexLocal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,54 @@ vi.mock('@/utils/spawnWithTerminalGuard', () => ({

vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
debug: vi.fn(),
warn: vi.fn()
}
}));

import { codexLocal, filterResumeSubcommand } from './codexLocal';
import { codexLocal, filterManagedSessionSubcommand } from './codexLocal';

describe('filterResumeSubcommand', () => {
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', () => {
expect(filterResumeSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']);
expect(filterManagedSessionSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']);
});

it('does not filter resume in middle of args', () => {
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']);
});
});

describe('codexLocal', () => {
Expand All @@ -58,7 +70,7 @@ describe('codexLocal', () => {

await codexLocal({
abort: controller.signal,
sessionId: null,
resumeSessionId: null,
path: 'C:\\workspace\\project',
onSessionFound: vi.fn(),
mcpServers: {
Expand Down
27 changes: 15 additions & 12 deletions cli/src/codex/codexLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,28 @@ import { codexSystemPrompt } from './utils/systemPrompt';
import type { ReasoningEffort } from './appServerTypes';

/**
* Filter out 'resume' subcommand which is managed internally by hapi.
* Codex CLI format is `codex resume <session-id>`, so subcommand is always first.
* Filter out HAPI-managed session subcommands which are handled internally.
* Codex CLI format is `codex <subcommand> <session-id>`, 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;
modelReasoningEffort?: ReasoningEffort;
Expand All @@ -44,9 +45,11 @@ export async function codexLocal(opts: {
}): Promise<void> {
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) {
Expand Down Expand Up @@ -74,7 +77,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);
}

Expand Down
4 changes: 3 additions & 1 deletion cli/src/codex/codexLocalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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 primarySessionId = resumeSessionId;
let primaryTranscriptPath: string | null = null;
let scanner: CodexSessionScanner | null = null;
Expand Down Expand Up @@ -168,7 +169,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
launch: async (abortSignal) => {
await codexLocal({
path: session.path,
sessionId: resumeSessionId,
resumeSessionId,
forkSessionId,
modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined,
onSessionFound: handleSessionFound,
abort: abortSignal,
Expand Down
Loading
Loading