fix(pi): read pi's composer rule so a pi turn can end

A pi session stayed "working" for good once a turn started. pi's braille
spinner trips the SPINNER_PATTERN fast path, which marks the pane working,
but only a composer glyph arms the idle confirmation and pi declared none,
so it fell back to Claude's `❯`, which pi never draws. Measured on beta136:
an errored turn stayed busy/isWorking for 3+ minutes after pi was back at
rest.

pi has no composer glyph. Measured on a live pi 1.1.0 pane (capture-pane
every 250 ms through a turn): its composer sits between two `─` rules, and
while a turn runs it rewrites the top rule as `── ⠏ Working ───` on every
frame. The registry entry now names the rule as the glyph that arms the
check and a spinner frame inside it as the working line.

The same glyph settles a reattached pi pane (a restored pane gets no launch
timer): tmux's reattach repaint carries `─`, which arms the confirmation.
The submit verifier reads the last rule, finds no prompt text and stands
down, so it can never press Enter on a pi pane.

Verified on an isolated instance from this branch: a real pi turn emitted
session:working then session:idle, and after a server restart the restored
pi pane went busy -> idle in about 3 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 01:49:19 +02:00
parent 40560aced1
commit b539780f33
5 changed files with 121 additions and 8 deletions
+94 -3
View File
@@ -1,5 +1,5 @@
/**
* Working/idle detection for an interactive agent pane, Claude's and Codex's.
* Working/idle detection for an interactive agent pane, Claude's, Codex's and pi's.
*
* The bug this pins: Claude redraws the composer (`❯`) about once a second all
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
@@ -13,12 +13,14 @@
* work exactly as before.
*
* The status-line fixtures below are verbatim captures from live panes
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220 and Codex CLI 0.152.1.
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220, Codex CLI 0.152.1 and pi 1.1.0.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js';
import { stripAnsi } from '../src/utils/index.js';
import { promptStillInComposer } from '../src/session-submit-verifier.js';
import {
trackActivityStreak,
isSustainedActivity,
@@ -44,7 +46,7 @@ function feed(session: Session, data: string): void {
* A session whose mux reports a fixed (or scripted) screen, so the pane probe has
* something to read. Only `capturePaneText` is exercised by these paths.
*/
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' = 'claude'): Session {
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' | 'pi' = 'claude'): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
isAvailable: () => true,
@@ -72,6 +74,20 @@ const CODEX_FINISHED =
/** Codex's own composer repaint, the frame that arms the idle confirmation. */
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
/**
* pi's pane, verbatim from a live pi 1.1.0 capture (rules shortened): no composer glyph,
* the composer sits between two `─` rules, and a running turn puts a braille spinner and
* its status into the TOP rule. At rest both rules are plain.
*/
const PI_RULE = '─'.repeat(48);
const PI_FOOTER = '~/codeman-cases/testcase\n0.8%/253k (auto) qwen3.8-27b-pi • xhigh\n';
const PI_WORKING = ` say ok\n── ⠏ Working ${PI_RULE}\n\n${PI_RULE}\n${PI_FOOTER}`;
const PI_AT_REST = ` Error: Retry failed after 3 attempts: Connection error.\n${PI_RULE}\n\n${PI_RULE}\n${PI_FOOTER}`;
/** One spinner frame on the wire: pi rewrites the whole top rule each time (~80 ms). */
const PI_SPINNER_FRAME = `\x1b[35;1H\x1b(B\x1b[m\x1b[A\x1b[K\x1b[95m── ⠼\x1b[39m \x1b[95mWorking ${PI_RULE}`;
/** The turn's last repaint: the top rule drawn plain again. */
const PI_RULE_REPAINT = `\x1b[35;1H\x1b(B\x1b[m\x1b[A\x1b[K${PI_RULE}`;
/** A composer repaint: the frame Claude ships roughly once a second while working. */
const COMPOSER_REPAINT =
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
@@ -492,6 +508,81 @@ describe("codex's work-detection descriptor", () => {
});
});
describe("pi's work-detection descriptor", () => {
const pi = getCli('pi')?.capabilities.workDetect;
it('matches the spinner pi embeds in its composer rule while a turn runs', () => {
expect(new RegExp(pi!.workingLine).test(PI_WORKING)).toBe(true);
// The stream detector reads the ANSI-stripped chunk.
expect(new RegExp(pi!.workingLine).test(stripAnsi(PI_SPINNER_FRAME))).toBe(true);
});
it('does not match the plain rules of a pane at rest', () => {
expect(new RegExp(pi!.workingLine).test(PI_AT_REST)).toBe(false);
});
it('names a glyph every pi repaint carries, so the idle check can arm', () => {
expect(PI_SPINNER_FRAME).toContain(pi!.promptGlyph);
expect(PI_RULE_REPAINT).toContain(pi!.promptGlyph);
});
it('leaves the submit verifier unable to press Enter on a pi pane', () => {
// The verifier reads the last row starting with the glyph, which for pi is a bare
// rule with no prompt text in it: it must stand down, never report "unsubmitted".
expect(promptStillInComposer(PI_AT_REST, 'say ok', pi!.promptGlyph)).toBe(false);
expect(promptStillInComposer(PI_WORKING, 'say ok', pi!.promptGlyph)).toBe(false);
});
});
describe('pi interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
it('marks a pi turn working, and lets it end', () => {
vi.useFakeTimers();
let screen = PI_WORKING;
const session = withFakePane(() => screen, 'pi');
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
// Eight seconds of spinner frames: every one carries the rule glyph, which must
// not end the turn while the pane is still animating.
for (let i = 0; i < 80; i++) {
feed(session, PI_SPINNER_FRAME);
vi.advanceTimersByTime(100);
}
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
// Turn over. Before pi declared its rule and spinner, nothing ever armed the idle
// check (pi never draws `❯`), so the session stayed busy for good.
screen = PI_AT_REST;
feed(session, PI_RULE_REPAINT);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('settles a reattached pi pane that is at rest', () => {
vi.useFakeTimers();
// A restored pane starts in the `busy` that startInteractive() sets and gets no
// launch timer; the reattach repaint is what has to bring it to idle.
const session = withFakePane(PI_AT_REST, 'pi');
(session as unknown as { _status: string })._status = 'busy';
const events: string[] = [];
session.on('idle', () => events.push('idle'));
feed(session, PI_RULE_REPAINT);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
});
describe('wire activity stamp across recovery', () => {
// The stamp both home screens sort the quiet group on. Recovery restores the
// previous run's value, and the settle window keeps the boot attach repaint