feat: add hapi resume command (#647)

This commit is contained in:
leko
2026-05-20 06:18:42 +08:00
committed by GitHub
parent 79d919675e
commit 197f327590
29 changed files with 1777 additions and 93 deletions
+10
View File
@@ -34,6 +34,16 @@ Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your ter
Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram.
- `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`.
Note: OpenCode supports local and remote modes; local mode streams via OpenCode plugins.
- `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally.
### Resume a remote session locally
```bash
hapi resume
hapi resume <session-id>
```
`hapi resume` lists resumable sessions for the current machine. `hapi resume <session-id>` hands off an active remote session and opens the same HAPI session in the local terminal.
### Authentication
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { registerLocalHandoffHandler } from './localHandoff'
describe('registerLocalHandoffHandler', () => {
it('registers handoff-local and schedules clean exit', async () => {
const handlers = new Map<string, (params?: unknown) => unknown>()
const rpcHandlerManager: Parameters<typeof registerLocalHandoffHandler>[0] = {
registerHandler: (method, handler) => {
handlers.set(method, handler as (params?: unknown) => unknown)
}
}
const lifecycle = {
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn(),
cleanupAndExit: vi.fn(async () => {})
}
registerLocalHandoffHandler(rpcHandlerManager, lifecycle)
const handler = handlers.get('handoff-local')
expect(handler).toBeDefined()
expect(await handler?.()).toEqual({ ok: true })
await new Promise((resolve) => setImmediate(resolve))
expect(lifecycle.setArchiveReason).toHaveBeenCalledWith('Handed off to local terminal')
expect(lifecycle.setSessionEndReason).toHaveBeenCalledWith('handoff')
expect(lifecycle.cleanupAndExit).toHaveBeenCalledWith(0)
})
})
+28
View File
@@ -0,0 +1,28 @@
import type { SessionEndReason } from '@hapi/protocol'
type RpcHandlerManagerLike = {
registerHandler<TRequest = unknown, TResponse = unknown>(
method: string,
handler: (params: TRequest) => Promise<TResponse> | TResponse
): void
}
type LocalHandoffLifecycle = {
setArchiveReason: (reason: string) => void
setSessionEndReason: (reason: SessionEndReason) => void
cleanupAndExit: (codeOverride?: number) => Promise<void>
}
export function registerLocalHandoffHandler(
rpcHandlerManager: RpcHandlerManagerLike,
lifecycle: LocalHandoffLifecycle
): void {
rpcHandlerManager.registerHandler('handoff-local', () => {
lifecycle.setArchiveReason('Handed off to local terminal')
lifecycle.setSessionEndReason('handoff')
setImmediate(() => {
void lifecycle.cleanupAndExit(0)
})
return { ok: true }
})
}
+171 -18
View File
@@ -1,29 +1,182 @@
import { afterEach, describe, expect, it } from 'vitest'
import { buildSessionMetadata } from './sessionFactory'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from '@/api/types'
describe('buildSessionMetadata', () => {
const originalHostname = process.env.HAPI_HOSTNAME
const {
getSessionMock,
getOrCreateMachineMock,
sessionSyncClientMock,
notifyRunnerSessionStartedMock,
readSettingsMock
} = vi.hoisted(() => ({
getSessionMock: vi.fn(),
getOrCreateMachineMock: vi.fn(),
sessionSyncClientMock: vi.fn(),
notifyRunnerSessionStartedMock: vi.fn(async () => ({})),
readSettingsMock: vi.fn()
}))
afterEach(() => {
if (originalHostname === undefined) {
delete process.env.HAPI_HOSTNAME
} else {
process.env.HAPI_HOSTNAME = originalHostname
}
vi.mock('@/api/api', () => ({
ApiClient: {
create: async () => ({
getSession: getSessionMock,
getOrCreateMachine: getOrCreateMachineMock,
sessionSyncClient: sessionSyncClientMock
})
}
}))
vi.mock('@/runner/controlClient', () => ({
notifyRunnerSessionStarted: notifyRunnerSessionStartedMock
}))
vi.mock('@/persistence', () => ({
readSettings: readSettingsMock
}))
vi.mock('@/configuration', () => ({
configuration: {
happyHomeDir: '/tmp/.hapi',
logsDir: '/tmp/.hapi/logs',
isRunnerProcess: false
}
}))
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}))
import { bootstrapExistingSession, buildSessionMetadata } from './sessionFactory'
function createSession(): Session {
return {
id: 'hapi-session-1',
namespace: 'default',
seq: 1,
createdAt: 1,
updatedAt: 1,
active: false,
activeAt: 1,
metadata: {
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
metadataVersion: 1,
agentState: { controlledByUser: false },
agentStateVersion: 1,
thinking: false,
thinkingAt: 1,
todos: [],
model: null,
modelReasoningEffort: null,
effort: null,
permissionMode: undefined,
collaborationMode: undefined
}
}
describe('bootstrapExistingSession', () => {
beforeEach(() => {
getSessionMock.mockReset()
getOrCreateMachineMock.mockReset()
sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset()
})
it('uses HAPI_HOSTNAME for session metadata host when provided', () => {
process.env.HAPI_HOSTNAME = 'custom-session-host'
it('loads an existing HAPI session and reports it to the runner', async () => {
const session = createSession()
const sessionClient = {
updateMetadata: vi.fn()
}
getSessionMock.mockResolvedValue(session)
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
sessionSyncClientMock.mockReturnValue(sessionClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const metadata = buildSessionMetadata({
const result = await bootstrapExistingSession({
sessionId: 'hapi-session-1',
flavor: 'codex',
startedBy: 'terminal',
workingDirectory: '/tmp/project',
machineId: 'machine-1',
now: 123
workingDirectory: '/tmp/project'
})
expect(metadata.host).toBe('custom-session-host')
expect(result.sessionInfo.id).toBe('hapi-session-1')
expect(result.workingDirectory).toBe('/tmp/project')
expect(sessionSyncClientMock).toHaveBeenCalledWith(session)
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
path: '/tmp/project',
flavor: 'codex',
startedBy: 'terminal',
startedFromRunner: false,
machineId: 'machine-1'
})
)
})
it('preserves existing native resume metadata when reactivating a session', async () => {
const session = createSession()
const existingMetadata = session.metadata
if (!existingMetadata) throw new Error('expected test session metadata')
session.metadata = {
...existingMetadata,
claudeSessionId: 'claude-thread-1',
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
opencodeSessionId: 'opencode-thread-1',
cursorSessionId: 'cursor-thread-1',
summary: {
text: 'resume me',
updatedAt: 100
},
tools: ['read_file'],
slashCommands: ['/compact']
}
const sessionClient = {
updateMetadata: vi.fn()
}
getSessionMock.mockResolvedValue(session)
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
sessionSyncClientMock.mockReturnValue(sessionClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapExistingSession({
sessionId: 'hapi-session-1',
flavor: 'codex',
workingDirectory: '/tmp/project'
})
expect(result.metadata).toEqual(expect.objectContaining({
claudeSessionId: 'claude-thread-1',
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
opencodeSessionId: 'opencode-thread-1',
cursorSessionId: 'cursor-thread-1',
summary: {
text: 'resume me',
updatedAt: 100
},
tools: ['read_file'],
slashCommands: ['/compact']
}))
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
const updateHandler = sessionClient.updateMetadata.mock.calls[0][0]
expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({
codexSessionId: 'codex-thread-1'
}))
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
codexSessionId: 'codex-thread-1'
})
)
})
it('advertises remote terminal capability in session metadata', () => {
+69
View File
@@ -86,6 +86,25 @@ export function buildSessionMetadata(options: {
}
}
function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Partial<Metadata> {
if (!metadata) return {}
const preserved: Partial<Metadata> = {}
if (metadata.name !== undefined) preserved.name = metadata.name
if (metadata.summary !== undefined) preserved.summary = metadata.summary
if (metadata.claudeSessionId !== undefined) preserved.claudeSessionId = metadata.claudeSessionId
if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId
if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId
if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId
if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId
if (metadata.tools !== undefined) preserved.tools = metadata.tools
if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands
if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree
return preserved
}
async function getMachineIdOrExit(): Promise<string> {
const settings = await readSettings()
const machineId = settings?.machineId
@@ -156,3 +175,53 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis
workingDirectory
}
}
export async function bootstrapExistingSession(options: {
sessionId: string
flavor: string
startedBy?: SessionStartedBy
workingDirectory: string
metadataOverrides?: Partial<Metadata>
}): Promise<SessionBootstrapResult> {
const startedBy = options.startedBy ?? 'terminal'
const api = await ApiClient.create()
const machineId = await getMachineIdOrExit()
await api.getOrCreateMachine({
machineId,
metadata: buildMachineMetadata()
})
const sessionInfo = await api.getSession(options.sessionId)
const baseMetadata = buildSessionMetadata({
flavor: options.flavor,
startedBy,
workingDirectory: options.workingDirectory,
machineId
})
const metadata = {
...baseMetadata,
...pickExistingSessionMetadata(sessionInfo.metadata),
...options.metadataOverrides
}
const buildUpdatedMetadata = (current: Metadata): Metadata => ({
...baseMetadata,
...pickExistingSessionMetadata(current),
...options.metadataOverrides
})
const session = api.sessionSyncClient(sessionInfo)
session.updateMetadata(buildUpdatedMetadata)
await reportSessionStarted(sessionInfo.id, metadata)
return {
api,
session,
sessionInfo,
metadata,
machineId,
startedBy,
workingDirectory: options.workingDirectory
}
}
+115 -1
View File
@@ -1,6 +1,18 @@
import axios from 'axios'
import type { AgentState, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import { AgentStateSchema, CreateMachineResponseSchema, CreateSessionResponseSchema, RunnerStateSchema, MachineMetadataSchema, MetadataSchema } from '@/api/types'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import {
AgentStateSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
GetSessionResponseSchema,
LocalHandoffResponseSchema,
LocalResumeTargetResponseSchema,
RunnerStateSchema,
MachineMetadataSchema,
MetadataSchema,
ResumableSessionsResponseSchema
} from '@/api/types'
import { configuration } from '@/configuration'
import { getAuthToken } from '@/api/auth'
import { apiValidationError } from '@/utils/errorUtils'
@@ -15,6 +27,13 @@ export class ApiClient {
private constructor(private readonly token: string) { }
private authHeaders(): Record<string, string> {
return buildHubRequestHeaders({
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
})
}
async getOrCreateSession(opts: {
tag: string
metadata: Metadata
@@ -84,6 +103,55 @@ export class ApiClient {
}
}
async getSession(sessionId: string): Promise<Session> {
const response = await axios.get(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}`,
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = GetSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id response', response)
}
const raw = parsed.data.session
const metadata = (() => {
if (raw.metadata == null) return null
const parsedMetadata = MetadataSchema.safeParse(raw.metadata)
return parsedMetadata.success ? parsedMetadata.data : null
})()
const agentState = (() => {
if (raw.agentState == null) return null
const parsedAgentState = AgentStateSchema.safeParse(raw.agentState)
return parsedAgentState.success ? parsedAgentState.data : null
})()
return {
id: raw.id,
namespace: raw.namespace,
seq: raw.seq,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt,
active: raw.active,
activeAt: raw.activeAt,
metadata,
metadataVersion: raw.metadataVersion,
agentState,
agentStateVersion: raw.agentStateVersion,
thinking: raw.thinking,
thinkingAt: raw.thinkingAt,
todos: raw.todos,
model: raw.model,
modelReasoningEffort: raw.modelReasoningEffort,
effort: raw.effort,
permissionMode: raw.permissionMode,
collaborationMode: raw.collaborationMode
}
}
async getOrCreateMachine(opts: {
machineId: string
metadata: MachineMetadata
@@ -138,6 +206,52 @@ export class ApiClient {
}
}
async listResumableSessions(machineId?: string): Promise<ResumableSession[]> {
const qs = machineId ? `?machineId=${encodeURIComponent(machineId)}` : ''
const response = await axios.get(
`${configuration.apiUrl}/cli/sessions/resumable${qs}`,
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = ResumableSessionsResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/resumable response', response)
}
return parsed.data.sessions
}
async getLocalResumeTarget(sessionId: string): Promise<LocalResumeTarget> {
const response = await axios.get(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/resume-target`,
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = LocalResumeTargetResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id/resume-target response', response)
}
return parsed.data.target
}
async handoffSessionToLocal(sessionId: string): Promise<void> {
const response = await axios.post(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/handoff-local`,
{},
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = LocalHandoffResponseSchema.safeParse(response.data)
if (!parsed.success || !parsed.data.ok) {
throw apiValidationError('Invalid /cli/sessions/:id/handoff-local response', response)
}
}
sessionSyncClient(session: Session): ApiSessionClient {
return new ApiSessionClient(this.token, session)
}
+14
View File
@@ -6,6 +6,11 @@ import {
PermissionModeSchema,
TodosSchema
} from '@hapi/protocol/schemas'
import {
LocalHandoffResponseSchema,
LocalResumeTargetResponseSchema,
ResumableSessionsResponseSchema
} from '@hapi/protocol'
import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
import { z } from 'zod'
import { UsageSchema } from '@/claude/types'
@@ -142,6 +147,15 @@ export const CreateMachineResponseSchema = z.object({
export type CreateMachineResponse = z.infer<typeof CreateMachineResponseSchema>
export const GetSessionResponseSchema = CreateSessionResponseSchema
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>
export {
LocalHandoffResponseSchema,
LocalResumeTargetResponseSchema,
ResumableSessionsResponseSchema
}
export const MessageMetaSchema = z.object({
sentFrom: z.string().optional(),
fallbackModel: z.string().nullable().optional(),
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
const runLocalRemoteSessionMock = vi.hoisted(() => vi.fn(async (options: { session: { stopKeepAlive: () => void } }) => {
options.session.stopKeepAlive()
}))
vi.mock('@/agent/loopBase', () => ({
runLocalRemoteSession: runLocalRemoteSessionMock
}))
vi.mock('@/ui/logger', () => ({
logger: {
logFilePath: '/tmp/hapi.log',
debug: vi.fn()
}
}))
vi.mock('./claudeLocalLauncher', () => ({
claudeLocalLauncher: vi.fn()
}))
vi.mock('./claudeRemoteLauncher', () => ({
claudeRemoteLauncher: vi.fn()
}))
import { loop } from './loop'
describe('claude loop', () => {
it('initializes the Claude session id from resumeSessionId', async () => {
const sessionClient = {
keepAlive: vi.fn(),
emitMessagesConsumed: vi.fn(),
updateMetadata: vi.fn()
}
await loop({
path: '/tmp/project',
startingMode: 'local',
onModeChange: () => {},
mcpServers: {},
session: sessionClient as never,
api: {} as never,
messageQueue: {} as never,
hookSettingsPath: '/tmp/hooks.json',
resumeSessionId: '11111111-1111-4111-8111-111111111111'
})
expect(runLocalRemoteSessionMock).toHaveBeenCalledWith(expect.objectContaining({
session: expect.objectContaining({
sessionId: '11111111-1111-4111-8111-111111111111'
})
}))
})
})
+2 -1
View File
@@ -39,6 +39,7 @@ interface LoopOptions {
allowedTools?: string[]
onSessionReady?: (session: Session) => void
hookSettingsPath: string
resumeSessionId?: string
}
export async function loop(opts: LoopOptions) {
@@ -51,7 +52,7 @@ export async function loop(opts: LoopOptions) {
api: opts.api,
client: opts.session,
path: opts.path,
sessionId: null,
sessionId: opts.resumeSessionId ?? null,
claudeEnvVars: opts.claudeEnvVars,
claudeArgs: opts.claudeArgs,
mcpServers: opts.mcpServers,
+24 -10
View File
@@ -12,7 +12,8 @@ import { startHookServer } from '@/claude/utils/startHookServer';
import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/modules/common/hooks/generateHookSettings';
import { registerKillSessionHandler } from './registerKillSessionHandler';
import type { Session } from './session';
import { bootstrapSession } from '@/agent/sessionFactory';
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { PermissionModeSchema } from '@hapi/protocol/schemas';
@@ -30,10 +31,13 @@ export interface StartOptions {
claudeEnvVars?: Record<string, string>
claudeArgs?: string[]
startedBy?: 'runner' | 'terminal'
existingSessionId?: string
workingDirectory?: string
resumeSessionId?: string
}
export async function runClaude(options: StartOptions = {}): Promise<void> {
const workingDirectory = getInvokedCwd();
const workingDirectory = options.workingDirectory ?? getInvokedCwd();
const startedBy = options.startedBy ?? 'terminal';
// Log environment info at startup
@@ -51,14 +55,22 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
const initialState: AgentState = {};
const initialModel = normalizeClaudeSessionModel(options.model);
const initialEffort = normalizeClaudeSessionEffort(options.effort);
const { api, session, sessionInfo } = await bootstrapSession({
flavor: 'claude',
startedBy,
workingDirectory,
agentState: initialState,
model: initialModel ?? undefined,
effort: initialEffort ?? undefined
});
const bootstrap = options.existingSessionId
? await bootstrapExistingSession({
sessionId: options.existingSessionId,
flavor: 'claude',
startedBy,
workingDirectory
})
: await bootstrapSession({
flavor: 'claude',
startedBy,
workingDirectory,
agentState: initialState,
model: initialModel ?? undefined,
effort: initialEffort ?? undefined
});
const { api, session, sessionInfo } = bootstrap;
logger.debug(`Session created: ${sessionInfo.id}`);
// Extract SDK metadata in background and update session when ready
@@ -133,6 +145,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
// Set initial agent state
const startingMode = options.startingMode ?? (startedBy === 'runner' ? 'remote' : 'local');
@@ -419,6 +432,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
claudeEnvVars: options.claudeEnvVars,
claudeArgs: options.claudeArgs,
startedBy,
resumeSessionId: options.resumeSessionId,
hookSettingsPath
});
} catch (error) {
+141
View File
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { runCodex } from './runCodex'
const mockCodexSession = vi.hoisted(() => ({
setPermissionMode: vi.fn(),
setModel: vi.fn(),
setModelReasoningEffort: vi.fn(),
setCollaborationMode: vi.fn(),
stopKeepAlive: vi.fn()
}))
const harness = vi.hoisted(() => ({
bootstrapArgs: [] as Array<Record<string, unknown>>,
loopArgs: [] as Array<Record<string, unknown>>,
session: {
onUserMessage: vi.fn(),
onCancelQueuedMessage: vi.fn(),
rpcHandlerManager: {
registerHandler: vi.fn()
}
}
}))
vi.mock('@/agent/sessionFactory', () => ({
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
harness.bootstrapArgs.push(options)
return {
api: {},
session: harness.session
}
}),
bootstrapExistingSession: vi.fn(async (options: Record<string, unknown>) => {
harness.bootstrapArgs.push(options)
return {
api: {},
session: harness.session
}
})
}))
vi.mock('./loop', () => ({
loop: vi.fn(async (options: Record<string, unknown>) => {
harness.loopArgs.push(options)
const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined
onSessionReady?.(mockCodexSession)
})
}))
vi.mock('@/claude/registerKillSessionHandler', () => ({
registerKillSessionHandler: vi.fn()
}))
const lifecycleMock = vi.hoisted(() => ({
registerProcessHandlers: vi.fn(),
cleanupAndExit: vi.fn(async () => {}),
markCrash: vi.fn(),
setExitCode: vi.fn(),
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn()
}))
vi.mock('@/agent/runnerLifecycle', () => ({
createModeChangeHandler: vi.fn(() => vi.fn()),
createRunnerLifecycle: vi.fn(() => lifecycleMock),
setControlledByUser: vi.fn()
}))
vi.mock('@/agent/localHandoff', () => ({
registerLocalHandoffHandler: vi.fn()
}))
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}))
vi.mock('@/utils/attachmentFormatter', () => ({
formatMessageWithAttachments: vi.fn((text: string) => text)
}))
vi.mock('@/modules/common/slashCommands', () => ({
listSlashCommands: vi.fn(async () => [])
}))
vi.mock('./utils/slashCommands', () => ({
resolveCodexSlashCommand: vi.fn(() => ({
kind: 'passthrough'
}))
}))
vi.mock('./codexSpecialCommands', () => ({
parseCodexSpecialCommand: vi.fn(() => ({}))
}))
vi.mock('./utils/codexCliOverrides', () => ({
parseCodexCliOverrides: vi.fn(() => ({}))
}))
import { runCodex as runCodexImpl } from './runCodex'
describe('runCodex', () => {
beforeEach(() => {
harness.bootstrapArgs.length = 0
harness.loopArgs.length = 0
harness.session.onUserMessage.mockReset()
harness.session.onCancelQueuedMessage.mockReset()
harness.session.rpcHandlerManager.registerHandler.mockReset()
mockCodexSession.setPermissionMode.mockReset()
mockCodexSession.setModel.mockReset()
mockCodexSession.setModelReasoningEffort.mockReset()
mockCodexSession.setCollaborationMode.mockReset()
lifecycleMock.registerProcessHandlers.mockClear()
lifecycleMock.cleanupAndExit.mockClear()
lifecycleMock.markCrash.mockClear()
lifecycleMock.setExitCode.mockClear()
lifecycleMock.setArchiveReason.mockClear()
lifecycleMock.setSessionEndReason.mockClear()
})
it('uses the requested collaboration mode when resuming locally', async () => {
const options = {
existingSessionId: 'hapi-session-1',
workingDirectory: '/tmp/project',
resumeSessionId: 'codex-thread-1',
collaborationMode: 'plan'
} as Parameters<typeof runCodex>[0] & { collaborationMode: 'plan' }
await runCodexImpl(options)
expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({
sessionId: 'hapi-session-1',
workingDirectory: '/tmp/project'
}))
expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
resumeSessionId: 'codex-thread-1',
collaborationMode: 'plan'
}))
expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan')
})
})
+24 -11
View File
@@ -7,7 +7,8 @@ import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'
import type { AgentState } from '@/api/types';
import type { CodexSession } from './session';
import { parseCodexCliOverrides } from './utils/codexCliOverrides';
import { bootstrapSession } from '@/agent/sessionFactory';
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
@@ -29,8 +30,11 @@ export async function runCodex(opts: {
resumeSessionId?: string;
model?: string;
modelReasoningEffort?: ReasoningEffort;
collaborationMode?: EnhancedMode['collaborationMode'];
existingSessionId?: string;
workingDirectory?: string;
}): Promise<void> {
const workingDirectory = getInvokedCwd();
const workingDirectory = opts.workingDirectory ?? getInvokedCwd();
const startedBy = opts.startedBy ?? 'terminal';
logger.debug(`[codex] Starting with options: startedBy=${startedBy}`);
@@ -38,14 +42,22 @@ export async function runCodex(opts: {
let state: AgentState = {
controlledByUser: false
};
const { api, session } = await bootstrapSession({
flavor: 'codex',
startedBy,
workingDirectory,
agentState: state,
model: opts.model,
modelReasoningEffort: opts.modelReasoningEffort
});
const bootstrap = opts.existingSessionId
? await bootstrapExistingSession({
sessionId: opts.existingSessionId,
flavor: 'codex',
startedBy,
workingDirectory
})
: await bootstrapSession({
flavor: 'codex',
startedBy,
workingDirectory,
agentState: state,
model: opts.model,
modelReasoningEffort: opts.modelReasoningEffort
});
const { api, session } = bootstrap;
const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local';
@@ -64,7 +76,7 @@ export async function runCodex(opts: {
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
let currentModel = opts.model;
let currentModelReasoningEffort: ReasoningEffort | undefined = opts.modelReasoningEffort;
let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default';
let currentCollaborationMode: EnhancedMode['collaborationMode'] = opts.collaborationMode ?? 'default';
const lifecycle = createRunnerLifecycle({
session,
@@ -74,6 +86,7 @@ export async function runCodex(opts: {
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => {
const sessionInstance = sessionWrapperRef.current;
+1
View File
@@ -90,6 +90,7 @@ ${chalk.bold('Usage:')}
hapi cursor Start Cursor Agent mode
hapi gemini Start Gemini ACP mode
hapi opencode Start OpenCode ACP mode
hapi resume [id] Resume an existing HAPI session locally
hapi mcp Start MCP stdio bridge
hapi connect (not available in direct-connect mode)
hapi notify (not available in direct-connect mode)
+2
View File
@@ -4,6 +4,7 @@ import { codexCommand } from './codex'
import { cursorCommand } from './cursor'
import { connectCommand } from './connect'
import { runnerCommand } from './runner'
import { resumeCommand } from './resume'
import { doctorCommand } from './doctor'
import { geminiCommand } from './gemini'
import { opencodeCommand } from './opencode'
@@ -25,6 +26,7 @@ const COMMANDS: CommandDefinition[] = [
{ ...hubCommand, name: 'server' },
hookForwarderCommand,
doctorCommand,
resumeCommand,
runnerCommand,
notifyCommand
]
+190
View File
@@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
initializeTokenMock,
maybeAutoStartServerMock,
authAndSetupMachineIfNeededMock,
listResumableSessionsMock,
getLocalResumeTargetMock,
handoffSessionToLocalMock,
runCodexMock,
runClaudeMock,
assertCodexLocalSupportedMock,
existsSyncMock
} = vi.hoisted(() => ({
initializeTokenMock: vi.fn(async () => {}),
maybeAutoStartServerMock: vi.fn(async () => {}),
authAndSetupMachineIfNeededMock: vi.fn(async () => ({ machineId: 'machine-1' })),
listResumableSessionsMock: vi.fn(),
getLocalResumeTargetMock: vi.fn(),
handoffSessionToLocalMock: vi.fn(async () => {}),
runCodexMock: vi.fn(async () => {}),
runClaudeMock: vi.fn(async () => {}),
assertCodexLocalSupportedMock: vi.fn(),
existsSyncMock: vi.fn(() => true)
}))
vi.mock('@/ui/tokenInit', () => ({ initializeToken: initializeTokenMock }))
vi.mock('@/utils/autoStartServer', () => ({ maybeAutoStartServer: maybeAutoStartServerMock }))
vi.mock('@/ui/auth', () => ({ authAndSetupMachineIfNeeded: authAndSetupMachineIfNeededMock }))
vi.mock('@/api/api', () => ({
ApiClient: {
create: async () => ({
listResumableSessions: listResumableSessionsMock,
getLocalResumeTarget: getLocalResumeTargetMock,
handoffSessionToLocal: handoffSessionToLocalMock
})
}
}))
vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock }))
vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock }))
vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock }))
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
import { resumeCommand } from './resume'
function createContext(commandArgs: string[]) {
return {
args: ['resume'].concat(commandArgs),
subcommand: 'resume',
commandArgs
}
}
describe('resumeCommand', () => {
beforeEach(() => {
initializeTokenMock.mockClear()
maybeAutoStartServerMock.mockClear()
authAndSetupMachineIfNeededMock.mockClear()
listResumableSessionsMock.mockReset()
getLocalResumeTargetMock.mockReset()
handoffSessionToLocalMock.mockClear()
runCodexMock.mockClear()
runClaudeMock.mockClear()
assertCodexLocalSupportedMock.mockClear()
existsSyncMock.mockReturnValue(true)
})
it('resumes a Codex target by HAPI session id', async () => {
getLocalResumeTargetMock.mockResolvedValue({
sessionId: 'hapi-session-1',
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-1',
active: true,
thinking: false,
controlledByUser: false,
agentSessionId: 'codex-thread-1',
model: 'gpt-5.4',
modelReasoningEffort: 'xhigh',
permissionMode: 'default',
collaborationMode: 'default'
})
await resumeCommand.run(createContext(['hapi-session-1']))
expect(handoffSessionToLocalMock).toHaveBeenCalledWith('hapi-session-1')
expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce()
expect(runCodexMock).toHaveBeenCalledWith({
existingSessionId: 'hapi-session-1',
workingDirectory: '/tmp/project',
resumeSessionId: 'codex-thread-1',
startedBy: 'terminal',
permissionMode: 'default',
model: 'gpt-5.4',
modelReasoningEffort: 'xhigh',
collaborationMode: 'default'
})
})
it('resumes an inactive Claude target without handoff', async () => {
getLocalResumeTargetMock.mockResolvedValue({
sessionId: 'hapi-session-2',
flavor: 'claude',
directory: '/tmp/project',
machineId: 'machine-1',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: '11111111-1111-4111-8111-111111111111',
model: 'sonnet',
effort: 'high',
permissionMode: 'default'
})
await resumeCommand.run(createContext(['hapi-session-2']))
expect(handoffSessionToLocalMock).not.toHaveBeenCalled()
expect(runClaudeMock).toHaveBeenCalledWith({
existingSessionId: 'hapi-session-2',
workingDirectory: '/tmp/project',
resumeSessionId: '11111111-1111-4111-8111-111111111111',
startedBy: 'terminal',
startingMode: 'local',
permissionMode: 'default',
model: 'sonnet',
effort: 'high'
})
})
it('fails before launching when the target belongs to another machine', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 'undefined'}`)
}) as never)
getLocalResumeTargetMock.mockResolvedValue({
sessionId: 'hapi-session-3',
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-2',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: 'codex-thread-1'
})
try {
await expect(resumeCommand.run(createContext(['hapi-session-3']))).rejects.toThrow('process.exit:1')
expect(runCodexMock).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('another machine'))
} finally {
consoleErrorSpy.mockRestore()
exitSpy.mockRestore()
}
})
it('resumes an inactive local target even when controlledByUser is sticky', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 'undefined'}`)
}) as never)
getLocalResumeTargetMock.mockResolvedValue({
sessionId: 'hapi-session-4',
flavor: 'claude',
directory: '/tmp/project',
machineId: 'machine-1',
active: false,
thinking: false,
controlledByUser: true,
agentSessionId: '11111111-1111-4111-8111-111111111111',
permissionMode: 'default'
})
try {
await resumeCommand.run(createContext(['hapi-session-4']))
expect(exitSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).not.toHaveBeenCalled()
expect(handoffSessionToLocalMock).not.toHaveBeenCalled()
expect(runClaudeMock).toHaveBeenCalledWith(expect.objectContaining({
existingSessionId: 'hapi-session-4',
resumeSessionId: '11111111-1111-4111-8111-111111111111'
}))
} finally {
consoleErrorSpy.mockRestore()
exitSpy.mockRestore()
}
})
})
+197
View File
@@ -0,0 +1,197 @@
import chalk from 'chalk'
import { existsSync } from 'node:fs'
import * as readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import type {
ClaudePermissionMode,
CodexPermissionMode,
CursorPermissionMode,
GeminiPermissionMode,
OpencodePermissionMode
} from '@hapi/protocol/types'
import { ApiClient } from '@/api/api'
import type { ReasoningEffort } from '@/codex/appServerTypes'
import { authAndSetupMachineIfNeeded } from '@/ui/auth'
import { initializeToken } from '@/ui/tokenInit'
import { maybeAutoStartServer } from '@/utils/autoStartServer'
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
import type { CommandDefinition } from './types'
function formatSessionLine(session: ResumableSession, index: number): string {
const name = session.name ?? session.summary ?? session.sessionId
const state = session.active
? session.controlledByUser ? 'local' : 'remote'
: 'inactive'
return `${index + 1}. ${session.flavor.padEnd(8)} ${state.padEnd(8)} ${name} ${session.directory}`
}
async function selectSession(sessions: ResumableSession[]): Promise<string> {
console.log(chalk.bold('Resumable sessions'))
console.log('')
sessions.forEach((session, index) => {
console.log(formatSessionLine(session, index))
})
console.log('')
const rl = readline.createInterface({ input, output })
try {
const answer = await rl.question(chalk.cyan('Select session: '))
const index = Number(answer.trim()) - 1
if (!Number.isInteger(index) || index < 0 || index >= sessions.length) {
throw new Error('Invalid selection')
}
return sessions[index].sessionId
} finally {
rl.close()
}
}
function assertTargetMachine(target: LocalResumeTarget, machineId: string): void {
if (!target.machineId) {
throw new Error('Session metadata missing machine id')
}
if (target.machineId !== machineId) {
throw new Error(`Session belongs to another machine (${target.machineId})`)
}
}
function assertDirectoryExists(target: LocalResumeTarget): void {
if (!existsSync(target.directory)) {
throw new Error(`Session directory does not exist: ${target.directory}`)
}
}
async function dispatchLocalResume(target: LocalResumeTarget): Promise<void> {
const base = {
existingSessionId: target.sessionId,
workingDirectory: target.directory,
resumeSessionId: target.agentSessionId,
startedBy: 'terminal' as const,
permissionMode: target.permissionMode
}
if (target.flavor === 'claude') {
const { runClaude } = await import('@/claude/runClaude')
await runClaude({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
permissionMode: base.permissionMode as ClaudePermissionMode | undefined,
startingMode: 'local',
model: target.model ?? undefined,
effort: target.effort ?? undefined
})
return
}
if (target.flavor === 'codex') {
assertCodexLocalSupported()
const { runCodex } = await import('@/codex/runCodex')
await runCodex({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
permissionMode: base.permissionMode as CodexPermissionMode | undefined,
model: target.model ?? undefined,
modelReasoningEffort: (target.modelReasoningEffort ?? undefined) as ReasoningEffort | undefined,
collaborationMode: target.collaborationMode
})
return
}
if (target.flavor === 'gemini') {
const { runGemini } = await import('@/gemini/runGemini')
await runGemini({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
permissionMode: base.permissionMode as GeminiPermissionMode | undefined,
startingMode: 'local',
model: target.model ?? undefined
})
return
}
if (target.flavor === 'opencode') {
const { runOpencode } = await import('@/opencode/runOpencode')
await runOpencode({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
permissionMode: base.permissionMode as OpencodePermissionMode | undefined,
startingMode: 'local',
model: target.model ?? undefined
})
return
}
const { runCursor } = await import('@/cursor/runCursor')
await runCursor({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
permissionMode: base.permissionMode as CursorPermissionMode | undefined,
model: target.model ?? undefined
})
}
async function resolveSessionId(api: ApiClient, machineId: string, args: string[]): Promise<string> {
const explicit = args[0]
if (explicit) {
return explicit
}
const sessions = await api.listResumableSessions(machineId)
if (sessions.length === 0) {
throw new Error('No resumable sessions found for this machine')
}
if (!process.stdin.isTTY) {
for (const [index, session] of sessions.entries()) {
console.log(formatSessionLine(session, index))
}
throw new Error('Run: hapi resume <session-id>')
}
return await selectSession(sessions)
}
export const resumeCommand: CommandDefinition = {
name: 'resume',
requiresRuntimeAssets: true,
run: async ({ commandArgs }) => {
try {
await initializeToken()
await maybeAutoStartServer()
const { machineId } = await authAndSetupMachineIfNeeded()
const api = await ApiClient.create()
const sessionId = await resolveSessionId(api, machineId, commandArgs)
const target = await api.getLocalResumeTarget(sessionId)
assertTargetMachine(target, machineId)
assertDirectoryExists(target)
if (target.active && target.controlledByUser) {
throw new Error('Session is already controlled by a local terminal')
}
if (target.active) {
await api.handoffSessionToLocal(target.sessionId)
}
await dispatchLocalResume(target)
} catch (error) {
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')
if (process.env.DEBUG) {
console.error(error)
}
process.exit(1)
}
}
}
+21 -9
View File
@@ -5,7 +5,8 @@ import { hashObject } from '@/utils/deterministicJson';
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import type { AgentState } from '@/api/types';
import type { CursorSession } from './session';
import { bootstrapSession } from '@/agent/sessionFactory';
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { PermissionModeSchema } from '@hapi/protocol/schemas';
@@ -26,8 +27,10 @@ export async function runCursor(opts: {
permissionMode?: PermissionMode;
resumeSessionId?: string;
model?: string;
existingSessionId?: string;
workingDirectory?: string;
}): Promise<void> {
const workingDirectory = getInvokedCwd();
const workingDirectory = opts.workingDirectory ?? getInvokedCwd();
const startedBy = opts.startedBy ?? 'terminal';
logger.debug(`[cursor] Starting with options: startedBy=${startedBy}`);
@@ -35,13 +38,21 @@ export async function runCursor(opts: {
const state: AgentState = {
controlledByUser: false
};
const { api, session } = await bootstrapSession({
flavor: 'cursor',
startedBy,
workingDirectory,
agentState: state,
model: opts.model
});
const bootstrap = opts.existingSessionId
? await bootstrapExistingSession({
sessionId: opts.existingSessionId,
flavor: 'cursor',
startedBy,
workingDirectory
})
: await bootstrapSession({
flavor: 'cursor',
startedBy,
workingDirectory,
agentState: state,
model: opts.model
});
const { api, session } = bootstrap;
const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local';
@@ -67,6 +78,7 @@ export async function runCursor(opts: {
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
const sessionInstance = sessionWrapperRef.current;
+21 -9
View File
@@ -6,7 +6,8 @@ import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'
import type { AgentState } from '@/api/types';
import type { GeminiSession } from './session';
import type { GeminiMode, PermissionMode } from './types';
import { bootstrapSession } from '@/agent/sessionFactory';
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { startHookServer } from '@/claude/utils/startHookServer';
import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings';
@@ -22,8 +23,10 @@ export async function runGemini(opts: {
permissionMode?: PermissionMode;
model?: string;
resumeSessionId?: string;
existingSessionId?: string;
workingDirectory?: string;
} = {}): Promise<void> {
const workingDirectory = getInvokedCwd();
const workingDirectory = opts.workingDirectory ?? getInvokedCwd();
const startedBy = opts.startedBy ?? 'terminal';
logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
@@ -47,13 +50,21 @@ export async function runGemini(opts: {
? undefined
: runtimeConfig.model;
const { api, session } = await bootstrapSession({
flavor: 'gemini',
startedBy,
workingDirectory,
agentState: initialState,
model: persistedModel
});
const bootstrap = opts.existingSessionId
? await bootstrapExistingSession({
sessionId: opts.existingSessionId,
flavor: 'gemini',
startedBy,
workingDirectory
})
: await bootstrapSession({
flavor: 'gemini',
startedBy,
workingDirectory,
agentState: initialState,
model: persistedModel
});
const { api, session } = bootstrap;
const startingMode: 'local' | 'remote' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'local');
@@ -104,6 +115,7 @@ export async function runGemini(opts: {
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
const sessionInstance = sessionWrapperRef.current;
+21 -9
View File
@@ -6,7 +6,8 @@ import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'
import type { AgentState } from '@/api/types';
import type { OpencodeSession } from './session';
import type { OpencodeMode, PermissionMode } from './types';
import { bootstrapSession } from '@/agent/sessionFactory';
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { PermissionModeSchema } from '@hapi/protocol/schemas';
@@ -20,8 +21,10 @@ export async function runOpencode(opts: {
permissionMode?: PermissionMode;
model?: string;
resumeSessionId?: string;
existingSessionId?: string;
workingDirectory?: string;
} = {}): Promise<void> {
const workingDirectory = getInvokedCwd();
const workingDirectory = opts.workingDirectory ?? getInvokedCwd();
const startedBy = opts.startedBy ?? 'terminal';
logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
@@ -40,13 +43,21 @@ export async function runOpencode(opts: {
// not by this initial bootstrap.
const initialModel = opts.model ?? null;
const { api, session } = await bootstrapSession({
flavor: 'opencode',
startedBy,
workingDirectory,
agentState: initialState,
model: initialModel ?? undefined
});
const bootstrap = opts.existingSessionId
? await bootstrapExistingSession({
sessionId: opts.existingSessionId,
flavor: 'opencode',
startedBy,
workingDirectory
})
: await bootstrapSession({
flavor: 'opencode',
startedBy,
workingDirectory,
agentState: initialState,
model: initialModel ?? undefined
});
const { api, session } = bootstrap;
const startingMode: 'local' | 'remote' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'local');
@@ -83,6 +94,7 @@ export async function runOpencode(opts: {
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
const sessionInstance = sessionWrapperRef.current;
+4
View File
@@ -143,6 +143,10 @@ export class RpcGateway {
await this.sessionRpc(sessionId, 'killSession', {})
}
async handoffSessionToLocal(sessionId: string): Promise<void> {
await this.sessionRpc(sessionId, 'handoff-local', {})
}
async spawnSession(
machineId: string,
directory: string,
+193
View File
@@ -864,6 +864,199 @@ describe('session model', () => {
}
})
it('resolves a local resume target for a Codex session', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-codex',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
{ controlledByUser: false },
'default',
'gpt-5.4',
undefined,
'xhigh'
)
const result = engine.resolveLocalResumeTarget(session.id, 'default')
expect(result).toEqual({
type: 'success',
target: {
sessionId: session.id,
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-1',
host: 'localhost',
active: session.active,
thinking: session.thinking,
controlledByUser: false,
agentSessionId: 'codex-thread-1',
model: 'gpt-5.4',
effort: null,
modelReasoningEffort: 'xhigh',
permissionMode: undefined,
collaborationMode: undefined
}
})
} finally {
engine.stop()
}
})
it('recovers a Claude local resume target from stored messages', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-claude-from-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
sessionId: '22222222-2222-4222-8222-222222222222'
}
}
})
const result = engine.resolveLocalResumeTarget(session.id, 'default')
expect(result.type).toBe('success')
if (result.type === 'success') {
expect(result.target.flavor).toBe('claude')
expect(result.target.agentSessionId).toBe('22222222-2222-4222-8222-222222222222')
}
} finally {
engine.stop()
}
})
it('returns resume_unavailable when the local resume target lacks an agent session id', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-no-agent-id',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex'
},
null,
'default'
)
expect(engine.resolveLocalResumeTarget(session.id, 'default')).toEqual({
type: 'error',
message: 'Resume session ID unavailable',
code: 'resume_unavailable'
})
} finally {
engine.stop()
}
})
it('local handoff succeeds immediately for inactive sessions', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-handoff-inactive',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
{ controlledByUser: false },
'default'
)
engine.handleSessionEnd({ sid: session.id, time: Date.now() })
await expect(engine.handoffSessionToLocal(session.id, 'default')).resolves.toEqual({
type: 'success'
})
} finally {
engine.stop()
}
})
it('local handoff rejects sessions already controlled by a local terminal', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-handoff-already-local',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
{ controlledByUser: true },
'default'
)
engine.handleSessionAlive({ sid: session.id, time: Date.now(), mode: 'local' })
await expect(engine.handoffSessionToLocal(session.id, 'default')).resolves.toEqual({
type: 'error',
message: 'Session is already controlled by a local terminal',
code: 'already_local'
})
} finally {
engine.stop()
}
})
describe('session dedup by agent session ID', () => {
it('merges duplicate when codexSessionId collides', async () => {
const store = new Store(':memory:')
+168 -21
View File
@@ -7,7 +7,8 @@
* - No E2E encryption; data is stored as JSON in SQLite
*/
import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
import type { Server } from 'socket.io'
import type { Store, CancelQueuedMessageResult } from '../store'
import type { RpcRegistry } from '../socket/rpcRegistry'
@@ -52,6 +53,14 @@ 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 LocalResumeTargetResult =
| { type: 'success'; target: LocalResumeTarget }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' }
export type LocalHandoffResult =
| { type: 'success' }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' }
export class SyncEngine {
private readonly eventPublisher: EventPublisher
private readonly sessionCache: SessionCache
@@ -437,6 +446,100 @@ export class SyncEngine {
)
}
private resolveFlavor(session: Session): AgentFlavor {
const flavor = session.metadata?.flavor
return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor'
? flavor
: 'claude'
}
private resolveAgentResumeId(session: Session, namespace: string): string | null {
const metadata = session.metadata
if (!metadata) {
return null
}
const flavor = this.resolveFlavor(session)
if (flavor === 'codex') return metadata.codexSessionId ?? null
if (flavor === 'gemini') return metadata.geminiSessionId ?? null
if (flavor === 'opencode') return metadata.opencodeSessionId ?? null
if (flavor === 'cursor') return metadata.cursorSessionId ?? null
return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace)
}
resolveLocalResumeTarget(sessionId: string, namespace: string): LocalResumeTargetResult {
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' || metadata.path.length === 0) {
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' }
}
const agentSessionId = this.resolveAgentResumeId(session, namespace)
if (!agentSessionId) {
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
}
return {
type: 'success',
target: {
sessionId: access.sessionId,
flavor: this.resolveFlavor(session),
directory: metadata.path,
machineId: metadata.machineId,
host: metadata.host,
active: session.active,
thinking: session.thinking,
controlledByUser: session.agentState?.controlledByUser === true,
agentSessionId,
model: session.model ?? null,
effort: session.effort ?? null,
modelReasoningEffort: session.modelReasoningEffort ?? null,
permissionMode: session.permissionMode,
collaborationMode: session.collaborationMode
}
}
}
listLocalResumableSessions(namespace: string, opts?: { machineId?: string }): ResumableSession[] {
return this.getSessionsByNamespace(namespace)
.map((session) => this.resolveLocalResumeTarget(session.id, namespace))
.filter((result): result is { type: 'success'; target: LocalResumeTarget } => result.type === 'success')
.map(({ target }) => {
const session = this.getSessionByNamespace(target.sessionId, namespace)
return {
sessionId: target.sessionId,
flavor: target.flavor,
directory: target.directory,
machineId: target.machineId,
host: target.host,
active: target.active,
thinking: target.thinking,
controlledByUser: target.controlledByUser,
agentSessionId: target.agentSessionId,
model: target.model,
effort: target.effort,
modelReasoningEffort: target.modelReasoningEffort,
permissionMode: target.permissionMode,
collaborationMode: target.collaborationMode,
updatedAt: session?.updatedAt ?? 0,
name: session?.metadata?.name,
summary: session?.metadata?.summary?.text
}
})
.filter((session) => !opts?.machineId || session.machineId === opts.machineId)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
@@ -452,27 +555,15 @@ export class SyncEngine {
return { type: 'success', sessionId: access.sessionId }
}
const metadata = session.metadata
if (!metadata || typeof metadata.path !== 'string') {
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' }
const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace)
if (targetResult.type === 'error') {
return targetResult
}
const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' || metadata.flavor === 'cursor'
? metadata.flavor
: 'claude'
const resumeToken = flavor === 'codex'
? metadata.codexSessionId
: flavor === 'gemini'
? metadata.geminiSessionId
: flavor === 'opencode'
? metadata.opencodeSessionId
: flavor === 'cursor'
? metadata.cursorSessionId
: (metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(access.sessionId, namespace))
if (!resumeToken) {
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
}
const target = targetResult.target
const metadata = session.metadata!
const flavor = target.flavor
const resumeToken = target.agentSessionId
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
if (onlineMachines.length === 0) {
@@ -498,7 +589,7 @@ export class SyncEngine {
const effectivePermissionMode = opts?.permissionMode ?? session.permissionMode ?? undefined
const spawnResult = await this.rpcGateway.spawnSession(
targetMachine.id,
metadata.path,
target.directory,
flavor,
session.model ?? undefined,
session.modelReasoningEffort ?? undefined,
@@ -537,6 +628,50 @@ export class SyncEngine {
return { type: 'success', sessionId: spawnResult.sessionId }
}
async handoffSessionToLocal(sessionId: string, namespace: string): Promise<LocalHandoffResult> {
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'
}
}
if (!access.session.active) {
return { type: 'success' }
}
if (access.session.agentState?.controlledByUser === true) {
return {
type: 'error',
message: 'Session is already controlled by a local terminal',
code: 'already_local'
}
}
try {
await this.rpcGateway.handoffSessionToLocal(access.sessionId)
} catch (error) {
return {
type: 'error',
message: error instanceof Error ? error.message : String(error),
code: 'handoff_failed'
}
}
const inactive = await this.waitForSessionInactive(access.sessionId)
if (!inactive) {
return {
type: 'error',
message: 'Timed out waiting for remote session to hand off',
code: 'handoff_failed'
}
}
return { type: 'success' }
}
private recoverClaudeSessionIdFromMessages(sessionId: string, namespace: string): string | null {
const messages = this.messageService.getMessages(sessionId, 200)
for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -641,6 +776,18 @@ export class SyncEngine {
return false
}
async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const session = this.getSession(sessionId)
if (!session?.active) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
}
async checkPathsExist(machineId: string, paths: string[]): Promise<Record<string, boolean>> {
return await this.rpcGateway.checkPathsExist(machineId, paths)
}
+116
View File
@@ -0,0 +1,116 @@
import { beforeAll, describe, expect, it } from 'bun:test'
import { Hono } from 'hono'
import type { SyncEngine } from '../../sync/syncEngine'
import { createConfiguration } from '../../configuration'
import { createCliRoutes } from './cli'
function createApp(engine: Partial<SyncEngine>) {
const app = new Hono()
app.route('/cli', createCliRoutes(() => engine as SyncEngine))
return app
}
function authHeaders() {
return {
authorization: 'Bearer test-token'
}
}
beforeAll(async () => {
const config = await createConfiguration()
config._setCliApiToken('test-token', 'env', false)
})
describe('cli resume routes', () => {
it('returns local resumable sessions', async () => {
const app = createApp({
listLocalResumableSessions: () => [{
sessionId: 'session-1',
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-1',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: 'codex-thread-1',
updatedAt: 123
}]
} as never)
const response = await app.request('/cli/sessions/resumable?machineId=machine-1', {
headers: authHeaders()
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
sessions: [{
sessionId: 'session-1',
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-1',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: 'codex-thread-1',
updatedAt: 123
}]
})
})
it('returns a local resume target', async () => {
const app = createApp({
resolveLocalResumeTarget: () => ({
type: 'success',
target: {
sessionId: 'session-1',
flavor: 'claude',
directory: '/tmp/project',
machineId: 'machine-1',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: '11111111-1111-4111-8111-111111111111'
}
})
} as never)
const response = await app.request('/cli/sessions/session-1/resume-target', {
headers: authHeaders()
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
target: {
sessionId: 'session-1',
flavor: 'claude',
directory: '/tmp/project',
machineId: 'machine-1',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: '11111111-1111-4111-8111-111111111111'
}
})
})
it('returns handoff errors with status codes', async () => {
const app = createApp({
handoffSessionToLocal: async () => ({
type: 'error',
message: 'Session is already controlled by a local terminal',
code: 'already_local'
})
} as never)
const response = await app.request('/cli/sessions/session-1/handoff-local', {
method: 'POST',
headers: authHeaders()
})
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'Session is already controlled by a local terminal',
code: 'already_local'
})
})
})
+49
View File
@@ -115,6 +115,55 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
return c.json({ session })
})
app.get('/sessions/resumable', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const namespace = c.get('namespace')
const machineId = c.req.query('machineId') || undefined
const sessions = engine.listLocalResumableSessions(namespace, { machineId })
return c.json({ sessions })
})
app.get('/sessions/:id/resume-target', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const namespace = c.get('namespace')
const result = engine.resolveLocalResumeTarget(c.req.param('id'), namespace)
if (result.type === 'error') {
const status = result.code === 'access_denied' ? 403
: result.code === 'session_not_found' ? 404
: 409
return c.json({ error: result.message, code: result.code }, status)
}
return c.json({ target: result.target })
})
app.post('/sessions/:id/handoff-local', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const namespace = c.get('namespace')
const result = await engine.handoffSessionToLocal(c.req.param('id'), namespace)
if (result.type === 'error') {
const status = result.code === 'access_denied' ? 403
: result.code === 'session_not_found' ? 404
: result.code === 'already_local' ? 409
: 500
return c.json({ error: result.message, code: result.code }, status)
}
return c.json({ ok: true })
})
app.get('/sessions/:id', (c) => {
const engine = getSyncEngine()
if (!engine) {
+1
View File
@@ -2,6 +2,7 @@ export * from './messages'
export * from './flavors'
export * from './models'
export * from './modes'
export * from './resume'
export * from './socket'
export * from './sessionSummary'
export * from './utils'
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'bun:test'
import { LocalResumeTargetSchema, ResumableSessionSchema } from './resume'
import { SyncEventSchema } from './schemas'
import { SessionEndReasonSchema } from './socket'
describe('resume schemas', () => {
it('accepts a local resume target', () => {
const parsed = LocalResumeTargetSchema.safeParse({
sessionId: 'hapi-session-1',
flavor: 'codex',
directory: '/tmp/project',
machineId: 'machine-1',
host: 'devbox',
active: true,
thinking: false,
controlledByUser: false,
agentSessionId: 'codex-thread-1',
model: 'gpt-5.4',
effort: null,
modelReasoningEffort: 'xhigh',
permissionMode: 'default',
collaborationMode: 'default'
})
expect(parsed.success).toBe(true)
})
it('accepts a resumable session summary', () => {
const parsed = ResumableSessionSchema.safeParse({
sessionId: 'hapi-session-1',
flavor: 'claude',
directory: '/tmp/project',
active: false,
thinking: false,
controlledByUser: false,
agentSessionId: '11111111-1111-4111-8111-111111111111',
updatedAt: 123,
name: 'project work',
summary: 'finish docs'
})
expect(parsed.success).toBe(true)
})
it('accepts handoff as a session end reason', () => {
expect(SessionEndReasonSchema.parse('handoff')).toBe('handoff')
})
it('accepts handoff in session-ended sync events', () => {
const parsed = SyncEventSchema.safeParse({
type: 'session-ended',
sessionId: 'hapi-session-1',
reason: 'handoff'
})
expect(parsed.success).toBe(true)
})
})
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod'
import { CodexCollaborationModeSchema, PermissionModeSchema } from './schemas'
export const AgentFlavorSchema = z.enum(['claude', 'codex', 'gemini', 'opencode', 'cursor'])
export const LocalResumeTargetSchema = z.object({
sessionId: z.string().min(1),
flavor: AgentFlavorSchema,
directory: z.string().min(1),
machineId: z.string().optional(),
host: z.string().optional(),
active: z.boolean(),
thinking: z.boolean(),
controlledByUser: z.boolean(),
agentSessionId: z.string().min(1),
model: z.string().nullable().optional(),
effort: z.string().nullable().optional(),
modelReasoningEffort: z.string().nullable().optional(),
permissionMode: PermissionModeSchema.optional(),
collaborationMode: CodexCollaborationModeSchema.optional()
})
export type LocalResumeTarget = z.infer<typeof LocalResumeTargetSchema>
export const ResumableSessionSchema = LocalResumeTargetSchema.extend({
updatedAt: z.number(),
name: z.string().optional(),
summary: z.string().optional()
})
export type ResumableSession = z.infer<typeof ResumableSessionSchema>
export const LocalResumeTargetResponseSchema = z.object({
target: LocalResumeTargetSchema
})
export type LocalResumeTargetResponse = z.infer<typeof LocalResumeTargetResponseSchema>
export const ResumableSessionsResponseSchema = z.object({
sessions: z.array(ResumableSessionSchema)
})
export type ResumableSessionsResponse = z.infer<typeof ResumableSessionsResponseSchema>
export const LocalHandoffResponseSchema = z.object({
ok: z.boolean()
})
export type LocalHandoffResponse = z.infer<typeof LocalHandoffResponseSchema>
+3 -1
View File
@@ -3,6 +3,8 @@ import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes'
export const PermissionModeSchema = z.enum(PERMISSION_MODES)
export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES)
export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error', 'handoff'])
export type SessionEndReason = z.infer<typeof SessionEndReasonSchema>
const MetadataSummarySchema = z.object({
text: z.string(),
@@ -242,7 +244,7 @@ export const SyncEventSchema = z.discriminatedUnion('type', [
}),
SessionChangedSchema.extend({
type: z.literal('session-ended'),
reason: z.enum(['completed', 'terminated', 'error']).optional()
reason: SessionEndReasonSchema.optional()
}),
MachineChangedSchema.extend({
type: z.literal('machine-updated'),
+2 -3
View File
@@ -1,5 +1,7 @@
import { z } from 'zod'
import type { CodexCollaborationMode, PermissionMode } from './modes'
import type { SessionEndReason } from './schemas'
export { SessionEndReasonSchema, type SessionEndReason } from './schemas'
export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found'
@@ -67,9 +69,6 @@ export const TerminalErrorPayloadSchema = z.object({
})
export type TerminalErrorPayload = z.infer<typeof TerminalErrorPayloadSchema>
export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error'])
export type SessionEndReason = z.infer<typeof SessionEndReasonSchema>
export const UpdateNewMessageBodySchema = z.object({
t: z.literal('new-message'),
sid: z.string(),