diff --git a/src/session.ts b/src/session.ts index 49d42903..52d308c7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2783,19 +2783,14 @@ export class Session extends EventEmitter { this.id; // For NEW mux sessions: wait for readiness then clean buffer - // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch + // For RESTORED mux sessions: leave the buffer alone - client will fetch it on tab switch if (!isRestored) { if (isExternalCliMode(this.mode)) { // External CLIs use custom TUIs — no ❯ prompt to detect. // Wait for TUI to stabilize (output stops changing), then mark ready. // Don't clear the buffer — the TUI's initial render IS the useful content. // Emit needsRefresh so the client fetches the full buffer once the TUI has rendered. - this._promptCheckTimeout = setTimeout(() => { - this._promptCheckTimeout = null; - if (this._isStopped) return; - this._status = 'idle'; - this.emit('needsRefresh'); - }, 3000); + this._armPaneSettle(false); } else { // Claude mode: wait for ❯ prompt this._promptCheckInterval = setInterval(() => { @@ -2827,6 +2822,8 @@ export class Session extends EventEmitter { this._promptCheckTimeout = null; }, 5000); } + } else { + this._armPaneSettle(true); } } catch (err) { console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); @@ -3433,14 +3430,68 @@ export class Session extends EventEmitter { // 1. Claude was working and is now at prompt (normal case) // 2. Session just started and is ready (status is 'busy' but _isWorking is false) const wasWorking = this._isWorking; - const isInitialReady = this._status === 'busy' && !this._isWorking; - if (wasWorking || isInitialReady) { - this._isWorking = false; - this._status = 'idle'; - this._lastPromptTime = Date.now(); - if (wasWorking) this._maybeCaptureOmpSessionId(); - this.emit('idle'); - } + if (wasWorking || this._status === 'busy') this._concludeIdle(wasWorking); + } + + /** + * The one place a pane is concluded idle: status, working flag and prompt stamp + * change together, and the change is ANNOUNCED. The `idle` event is what the web + * server turns into `session:idle` plus a state broadcast, so a path that flips + * `_status` without it leaves every browser on the `busy` it was last sent. A fresh + * codex pane used to stay "working" in the UI for its whole life that way. + * + * @param turnEnded a real turn just finished (not a pane becoming ready at launch) + */ + private _concludeIdle(turnEnded: boolean): void { + this._isWorking = false; + this._status = 'idle'; + this._lastPromptTime = Date.now(); + // Only a finished turn proves omp has written its session file; a pane that is + // merely ready has nothing to resolve yet and could claim a neighbour's file. + if (turnEnded) this._maybeCaptureOmpSessionId(); + this.emit('idle'); + } + + /** + * Arm the launch settle (`_settlePaneStartup`) for a pane `startInteractive()` just + * started or re-attached, when one applies: + * - a NEW pane of an external CLI, whose TUI has no ❯ for the Claude wait to find; + * - a RESTORED pane (Codeman restart, auto-reattach, tile Attach) of a CLI that + * declares no `capabilities.workDetect`. It is `busy` from `_resetBuffers()` like a + * new pane, and with no composer glyph to arm `_confirmIdle()` nothing else would + * ever settle it. A restored claude or codex pane is left to its glyph, which + * reads the screen first, so a restart in mid-turn is never called idle. + */ + private _armPaneSettle(isRestored: boolean): void { + const applies = isRestored ? !getCli(this.mode)?.capabilities.workDetect : isExternalCliMode(this.mode); + if (!applies) return; + this._promptCheckTimeout = setTimeout(() => this._settlePaneStartup(!isRestored), 3000); + } + + /** + * The launch settle: 3 s after a NEW external-CLI pane spawned, or after ANY pane of a + * CLI without work detection was re-attached, its TUI is taken to have rendered. A pane + * still in its spawn-time `busy` is concluded idle (announced, see `_concludeIdle`), + * then, for a new pane, the browser is told to refetch the rendered screen. + * + * ⚠️ This used to set `_status = 'idle'` without an event. When the launch paint + * never tripped `_markWorking()`, the later `_confirmIdle()` found the status + * already idle and emitted nothing, so no browser ever learned the pane was ready. + * + * A pane marked working by then is left alone only when its CLI declares + * `capabilities.workDetect`: that CLI's composer glyph arms `_confirmIdle()`, which + * reads the screen and ends the turn properly. For every other CLI this timer is the + * only thing that ever settles a fresh pane, so it settles it even if a stray spinner + * glyph in the launch paint latched `_isWorking`. + * + * @param refreshScreen emit `needsRefresh` (a new pane; an attach refetches by itself) + */ + private _settlePaneStartup(refreshScreen: boolean): void { + this._promptCheckTimeout = null; + if (this._isStopped) return; + const leaveToConfirm = this._isWorking && !!getCli(this.mode)?.capabilities.workDetect; + if (this._status === 'busy' && !leaveToConfirm) this._concludeIdle(false); + if (refreshScreen) this.emit('needsRefresh'); } /** diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts index 6e4b1ab2..4ee2d04f 100644 --- a/test/session-activity.test.ts +++ b/test/session-activity.test.ts @@ -296,6 +296,176 @@ describe('Session interactive idle detection', () => { }); }); +/** + * The launch settle (`_armPaneSettle` / `_settlePaneStartup`, 3 s after `startInteractive()` + * started a pane). The bug this pins: it set the status to idle WITHOUT an event, and when + * the launch paint never marked the pane working, the later idle confirmation found the + * status already idle and announced nothing either. The browser kept the `busy` the spawn + * broadcast, so a fresh codex, pi or opencode tile spun "working" for as long as it sat at + * its composer (measured on the 1.36.0 beta: `lastPromptTime: 0`, never an idle edge). + * A RESTORED pane of a CLI without work detection never got the settle at all. + */ +describe('external CLI launch settle', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + type LaunchInternals = { _resetBuffers(): void; _armPaneSettle(isRestored: boolean): void }; + + /** Codex at its composer after launch, verbatim from the beta pane that showed the bug. */ + const CODEX_READY = + ' >_ OpenAI Codex (v0.162.0)\n ~/codeman-cases/testcase\n› Ask Codex to do anything\n' + + ' GPT-6-Luna default · ~/codeman-cases/testcase\n ? for shortcuts\n'; + + /** A session as `startInteractive()` leaves it: spawn-time `busy`, settle armed if it applies. */ + function launch(session: Session, isRestored = false): string[] { + const internals = session as unknown as LaunchInternals; + const events: string[] = []; + session.on('idle', () => events.push('idle')); + session.on('working', () => events.push('working')); + session.on('needsRefresh', () => events.push('needsRefresh')); + internals._resetBuffers(); + internals._armPaneSettle(isRestored); + return events; + } + + it('announces a codex pane that paints its composer and goes quiet after the timer', () => { + vi.useFakeTimers(); + const session = withFakePane(CODEX_READY, 'codex'); + const events = launch(session); + + // The composer arms the confirmation, but a second paint keeps the pane + // from going quiet until after the 3 s timer has fired. + feed(session, CODEX_COMPOSER_REPAINT); + vi.advanceTimersByTime(600); + feed(session, '\x1b[33;3H\x1b[2m? for shortcuts\x1b[0m'); + vi.advanceTimersByTime(60_000); + + expect(events).toEqual(['idle', 'needsRefresh']); + expect(session.status).toBe('idle'); + expect(session.isWorking).toBe(false); + }); + + it('announces a codex pane whose launch paint never arms the confirmation', () => { + vi.useFakeTimers(); + const session = withFakePane(CODEX_READY, 'codex'); + const events = launch(session); + + feed(session, '\x1b[1;3H>_ OpenAI Codex (v0.162.0)'); + vi.advanceTimersByTime(3000); + + expect(events).toEqual(['idle', 'needsRefresh']); + expect(session.status).toBe('idle'); + + // A composer repaint later on (a tile resize) must not announce it twice. + feed(session, CODEX_COMPOSER_REPAINT); + vi.advanceTimersByTime(60_000); + expect(events).toEqual(['idle', 'needsRefresh']); + }); + + it('does not announce twice when the confirmation already concluded before the timer', () => { + vi.useFakeTimers(); + const session = withFakePane(CODEX_READY, 'codex'); + const events = launch(session); + + feed(session, CODEX_COMPOSER_REPAINT); + vi.advanceTimersByTime(60_000); + + expect(events).toEqual(['idle', 'needsRefresh']); + expect(session.status).toBe('idle'); + }); + + it('leaves a codex pane that is already working to its own confirmation', () => { + vi.useFakeTimers(); + let screen = CODEX_WORKING; + const session = withFakePane(() => screen, 'codex'); + const events = launch(session); + + for (let i = 0; i < 4; i++) { + feed(session, CODEX_COMPOSER_REPAINT); + vi.advanceTimersByTime(1000); + } + vi.advanceTimersByTime(10_000); + + // The timer fired mid-turn and did not call the turn over. + expect(events).toEqual(['working', 'needsRefresh']); + expect(session.status).toBe('busy'); + + screen = CODEX_FINISHED; + vi.advanceTimersByTime(20_000); + expect(events).toEqual(['working', 'needsRefresh', 'idle']); + expect(session.status).toBe('idle'); + }); + + it('settles a CLI without work detection even when a launch spinner latched it working', () => { + vi.useFakeTimers(); + // Nothing arms an idle confirmation for a CLI that names no composer glyph, so + // the launch timer is the only thing that can ever settle this pane. + expect(getCli('opencode')?.capabilities.workDetect).toBeUndefined(); + const session = new Session({ workingDir: '/tmp', mode: 'opencode' }); + const events = launch(session); + + feed(session, '\x1b[5;3H⠋ Loading'); + expect(session.isWorking).toBe(true); + vi.advanceTimersByTime(3000); + + expect(events).toEqual(['working', 'idle', 'needsRefresh']); + expect(session.status).toBe('idle'); + expect(session.isWorking).toBe(false); + }); + + it('settles a RESTORED pane of a CLI without work detection, without a refetch', () => { + vi.useFakeTimers(); + // A Codeman restart re-attaches every surviving pane through startInteractive(), + // which leaves it busy; opencode and gemini have no glyph that would ever clear that. + for (const mode of ['opencode', 'gemini', 'shell'] as const) { + expect(getCli(mode)?.capabilities.workDetect).toBeUndefined(); + const session = new Session({ workingDir: '/tmp', mode }); + const events = launch(session, true); + expect(session.status).toBe('busy'); + + vi.advanceTimersByTime(3000); + + expect(events).toEqual(['idle']); + expect(session.status).toBe('idle'); + } + }); + + it('leaves a RESTORED claude or codex pane to its own glyph, so a restart mid-turn is not called idle', () => { + vi.useFakeTimers(); + for (const mode of ['claude', 'codex'] as const) { + const session = new Session({ workingDir: '/tmp', mode }); + const events = launch(session, true); + + vi.advanceTimersByTime(3000); + + expect(events).toEqual([]); + expect(session.status).toBe('busy'); + } + }); + + it('arms nothing for a NEW claude pane, which waits for its ❯ instead', () => { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp', mode: 'claude' }); + const events = launch(session); + + vi.advanceTimersByTime(3000); + + expect(events).toEqual([]); + }); + + it('does nothing for a session stopped before the timer', () => { + vi.useFakeTimers(); + const session = withFakePane(CODEX_READY, 'codex'); + const events = launch(session); + (session as unknown as { _isStopped: boolean })._isStopped = true; + + vi.advanceTimersByTime(3000); + + expect(events).toEqual([]); + }); +}); + describe("codex's work-detection descriptor", () => { const codex = getCli('codex')?.capabilities.workDetect;