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
+7 -3
View File
@@ -223,9 +223,13 @@ command override instead.
## Known gaps
- **No idle/completion hook.** Pi has no hook system Codeman can install into, so
idle detection falls back to output-stabilization like the other external CLIs.
Pi 0.84.0 shipped an `agent_settled` extension event that is a genuine idle
signal; a Codeman pi extension using it is the highest-value follow-up.
idle detection reads the screen: the registry entry's `workDetect` names pi's
composer rule (`─`) as the glyph that arms the idle check and the spinner pi embeds
in that rule while a turn runs (`── ⠏ Working ───`) as the working line, measured
on pi 1.1.0. Without it a pi session that had started a turn never left `busy`,
since pi never draws Claude's `❯`. Pi 0.84.0 shipped an `agent_settled` extension
event that is a genuine idle signal; a Codeman pi extension using it is still the
highest-value follow-up.
- **No response viewer.** Pi writes JSONL v3 session files under
`~/.pi/agent/sessions/`; nothing reads them yet.
- **Cron jobs mis-detect readiness.** The cron readiness poll looks for `❯` or a
+5 -1
View File
@@ -69,7 +69,7 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes |
| Precise idle detection | Yes | Codex: same screen check, via its own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Precise idle detection | Yes | Codex and Pi: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek yes; others no |
@@ -169,6 +169,10 @@ Pi needs the opposite instincts from every other CLI here.
`HF_TOKEN`, and so on) share no common prefix, and the environment allowlist is global
rather than per mode, so admitting them for Pi would widen the allowlist for every mode at
once. They stay out.
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
that to tell working from idle. Before 1.36.0 a Pi session that had started a turn showed
as working for good.
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
+14
View File
@@ -935,6 +935,20 @@ const PI: CliEntry = {
...agentDefaults(),
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
// while a turn runs it embeds its status in the TOP rule as `── ⠏ Working ───…`, the
// braille frame animating every ~80 ms; at rest both rules are plain `─`. So the rule
// is the glyph that arms the idle confirmation, and a spinner frame inside it is the
// working line (the frame, not the word: an extension can replace "Working").
// ⚠️ Without this entry a pi session never left `busy` once marked working: the
// braille spinner trips SPINNER_PATTERN, and pi never draws Claude's `❯`, the
// fallback that would have armed the idle check. The rules carry no prompt text, so
// the submit verifier reading them stands down instead of re-pressing Enter.
workDetect: {
promptGlyph: '─',
workingLine: '── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] ',
},
// pi's absent-config default is an interactive trust PROMPT the session user could
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
+1 -1
View File
@@ -4479,7 +4479,7 @@ export class Session extends EventEmitter {
: this._mux.capturePaneText?.(this._muxSession.muxName),
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
// Only claude and codex declare a promptGlyph; the other eight modes would fall back
// Only claude, codex and pi declare a promptGlyph; the other modes would fall back
// to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and
// spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits
// on screen for as long as the command runs, promptStillInComposer() reads that as
+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