diff --git a/src/main/agents/claude.test.ts b/src/main/agents/claude.test.ts index 5cb6e24c..919a64ad 100644 --- a/src/main/agents/claude.test.ts +++ b/src/main/agents/claude.test.ts @@ -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 --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', () => { diff --git a/src/main/agents/claude.ts b/src/main/agents/claude.ts index dfb2066b..57987f02 100644 --- a/src/main/agents/claude.ts +++ b/src/main/agents/claude.ts @@ -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)}` : '' @@ -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) { diff --git a/src/main/agents/codex.test.ts b/src/main/agents/codex.test.ts index 06e3c486..d5ec3f77 100644 --- a/src/main/agents/codex.test.ts +++ b/src/main/agents/codex.test.ts @@ -30,7 +30,7 @@ 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') @@ -38,6 +38,23 @@ 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 `, 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 = { diff --git a/src/main/agents/codex.ts b/src/main/agents/codex.ts index 7ff1ea7b..ee2f2b2c 100644 --- a/src/main/agents/codex.ts +++ b/src/main/agents/codex.ts @@ -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 @@ -208,6 +219,12 @@ export function buildSpawnArgs(opts: AgentSpawnOpts): string { cmd += ` --model ${shellQuote(opts.model)}` } + // Fork: `codex fork ` 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 } diff --git a/src/main/agents/index.ts b/src/main/agents/index.ts index e2feaf16..5a909785 100644 --- a/src/main/agents/index.ts +++ b/src/main/agents/index.ts @@ -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 `), + * leaving the source untouched. Takes precedence over sessionId. */ + forkFromSessionId?: string initialPrompt?: string teleportSessionId?: string sessionName?: string @@ -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 } diff --git a/src/main/index.ts b/src/main/index.ts index 32718428..bea40061 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,14 @@ import { join } from 'path' import { PtyManager } from './pty-manager' import { ApprovalBridge } from './approval-bridge' import { JsonClaudeManager, bundledClaudeBinPath } from './json-claude-manager' +import { + deriveSpawnSpec, + resolveTabBirth, + newestResumableSession, + type SessionSpawnSpec, + type SessionBirthDeps +} from './session-birth' +import { getAgentInfo } from '../shared/agent-registry' import { shellQuote } from './shell-quote' import { readAttachmentImage, @@ -798,35 +806,68 @@ const panesFSM = new PanesFSM(store, { }, startJsonClaude: (sessionId, worktreePath) => { startJsonClaudeSession(sessionId, worktreePath) - } + }, + getResumeSessionForNewSession: (worktreePath, agentKind) => + newestResumableSession(worktreePath, [], birthDeps(agentKind)) }) -/** Look up a json-claude tab's persisted `model` override by sessionId - * (which is also the tab id for json-claude tabs). Returned to the - * json-claude manager so resume/kickoff/wake all respect a per-tab pin - * that was set when the worktree was created. */ -function findJsonClaudeTabModel(sessionId: string): string | undefined { +/** Find a json-claude tab anywhere in the pane tree by its tab id. */ +function findJsonClaudeTab(tabId: string): TerminalTab | undefined { const panes = store.getSnapshot().state.terminals.panes for (const tree of Object.values(panes)) { for (const leaf of getLeaves(tree)) { for (const tab of leaf.tabs) { - if (tab.id === sessionId && tab.type === 'json-claude') { - return tab.model && tab.model.trim() ? tab.model.trim() : undefined - } + if (tab.id === tabId && tab.type === 'json-claude') return tab } } } return undefined } +/** Look up a json-claude tab's persisted `model` override by tab id. + * Returned to the json-claude manager so resume/kickoff/wake all respect + * a per-tab pin that was set when the worktree was created. */ +function findJsonClaudeTabModel(tabId: string): string | undefined { + const tab = findJsonClaudeTab(tabId) + return tab?.model && tab.model.trim() ? tab.model.trim() : undefined +} + +/** Injected disk facts for the pure birth decision logic (session-birth.ts), + * per agent kind. Claude's session store (`~/.claude/projects`) is shared by + * chat (json-claude) and xterm Claude tabs, so both resolve through the same + * deps; Codex reads `~/.codex/sessions`. Kept thin so the module stays + * unit-testable. */ +function birthDeps(kind: AgentKind): SessionBirthDeps { + const agent = getAgent(kind) + return { + hasTranscript: (id, wt) => agent.sessionFileExists(wt, id), + listSessions: (wt) => agent.listSessions(wt) + } +} + /** Single source of truth for "spin up the json-claude subprocess for - * this sessionId". Used by the jsonClaude:start IPC handler, the - * panesFSM's startJsonClaudeWithPrompt + startJsonClaude options - * (kickoff + wake), so all paths produce the same dispatch + seed + - * create order. Idempotent — JsonClaudeManager.create() short-circuits - * if the instance is already running. */ -function startJsonClaudeSession(sessionId: string, worktreePath: string): void { + * this tab". Used by the jsonClaude:start IPC handler, the panesFSM's + * startJsonClaudeWithPrompt + startJsonClaude options (kickoff + wake), + * and createChatTab's fork pre-spawn, so all paths produce the same + * dispatch + seed + create order. The slice + manager instance are keyed + * by the stable tab id; `spec` (explicit, or derived from the tab) only + * controls which on-disk claude session the subprocess attaches to. + * Idempotent — JsonClaudeManager.create() short-circuits if the instance + * is already running. */ +function startJsonClaudeSession( + sessionId: string, + worktreePath: string, + spec?: SessionSpawnSpec +): void { if (jsonClaudeManager.hasSession(sessionId)) return + const resolved = + spec ?? + deriveSpawnSpec( + sessionId, + store.getSnapshot().state.terminals.panes[worktreePath], + worktreePath, + birthDeps('claude') + ) store.dispatch({ type: 'jsonClaude/sessionStarted', payload: { @@ -836,11 +877,104 @@ function startJsonClaudeSession(sessionId: string, worktreePath: string): void { store.getSnapshot().state.settings.jsonModeDefaultPermissionMode } }) - jsonClaudeManager.seedFromTranscript(sessionId, worktreePath) + // Seed the slice from whichever transcript this spawn attaches to so the + // renderer shows prior history immediately. A fork seeds from the SOURCE + // session (claude won't re-stream the inherited turns); a resume from the + // resumed id; a blank has no transcript yet (no-op). + const transcriptId = + resolved.kind === 'resume' + ? resolved.resumeSessionId + : resolved.kind === 'fork' + ? resolved.srcSessionId + : sessionId + jsonClaudeManager.seedFromTranscript(sessionId, worktreePath, transcriptId) const permMode = store.getSnapshot().state.jsonClaude.sessions[sessionId]?.permissionMode || 'default' - jsonClaudeManager.create(sessionId, worktreePath, permMode, findJsonClaudeTabModel(sessionId)) + jsonClaudeManager.create( + sessionId, + worktreePath, + permMode, + findJsonClaudeTabModel(sessionId), + resolved + ) +} + +/** Create a new chat (json-claude) tab in a worktree, applying the + * implicit fork/resume/blank rule (resolveTabBirth). Returns the new + * tab id. A fork pre-spawns immediately because its source session id + * can't be recovered from the tab afterward (the tab's sessionId starts + * unset and is filled in once claude reveals the forked id); blank and + * resume defer to the renderer's mount-time start, which re-derives the + * spec from the tab's persisted sessionId. */ +/** Apply the implicit fork/resume/blank rule for a new tab of `kind`. */ +function resolveBirth( + worktreePath: string, + kind: AgentKind, + paneId?: string +): SessionSpawnSpec { + return resolveTabBirth( + store.getSnapshot().state.terminals.panes[worktreePath], + worktreePath, + kind, + birthDeps(kind), + paneId + ) +} + +function createChatTab(worktreePath: string, paneId?: string): string { + const spec = resolveBirth(worktreePath, 'claude', paneId) + const tabId = randomUUID() + const sessionId = + spec.kind === 'resume' + ? spec.resumeSessionId + : spec.kind === 'fork' + ? undefined + : tabId + panesFSM.addTab( + worktreePath, + { id: tabId, type: 'json-claude', label: 'Chat', sessionId, mode: 'awake' }, + paneId + ) + if (spec.kind === 'fork') { + startJsonClaudeSession(tabId, worktreePath, spec) + } + return tabId +} + +/** Create a new xterm agent (Claude/Codex) tab, applying the same implicit + * fork/resume/blank rule (resolveTabBirth) scoped to the agent's own kind. + * Returns the new tab id. Unlike chat, the subprocess is spawned by the + * renderer when XTerminal mounts (via buildAgentSpawnArgs), so there's no + * pre-spawn: a fork rides on the transient `forkFromSessionId` (consumed on + * first spawn, after which the agent-minted id is discovered onto sessionId + * via terminals/sessionIdDiscovered); resume sets sessionId so buildSpawnArgs + * --resumes it; blank assigns a fresh id (Claude) or lets the agent self-id + * (Codex). */ +function createAgentTab( + worktreePath: string, + agentKind: AgentKind, + paneId?: string +): string { + const spec = resolveBirth(worktreePath, agentKind, paneId) + const info = getAgentInfo(agentKind) + const tabId = `agent-${worktreePath.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}` + const base = { + id: tabId, + type: 'agent' as const, + agentKind, + label: info.displayName + } + let tab: TerminalTab + if (spec.kind === 'fork') { + tab = { ...base, sessionId: undefined, forkFromSessionId: spec.srcSessionId } + } else if (spec.kind === 'resume') { + tab = { ...base, sessionId: spec.resumeSessionId } + } else { + tab = { ...base, sessionId: info.assignsSessionId ? randomUUID() : undefined } + } + panesFSM.addTab(worktreePath, tab, paneId) + return tabId } const worktreesFSM = new WorktreesFSM(store, { @@ -2450,6 +2584,7 @@ function registerIpcHandlers(): void { 'agent:buildSpawnArgs', (_ctx, agentKind: string, opts: { terminalId: string; cwd: string; sessionId?: string; + forkFromSessionId?: string; initialPrompt?: string; teleportSessionId?: string; sessionName?: string; modelOverride?: string @@ -2805,6 +2940,28 @@ function registerIpcHandlers(): void { startJsonClaudeSession(sessionId, cwd) return true }) + transport.onRequest( + 'jsonClaude:addChatTab', + (_ctx, worktreePath: string, paneId?: string) => { + if (!worktreePath) return null + // The fork/resume/blank decision needs disk + pane-tree state, so it + // lives in main. Returns the new tab id; the renderer focuses it. + const tabId = createChatTab(worktreePath, paneId) + log('json-claude', `IPC addChatTab worktree=${worktreePath} → ${tabId}`) + return tabId + } + ) + transport.onRequest( + 'agent:addTab', + (_ctx, worktreePath: string, agentKind: string, paneId?: string) => { + if (!worktreePath) return null + // Same implicit fork/resume/blank decision as chat, for xterm agent + // tabs. Returns the new tab id; the renderer focuses it. + const tabId = createAgentTab(worktreePath, toAgentKind(agentKind), paneId) + log('panes', `IPC agent:addTab worktree=${worktreePath} kind=${agentKind} → ${tabId}`) + return tabId + } + ) transport.onSignal( 'jsonClaude:send', ( diff --git a/src/main/json-claude-manager.test.ts b/src/main/json-claude-manager.test.ts index 17e81a5a..eff866d9 100644 --- a/src/main/json-claude-manager.test.ts +++ b/src/main/json-claude-manager.test.ts @@ -391,4 +391,119 @@ describe('JsonClaudeManager', () => { proc.emit('exit', 1, null) expect(store.getSnapshot().state.jsonClaude.sessions[sessionId]?.state).toBe('exited') }) + + // --- spawn spec → CLI flags -------------------------------------------- + + function startWithSpec( + spec?: Parameters[4] + ): string { + const store = new Store() + const mgr = makeManager(store) + const sessionId = 'tab-x' + const cwd = '/tmp/wt' + store.dispatch({ + type: 'jsonClaude/sessionStarted', + payload: { sessionId, worktreePath: cwd } + }) + mgr.create(sessionId, cwd, 'default', undefined, spec) + return lastSpawnCmdLine() + } + + it('blank spec (and the default) spawns with --session-id, not --resume', () => { + for (const cmd of [startWithSpec(), startWithSpec({ kind: 'blank' })]) { + expect(cmd).toContain('--session-id tab-x') + expect(cmd).not.toContain('--resume') + expect(cmd).not.toContain('--fork-session') + } + }) + + it('resume spec spawns with --resume , never --session-id or --fork-session', () => { + const cmd = startWithSpec({ kind: 'resume', resumeSessionId: 'old-sid' }) + expect(cmd).toContain('--resume old-sid') + expect(cmd).not.toContain('--session-id') + expect(cmd).not.toContain('--fork-session') + }) + + it('fork spec spawns with --resume --fork-session, never --session-id', () => { + const cmd = startWithSpec({ kind: 'fork', srcSessionId: 'src-sid' }) + expect(cmd).toContain('--resume src-sid') + expect(cmd).toContain('--fork-session') + expect(cmd).not.toContain('--session-id') + }) + + // --- fork session-id discovery from the init event --------------------- + + function seedChatTab(store: Store, wt: string, tabId: string): void { + store.dispatch({ + type: 'terminals/panesForWorktreeChanged', + payload: { + worktreePath: wt, + panes: { + type: 'leaf', + id: 'p1', + tabs: [{ id: tabId, type: 'json-claude', label: 'Chat', mode: 'awake' }], + activeTabId: tabId + } + } + }) + } + function tabSessionId(store: Store, wt: string, tabId: string): string | undefined { + const tree = store.getSnapshot().state.terminals.panes[wt] + if (tree?.type !== 'leaf') return undefined + return tree.tabs.find((t) => t.id === tabId)?.sessionId + } + function fireInit(proc: ReturnType, sessionId?: string): void { + proc.stdout.emit( + 'data', + Buffer.from( + JSON.stringify({ type: 'system', subtype: 'init', session_id: sessionId }) + '\n' + ) + ) + } + + it('fork: binds the claude-minted session id from init onto the tab (not the tab id)', () => { + const store = new Store() + const mgr = makeManager(store) + const wt = '/tmp/wt' + const tabId = 'tab-fork' + seedChatTab(store, wt, tabId) + store.dispatch({ type: 'jsonClaude/sessionStarted', payload: { sessionId: tabId, worktreePath: wt } }) + mgr.create(tabId, wt, 'default', undefined, { kind: 'fork', srcSessionId: 'src-sid' }) + + fireInit(sessionProcs()[sessionProcs().length - 1], 'minted-xyz') + + expect(tabSessionId(store, wt, tabId)).toBe('minted-xyz') + }) + + it('fork: a second init does not overwrite the already-discovered id', () => { + const store = new Store() + const mgr = makeManager(store) + const wt = '/tmp/wt' + const tabId = 'tab-fork2' + seedChatTab(store, wt, tabId) + store.dispatch({ type: 'jsonClaude/sessionStarted', payload: { sessionId: tabId, worktreePath: wt } }) + mgr.create(tabId, wt, 'default', undefined, { kind: 'fork', srcSessionId: 'src-sid' }) + const proc = sessionProcs()[sessionProcs().length - 1] + + fireInit(proc, 'first-id') + fireInit(proc, 'second-id') + + expect(tabSessionId(store, wt, tabId)).toBe('first-id') + }) + + it('blank: init does not rebind the tab session id', () => { + const store = new Store() + const mgr = makeManager(store) + const wt = '/tmp/wt' + const tabId = 'tab-blank' + seedChatTab(store, wt, tabId) + store.dispatch({ type: 'jsonClaude/sessionStarted', payload: { sessionId: tabId, worktreePath: wt } }) + // Blank pins --session-id , so claudeSessionId is known up front + // and the init session_id must be ignored for binding. + mgr.create(tabId, wt, 'default', undefined, { kind: 'blank' }) + + fireInit(sessionProcs()[sessionProcs().length - 1], 'should-be-ignored') + + expect(tabSessionId(store, wt, tabId)).toBeUndefined() + }) }) diff --git a/src/main/json-claude-manager.ts b/src/main/json-claude-manager.ts index b8506fa5..e6573364 100644 --- a/src/main/json-claude-manager.ts +++ b/src/main/json-claude-manager.ts @@ -31,6 +31,7 @@ import type { JsonClaudeSessionState } from '../shared/state/json-claude' import type { ClaudeLaunchSettings } from './claude-launch' +import type { SessionSpawnSpec } from './session-birth' import { log } from './debug' import { shellQuote } from './shell-quote' import { resolveUserShell, loginShellCommandArgs } from './user-shell' @@ -39,6 +40,13 @@ interface JsonClaudeInstance { proc: ChildProcessWithoutNullStreams sessionId: string worktreePath: string + /** The on-disk `claude` session id this subprocess reads/writes — + * i.e. the `.jsonl` basename. Equals `sessionId` (the tab id) for + * blank tabs, the resumed id for resume tabs, and is null for a fork + * until the init event reveals the freshly-minted id. Used for every + * transcript-path operation (seed, rewind) so they target the right + * file when the tab id and session id diverge. */ + claudeSessionId: string | null buf: string /** Monotonically increasing counter used to build stable chat entry ids. */ entryCounter: number @@ -224,12 +232,19 @@ export class JsonClaudeManager { * the renderer's chat scrollback is empty after a full app restart. * No-op if the jsonl doesn't exist yet (first turn) or the session * already has entries (renderer reload — main's slice survived). */ - seedFromTranscript(sessionId: string, worktreePath: string): void { + seedFromTranscript( + sessionId: string, + worktreePath: string, + transcriptSessionId: string = sessionId + ): void { const session = this.store.getSnapshot().state.jsonClaude.sessions[sessionId] if (session && session.entries.length > 0) return - const seededEntries = this.parseTranscriptEntries(sessionId, worktreePath) + const seededEntries = this.parseTranscriptEntries( + transcriptSessionId, + worktreePath + ) if (seededEntries.length === 0) return const compactCount = seededEntries.filter((e) => e.kind === 'compact').length log( @@ -387,13 +402,17 @@ export class JsonClaudeManager { sessionId: string, worktreePath: string, permissionMode: JsonClaudePermissionMode = 'default', - modelOverride?: string + modelOverride?: string, + spec: SessionSpawnSpec = { kind: 'blank' } ): void { if (this.instances.has(sessionId)) { log('json-claude', `create no-op — already running sessionId=${sessionId}`) return } - log('json-claude', `create begin sessionId=${sessionId} mode=${permissionMode}`) + log( + 'json-claude', + `create begin sessionId=${sessionId} mode=${permissionMode} spec=${spec.kind}` + ) const socketPath = this.opts.getApprovalSocketPath(sessionId) // MCP config — two stdio servers, both spawned via @@ -448,10 +467,21 @@ export class JsonClaudeManager { const useSystemClaude = this.opts.getUseSystemClaude() const claudeCommand = this.opts.getClaudeCommand() || 'claude' - const existingSession = existsSync(transcriptPathFor(sessionId, worktreePath)) - const resumeOrSet = existingSession - ? ['--resume', sessionId] - : ['--session-id', sessionId] + // Resolve the spawn flags + the on-disk session id this subprocess + // will own. For a fork we don't know the minted id yet — claude + // generates it and we learn it from the init event (see handleStreamLine). + let claudeSessionId: string | null + let resumeOrSet: string[] + if (spec.kind === 'resume') { + claudeSessionId = spec.resumeSessionId + resumeOrSet = ['--resume', spec.resumeSessionId] + } else if (spec.kind === 'fork') { + claudeSessionId = null + resumeOrSet = ['--resume', spec.srcSessionId, '--fork-session'] + } else { + claudeSessionId = sessionId + resumeOrSet = ['--session-id', sessionId] + } const launchSettings = this.opts.getLaunchSettings(worktreePath, modelOverride) const args = [ @@ -553,6 +583,7 @@ export class JsonClaudeManager { proc, sessionId, worktreePath, + claudeSessionId, buf: '', entryCounter: 0, partial: null, @@ -879,6 +910,13 @@ export class JsonClaudeManager { return { ok: false, reason: 'nothing after this message to drop' } } + // Resolve the on-disk transcript id BEFORE kill() drops the instance — + // a resumed/forked tab's transcript lives under claudeSessionId, not + // the tab id. Falls back to the tab id for blank tabs (and the rare + // case the session id was never discovered). + const transcriptSessionId = + this.instances.get(sessionId)?.claudeSessionId ?? sessionId + // Kill the subprocess first so it doesn't keep writing to the jsonl // while we truncate. kill() removes from the instance map BEFORE // SIGTERM so the exit handler's stale-instance guard bails on the @@ -886,7 +924,7 @@ export class JsonClaudeManager { if (this.instances.has(sessionId)) this.kill(sessionId) const truncResult = this.truncateTranscriptAfterMessage( - sessionId, + transcriptSessionId, session.worktreePath, target.apiMessageId ) @@ -901,7 +939,7 @@ export class JsonClaudeManager { // a positional prune would leave the slice and the jsonl out of // sync. Reseeding makes them agree. const freshEntries = this.parseTranscriptEntries( - sessionId, + transcriptSessionId, session.worktreePath ) this.store.dispatch({ @@ -1233,8 +1271,27 @@ export class JsonClaudeManager { return } if (type === 'system' && subtype === 'init') { - // Session id is already known (we pinned it via --session-id), but - // the init payload includes the canonical slash_commands list — keep + // Fork tabs spawn with `--fork-session` and no pinned id, so claude + // mints the session id itself. The init payload is the first place + // we see it. Bind it to the tab (terminals/sessionIdDiscovered writes + // it onto TerminalTab.sessionId, which is persisted) so a later + // reload resumes the forked session, and record it on the instance + // so transcript-path ops (rewind) target the right file. + if (instance.claudeSessionId === null) { + const sid = parsed['session_id'] + if (typeof sid === 'string' && sid) { + instance.claudeSessionId = sid + log( + 'json-claude', + `fork session id discovered tab=${instance.sessionId} → ${sid}` + ) + this.store.dispatch({ + type: 'terminals/sessionIdDiscovered', + payload: { terminalId: instance.sessionId, sessionId: sid } + }) + } + } + // The init payload includes the canonical slash_commands list — keep // it as a freshness signal in case anything's been installed since // the per-cwd probe ran. (Init only fires once a real user message // arrives — see probeSlashCommands for why we also probe up-front.) diff --git a/src/main/panes-fsm.ts b/src/main/panes-fsm.ts index 7ef86c68..7d4b953e 100644 --- a/src/main/panes-fsm.ts +++ b/src/main/panes-fsm.ts @@ -62,6 +62,16 @@ interface PanesFSMOptions { * fresh subprocess. Idempotent: a no-op if the session is already * running. */ startJsonClaude?: (sessionId: string, worktreePath: string) => void + /** When the first agent tab (chat or xterm) opens into a worktree + * (ensureInitialized), return the on-disk session id to resume for the + * given agent kind — the newest prior session in this worktree — or + * undefined to start fresh. Lets a reopened worktree pick up its last + * conversation instead of a blank one. Bypassed when the launch carries + * an initialPrompt (a deliberate new task wants a fresh session). */ + getResumeSessionForNewSession?: ( + worktreePath: string, + agentKind: AgentKind + ) => string | undefined } function newPaneId(): string { @@ -73,10 +83,18 @@ function newSplitId(): string { } function stripTransientTabFields(tab: TerminalTab): TerminalTab { - if (!tab.initialPrompt && !tab.teleportSessionId) return tab - const { initialPrompt: _ip, teleportSessionId: _ts, ...rest } = tab + if (!tab.initialPrompt && !tab.teleportSessionId && !tab.forkFromSessionId) { + return tab + } + const { + initialPrompt: _ip, + teleportSessionId: _ts, + forkFromSessionId: _ff, + ...rest + } = tab void _ip void _ts + void _ff return rest } @@ -239,17 +257,26 @@ export class PanesFSM { !opts?.teleportSessionId let agentTab: TerminalTab let jsonClaudeKickoff: { sessionId: string; initialPrompt?: string; model?: string } | null = null + // Resume the worktree's last known session when opening a prompt-less + // tab; a prompted launch starts fresh. The tab id stays the stable + // slice/instance key — only sessionId (the --resume target) points at + // the prior session. The renderer's mount-time start (chat) / + // buildSpawnArgs (xterm) --resumes it. + const resumeId = + opts?.initialPrompt || opts?.teleportSessionId + ? undefined + : this.opts.getResumeSessionForNewSession?.(wtPath, agentKind) if (wantsJson) { - const sessionId = crypto.randomUUID() + const tabId = crypto.randomUUID() agentTab = { - id: sessionId, + id: tabId, type: 'json-claude', label: 'Chat', - sessionId, + sessionId: resumeId ?? tabId, mode: 'awake', model } - jsonClaudeKickoff = { sessionId, initialPrompt: opts?.initialPrompt, model } + jsonClaudeKickoff = { sessionId: tabId, initialPrompt: opts?.initialPrompt, model } } else { const agentTabId = `agent-${wtPath.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}` agentTab = { @@ -257,7 +284,8 @@ export class PanesFSM { type: 'agent', agentKind, label: agentInfo.displayName, - sessionId: agentInfo.assignsSessionId ? crypto.randomUUID() : undefined, + sessionId: + resumeId ?? (agentInfo.assignsSessionId ? crypto.randomUUID() : undefined), initialPrompt: opts?.teleportSessionId ? undefined : opts?.initialPrompt, teleportSessionId: opts?.teleportSessionId, model diff --git a/src/main/session-birth.test.ts b/src/main/session-birth.test.ts new file mode 100644 index 00000000..f067e4aa --- /dev/null +++ b/src/main/session-birth.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest' +import type { PaneLeaf, PaneNode, TerminalTab } from '../shared/state/terminals' +import { + agentKindOfTab, + deriveSpawnSpec, + newestResumableSession, + pickForkSource, + resolveTabBirth, + type SessionBirthDeps +} from './session-birth' + +const WT = '/wt/feature' + +// --- builders ------------------------------------------------------------- + +function chatTab(id: string, sessionId?: string): TerminalTab { + return { id, type: 'json-claude', label: 'Chat', sessionId, mode: 'awake' } +} +function claudeTab(id: string, sessionId?: string): TerminalTab { + return { id, type: 'agent', agentKind: 'claude', label: 'Claude', sessionId } +} +function codexTab(id: string, sessionId?: string): TerminalTab { + return { id, type: 'agent', agentKind: 'codex', label: 'Codex', sessionId } +} +function shellTab(id: string): TerminalTab { + return { id, type: 'shell', label: 'Shell' } +} +function leaf(id: string, tabs: TerminalTab[], activeTabId?: string): PaneLeaf { + return { type: 'leaf', id, tabs, activeTabId: activeTabId ?? tabs[0]?.id ?? '' } +} +function split(dir: 'horizontal' | 'vertical', a: PaneNode, b: PaneNode): PaneNode { + return { type: 'split', id: `${a.id}-${b.id}`, direction: dir, ratio: 0.5, children: [a, b] } +} + +/** Deps backed by an explicit set of on-disk transcript ids + an ordered + * (newest-first) session list, so tests control disk facts precisely. */ +function deps(opts: { + transcripts?: string[] + sessions?: string[] +}): SessionBirthDeps { + const present = new Set(opts.transcripts ?? []) + const sessions = (opts.sessions ?? []).map((sessionId, i) => ({ + sessionId, + mtimeMs: 1000 - i + })) + return { + hasTranscript: (id) => present.has(id), + listSessions: () => sessions + } +} + +// --- agentKindOfTab ------------------------------------------------------- + +describe('agentKindOfTab', () => { + it('maps json-claude → claude, agent → its kind, others → null', () => { + expect(agentKindOfTab(chatTab('c'))).toBe('claude') + expect(agentKindOfTab(claudeTab('a'))).toBe('claude') + expect(agentKindOfTab(codexTab('x'))).toBe('codex') + expect(agentKindOfTab(shellTab('s'))).toBeNull() + expect( + agentKindOfTab({ id: 'a', type: 'agent', label: 'Agent' }) + ).toBe('claude') + }) +}) + +// --- resolveTabBirth: target claude --------------------------------------- + +describe('resolveTabBirth (claude)', () => { + const C = 'claude' as const + + it('brand-new worktree with no prior sessions → blank', () => { + expect(resolveTabBirth(undefined, WT, C, deps({}))).toEqual({ kind: 'blank' }) + }) + + it('no agent tabs + prior sessions on disk → resume the newest', () => { + const spec = resolveTabBirth(undefined, WT, C, deps({ sessions: ['s-new', 's-old'] })) + expect(spec).toEqual({ kind: 'resume', resumeSessionId: 's-new' }) + }) + + it('only a shell tab open (no agent) → still resumes last known', () => { + const tree = leaf('p1', [shellTab('sh1')]) + expect(resolveTabBirth(tree, WT, C, deps({ sessions: ['s1'] }))).toEqual({ + kind: 'resume', + resumeSessionId: 's1' + }) + }) + + it('a chat tab with a transcript open → fork from it', () => { + const tree = leaf('p1', [chatTab('c1', 'c1')]) + expect(resolveTabBirth(tree, WT, C, deps({ transcripts: ['c1'] }))).toEqual({ + kind: 'fork', + srcSessionId: 'c1' + }) + }) + + it('an xterm CLAUDE tab with a transcript → new claude forks it (cross-surface)', () => { + const tree = leaf('p1', [claudeTab('a1', 'sid-a1')]) + expect(resolveTabBirth(tree, WT, C, deps({ transcripts: ['sid-a1'] }))).toEqual({ + kind: 'fork', + srcSessionId: 'sid-a1' + }) + }) + + it('a same-kind tab with no transcript yet → blank, not fork', () => { + const tree = leaf('p1', [claudeTab('a1', 'sid-a1')]) + expect(resolveTabBirth(tree, WT, C, deps({ transcripts: [] }))).toEqual({ + kind: 'blank' + }) + }) + + it('only a CODEX tab open (target claude) → blank (no claude to fork, agent present)', () => { + const tree = leaf('p1', [codexTab('x1', 'sid-x1')]) + expect( + resolveTabBirth(tree, WT, C, deps({ transcripts: ['sid-x1'], sessions: ['s1'] })) + ).toEqual({ kind: 'blank' }) + }) + + it('forks from the same-kind tab active in the TARGET pane across a split', () => { + const tree = split( + 'vertical', + leaf('pL', [chatTab('cL', 'cL')]), + leaf('pR', [claudeTab('aR', 'cR')]) + ) + const d = deps({ transcripts: ['cL', 'cR'] }) + expect(resolveTabBirth(tree, WT, C, d, 'pR')).toEqual({ kind: 'fork', srcSessionId: 'cR' }) + expect(resolveTabBirth(tree, WT, C, d, 'pL')).toEqual({ kind: 'fork', srcSessionId: 'cL' }) + }) +}) + +// --- resolveTabBirth: target codex ---------------------------------------- + +describe('resolveTabBirth (codex)', () => { + const X = 'codex' as const + + it('a codex tab with a transcript open → fork it', () => { + const tree = leaf('p1', [codexTab('x1', 'sid-x1')]) + expect(resolveTabBirth(tree, WT, X, deps({ transcripts: ['sid-x1'] }))).toEqual({ + kind: 'fork', + srcSessionId: 'sid-x1' + }) + }) + + it('a claude chat open (target codex) → blank (cannot fork claude into codex)', () => { + const tree = leaf('p1', [chatTab('c1', 'c1')]) + expect( + resolveTabBirth(tree, WT, X, deps({ transcripts: ['c1'], sessions: ['s1'] })) + ).toEqual({ kind: 'blank' }) + }) + + it('no agent tabs + codex sessions on disk → resume newest codex session', () => { + expect(resolveTabBirth(undefined, WT, X, deps({ sessions: ['cx-new', 'cx-old'] }))).toEqual({ + kind: 'resume', + resumeSessionId: 'cx-new' + }) + }) +}) + +// --- newestResumableSession ---------------------------------------------- + +describe('newestResumableSession', () => { + it('returns the newest session not already open', () => { + const open = [chatTab('t1', 's-open')] + expect( + newestResumableSession(WT, open, deps({ sessions: ['s-open', 's-free'] })) + ).toBe('s-free') + }) + + it('excludes by both tab id and tab sessionId', () => { + const open = [chatTab('s-byid'), chatTab('t2', 's-bysid')] + expect( + newestResumableSession(WT, open, deps({ sessions: ['s-byid', 's-bysid', 's-free'] })) + ).toBe('s-free') + }) + + it('undefined when every session is already open', () => { + const open = [chatTab('t1', 's1'), chatTab('t2', 's2')] + expect( + newestResumableSession(WT, open, deps({ sessions: ['s1', 's2'] })) + ).toBeUndefined() + }) + + it('undefined when there are no sessions on disk', () => { + expect(newestResumableSession(WT, [], deps({}))).toBeUndefined() + }) +}) + +// --- pickForkSource ------------------------------------------------------- + +describe('pickForkSource', () => { + it('prefers the tab active in the target pane', () => { + const tabs = [chatTab('cA', 'cA'), claudeTab('cB', 'cB')] + const tree = leaf('p1', tabs, 'cB') + expect(pickForkSource(tree, tabs, WT, deps({ transcripts: ['cA', 'cB'] }), 'p1')).toBe('cB') + }) + + it('falls back to the first same-kind tab with a transcript when active has none', () => { + const tabs = [chatTab('cA', 'cA'), chatTab('cB', 'cB')] + const tree = leaf('p1', tabs, 'cA') + expect(pickForkSource(tree, tabs, WT, deps({ transcripts: ['cB'] }), 'p1')).toBe('cB') + }) + + it('undefined when no same-kind tab has a transcript', () => { + const tabs = [chatTab('cA', 'cA')] + const tree = leaf('p1', tabs, 'cA') + expect(pickForkSource(tree, tabs, WT, deps({ transcripts: [] }), 'p1')).toBeUndefined() + }) + + it('uses tab id as the fork source when sessionId is unset', () => { + const tabs = [chatTab('cA')] + const tree = leaf('p1', tabs, 'cA') + expect(pickForkSource(tree, tabs, WT, deps({ transcripts: ['cA'] }), 'p1')).toBe('cA') + }) +}) + +// --- deriveSpawnSpec ------------------------------------------------------ + +describe('deriveSpawnSpec', () => { + it('resumes the tab’s own id when only that transcript exists (blank tab reload)', () => { + const tree = leaf('p1', [chatTab('tab1', 'tab1')]) + expect(deriveSpawnSpec('tab1', tree, WT, deps({ transcripts: ['tab1'] }))).toEqual({ + kind: 'resume', + resumeSessionId: 'tab1' + }) + }) + + it('resumes the decoupled sessionId when it differs and has a transcript (forked tab reload)', () => { + const tree = leaf('p1', [chatTab('tab1', 'forked-sid')]) + expect(deriveSpawnSpec('tab1', tree, WT, deps({ transcripts: ['forked-sid'] }))).toEqual({ + kind: 'resume', + resumeSessionId: 'forked-sid' + }) + }) + + it('works for an xterm agent tab too (resumes its decoupled sessionId)', () => { + const tree = leaf('p1', [codexTab('tab1', 'codex-sid')]) + expect(deriveSpawnSpec('tab1', tree, WT, deps({ transcripts: ['codex-sid'] }))).toEqual({ + kind: 'resume', + resumeSessionId: 'codex-sid' + }) + }) + + it('blank when no transcript exists for either id', () => { + const tree = leaf('p1', [chatTab('tab1', 'tab1')]) + expect(deriveSpawnSpec('tab1', tree, WT, deps({}))).toEqual({ kind: 'blank' }) + }) +}) diff --git a/src/main/session-birth.ts b/src/main/session-birth.ts new file mode 100644 index 00000000..6127c216 --- /dev/null +++ b/src/main/session-birth.ts @@ -0,0 +1,161 @@ +// Pure decision logic for how a newly-created or re-spawned agent surface +// (a json-claude chat tab OR an xterm agent tab) attaches to an agent +// session. Two questions live here: +// +// * resolveTabBirth — what should a BRAND-NEW tab do, given what's already +// open in the worktree? (fork / resume-last / blank) +// * deriveSpawnSpec — how should an ALREADY-EXISTING tab re-attach when its +// subprocess is (re)spawned on mount or wake? +// +// Grouping is by AGENT KIND, not tab surface: a json-claude chat tab and an +// xterm Claude tab share the same `~/.claude/projects` session store, so they +// fork from / resume each other; Codex tabs form their own group. The on-disk +// facts (does a transcript exist, what sessions exist) are injected per kind +// via SessionBirthDeps so this module stays free of store/disk/manager +// coupling and is unit-testable. +// +// The invariant these encode: the tab id is the stable slice/instance key, +// while the agent session id (the on-disk transcript basename) may differ — a +// resumed tab carries the resumed id, a fork's id is minted by the agent and +// discovered later. See SessionSpawnSpec for the three shapes. + +import type { AgentKind, PaneNode, TerminalTab } from '../shared/state/terminals' +import { getLeaves } from '../shared/state/terminals' + +/** How an agent subprocess should attach to a session. The slice/instance/tab + * id is always the stable key; this controls only which agent session the + * subprocess reads/writes: + * - blank: fresh session (Claude pins --session-id; Codex self-assigns). + * - resume: re-attach an existing on-disk session. + * - fork: branch an existing session into a brand-new one. The agent + * mints the new id; we discover it from the first init/hook event + * and bind it to the tab. */ +export type SessionSpawnSpec = + | { kind: 'blank' } + | { kind: 'resume'; resumeSessionId: string } + | { kind: 'fork'; srcSessionId: string } + +export interface SessionBirthDeps { + /** True when an on-disk transcript exists for this agent session id. */ + hasTranscript: (sessionId: string, worktreePath: string) => boolean + /** On-disk sessions for this worktree, newest first by mtime. */ + listSessions: ( + worktreePath: string + ) => Array<{ sessionId: string; mtimeMs: number }> +} + +/** The agent kind a tab represents for fork/resume grouping, or null for + * non-agent tabs (shell/diff/file/browser/review). A json-claude chat tab is + * Claude; an agent tab is its agentKind (defaulting to Claude). */ +export function agentKindOfTab(tab: TerminalTab): AgentKind | null { + if (tab.type === 'json-claude') return 'claude' + if (tab.type === 'agent') return tab.agentKind ?? 'claude' + return null +} + +/** Flatten a worktree's pane tree to its tabs (in pane/tab order). */ +export function flattenTabs(tree: PaneNode | undefined): TerminalTab[] { + if (!tree) return [] + const tabs: TerminalTab[] = [] + for (const leaf of getLeaves(tree)) tabs.push(...leaf.tabs) + return tabs +} + +/** Resolve how an existing tab should re-attach to a session. Keys off the + * tab's persisted `sessionId`: a resumed/forked tab carries a session id that + * differs from the tab id, so we --resume it; a tab whose id equals its + * session id --resumes its own id when a transcript exists (continue after + * reload), else starts fresh. */ +export function deriveSpawnSpec( + tabId: string, + tree: PaneNode | undefined, + worktreePath: string, + deps: SessionBirthDeps +): SessionSpawnSpec { + const tab = flattenTabs(tree).find((t) => t.id === tabId) + const sid = tab?.sessionId + if (sid && sid !== tabId && deps.hasTranscript(sid, worktreePath)) { + return { kind: 'resume', resumeSessionId: sid } + } + if (deps.hasTranscript(tabId, worktreePath)) { + return { kind: 'resume', resumeSessionId: tabId } + } + return { kind: 'blank' } +} + +/** Pick the session id to fork from among same-kind tabs: prefer the tab + * active in the target pane, else any same-kind tab, taking the first whose + * transcript actually exists on disk. Returns undefined when no open + * same-kind tab has a forkable transcript yet (e.g. a zero-turn session). */ +export function pickForkSource( + tree: PaneNode | undefined, + sameKindTabs: TerminalTab[], + worktreePath: string, + deps: SessionBirthDeps, + paneId?: string +): string | undefined { + const candidates: string[] = [] + if (tree) { + const leaves = getLeaves(tree) + const target = paneId ? leaves.find((l) => l.id === paneId) : undefined + const active = target + ? sameKindTabs.find((t) => t.id === target.activeTabId) + : undefined + if (active) candidates.push(active.sessionId ?? active.id) + } + for (const t of sameKindTabs) candidates.push(t.sessionId ?? t.id) + for (const id of candidates) { + if (deps.hasTranscript(id, worktreePath)) return id + } + return undefined +} + +/** Newest on-disk session for this worktree that isn't already open in a tab + * (so resume doesn't collide with a live session). */ +export function newestResumableSession( + worktreePath: string, + openTabs: TerminalTab[], + deps: SessionBirthDeps +): string | undefined { + const openIds = new Set() + for (const t of openTabs) { + openIds.add(t.id) + if (t.sessionId) openIds.add(t.sessionId) + } + for (const { sessionId } of deps.listSessions(worktreePath)) { + if (!openIds.has(sessionId)) return sessionId + } + return undefined +} + +/** Decide what a NEW tab of `targetKind` should do when created in a worktree, + * driven entirely by what's already open there: + * 1. A same-kind agent tab already exists → fork from the active one (tip). + * 2. No agent tabs of ANY kind → resume the worktree's last known + * session (excluding open ones). + * 3. Otherwise → blank. + * "Always fork, never blank" once a same-kind tab exists; a forkable source + * needs an on-disk transcript (a zero-turn session has nothing to fork). */ +export function resolveTabBirth( + tree: PaneNode | undefined, + worktreePath: string, + targetKind: AgentKind, + deps: SessionBirthDeps, + paneId?: string +): SessionSpawnSpec { + const tabs = flattenTabs(tree) + const sameKind = tabs.filter((t) => agentKindOfTab(t) === targetKind) + + if (sameKind.length > 0) { + const src = pickForkSource(tree, sameKind, worktreePath, deps, paneId) + if (src) return { kind: 'fork', srcSessionId: src } + return { kind: 'blank' } + } + + const hasAnyAgentTab = tabs.some((t) => agentKindOfTab(t) !== null) + if (!hasAnyAgentTab) { + const resumeId = newestResumableSession(worktreePath, tabs, deps) + if (resumeId) return { kind: 'resume', resumeSessionId: resumeId } + } + return { kind: 'blank' } +} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c2ccab45..6fd60f6f 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -304,6 +304,10 @@ export function buildBackend( panesAddTab: (wtPath: string, tab: unknown, paneId?: string) => req('panes:addTab', wtPath, tab, paneId), + addChatTab: (wtPath: string, paneId?: string) => + req('jsonClaude:addChatTab', wtPath, paneId), + addAgentTab: (wtPath: string, agentKind: string, paneId?: string) => + req('agent:addTab', wtPath, agentKind, paneId), panesCloseTab: (wtPath: string, tabId: string) => req('panes:closeTab', wtPath, tabId), panesRestartAgentTab: (wtPath: string, tabId: string, newId: string) => req('panes:restartAgentTab', wtPath, tabId, newId), @@ -355,6 +359,7 @@ export function buildBackend( terminalId: string cwd: string sessionId?: string + forkFromSessionId?: string initialPrompt?: string teleportSessionId?: string sessionName?: string diff --git a/src/renderer/components/MobileTerminal.tsx b/src/renderer/components/MobileTerminal.tsx index 947a10be..ae6de03b 100644 --- a/src/renderer/components/MobileTerminal.tsx +++ b/src/renderer/components/MobileTerminal.tsx @@ -306,6 +306,7 @@ export function MobileTerminal({ worktreePath, tab }: MobileTerminalProps): JSX. visible={true} sessionName={tab.label} sessionId={tab.sessionId} + forkFromSessionId={tab.forkFromSessionId} modelOverride={tab.type === 'agent' ? tab.model : undefined} /> {/* Hidden textarea — pointer-events:none so touch scrolling on the diff --git a/src/renderer/components/WorkspaceView.tsx b/src/renderer/components/WorkspaceView.tsx index ce285124..fbef0468 100644 --- a/src/renderer/components/WorkspaceView.tsx +++ b/src/renderer/components/WorkspaceView.tsx @@ -641,6 +641,7 @@ export function WorkspaceView({ visible={visible && isActiveInPane} sessionName={tab.type === 'agent' && nameAgentSessions ? `${repoLabel}/${branch}` : undefined} sessionId={tab.sessionId} + forkFromSessionId={tab.forkFromSessionId} initialPrompt={tab.initialPrompt} teleportSessionId={tab.teleportSessionId} modelOverride={tab.type === 'agent' ? tab.model : undefined} diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index 8e5bed5b..60cfdc4f 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -270,6 +270,9 @@ interface XTerminalProps { visible: boolean sessionName?: string sessionId?: string + /** Agent tabs only: one-shot "fork from this session" source id. Passed to + * buildSpawnArgs on first spawn (Claude --fork-session / Codex fork). */ + forkFromSessionId?: string initialPrompt?: string teleportSessionId?: string modelOverride?: string @@ -298,7 +301,7 @@ interface XTerminalProps { onSwitchToChat?: () => void } -export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, backgroundVar, preamble, hideRestoreNotice, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { +export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionName, sessionId, forkFromSessionId, initialPrompt, teleportSessionId, modelOverride, shellCommand, shellCwd, backgroundVar, preamble, hideRestoreNotice, onRestartAgent, onSwitchToChat }: XTerminalProps): JSX.Element { // Lazy font-cache init — fires once on first XTerminal mount. See // initFontCache() comment for why this is lazy rather than at module // top. @@ -340,6 +343,17 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa const fitAddonRef = useRef(null) const searchAddonRef = useRef(null) const searchInputRef = useRef(null) + const restartButtonRef = useRef(null) + + // When the agent-exited overlay appears on the visible pane, focus the + // "Start a new session" button so Enter/Space confirms it (a focused + // native