fix(session): announce when a fresh or re-attached agent pane goes idle

A fresh codex, pi or opencode tile could spin "working" forever while the
pane sat at its composer. The external-CLI launch timer set the status to
idle WITHOUT an event, only `needsRefresh`. When the launch paint never
marked the pane working, the later idle confirmation found the status
already idle and announced nothing either, so every open browser kept the
`busy` from the spawn broadcast. A reload fixed it, which is why only open
pages were stuck. Measured on the 1.36.0 beta: w8 (codex) had
`lastPromptTime: 0`, i.e. no idle edge ever, and a page held a fresh pi
session at `busy` while GET /api/sessions said `idle`. pi and opencode hit
it on every launch (no work detection, so the timer is all they have);
codex only when its launch paint lost the race against the timer.

- `_concludeIdle()` is now the one place a pane is concluded idle: status,
  working flag and prompt stamp change together and `idle` is emitted
  (session:idle + state broadcast). `_confirmIdle()` uses it too.
- The launch settle (`_settlePaneStartup`) concludes a pane still in its
  spawn-time `busy`. A pane already working is left to `_confirmIdle()`
  only when its CLI declares `capabilities.workDetect`; for the rest the
  timer is the only thing that can settle it, so it also clears a working
  flag a launch spinner glyph latched.
- `_armPaneSettle()` also arms it for a RESTORED pane (Codeman restart,
  auto-reattach, tile Attach) of a CLI without work detection (at this
  commit shell, opencode, gemini, antigravity, pi, grok, deepseek and omp),
  which used to stay `busy` server-side with nothing to clear it. Restored claude and
  codex panes stay on their composer glyph, so a restart mid-turn is never
  called idle. No `needsRefresh` there: an attach refetches by itself.

Pre-existing since the OpenCode integration, not a regression of this
release. Restore-path gap reported by the opencode and pi sessions working
the same symptom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 01:49:04 +02:00
parent 09d3b9a3bf
commit 40560aced1
2 changed files with 236 additions and 15 deletions
+66 -15
View File
@@ -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');
}
/**
+170
View File
@@ -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;