Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
ca2557e
docs: add codex resume recovery design
duxiaoxiong Apr 1, 2026
6c9b1d2
Add Codex session file resolver
duxiaoxiong Apr 1, 2026
4a99709
Add explicit Codex resume scanner mode
duxiaoxiong Apr 1, 2026
b054090
Wire Codex resume transcript resolver
duxiaoxiong Apr 1, 2026
9d5cb09
Make remote Codex resume strict
duxiaoxiong Apr 2, 2026
16b7701
test(codex): fix local launcher mock typing
duxiaoxiong Apr 2, 2026
4e04552
fix(codex): segment multilineage transcript replay
duxiaoxiong Apr 2, 2026
892d83a
feat(codex): normalize replay tool names for block rendering
duxiaoxiong Apr 2, 2026
129f410
feat(web): add codex-first tool presentations
duxiaoxiong Apr 2, 2026
2dc21f5
feat(web): render codex replay execution blocks
duxiaoxiong Apr 2, 2026
941c649
docs(spec): design codex subagent nesting
duxiaoxiong Apr 2, 2026
585369a
feat(web): finish codex subagent block renderers
duxiaoxiong Apr 2, 2026
99fda93
feat(web): detect codex inline subagent sidechains
duxiaoxiong Apr 2, 2026
9cad1db
feat(web): nest codex inline subagent conversations
duxiaoxiong Apr 2, 2026
51de846
fix(web): preserve codex structured result fallback
duxiaoxiong Apr 2, 2026
b5eb089
Add Codex sidechain normalization
duxiaoxiong Apr 2, 2026
c2ad568
Restrict Codex sidechain keys to sidechains
duxiaoxiong Apr 2, 2026
7745ac4
Add Codex sidechain normalization
duxiaoxiong Apr 2, 2026
0e210e7
Restrict Codex sidechain keys to sidechains
duxiaoxiong Apr 2, 2026
88b16e7
feat(codex): replay child transcripts under spawn blocks
duxiaoxiong Apr 2, 2026
575d573
feat(web): add codex subagent preview dialog
duxiaoxiong Apr 2, 2026
6bb9739
feat(web): merge codex agent lifecycle blocks
duxiaoxiong Apr 2, 2026
896082f
feat(web): polish codex subagent dialog
duxiaoxiong Apr 2, 2026
c042cd9
fix(codex): support live subagent cards and mobile dialog
duxiaoxiong Apr 2, 2026
078b71a
refactor(web): remove redundant subagent dialog footer
duxiaoxiong Apr 2, 2026
4209e20
fix(codex): support live subagent events
duxiaoxiong Apr 2, 2026
fd8812a
fix(codex): separate live child cards and preserve root title
duxiaoxiong Apr 2, 2026
cfae3bf
fix(codex): tighten child replies and compact lifecycle cards
duxiaoxiong Apr 2, 2026
b16d844
fix(web): preserve live codex child routing
duxiaoxiong Apr 2, 2026
231296e
fix(codex): preserve child replies and hide agent ids
duxiaoxiong Apr 2, 2026
3078f38
refactor(web): simplify codex subagent card chrome
duxiaoxiong Apr 2, 2026
1af9ff7
Revert "refactor(web): simplify codex subagent card chrome"
duxiaoxiong Apr 2, 2026
6943b05
fix(web): keep parallel codex child replies separated
duxiaoxiong Apr 2, 2026
c9e4ceb
fix(codex): nest live child tool events
duxiaoxiong Apr 2, 2026
00b036c
fix(codex): promote live child tool sidechains
duxiaoxiong Apr 2, 2026
dcd729a
fix(web): improve codex subagent lifecycle summaries
duxiaoxiong Apr 2, 2026
2553dd2
feat: add importable session rpc contracts
duxiaoxiong Apr 3, 2026
34d8844
fix: share importable session rpc types
duxiaoxiong Apr 3, 2026
8de7b11
feat: scan importable codex sessions
duxiaoxiong Apr 3, 2026
d0f0262
Add machine RPC for importable Codex sessions
duxiaoxiong Apr 3, 2026
868aa69
fix: harden codex importable session scan
duxiaoxiong Apr 3, 2026
12ed6cc
fix: register machine rpc during connect
duxiaoxiong Apr 3, 2026
d5c10d5
feat: import existing codex sessions in web
duxiaoxiong Apr 3, 2026
491b329
feat: widen importable session contracts for claude
duxiaoxiong Apr 3, 2026
5b0c82b
fix: tighten importable session rpc agent checks
duxiaoxiong Apr 3, 2026
e00bc3a
feat: scan importable claude sessions
duxiaoxiong Apr 3, 2026
7c3bb7a
test: tighten claude import assertions
duxiaoxiong Apr 3, 2026
fe59fdf
fix: select resumed claude import session
duxiaoxiong Apr 3, 2026
ee6f010
fix: replay claude transcript history on import resume
duxiaoxiong Apr 3, 2026
3f01443
fix: tighten claude import replay gating
duxiaoxiong Apr 3, 2026
20f455b
test: harden claude import replay contract
duxiaoxiong Apr 3, 2026
4fe878c
feat: add claude import and refresh orchestration
duxiaoxiong Apr 3, 2026
b0c9132
feat: enable claude import existing sessions
duxiaoxiong Apr 3, 2026
5bb12d7
fix: isolate import existing state per agent
duxiaoxiong Apr 3, 2026
d57f3a4
docs: add claude subagent parity design
duxiaoxiong Apr 3, 2026
40d8531
feat: add normalized subagent semantic layer
duxiaoxiong Apr 3, 2026
021b1d1
refactor: align codex lineage with normalized subagent semantics
duxiaoxiong Apr 3, 2026
7ba1189
fix: wire codex child lifecycle sidechain metadata
duxiaoxiong Apr 3, 2026
ed8fa4d
feat: normalize claude subagent metadata
duxiaoxiong Apr 3, 2026
e5bcbe3
feat: normalize claude subagent metadata
duxiaoxiong Apr 3, 2026
24aad76
fix: preserve claude replay subagent meta
duxiaoxiong Apr 3, 2026
77719eb
fix: persist claude result subagent meta
duxiaoxiong Apr 3, 2026
0bb503d
test: align claude result meta converter coverage
duxiaoxiong Apr 3, 2026
713568a
fix: isolate claude subagent state and events
duxiaoxiong Apr 3, 2026
3c8b518
feat: link claude child transcripts into sidechains
duxiaoxiong Apr 3, 2026
ec155d4
fix: tighten claude child transcript linking
duxiaoxiong Apr 4, 2026
76fe209
feat: normalize hub team extraction for subagents
duxiaoxiong Apr 4, 2026
aa4f194
test: compare normalized team deltas across agents
duxiaoxiong Apr 4, 2026
73811fe
test: cover normalized team delta precedence
duxiaoxiong Apr 4, 2026
7246be9
refactor: make web sidechain rendering agent-neutral
duxiaoxiong Apr 4, 2026
8aa6542
fix: key web sidechains by tool-use id
duxiaoxiong Apr 4, 2026
c182b8e
fix: preserve Claude sidechain keys in web tracing
duxiaoxiong Apr 4, 2026
e74a36b
fix: preserve unresolved web sidechain descendants
duxiaoxiong Apr 4, 2026
a95d9aa
fix: preserve unconsumed web sidechain groups
duxiaoxiong Apr 4, 2026
a9f3573
fix: preserve unconsumed sidechain prompts in order
duxiaoxiong Apr 4, 2026
3588099
feat: share subagent preview card for task runs
duxiaoxiong Apr 4, 2026
139febd
fix: keep task pending children inline
duxiaoxiong Apr 4, 2026
5e9da6b
fix: filter task preview transcript
duxiaoxiong Apr 4, 2026
0f894b8
refactor: re-import imported sessions instead of refresh
duxiaoxiong Apr 4, 2026
7dde6af
fix: make mobile image uploads work end-to-end
duxiaoxiong Apr 4, 2026
63332b0
feat: add session info dialog
duxiaoxiong Apr 4, 2026
8caec46
feat: generalize message queue, import existing sessions, and extract…
duxiaoxiong Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/src/agent/loopBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { AgentSessionBase } from './sessionBase';

export type LoopLauncher<TSession> = (session: TSession) => Promise<'switch' | 'exit'>;

export async function runLocalRemoteSession<TSession extends AgentSessionBase<any>>(opts: {
export async function runLocalRemoteSession<TSession extends AgentSessionBase<any, any>>(opts: {
session: TSession;
startingMode?: 'local' | 'remote';
logTag: string;
Expand All @@ -24,7 +24,7 @@ export async function runLocalRemoteSession<TSession extends AgentSessionBase<an
});
}

export async function runLocalRemoteLoop<TSession extends AgentSessionBase<any>>(opts: {
export async function runLocalRemoteLoop<TSession extends AgentSessionBase<any, any>>(opts: {
session: TSession;
startingMode?: 'local' | 'remote';
logTag: string;
Expand Down
10 changes: 5 additions & 5 deletions cli/src/agent/sessionBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { MessageQueue2 } from '@/utils/MessageQueue2';
import type { Metadata, SessionCollaborationMode, SessionEffort, SessionModel, SessionPermissionMode } from '@/api/types';
import { logger } from '@/ui/logger';

export type AgentSessionBaseOptions<Mode> = {
export type AgentSessionBaseOptions<Mode, Message = string> = {
api: ApiClient;
client: ApiSessionClient;
path: string;
logPath: string;
sessionId: string | null;
messageQueue: MessageQueue2<Mode>;
messageQueue: MessageQueue2<Mode, Message>;
onModeChange: (mode: 'local' | 'remote') => void;
mode?: 'local' | 'remote';
sessionLabel: string;
Expand All @@ -21,12 +21,12 @@ export type AgentSessionBaseOptions<Mode> = {
collaborationMode?: SessionCollaborationMode;
};

export class AgentSessionBase<Mode> {
export class AgentSessionBase<Mode, Message = string> {
readonly path: string;
readonly logPath: string;
readonly api: ApiClient;
readonly client: ApiSessionClient;
readonly queue: MessageQueue2<Mode>;
readonly queue: MessageQueue2<Mode, Message>;
protected readonly _onModeChange: (mode: 'local' | 'remote') => void;

sessionId: string | null;
Expand All @@ -43,7 +43,7 @@ export class AgentSessionBase<Mode> {
protected effort?: SessionEffort;
protected collaborationMode?: SessionCollaborationMode;

constructor(opts: AgentSessionBaseOptions<Mode>) {
constructor(opts: AgentSessionBaseOptions<Mode, Message>) {
this.path = opts.path;
this.api = opts.api;
this.client = opts.client;
Expand Down
177 changes: 177 additions & 0 deletions cli/src/api/apiMachine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

type FakeSocket = {
handlers: Map<string, (...args: any[]) => void>
emitted: Array<{ event: string, payload: unknown }>
on: (event: string, handler: (...args: any[]) => void) => FakeSocket
emit: (event: string, payload: unknown) => void
emitWithAck: (event: string, payload: unknown) => Promise<unknown>
close: () => void
}

const listImportableCodexSessionsMock = vi.hoisted(() => vi.fn())
const listImportableClaudeSessionsMock = vi.hoisted(() => vi.fn())
const fakeSocket = vi.hoisted<FakeSocket>(() => ({
handlers: new Map(),
emitted: [],
on(event, handler) {
this.handlers.set(event, handler)
return this
},
emit(event, payload) {
this.emitted.push({ event, payload })
},
emitWithAck: vi.fn(async (event: string) => {
if (event === 'machine-update-state') {
return { result: 'success', version: 1, runnerState: null }
}

if (event === 'machine-update-metadata') {
return { result: 'success', version: 1, metadata: null }
}

return { result: 'success', version: 1 }
}),
close() {}
}))

const importableSessionsResponse = {
sessions: [
{
agent: 'codex',
externalSessionId: 'codex-session-1',
cwd: '/work/project',
timestamp: 1712131200000,
transcriptPath: '/sessions/codex-session-1.jsonl',
previewTitle: 'Project draft',
previewPrompt: 'Build the project'
}
]
}

const importableClaudeSessionsResponse = {
sessions: [
{
agent: 'claude',
externalSessionId: 'claude-session-1',
cwd: '/work/project',
timestamp: 1712131200000,
transcriptPath: '/sessions/claude-session-1.jsonl',
previewTitle: 'Continue the refactor',
previewPrompt: 'Continue the refactor'
}
]
}

vi.mock('socket.io-client', () => ({
io: vi.fn(() => fakeSocket)
}))

vi.mock('@/codex/utils/listImportableCodexSessions', () => ({
listImportableCodexSessions: listImportableCodexSessionsMock
}))

vi.mock('@/claude/utils/listImportableClaudeSessions', () => ({
listImportableClaudeSessions: listImportableClaudeSessionsMock
}))

vi.mock('@/modules/common/registerCommonHandlers', () => ({
registerCommonHandlers: vi.fn()
}))

vi.mock('@/utils/invokedCwd', () => ({
getInvokedCwd: vi.fn(() => '/workspace')
}))

vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}))

import { ApiMachineClient } from './apiMachine'

describe('ApiMachineClient list-importable-sessions RPC', () => {
beforeEach(() => {
fakeSocket.handlers.clear()
fakeSocket.emitted.length = 0
vi.mocked(fakeSocket.emitWithAck).mockClear()
listImportableCodexSessionsMock.mockReset()
listImportableClaudeSessionsMock.mockReset()
listImportableCodexSessionsMock.mockResolvedValue(importableSessionsResponse)
listImportableClaudeSessionsMock.mockResolvedValue(importableClaudeSessionsResponse)
})

it('registers the RPC during connect and returns scanner results by agent', async () => {
const machine = {
id: 'machine-1',
metadata: null,
metadataVersion: 0,
runnerState: null,
runnerStateVersion: 0
} as never

const client = new ApiMachineClient('token', machine)
client.connect()

const connectHandler = fakeSocket.handlers.get('connect')
expect(connectHandler).toBeTypeOf('function')
connectHandler?.()

expect(fakeSocket.emitted).toEqual(
expect.arrayContaining([
expect.objectContaining({
event: 'rpc-register',
payload: { method: 'machine-1:path-exists' }
}),
expect.objectContaining({
event: 'rpc-register',
payload: { method: 'machine-1:list-importable-sessions' }
})
])
)

const rpcRequestHandler = fakeSocket.handlers.get('rpc-request')
expect(rpcRequestHandler).toBeTypeOf('function')

const codexResponse = await new Promise<string>((resolve) => {
rpcRequestHandler?.(
{
method: 'machine-1:list-importable-sessions',
params: JSON.stringify({ agent: 'codex' })
},
resolve
)
})

expect(codexResponse).toBe(JSON.stringify(importableSessionsResponse))

const missingAgentResponse = await new Promise<string>((resolve) => {
rpcRequestHandler?.(
{
method: 'machine-1:list-importable-sessions',
params: JSON.stringify({})
},
resolve
)
})

expect(missingAgentResponse).toBe(JSON.stringify({ sessions: [] }))
expect(listImportableCodexSessionsMock).toHaveBeenCalledTimes(1)

const claudeResponse = await new Promise<string>((resolve) => {
rpcRequestHandler?.(
{
method: 'machine-1:list-importable-sessions',
params: JSON.stringify({ agent: 'claude' })
},
resolve
)
})

expect(JSON.parse(claudeResponse)).toEqual(importableClaudeSessionsResponse)
expect(listImportableClaudeSessionsMock).toHaveBeenCalledTimes(1)

client.shutdown()
})
})
65 changes: 45 additions & 20 deletions cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import { backoff } from '@/utils/time'
import { getInvokedCwd } from '@/utils/invokedCwd'
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
import type {
RpcListImportableSessionsRequest,
RpcListImportableSessionsResponse,
SpawnSessionOptions,
SpawnSessionResult
} from '../modules/common/rpcTypes'
import { listImportableClaudeSessions } from '@/claude/utils/listImportableClaudeSessions'
import { listImportableCodexSessions } from '@/codex/utils/listImportableCodexSessions'
import { applyVersionedAck } from './versionedUpdate'

interface ServerToRunnerEvents {
Expand Down Expand Up @@ -79,25 +86,7 @@ export class ApiMachineClient {
})

registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd())

this.rpcHandlerManager.registerHandler<PathExistsRequest, PathExistsResponse>('path-exists', async (params) => {
const rawPaths = Array.isArray(params?.paths) ? params.paths : []
const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string')))
const exists: Record<string, boolean> = {}

await Promise.all(uniquePaths.map(async (path) => {
const trimmed = path.trim()
if (!trimmed) return
try {
const stats = await stat(trimmed)
exists[trimmed] = stats.isDirectory()
} catch {
exists[trimmed] = false
}
}))

return { exists }
})
this.registerMachineHandlers()
}

setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
Expand Down Expand Up @@ -154,6 +143,42 @@ export class ApiMachineClient {
})
}

private registerMachineHandlers(): void {
this.rpcHandlerManager.registerHandler<PathExistsRequest, PathExistsResponse>('path-exists', async (params) => {
const rawPaths = Array.isArray(params?.paths) ? params.paths : []
const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string')))
const exists: Record<string, boolean> = {}

await Promise.all(uniquePaths.map(async (path) => {
const trimmed = path.trim()
if (!trimmed) return
try {
const stats = await stat(trimmed)
exists[trimmed] = stats.isDirectory()
} catch {
exists[trimmed] = false
}
}))

return { exists }
})

this.rpcHandlerManager.registerHandler<RpcListImportableSessionsRequest, RpcListImportableSessionsResponse>(
'list-importable-sessions',
async (params) => {
if (params?.agent === 'codex') {
return await listImportableCodexSessions()
}

if (params?.agent === 'claude') {
return await listImportableClaudeSessions()
}

return { sessions: [] }
}
)
}

async updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise<void> {
await backoff(async () => {
const updated = handler(this.machine.metadata)
Expand Down
4 changes: 3 additions & 1 deletion cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export const MessageMetaSchema = z.object({
customSystemPrompt: z.string().nullable().optional(),
appendSystemPrompt: z.string().nullable().optional(),
allowedTools: z.array(z.string()).nullable().optional(),
disallowedTools: z.array(z.string()).nullable().optional()
disallowedTools: z.array(z.string()).nullable().optional(),
isSidechain: z.boolean().optional(),
sidechainKey: z.string().optional()
})

export type MessageMeta = z.infer<typeof MessageMetaSchema>
Expand Down
28 changes: 7 additions & 21 deletions cli/src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { systemPrompt } from "./utils/systemPrompt";
import { PermissionResult } from "./sdk/types";
import { getHapiBlobsDir } from "@/constants/uploadPaths";
import { getDefaultClaudeCodePath } from "./sdk/utils";
import { extractExplicitResumeSessionId } from "./utils/explicitResume";

export async function claudeRemote(opts: {

Expand Down Expand Up @@ -47,27 +48,12 @@ export async function claudeRemote(opts: {

// Extract --resume from claudeArgs if present (for first spawn)
if (!startFrom && opts.claudeArgs) {
for (let i = 0; i < opts.claudeArgs.length; i++) {
if (opts.claudeArgs[i] === '--resume') {
// Check if next arg exists and looks like a session ID
if (i + 1 < opts.claudeArgs.length) {
const nextArg = opts.claudeArgs[i + 1];
// If next arg doesn't start with dash and contains dashes, it's likely a UUID
if (!nextArg.startsWith('-') && nextArg.includes('-')) {
startFrom = nextArg;
logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`);
break;
} else {
// Just --resume without UUID - SDK doesn't support this
logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode');
break;
}
} else {
// --resume at end of args - SDK doesn't support this
logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode');
break;
}
}
const explicitResumeSessionId = extractExplicitResumeSessionId(opts.claudeArgs);
if (explicitResumeSessionId) {
startFrom = explicitResumeSessionId;
logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`);
} else if (opts.claudeArgs.includes('--resume')) {
logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode');
}
}

Expand Down
Loading