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;