Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -112,6 +112,7 @@ export class ApiMachineClient {
directory,
sessionId,
resumeSessionId,
forkSessionId,
machineId,
approvedNewDirectoryCreation,
agent,
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 @@ -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<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 @@ -8,6 +8,8 @@ import type {
ThreadStartResponse,
ThreadResumeParams,
ThreadResumeResponse,
ThreadForkParams,
ThreadForkResponse,
TurnStartParams,
TurnStartResponse,
TurnInterruptParams,
Expand Down Expand Up @@ -149,6 +151,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
43 changes: 31 additions & 12 deletions cli/src/codex/codexLocal.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
27 changes: 15 additions & 12 deletions cli/src/codex/codexLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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;
sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
Expand All @@ -35,9 +36,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 All @@ -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);
}

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 @@ -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<ReturnType<typeof createCodexSessionScanner>> | null = null;
const permissionMode = session.getPermissionMode();
const managedPermissionMode = permissionMode === 'read-only' || permissionMode === 'safe-yolo' || permissionMode === 'yolo'
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 50 additions & 2 deletions cli/src/codex/codexRemoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, never> }> {
const started = { turn: {} };
harness.notifications.push({ method: 'turn/started', params: started });
Expand Down Expand Up @@ -80,7 +105,7 @@ function createMode(): EnhancedMode {
};
}

function createSessionStub() {
function createSessionStub(opts?: { forkSessionId?: string | null }) {
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
queue.push('hello from launcher test', createMode());
queue.close();
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
Loading