mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-13 02:37:02 +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;
|
||||
|
||||
@@ -143,6 +143,10 @@ export class RpcGateway {
|
||||
await this.sessionRpc(sessionId, 'killSession', {})
|
||||
}
|
||||
|
||||
async handoffSessionToLocal(sessionId: string): Promise<void> {
|
||||
await this.sessionRpc(sessionId, 'handoff-local', {})
|
||||
}
|
||||
|
||||
async spawnSession(
|
||||
machineId: string,
|
||||
directory: string,
|
||||
|
||||
@@ -864,6 +864,199 @@ describe('session model', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a local resume target for a Codex session', () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'local-resume-codex',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'codex',
|
||||
codexSessionId: 'codex-thread-1'
|
||||
},
|
||||
{ controlledByUser: false },
|
||||
'default',
|
||||
'gpt-5.4',
|
||||
undefined,
|
||||
'xhigh'
|
||||
)
|
||||
|
||||
const result = engine.resolveLocalResumeTarget(session.id, 'default')
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'success',
|
||||
target: {
|
||||
sessionId: session.id,
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
host: 'localhost',
|
||||
active: session.active,
|
||||
thinking: session.thinking,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'codex-thread-1',
|
||||
model: 'gpt-5.4',
|
||||
effort: null,
|
||||
modelReasoningEffort: 'xhigh',
|
||||
permissionMode: undefined,
|
||||
collaborationMode: undefined
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers a Claude local resume target from stored messages', () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'local-resume-claude-from-message',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'claude'
|
||||
},
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
store.messages.addMessage(session.id, {
|
||||
role: 'agent',
|
||||
content: {
|
||||
type: 'output',
|
||||
data: {
|
||||
sessionId: '22222222-2222-4222-8222-222222222222'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = engine.resolveLocalResumeTarget(session.id, 'default')
|
||||
|
||||
expect(result.type).toBe('success')
|
||||
if (result.type === 'success') {
|
||||
expect(result.target.flavor).toBe('claude')
|
||||
expect(result.target.agentSessionId).toBe('22222222-2222-4222-8222-222222222222')
|
||||
}
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns resume_unavailable when the local resume target lacks an agent session id', () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'local-resume-no-agent-id',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'codex'
|
||||
},
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
|
||||
expect(engine.resolveLocalResumeTarget(session.id, 'default')).toEqual({
|
||||
type: 'error',
|
||||
message: 'Resume session ID unavailable',
|
||||
code: 'resume_unavailable'
|
||||
})
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('local handoff succeeds immediately for inactive sessions', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'local-handoff-inactive',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'codex',
|
||||
codexSessionId: 'codex-thread-1'
|
||||
},
|
||||
{ controlledByUser: false },
|
||||
'default'
|
||||
)
|
||||
engine.handleSessionEnd({ sid: session.id, time: Date.now() })
|
||||
|
||||
await expect(engine.handoffSessionToLocal(session.id, 'default')).resolves.toEqual({
|
||||
type: 'success'
|
||||
})
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('local handoff rejects sessions already controlled by a local terminal', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'local-handoff-already-local',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'codex',
|
||||
codexSessionId: 'codex-thread-1'
|
||||
},
|
||||
{ controlledByUser: true },
|
||||
'default'
|
||||
)
|
||||
engine.handleSessionAlive({ sid: session.id, time: Date.now(), mode: 'local' })
|
||||
|
||||
await expect(engine.handoffSessionToLocal(session.id, 'default')).resolves.toEqual({
|
||||
type: 'error',
|
||||
message: 'Session is already controlled by a local terminal',
|
||||
code: 'already_local'
|
||||
})
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
describe('session dedup by agent session ID', () => {
|
||||
it('merges duplicate when codexSessionId collides', async () => {
|
||||
const store = new Store(':memory:')
|
||||
|
||||
+168
-21
@@ -7,7 +7,8 @@
|
||||
* - No E2E encryption; data is stored as JSON in SQLite
|
||||
*/
|
||||
|
||||
import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
|
||||
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
|
||||
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
|
||||
import type { Server } from 'socket.io'
|
||||
import type { Store, CancelQueuedMessageResult } from '../store'
|
||||
import type { RpcRegistry } from '../socket/rpcRegistry'
|
||||
@@ -52,6 +53,14 @@ export type ResumeSessionResult =
|
||||
| { type: 'success'; sessionId: string }
|
||||
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' }
|
||||
|
||||
export type LocalResumeTargetResult =
|
||||
| { type: 'success'; target: LocalResumeTarget }
|
||||
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' }
|
||||
|
||||
export type LocalHandoffResult =
|
||||
| { type: 'success' }
|
||||
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' }
|
||||
|
||||
export class SyncEngine {
|
||||
private readonly eventPublisher: EventPublisher
|
||||
private readonly sessionCache: SessionCache
|
||||
@@ -437,6 +446,100 @@ export class SyncEngine {
|
||||
)
|
||||
}
|
||||
|
||||
private resolveFlavor(session: Session): AgentFlavor {
|
||||
const flavor = session.metadata?.flavor
|
||||
return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor'
|
||||
? flavor
|
||||
: 'claude'
|
||||
}
|
||||
|
||||
private resolveAgentResumeId(session: Session, namespace: string): string | null {
|
||||
const metadata = session.metadata
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
|
||||
const flavor = this.resolveFlavor(session)
|
||||
if (flavor === 'codex') return metadata.codexSessionId ?? null
|
||||
if (flavor === 'gemini') return metadata.geminiSessionId ?? null
|
||||
if (flavor === 'opencode') return metadata.opencodeSessionId ?? null
|
||||
if (flavor === 'cursor') return metadata.cursorSessionId ?? null
|
||||
|
||||
return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace)
|
||||
}
|
||||
|
||||
resolveLocalResumeTarget(sessionId: string, namespace: string): LocalResumeTargetResult {
|
||||
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
|
||||
if (!access.ok) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
|
||||
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
const session = access.session
|
||||
const metadata = session.metadata
|
||||
if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) {
|
||||
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' }
|
||||
}
|
||||
|
||||
const agentSessionId = this.resolveAgentResumeId(session, namespace)
|
||||
if (!agentSessionId) {
|
||||
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
target: {
|
||||
sessionId: access.sessionId,
|
||||
flavor: this.resolveFlavor(session),
|
||||
directory: metadata.path,
|
||||
machineId: metadata.machineId,
|
||||
host: metadata.host,
|
||||
active: session.active,
|
||||
thinking: session.thinking,
|
||||
controlledByUser: session.agentState?.controlledByUser === true,
|
||||
agentSessionId,
|
||||
model: session.model ?? null,
|
||||
effort: session.effort ?? null,
|
||||
modelReasoningEffort: session.modelReasoningEffort ?? null,
|
||||
permissionMode: session.permissionMode,
|
||||
collaborationMode: session.collaborationMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listLocalResumableSessions(namespace: string, opts?: { machineId?: string }): ResumableSession[] {
|
||||
return this.getSessionsByNamespace(namespace)
|
||||
.map((session) => this.resolveLocalResumeTarget(session.id, namespace))
|
||||
.filter((result): result is { type: 'success'; target: LocalResumeTarget } => result.type === 'success')
|
||||
.map(({ target }) => {
|
||||
const session = this.getSessionByNamespace(target.sessionId, namespace)
|
||||
return {
|
||||
sessionId: target.sessionId,
|
||||
flavor: target.flavor,
|
||||
directory: target.directory,
|
||||
machineId: target.machineId,
|
||||
host: target.host,
|
||||
active: target.active,
|
||||
thinking: target.thinking,
|
||||
controlledByUser: target.controlledByUser,
|
||||
agentSessionId: target.agentSessionId,
|
||||
model: target.model,
|
||||
effort: target.effort,
|
||||
modelReasoningEffort: target.modelReasoningEffort,
|
||||
permissionMode: target.permissionMode,
|
||||
collaborationMode: target.collaborationMode,
|
||||
updatedAt: session?.updatedAt ?? 0,
|
||||
name: session?.metadata?.name,
|
||||
summary: session?.metadata?.summary?.text
|
||||
}
|
||||
})
|
||||
.filter((session) => !opts?.machineId || session.machineId === opts.machineId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
|
||||
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
|
||||
if (!access.ok) {
|
||||
@@ -452,27 +555,15 @@ export class SyncEngine {
|
||||
return { type: 'success', sessionId: access.sessionId }
|
||||
}
|
||||
|
||||
const metadata = session.metadata
|
||||
if (!metadata || typeof metadata.path !== 'string') {
|
||||
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' }
|
||||
const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace)
|
||||
if (targetResult.type === 'error') {
|
||||
return targetResult
|
||||
}
|
||||
|
||||
const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' || metadata.flavor === 'cursor'
|
||||
? metadata.flavor
|
||||
: 'claude'
|
||||
const resumeToken = flavor === 'codex'
|
||||
? metadata.codexSessionId
|
||||
: flavor === 'gemini'
|
||||
? metadata.geminiSessionId
|
||||
: flavor === 'opencode'
|
||||
? metadata.opencodeSessionId
|
||||
: flavor === 'cursor'
|
||||
? metadata.cursorSessionId
|
||||
: (metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(access.sessionId, namespace))
|
||||
|
||||
if (!resumeToken) {
|
||||
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
|
||||
}
|
||||
const target = targetResult.target
|
||||
const metadata = session.metadata!
|
||||
const flavor = target.flavor
|
||||
const resumeToken = target.agentSessionId
|
||||
|
||||
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
|
||||
if (onlineMachines.length === 0) {
|
||||
@@ -498,7 +589,7 @@ export class SyncEngine {
|
||||
const effectivePermissionMode = opts?.permissionMode ?? session.permissionMode ?? undefined
|
||||
const spawnResult = await this.rpcGateway.spawnSession(
|
||||
targetMachine.id,
|
||||
metadata.path,
|
||||
target.directory,
|
||||
flavor,
|
||||
session.model ?? undefined,
|
||||
session.modelReasoningEffort ?? undefined,
|
||||
@@ -537,6 +628,50 @@ export class SyncEngine {
|
||||
return { type: 'success', sessionId: spawnResult.sessionId }
|
||||
}
|
||||
|
||||
async handoffSessionToLocal(sessionId: string, namespace: string): Promise<LocalHandoffResult> {
|
||||
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
|
||||
if (!access.ok) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
|
||||
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
|
||||
}
|
||||
}
|
||||
|
||||
if (!access.session.active) {
|
||||
return { type: 'success' }
|
||||
}
|
||||
|
||||
if (access.session.agentState?.controlledByUser === true) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Session is already controlled by a local terminal',
|
||||
code: 'already_local'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.rpcGateway.handoffSessionToLocal(access.sessionId)
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
code: 'handoff_failed'
|
||||
}
|
||||
}
|
||||
|
||||
const inactive = await this.waitForSessionInactive(access.sessionId)
|
||||
if (!inactive) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Timed out waiting for remote session to hand off',
|
||||
code: 'handoff_failed'
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'success' }
|
||||
}
|
||||
|
||||
private recoverClaudeSessionIdFromMessages(sessionId: string, namespace: string): string | null {
|
||||
const messages = this.messageService.getMessages(sessionId, 200)
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
@@ -641,6 +776,18 @@ export class SyncEngine {
|
||||
return false
|
||||
}
|
||||
|
||||
async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const session = this.getSession(sessionId)
|
||||
if (!session?.active) {
|
||||
return true
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async checkPathsExist(machineId: string, paths: string[]): Promise<Record<string, boolean>> {
|
||||
return await this.rpcGateway.checkPathsExist(machineId, paths)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeAll, describe, expect, it } from 'bun:test'
|
||||
import { Hono } from 'hono'
|
||||
import type { SyncEngine } from '../../sync/syncEngine'
|
||||
import { createConfiguration } from '../../configuration'
|
||||
import { createCliRoutes } from './cli'
|
||||
|
||||
function createApp(engine: Partial<SyncEngine>) {
|
||||
const app = new Hono()
|
||||
app.route('/cli', createCliRoutes(() => engine as SyncEngine))
|
||||
return app
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
authorization: 'Bearer test-token'
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const config = await createConfiguration()
|
||||
config._setCliApiToken('test-token', 'env', false)
|
||||
})
|
||||
|
||||
describe('cli resume routes', () => {
|
||||
it('returns local resumable sessions', async () => {
|
||||
const app = createApp({
|
||||
listLocalResumableSessions: () => [{
|
||||
sessionId: 'session-1',
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'codex-thread-1',
|
||||
updatedAt: 123
|
||||
}]
|
||||
} as never)
|
||||
|
||||
const response = await app.request('/cli/sessions/resumable?machineId=machine-1', {
|
||||
headers: authHeaders()
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'codex-thread-1',
|
||||
updatedAt: 123
|
||||
}]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a local resume target', async () => {
|
||||
const app = createApp({
|
||||
resolveLocalResumeTarget: () => ({
|
||||
type: 'success',
|
||||
target: {
|
||||
sessionId: 'session-1',
|
||||
flavor: 'claude',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: '11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
} as never)
|
||||
|
||||
const response = await app.request('/cli/sessions/session-1/resume-target', {
|
||||
headers: authHeaders()
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
target: {
|
||||
sessionId: 'session-1',
|
||||
flavor: 'claude',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: '11111111-1111-4111-8111-111111111111'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns handoff errors with status codes', async () => {
|
||||
const app = createApp({
|
||||
handoffSessionToLocal: async () => ({
|
||||
type: 'error',
|
||||
message: 'Session is already controlled by a local terminal',
|
||||
code: 'already_local'
|
||||
})
|
||||
} as never)
|
||||
|
||||
const response = await app.request('/cli/sessions/session-1/handoff-local', {
|
||||
method: 'POST',
|
||||
headers: authHeaders()
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Session is already controlled by a local terminal',
|
||||
code: 'already_local'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -115,6 +115,55 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
|
||||
return c.json({ session })
|
||||
})
|
||||
|
||||
app.get('/sessions/resumable', (c) => {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
|
||||
const namespace = c.get('namespace')
|
||||
const machineId = c.req.query('machineId') || undefined
|
||||
const sessions = engine.listLocalResumableSessions(namespace, { machineId })
|
||||
return c.json({ sessions })
|
||||
})
|
||||
|
||||
app.get('/sessions/:id/resume-target', (c) => {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
|
||||
const namespace = c.get('namespace')
|
||||
const result = engine.resolveLocalResumeTarget(c.req.param('id'), namespace)
|
||||
if (result.type === 'error') {
|
||||
const status = result.code === 'access_denied' ? 403
|
||||
: result.code === 'session_not_found' ? 404
|
||||
: 409
|
||||
return c.json({ error: result.message, code: result.code }, status)
|
||||
}
|
||||
|
||||
return c.json({ target: result.target })
|
||||
})
|
||||
|
||||
app.post('/sessions/:id/handoff-local', async (c) => {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
return c.json({ error: 'Not ready' }, 503)
|
||||
}
|
||||
|
||||
const namespace = c.get('namespace')
|
||||
const result = await engine.handoffSessionToLocal(c.req.param('id'), namespace)
|
||||
if (result.type === 'error') {
|
||||
const status = result.code === 'access_denied' ? 403
|
||||
: result.code === 'session_not_found' ? 404
|
||||
: result.code === 'already_local' ? 409
|
||||
: 500
|
||||
return c.json({ error: result.message, code: result.code }, status)
|
||||
}
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/sessions/:id', (c) => {
|
||||
const engine = getSyncEngine()
|
||||
if (!engine) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './messages'
|
||||
export * from './flavors'
|
||||
export * from './models'
|
||||
export * from './modes'
|
||||
export * from './resume'
|
||||
export * from './socket'
|
||||
export * from './sessionSummary'
|
||||
export * from './utils'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { LocalResumeTargetSchema, ResumableSessionSchema } from './resume'
|
||||
import { SyncEventSchema } from './schemas'
|
||||
import { SessionEndReasonSchema } from './socket'
|
||||
|
||||
describe('resume schemas', () => {
|
||||
it('accepts a local resume target', () => {
|
||||
const parsed = LocalResumeTargetSchema.safeParse({
|
||||
sessionId: 'hapi-session-1',
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
host: 'devbox',
|
||||
active: true,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'codex-thread-1',
|
||||
model: 'gpt-5.4',
|
||||
effort: null,
|
||||
modelReasoningEffort: 'xhigh',
|
||||
permissionMode: 'default',
|
||||
collaborationMode: 'default'
|
||||
})
|
||||
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a resumable session summary', () => {
|
||||
const parsed = ResumableSessionSchema.safeParse({
|
||||
sessionId: 'hapi-session-1',
|
||||
flavor: 'claude',
|
||||
directory: '/tmp/project',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: '11111111-1111-4111-8111-111111111111',
|
||||
updatedAt: 123,
|
||||
name: 'project work',
|
||||
summary: 'finish docs'
|
||||
})
|
||||
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts handoff as a session end reason', () => {
|
||||
expect(SessionEndReasonSchema.parse('handoff')).toBe('handoff')
|
||||
})
|
||||
|
||||
it('accepts handoff in session-ended sync events', () => {
|
||||
const parsed = SyncEventSchema.safeParse({
|
||||
type: 'session-ended',
|
||||
sessionId: 'hapi-session-1',
|
||||
reason: 'handoff'
|
||||
})
|
||||
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod'
|
||||
import { CodexCollaborationModeSchema, PermissionModeSchema } from './schemas'
|
||||
|
||||
export const AgentFlavorSchema = z.enum(['claude', 'codex', 'gemini', 'opencode', 'cursor'])
|
||||
|
||||
export const LocalResumeTargetSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
flavor: AgentFlavorSchema,
|
||||
directory: z.string().min(1),
|
||||
machineId: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
active: z.boolean(),
|
||||
thinking: z.boolean(),
|
||||
controlledByUser: z.boolean(),
|
||||
agentSessionId: z.string().min(1),
|
||||
model: z.string().nullable().optional(),
|
||||
effort: z.string().nullable().optional(),
|
||||
modelReasoningEffort: z.string().nullable().optional(),
|
||||
permissionMode: PermissionModeSchema.optional(),
|
||||
collaborationMode: CodexCollaborationModeSchema.optional()
|
||||
})
|
||||
|
||||
export type LocalResumeTarget = z.infer<typeof LocalResumeTargetSchema>
|
||||
|
||||
export const ResumableSessionSchema = LocalResumeTargetSchema.extend({
|
||||
updatedAt: z.number(),
|
||||
name: z.string().optional(),
|
||||
summary: z.string().optional()
|
||||
})
|
||||
|
||||
export type ResumableSession = z.infer<typeof ResumableSessionSchema>
|
||||
|
||||
export const LocalResumeTargetResponseSchema = z.object({
|
||||
target: LocalResumeTargetSchema
|
||||
})
|
||||
|
||||
export type LocalResumeTargetResponse = z.infer<typeof LocalResumeTargetResponseSchema>
|
||||
|
||||
export const ResumableSessionsResponseSchema = z.object({
|
||||
sessions: z.array(ResumableSessionSchema)
|
||||
})
|
||||
|
||||
export type ResumableSessionsResponse = z.infer<typeof ResumableSessionsResponseSchema>
|
||||
|
||||
export const LocalHandoffResponseSchema = z.object({
|
||||
ok: z.boolean()
|
||||
})
|
||||
|
||||
export type LocalHandoffResponse = z.infer<typeof LocalHandoffResponseSchema>
|
||||
@@ -3,6 +3,8 @@ import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes'
|
||||
|
||||
export const PermissionModeSchema = z.enum(PERMISSION_MODES)
|
||||
export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES)
|
||||
export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error', 'handoff'])
|
||||
export type SessionEndReason = z.infer<typeof SessionEndReasonSchema>
|
||||
|
||||
const MetadataSummarySchema = z.object({
|
||||
text: z.string(),
|
||||
@@ -242,7 +244,7 @@ export const SyncEventSchema = z.discriminatedUnion('type', [
|
||||
}),
|
||||
SessionChangedSchema.extend({
|
||||
type: z.literal('session-ended'),
|
||||
reason: z.enum(['completed', 'terminated', 'error']).optional()
|
||||
reason: SessionEndReasonSchema.optional()
|
||||
}),
|
||||
MachineChangedSchema.extend({
|
||||
type: z.literal('machine-updated'),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import type { CodexCollaborationMode, PermissionMode } from './modes'
|
||||
import type { SessionEndReason } from './schemas'
|
||||
export { SessionEndReasonSchema, type SessionEndReason } from './schemas'
|
||||
|
||||
export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found'
|
||||
|
||||
@@ -67,9 +69,6 @@ export const TerminalErrorPayloadSchema = z.object({
|
||||
})
|
||||
|
||||
export type TerminalErrorPayload = z.infer<typeof TerminalErrorPayloadSchema>
|
||||
export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error'])
|
||||
export type SessionEndReason = z.infer<typeof SessionEndReasonSchema>
|
||||
|
||||
export const UpdateNewMessageBodySchema = z.object({
|
||||
t: z.literal('new-message'),
|
||||
sid: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user