Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f9d918c
feat: support codex fork across cli hub and web
pppobear Apr 13, 2026
9f81dde
fix: preserve fork history and toast layout
pppobear Apr 9, 2026
ce01c13
fix: preserve custom title on fork
pppobear Apr 9, 2026
bb71547
refactor: unify fork feedback in web
pppobear Apr 9, 2026
cf03709
fix: preserve derived title on fork
pppobear Apr 9, 2026
12887cf
fix: preserve fork title across metadata updates
pppobear Apr 10, 2026
75fb9d9
feat: support precise codex history forks
pppobear May 3, 2026
bee8403
feat: fork from assistant responses
pppobear May 3, 2026
6a27825
fix: keep assistant fork actions visible
pppobear May 3, 2026
84026fc
fix: preserve assistant seq during reconcile
pppobear May 3, 2026
5182f27
fix: enlarge assistant fork action
pppobear May 3, 2026
f669a1e
fix: keep current session after fork
pppobear May 3, 2026
92b79fc
fix: prevent fork toast navigation
pppobear May 3, 2026
7eee76e
fix: keep all fork actions on current session
pppobear May 3, 2026
deb8cd4
fix: auto-apply web app updates
pppobear May 3, 2026
7522ab8
fix: suppress fork ready toast
pppobear May 3, 2026
7bc1a17
fix: suppress ready toast after forking
pppobear May 3, 2026
106db9b
fix: suppress fork bootstrap ready toast
pppobear May 3, 2026
16d4753
fix: harden codex fork flow
pppobear May 3, 2026
65bb3f2
fix: handle unavailable codex forks
pppobear May 3, 2026
51f2a69
fix: preserve codex fork reasoning effort
pppobear May 3, 2026
ea1ead2
fix: make user fork cut point precede turn
pppobear May 3, 2026
5bf8588
fix: avoid deleting fork history directory
pppobear May 3, 2026
725ed89
fix: clone codex history for forked sessions
pppobear May 3, 2026
17c6dbe
fix: move codex history during session merge
pppobear May 3, 2026
485f3c6
fix: preserve moved codex history collisions
pppobear May 3, 2026
a98fbcb
fix: preserve cloned codex history collisions
pppobear May 3, 2026
768386b
fix: resolve main rebase integration
pppobear May 15, 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 @@ -299,7 +299,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 @@ -314,6 +314,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 cancelQueuedMessageCallback: ((localId: string) => boolean) | null = null
private lastSeenMessageSeq: number | null = null
private backfillInFlight: Promise<void> | null = null
Expand Down Expand Up @@ -254,23 +259,23 @@ 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)
}
}

onCancelQueuedMessage(callback: (localId: string) => boolean): void {
this.cancelQueuedMessageCallback = callback
}

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 @@ -285,7 +290,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 @@ -513,6 +521,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 @@ -161,6 +163,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