Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions src/main/agents/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ describe('buildSpawnArgs', () => {
expect(result).toContain('--append-system-prompt')
expect(result).toContain("'\\''")
})

const sessionPath = (cwd: string, id: string): string =>
join(homedir(), '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'), `${id}.jsonl`)

it('blank: --session-id when no transcript exists for the session id', () => {
const result = buildSpawnArgs({ ...base, sessionId: 'fresh-id' })
expect(result).toContain('--session-id fresh-id')
expect(result).not.toContain('--resume')
expect(result).not.toContain('--fork-session')
})

it('resume: --resume when a transcript exists for the session id', () => {
fsState.files.set(sessionPath(base.cwd, 'old-id'), '{}')
const result = buildSpawnArgs({ ...base, sessionId: 'old-id' })
expect(result).toContain('--resume old-id')
expect(result).not.toContain('--session-id')
})

it('fork: --resume <src> --fork-session, never --session-id (even if sessionId set)', () => {
const result = buildSpawnArgs({ ...base, sessionId: 'tab-id', forkFromSessionId: 'src-id' })
expect(result).toContain('--resume src-id')
expect(result).toContain('--fork-session')
expect(result).not.toContain('--session-id')
})
})

describe('hook install / dedup', () => {
Expand Down
38 changes: 26 additions & 12 deletions src/main/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,27 +146,35 @@ export function sessionFileExists(cwd: string, sessionId: string): boolean {
}
}

export function latestSessionId(cwd: string): string | null {
export function listSessions(
cwd: string
): Array<{ sessionId: string; mtimeMs: number }> {
try {
const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-')
const dir = join(homedir(), '.claude', 'projects', encoded)
const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
if (files.length === 0) return null
let bestId: string | null = null
let bestMtime = -Infinity
for (const file of files) {
const mtime = statSync(join(dir, file)).mtimeMs
if (mtime > bestMtime) {
bestMtime = mtime
bestId = file.replace(/\.jsonl$/, '')
const out: Array<{ sessionId: string; mtimeMs: number }> = []
for (const file of readdirSync(dir)) {
if (!file.endsWith('.jsonl')) continue
try {
out.push({
sessionId: file.replace(/\.jsonl$/, ''),
mtimeMs: statSync(join(dir, file)).mtimeMs
})
} catch {
// Raced unlink between readdir and stat — skip.
}
}
return bestId
out.sort((a, b) => b.mtimeMs - a.mtimeMs)
return out
} catch {
return null
return []
}
}

export function latestSessionId(cwd: string): string | null {
return listSessions(cwd)[0]?.sessionId ?? null
}

export function buildSpawnArgs(opts: AgentSpawnOpts): string {
const modelFlag = opts.model && !opts.command.includes('--model') ? ` --model ${shellQuote(opts.model)}` : ''
const mcpFlag = opts.mcpConfigPath ? ` --mcp-config ${shellQuote(opts.mcpConfigPath)}` : ''
Expand All @@ -175,6 +183,12 @@ export function buildSpawnArgs(opts: AgentSpawnOpts): string {
const tuiPrefix = opts.tuiFullscreen ? 'CLAUDE_CODE_NO_FLICKER=1 ' : ''
const cmd = `${tuiPrefix}${opts.command}${modelFlag}${mcpFlag}${nameFlag}${systemPromptFlag}`

// Fork: branch the source session into a new one. Claude mints the new id
// and we discover it from the first hook event (no --session-id to pin).
if (opts.forkFromSessionId) {
return `${cmd} --resume ${opts.forkFromSessionId} --fork-session`
}

if (opts.teleportSessionId && opts.sessionId) {
const exists = sessionFileExists(opts.cwd, opts.sessionId)
if (!exists) {
Expand Down
19 changes: 18 additions & 1 deletion src/main/agents/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,31 @@ vi.mock('../hooks', () => ({

import { homedir } from 'os'
import { join } from 'path'
import { hooksInstalled, installHooks, hookEvents, uninstallHooks } from './codex'
import { buildSpawnArgs, hooksInstalled, installHooks, hookEvents, uninstallHooks } from './codex'

const HOOKS_PATH = join(homedir(), '.codex', 'hooks.json')

beforeEach(() => {
fsState.files.clear()
})

describe('codex buildSpawnArgs', () => {
const base = { command: 'codex', cwd: '/tmp/test' }

it('blank: no resume/fork when the session id has no recorded file', () => {
// readdirSync is mocked to [], so sessionFileExists is false.
const result = buildSpawnArgs({ ...base, sessionId: 'fresh-id' })
expect(result).not.toContain('resume')
expect(result).not.toContain('fork')
})

it('fork: `codex fork <src>`, ignoring any sessionId', () => {
const result = buildSpawnArgs({ ...base, sessionId: 'tab-id', forkFromSessionId: 'src-id' })
expect(result).toContain('fork src-id')
expect(result).not.toContain('resume')
})
})

describe('codex hook install / dedup', () => {
it('hooksInstalled() recognizes normalized entries with no _marker field', () => {
const data = {
Expand Down
35 changes: 26 additions & 9 deletions src/main/agents/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,34 +171,45 @@ export function sessionFileExists(_cwd: string, sessionId: string): boolean {
}
}

export function latestSessionId(_cwd: string): string | null {
export function listSessions(
_cwd: string
): Array<{ sessionId: string; mtimeMs: number }> {
try {
const sessionsDir = join(homedir(), '.codex', 'sessions')
let bestId: string | null = null
let bestMtime = -Infinity
const out: Array<{ sessionId: string; mtimeMs: number }> = []
const walkDir = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
walkDir(full)
} else if (entry.name.endsWith('.jsonl')) {
const mtime = statSync(full).mtimeMs
if (mtime > bestMtime) {
bestMtime = mtime
try {
const stem = entry.name.replace(/\.jsonl$/, '')
// Codex prefixes the file with a timestamp; the session id is the
// trailing uuid.
const uuidMatch = stem.match(/([0-9a-f]{4,}-[0-9a-f-]+)$/)
bestId = uuidMatch ? uuidMatch[1] : stem
out.push({
sessionId: uuidMatch ? uuidMatch[1] : stem,
mtimeMs: statSync(full).mtimeMs
})
} catch {
// Raced unlink between readdir and stat — skip.
}
}
}
}
walkDir(sessionsDir)
return bestId
out.sort((a, b) => b.mtimeMs - a.mtimeMs)
return out
} catch {
return null
return []
}
}

export function latestSessionId(_cwd: string): string | null {
return listSessions(_cwd)[0]?.sessionId ?? null
}

export function buildSpawnArgs(opts: AgentSpawnOpts): string {
// Codex MCP is configured globally via ~/.codex/config.toml, not per-terminal
// flags. The mcpConfigPath is unused here but the MCP server was already
Expand All @@ -208,6 +219,12 @@ export function buildSpawnArgs(opts: AgentSpawnOpts): string {
cmd += ` --model ${shellQuote(opts.model)}`
}

// Fork: `codex fork <id>` branches the source into a new session. Codex
// mints the new id and we discover it from the first hook event.
if (opts.forkFromSessionId) {
return `${cmd} fork ${opts.forkFromSessionId}`
}

if (!opts.sessionId) {
return opts.initialPrompt ? `${cmd} ${shellQuote(opts.initialPrompt)}` : cmd
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export interface AgentSpawnOpts {
command: string
cwd: string
sessionId?: string
/** Fork source: when set, the agent resumes this session but branches it
* into a brand-new one (Claude `--fork-session`, Codex `fork <id>`),
* leaving the source untouched. Takes precedence over sessionId. */
forkFromSessionId?: string
initialPrompt?: string
teleportSessionId?: string
sessionName?: string
Expand Down Expand Up @@ -38,6 +42,10 @@ export interface AgentModule {
stripHooksFromWorktree(worktreePath: string): boolean
sessionFileExists(cwd: string, sessionId: string): boolean
latestSessionId(cwd: string): string | null
/** This agent's recorded sessions for a worktree, newest first by mtime.
* Powers "resume the last known session" and excludes already-open ids
* at the call site. */
listSessions(cwd: string): Array<{ sessionId: string; mtimeMs: number }>
buildSpawnArgs(opts: AgentSpawnOpts): string
}

Expand Down
Loading
Loading