mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat: add hapi resume command (#647)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user