mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user