Skip to content
Merged
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
6 changes: 3 additions & 3 deletions frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export const SessionView = memo(() => {
category: 'tools',
enabled: () => isInSessionView,
action: () => handlePanelCreate('terminal', {
initialCommand: 'codex',
initialCommand: 'codex --yolo',
title: 'Codex'
}),
});
Expand Down Expand Up @@ -1034,7 +1034,7 @@ export const SessionView = memo(() => {
<button
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium text-text-tertiary border border-border-primary hover:bg-surface-hover hover:text-text-secondary transition-colors whitespace-nowrap flex-shrink-0"
onClick={() => handlePanelCreate('terminal', {
initialCommand: 'codex',
initialCommand: 'codex --yolo',
title: 'Codex'
})}
>
Expand Down Expand Up @@ -1190,7 +1190,7 @@ export const SessionView = memo(() => {
<button
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium text-text-tertiary border border-border-primary hover:bg-surface-hover hover:text-text-secondary transition-colors whitespace-nowrap flex-shrink-0"
onClick={() => handlePanelCreate('terminal', {
initialCommand: 'codex',
initialCommand: 'codex --yolo',
title: 'Codex'
})}
>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/panels/PanelTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ export const PanelTabBar: React.FC<PanelTabBarProps> = memo(({
role="menuitem"
className={menuItemClass}
onClick={() => handleAddPanel('terminal', {
initialCommand: 'codex',
initialCommand: 'codex --yolo',
title: 'Codex'
})}
>
Expand Down
13 changes: 8 additions & 5 deletions main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1333,24 +1333,27 @@ app.on('before-quit', async (event) => {
logToFile('2s wait complete');
}

// Phase 2: Save terminal states and mark Claude terminals as interrupted
// Phase 2: Save terminal states and mark CLI agent terminals as interrupted
logToFile('Phase 2: saving terminal states');
console.log('[Main] Saving terminal states...');
await terminalPanelManager.saveAllTerminalStates();

const interruptedPanels = new Map<string, string[]>(); // sessionId → panelIds

// Find all terminal panels running Claude and mark them as interrupted
// Find all terminal panels running supported CLI agents and mark them as interrupted
const allTerminalPanelIds = terminalPanelManager.getAllPanelIds();
for (const panelId of allTerminalPanelIds) {
const panel = panelManager.getPanel(panelId);
if (!panel) continue;

const customState = (panel.state?.customState || {}) as TerminalPanelState;
const hadClaude = customState.initialCommand && customState.initialCommand.toLowerCase().includes('claude');
const initialCommand = customState.initialCommand?.toLowerCase() ?? '';
const agentType = customState.agentType ??
(initialCommand.includes('claude') ? 'claude' : initialCommand.includes('codex') ? 'codex' : undefined);

if (hadClaude) {
if (agentType === 'claude' || agentType === 'codex') {
customState.wasInterrupted = true;
customState.agentType = agentType;
panel.state.customState = customState;
await panelManager.updatePanel(panelId, { state: panel.state });

Expand All @@ -1361,7 +1364,7 @@ app.on('before-quit', async (event) => {
interruptedPanels.set(panel.sessionId, [panelId]);
}
logToFile(`Marked terminal panel ${panelId} as interrupted`);
console.log(`[Main] Marked terminal panel ${panelId} as interrupted (Claude CLI, session-id = panel ID)`);
console.log(`[Main] Marked terminal panel ${panelId} as interrupted (${agentType} CLI)`);
}
}

Expand Down
43 changes: 38 additions & 5 deletions main/src/services/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1790,8 +1790,17 @@ export class SessionManager extends EventEmitter {
if (panel.type === 'terminal') {
const termState = panel.state?.customState as TerminalPanelState | undefined;
if (termState?.wasInterrupted && termState?.initialCommand) {
// Panel ID was used as --session-id when launching Claude, so it IS the resume ID
resumablePanels.push({ panelId: panel.id, panelType: 'terminal', resumeId: panel.id });
const agentType = termState.agentType ?? this.getTerminalAgentType(termState.initialCommand);
if (agentType === 'claude') {
// Panel ID was used as --session-id when launching Claude, so it IS the resume ID.
resumablePanels.push({ panelId: panel.id, panelType: 'terminal', resumeId: panel.id });
} else if (agentType === 'codex') {
resumablePanels.push({
panelId: panel.id,
panelType: 'terminal',
resumeId: termState.agentSessionId ?? 'interactive'
});
}
}
}
}
Expand Down Expand Up @@ -1830,10 +1839,27 @@ export class SessionManager extends EventEmitter {
const termState = panel.state?.customState as TerminalPanelState | undefined;

if (termState?.wasInterrupted && termState?.initialCommand) {
// Panel ID was passed as --session-id on original launch, so use it as resume ID
const state = panel.state;
const customState = (state.customState || {}) as TerminalPanelState;
customState.initialCommand = `claude --resume ${panel.id} --dangerously-skip-permissions`;
const agentType = customState.agentType ?? this.getTerminalAgentType(customState.initialCommand);

if (agentType === 'claude') {
// Panel ID was passed as --session-id on original launch, so use it as resume ID.
customState.initialCommand = `claude --resume ${panel.id} --dangerously-skip-permissions`;
} else if (agentType === 'codex') {
customState.initialCommand = customState.agentSessionId
? `codex resume --yolo ${customState.agentSessionId}`
: 'codex resume --yolo';
customState.agentType = 'codex';
if (customState.agentSessionId) {
console.log(`[SessionManager] Resuming Codex panel ${panel.id} with captured session ${customState.agentSessionId}`);
} else {
console.warn(`[SessionManager] Codex panel ${panel.id} has no captured session id; opening interactive resume picker`);
}
} else {
continue;
}

customState.wasInterrupted = undefined;
state.customState = customState;

Expand All @@ -1844,7 +1870,7 @@ export class SessionManager extends EventEmitter {
const reloadedPanel = this.db.getPanel(panel.id);
if (reloadedPanel) {
await terminalPanelManager.initializeTerminal(reloadedPanel, worktreePath);
console.log(`[SessionManager] Resumed terminal panel ${panel.id} with claude --resume ${panel.id}`);
console.log(`[SessionManager] Resumed terminal panel ${panel.id} with ${customState.initialCommand}`);
resumedPanelCount++;
}
}
Expand All @@ -1863,4 +1889,11 @@ export class SessionManager extends EventEmitter {
}
console.log(`[SessionManager] Dismissed ${sessionIds.length} interrupted sessions`);
}

private getTerminalAgentType(command?: string): TerminalPanelState['agentType'] | undefined {
const lower = command?.toLowerCase() ?? '';
if (lower.includes('claude')) return 'claude';
if (lower.includes('codex')) return 'codex';
return undefined;
}
}
68 changes: 63 additions & 5 deletions main/src/services/terminalPanelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const IDLE_THRESHOLD_MS = 30_000; // 30s — mark panel idle after no PTY output
const MAX_SCROLLBACK_BUFFER_SIZE = 500_000; // 500KB of normal shell history
const MAX_ALTERNATE_SCREEN_BUFFER_SIZE = 100_000; // 100KB of recent TUI redraw state

type CliAgentType = NonNullable<TerminalPanelState['agentType']>;

/**
* IPty-compatible shim over a ptyHost `PtyHandle`.
*
Expand Down Expand Up @@ -149,6 +151,8 @@ interface TerminalProcess {
idleTimer: ReturnType<typeof setTimeout> | null;
// DEC Mode 2026 synchronized-output block tracking — persists across chunks
inSyncBlock: boolean;
codexAgentSessionId?: string;
codexResumeOutputBuffer: string;
}

export class TerminalPanelManager {
Expand All @@ -161,6 +165,54 @@ export class TerminalPanelManager {
private activeSpawns = 0;
private spawnQueue: Array<{ resolve: () => void; priority: number }> = [];

private getCliAgentType(command?: string): CliAgentType | undefined {
const lower = command?.toLowerCase() ?? '';
if (lower.includes('claude')) return 'claude';
if (lower.includes('codex')) return 'codex';
return undefined;
}

private stripAnsiSequences(output: string): string {
// eslint-disable-next-line no-control-regex
return output.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g, '');
}

private extractCodexResumeId(output: string): string | undefined {
const clean = this.stripAnsiSequences(output);
const match = clean.match(/\bcodex\s+resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i);
return match?.[1];
}

private captureCodexSessionId(terminal: TerminalProcess, output: string): void {
terminal.codexResumeOutputBuffer = this.trimAnsiSafe(
terminal.codexResumeOutputBuffer + output,
2000
);

const agentSessionId = this.extractCodexResumeId(terminal.codexResumeOutputBuffer);
if (!agentSessionId) return;

const panel = panelManager.getPanel(terminal.panelId);
if (!panel) return;

const customState = (panel.state.customState || {}) as TerminalPanelState;
const agentType = customState.agentType ?? this.getCliAgentType(customState.initialCommand);
if (agentType !== 'codex' || customState.agentSessionId === agentSessionId) return;

terminal.codexAgentSessionId = agentSessionId;

panel.state.customState = {
...customState,
agentType: 'codex',
agentSessionId
} as TerminalPanelState;

void panelManager.updatePanel(terminal.panelId, { state: panel.state }).catch(error => {
console.warn(`[TerminalPanelManager] Failed to persist Codex session id for panel ${terminal.panelId}:`, error);
});
console.log(`[TerminalPanelManager] Captured Codex session id for panel ${terminal.panelId}: ${agentSessionId}`);
}

setAnalyticsManager(analyticsManager: AnalyticsManager): void {
this.analyticsManager = analyticsManager;
}
Expand Down Expand Up @@ -557,7 +609,8 @@ export class TerminalPanelManager {
isAlternateScreen: false,
activityStatus: 'idle',
idleTimer: null,
inSyncBlock: false
inSyncBlock: false,
codexResumeOutputBuffer: ''
};

// Store in map (ptyHost path: pid is already populated on the shim).
Expand All @@ -584,22 +637,23 @@ export class TerminalPanelManager {
let commandToRun: string | undefined;
if (initialCommand) {
commandToRun = initialCommand;
const cliAgentType = this.getCliAgentType(initialCommand);

// Mark CLI tool panels so the frontend can show an init overlay
const isCliCommand = initialCommand.toLowerCase().includes('claude') ||
initialCommand.toLowerCase().includes('codex');
const isCliCommand = cliAgentType !== undefined;
if (isCliCommand) {
const cliState = panel.state;
const cliCs = (cliState.customState || {}) as TerminalPanelState;
cliCs.isCliPanel = true;
cliCs.isCliReady = false; // Reset on (re-)launch so the overlay shows for fresh CLI processes
cliCs.agentType = cliAgentType;
cliState.customState = cliCs;
// Will be persisted below — either by the claude-specific block or the explicit call after it
}

// If this is a Claude CLI command, inject --session-id or --resume
if (
initialCommand.toLowerCase().includes('claude') &&
cliAgentType === 'claude' &&
!initialCommand.includes('--session-id') &&
!initialCommand.includes('--resume')
) {
Expand Down Expand Up @@ -801,6 +855,7 @@ export class TerminalPanelManager {

// Strip \x1b[2J inside DEC 2026 sync blocks before xterm.js sees the data
const filtered = this.filterSyncBlockClears(terminal, data);
this.captureCodexSessionId(terminal, filtered);

// Keep TUI redraw traffic separate from durable shell scrollback. Full-screen
// apps emit high-volume cursor/clear sequences that are useful only as a
Expand Down Expand Up @@ -1020,7 +1075,10 @@ export class TerminalPanelManager {
commandHistory: terminal.commandHistory.slice(-100), // Keep last 100 commands
lastActivityTime: terminal.lastActivity.toISOString(),
lastActiveCommand: terminal.currentCommand,
serializedBuffer: this.serializedBuffers.get(panelId)
serializedBuffer: this.serializedBuffers.get(panelId),
...(terminal.codexAgentSessionId
? { agentType: 'codex' as const, agentSessionId: terminal.codexAgentSessionId }
: {})
} as TerminalPanelState;

await panelManager.updatePanel(panelId, { state });
Expand Down
2 changes: 2 additions & 0 deletions shared/types/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface TerminalPanelState {
// Auto-resume state (for graceful shutdown/restart)
wasInterrupted?: boolean; // Whether this terminal was active when app shutdown occurred
hasClaudeSessionId?: boolean; // Whether --session-id was already passed to Claude (use --resume next time)
agentType?: 'claude' | 'codex'; // CLI agent type for panel-local resume behavior
agentSessionId?: string; // Agent-generated session ID for resuming conversations

// CLI tool init state
isCliPanel?: boolean; // True if this terminal runs a CLI tool (claude/codex)
Expand Down
Loading