fix(opencode): read opencode's composer bar and footer spinner so a turn can end

An opencode session that ran a tool stayed "working" for good. The running
tool row draws a braille spinner (`⠋ Sleep for 12 seconds...`), which trips
the generic SPINNER_PATTERN and marks the pane working, but only a composer
glyph arms the idle confirmation and opencode declared none, so it fell back
to Claude's `❯`, which opencode never draws. Measured on an isolated
instance: a 16 s turn latched busy/isWorking for the rest of the session.
A text-only turn had the opposite problem and never showed as working.

Measured on live opencode 1.3.0 panes (capture-pane every 250-300 ms through
real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream):

- Every composer row starts with a `┃` bar, and the submitted prompt lands in
  the transcript with the same bar, so a turn's first repaint arms the idle
  check, and tmux's reattach repaint does the same for a restored pane.
- While a turn runs the footer row starts with an 8-cell knight-rider
  spinner, `⬝■■■■■■⬝  esc interrupt`, redrawn about every 40 ms (largest
  gap mid-turn: 121 ms). At rest the TUI is silent and nothing on screen
  draws a `⬝`/`■` run, the 200-column sidebar included.
- The working line is the spinner run, `[⬝■]{8}`, not the label: tmux ships
  `esc` and `interrupt` as separate words joined by cursor moves, so the
  label never reaches the stream detector, and at 40 columns the footer
  wraps it to `esc` / `interr` / `upt`. All 344 spinner chunks of a turn
  match the run after Codeman's ANSI strip.
- A pending permission prompt replaces the composer and stops the spinner,
  so it reads as idle (waiting on the user).
- The last `┃` row on screen is the composer's agent/model row, or the
  permission box's closing bar, never the prompt text, so the submit
  verifier stands down and can never press Enter into a dialog.

Verified on an isolated instance from this branch: a 15 s tool turn emitted
exactly one session:working (+271 ms) and one session:idle (3 s after the
spinner stopped); a permission prompt read idle and the allowed turn went
working -> idle; after a server restart both restored opencode panes (one
at rest, one on a permission prompt) went busy -> idle in about 4 s; a
fresh launch reached an open page as idle in 3 s.

The launch-settle tests that used opencode as their example of a CLI
without work detection now use gemini and antigravity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 02:40:04 +02:00
parent db168cdbe5
commit e7d661158b
6 changed files with 229 additions and 11 deletions
+1 -1
View File
@@ -266,7 +266,7 @@ Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit
So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
+2
View File
@@ -920,6 +920,8 @@ const mode = cmd.includes('opencode') ? 'opencode' : 'claude';
> **DEFERRED**: This entire phase (except `waitForOpenCodeReady()`) is out of MVP scope. Idle detection, ANSI content filter, working/busy state tracking, and token parsing are all deferred until we have real PTY output data from stable OpenCode sessions. Only the basic TUI ready detection from `waitForOpenCodeReady()` is needed for the MVP and is included in Phase 3.
> **Update 2026-10-09 (working/idle shipped, from measured data):** the registry entry now declares `capabilities.workDetect` for opencode, measured on a live opencode 1.3.0 pane (pane captures every 250-300 ms through real turns at 40, 60, 120 and 200 columns, plus the raw PTY stream). Every composer row starts with a `┃` bar, which arms the shared screen-probed idle check; a running turn puts an 8-cell knight-rider spinner (`⬝■■■■■■⬝ esc interrupt`) at the head of the footer row, redrawn about every 40 ms, and `[⬝■]{8}` is the working line. The label is not the anchor: tmux ships `esc` and `interrupt` as separate words joined by cursor moves, and below about 45 columns the footer wraps it. At rest the TUI is silent (no cursor or timer redraws), and a pending permission prompt replaces the composer and stops the spinner, so it reads as idle. Before this, a turn that ran a tool latched the session `busy` for good (the tool row's braille spinner tripped the generic spinner detector, and nothing ever armed the idle check). Token parsing and the ANSI content filter remain deferred.
### Goal
Detect OpenCode's state from terminal output (idle, working, ready).
+6 -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 and Pi: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Precise idle detection | Yes | Codex, Pi and OpenCode: 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 |
@@ -121,6 +121,11 @@ Renders its own TUI, so Codeman treats readiness as output stabilization rather
watching for a prompt marker. Requires tmux, with no direct-PTY fallback, because its
environment is injected through socket-scoped `tmux setenv` rather than the command line.
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
session from an idle one. A pending permission prompt shows as idle, since it is waiting on
you. Before 1.36.0 an OpenCode session that had run a tool showed as working for good.
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
### Codex
+21
View File
@@ -493,6 +493,27 @@ const OPENCODE: CliEntry = {
...agentDefaults(),
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined },
// Measured on a live opencode 1.3.0 pane (capture-pane every 250-300 ms through real
// turns at 40, 60, 120 and 200 columns, plus the raw PTY stream, 2026-10-09). Every
// composer row starts with a `┃` bar, and the submitted prompt lands in the transcript
// with the same bar, so a turn's first repaint arms the idle confirmation and tmux's
// reattach repaint does the same for a restored pane. While a turn runs the footer row
// starts with an 8-cell knight-rider spinner, `⬝■■■■■■⬝ esc interrupt`, redrawn about
// every 40 ms (never a 2.5 s gap mid-turn, so silence cannot end one early); at rest the
// row holds only the key hints and nothing on screen draws a `⬝`/`■` run, the wide
// layout's sidebar included. The working line is the spinner run, not the label: tmux
// ships `esc` and `interrupt` as separate words joined by cursor moves, and below about
// 45 columns the footer wraps the label itself. A pending permission prompt replaces
// the composer and stops the spinner, so it reads as idle (waiting on the user).
// ⚠️ Without this entry an opencode session latched `busy` after any turn that ran a
// tool: the braille spinner on a running tool row trips SPINNER_PATTERN, and opencode
// never draws Claude's `❯`, the fallback that would have armed the idle check. The
// last `┃` row on screen is the composer's agent/model row (or the permission box's
// closing bar), never the prompt text, so the submit verifier stands down.
workDetect: {
promptGlyph: '┃',
workingLine: '[⬝■]{8}',
},
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
mcpConfig: {
path: '.config/opencode/opencode.json',
+1 -1
View File
@@ -4485,7 +4485,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, codex and pi declare a promptGlyph; the other modes would fall back
// Only claude, codex, pi and opencode 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
+198 -8
View File
@@ -1,5 +1,5 @@
/**
* Working/idle detection for an interactive agent pane, Claude's, Codex's and pi's.
* Working/idle detection for an interactive agent pane, Claude's, Codex's, pi's and opencode'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,7 +13,8 @@
* 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, Codex CLI 0.152.1 and pi 1.1.0.
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220, Codex CLI 0.152.1, pi 1.1.0
* and opencode 1.3.0.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
@@ -46,7 +47,10 @@ 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' | 'pi' = 'claude'): Session {
function withFakePane(
screen: string | (() => string),
mode: 'claude' | 'codex' | 'pi' | 'opencode' = 'claude'
): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
isAvailable: () => true,
@@ -88,6 +92,53 @@ const PI_SPINNER_FRAME = `\x1b[35;1H\x1b(B\x1b[m\x1b[A\x1b[K\x1b[95m── ⠼\x
/** 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}`;
/**
* opencode's pane, verbatim from live opencode 1.3.0 captures (rows shortened). Every
* composer row starts with a `┃` bar; a running turn puts an 8-cell spinner at the head
* of the footer row, which is plain key hints at rest.
*/
const OC_RULE = '▀'.repeat(48);
const OC_COMPOSER = ` ┃\n ┃\n ┃\n ┃ Build qwen3.8-27b Qwen 5090\n ╹${OC_RULE}\n`;
const OC_WORKING =
' ┃\n ┃ $ sleep 12; echo done\n ┃\n ▣ Build · qwen3.8-27b\n' +
`${OC_COMPOSER} ■⬝⬝⬝⬝⬝⬝⬝ esc interrupt tab agents ctrl+p commands\n`;
const OC_AT_REST =
' It is commonly used to insert deliberate delays between automated tasks.\n\n' +
` ▣ Build · qwen3.8-27b · 16.3s\n${OC_COMPOSER} tab agents ctrl+p commands\n`;
/** The 200-column layout at rest: a right-hand sidebar with the todo list and version. */
const OC_AT_REST_WIDE =
' ┃ [✓] check disk ▼ Todo\n' +
' ┃ [✓] check memory [✓] check disk\n' +
' ┃ [•] summarize [✓] check memory\n' +
` ▣ Build · qwen3.8-27b · 11.9s [•] summarize\n${OC_COMPOSER}` +
' tab agents ctrl+p commands • OpenCode 1.3.0\n';
/** At 40 columns the footer wraps its own label; the spinner run stays whole. */
const OC_WORKING_PHONE =
` ┃ Build qwen3.8-27b Qwen 5090\n ╹${'▀'.repeat(35)}\n` +
' ⬝⬝⬝⬝⬝⬝⬝⬝ esc tab ctrl+p\n interragents commands\n upt\n';
/** A permission prompt replaces the composer, with its own bars, and stops the spinner. */
const OC_PERMISSION =
' ▣ Build · qwen3.8-27b\n ┃\n ┃ △ Permission required\n' +
' ┃ # Echo permission-check to test bash tool\n ┃\n ┃ $ echo permission-check\n ┃\n ┃\n' +
' ┃ Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm\n ┃\n';
/** One spinner frame on the wire (~40 ms apart through a turn), verbatim. */
const OC_SPINNER_FRAME =
'\x1b[?2026h\x1b[39;4H\x1b[38;2;103;175;249m\x1b[48;2;10;10;10m■\x1b[38;2;92;156;245m■' +
'\x1b[38;2;33;50;75m⬝⬝⬝⬝⬝⬝\x1b[35;6H\x1b(B\x1b[m\x1b[?2026l';
/** A running tool row: its braille spinner is what used to latch the session busy. */
const OC_TOOL_ROW =
'\x1b[18;3H\x1b[38;2;10;10;10m┃\x1b[38;2;255;255;255m \x1b[38;2;128;128;128m⠋\x1b[38;2;255;255;255m ' +
'\x1b[38;2;128;128;128mSleep for 12 seconds then print done';
/** The composer's agent/model row repainted, verbatim. */
const OC_COMPOSER_REPAINT =
'\x1b[37;3H\x1b[38;2;92;156;245m\x1b[48;2;10;10;10m┃\x1b[38;2;255;255;255m\x1b[48;2;30;30;30m ' +
'\x1b[38;2;92;156;245mBuild \x1b[38;2;255;255;255m \x1b[38;2;238;238;238mqwen3.8-27b\x1b[38;2;255;255;255m ' +
'\x1b[38;2;128;128;128mQwen 5090';
/** The turn's last chunk, verbatim: it blanks the spinner and carries no `┃`. */
const OC_TURN_END =
'\x1b[?2026h\x1b[32;28H\x1b[38;2;128;128;128m\x1b[48;2;10;10;10m · 16.3s\x1b[39;4H\x1b[38;2;255;255;255m ' +
'\x1b[2C \x1b[35;6H\x1b(B\x1b[m\x1b[?2026l';
/** 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';
@@ -417,8 +468,8 @@ describe('external CLI launch settle', () => {
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' });
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
const session = new Session({ workingDir: '/tmp', mode: 'gemini' });
const events = launch(session);
feed(session, '\x1b[5;3H⠋ Loading');
@@ -460,7 +511,7 @@ describe('external CLI launch settle', () => {
it('still settles a prompted CLI without work detection, which has nothing else to settle it', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'opencode' });
const session = new Session({ workingDir: '/tmp', mode: 'gemini' });
const events = launch(session);
vi.advanceTimersByTime(2000);
@@ -473,8 +524,8 @@ describe('external CLI launch settle', () => {
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) {
// which leaves it busy; gemini and antigravity have no glyph that would ever clear that.
for (const mode of ['gemini', 'antigravity', 'shell'] as const) {
expect(getCli(mode)?.capabilities.workDetect).toBeUndefined();
const session = new Session({ workingDir: '/tmp', mode });
const events = launch(session, true);
@@ -623,6 +674,145 @@ describe('pi interactive idle detection', () => {
});
});
describe("opencode's work-detection descriptor", () => {
const oc = getCli('opencode')?.capabilities.workDetect;
it('matches the spinner at the head of the footer while a turn runs, at any width', () => {
expect(new RegExp(oc!.workingLine).test(OC_WORKING)).toBe(true);
// Below ~45 columns the footer wraps `esc interrupt`, which is why the label is not the anchor.
expect(new RegExp(oc!.workingLine).test(OC_WORKING_PHONE)).toBe(true);
// The stream detector reads the ANSI-stripped chunk.
expect(new RegExp(oc!.workingLine).test(stripAnsi(OC_SPINNER_FRAME))).toBe(true);
});
it('does not match a pane at rest, the wide sidebar layout or a pending permission prompt', () => {
expect(new RegExp(oc!.workingLine).test(OC_AT_REST)).toBe(false);
expect(new RegExp(oc!.workingLine).test(OC_AT_REST_WIDE)).toBe(false);
expect(new RegExp(oc!.workingLine).test(OC_PERMISSION)).toBe(false);
});
it('names the bar the composer, the transcript and a running tool row are drawn with', () => {
expect(OC_COMPOSER_REPAINT).toContain(oc!.promptGlyph);
expect(OC_TOOL_ROW).toContain(oc!.promptGlyph);
expect(OC_AT_REST).toContain(oc!.promptGlyph);
});
it('leaves the submit verifier unable to press Enter on an opencode pane', () => {
// The last `┃` row is the composer's agent/model row, or the permission box's closing
// bar: never the prompt text, so the verifier stands down rather than re-pressing Enter.
const typed = OC_COMPOSER.replace(' ┃\n ┃\n', ' ┃\n ┃ say ok\n');
expect(promptStillInComposer(typed, 'say ok', oc!.promptGlyph)).toBe(false);
expect(promptStillInComposer(OC_AT_REST, 'say ok', oc!.promptGlyph)).toBe(false);
expect(promptStillInComposer(OC_WORKING, 'sleep 12', oc!.promptGlyph)).toBe(false);
expect(promptStillInComposer(OC_PERMISSION, 'Echo permission-check', oc!.promptGlyph)).toBe(false);
});
});
describe('opencode interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
/** A turn's worth of spinner frames, 40 ms apart, as opencode ships them. */
function spin(session: Session, ms: number): void {
for (let t = 0; t < ms; t += 40) {
feed(session, OC_SPINNER_FRAME);
vi.advanceTimersByTime(40);
}
}
it('lets a turn that ran a tool end, instead of latching busy', () => {
vi.useFakeTimers();
let screen = OC_WORKING;
const session = withFakePane(() => screen, 'opencode');
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
// The tool row's braille marks the pane working; its bar arms the idle check, which
// must not end the turn while the spinner still animates.
feed(session, OC_TOOL_ROW);
spin(session, 8000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
// Before opencode declared its bar and spinner, nothing ever armed the idle check
// (opencode never draws `❯`), so this session stayed busy for good.
screen = OC_AT_REST;
feed(session, OC_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('marks a text-only turn working off the spinner, with no braille anywhere', () => {
vi.useFakeTimers();
let screen = OC_WORKING;
const session = withFakePane(() => screen, 'opencode');
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
feed(session, OC_COMPOSER_REPAINT);
spin(session, 4000);
expect(events).toEqual(['working']);
screen = OC_AT_REST;
feed(session, OC_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
});
it('reads a pending permission prompt as idle, and ends the resumed turn too', () => {
vi.useFakeTimers();
let screen = OC_WORKING;
const session = withFakePane(() => screen, 'opencode');
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
feed(session, OC_TOOL_ROW);
spin(session, 3000);
// The prompt replaces the composer and the pane goes silent: waiting on the user.
screen = OC_PERMISSION;
feed(session, '\x1b[30;3H\x1b[38;2;250;178;131m┃\x1b[38;2;255;255;255m △ Permission required');
vi.advanceTimersByTime(10_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
// Allowed: the composer comes back (its bar re-arms the check) and the spinner resumes.
screen = OC_WORKING;
feed(session, OC_COMPOSER_REPAINT);
spin(session, 4000);
expect(events).toEqual(['working', 'idle', 'working']);
screen = OC_AT_REST;
feed(session, OC_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle', 'working', 'idle']);
expect(session.status).toBe('idle');
});
it('settles a reattached opencode pane that is at rest', () => {
vi.useFakeTimers();
// A restored pane starts in the `busy` that startInteractive() sets and, now that
// opencode declares work detection, gets no launch timer: tmux's reattach repaint
// carries the composer's bar, and that is what has to bring it to idle.
const session = withFakePane(OC_AT_REST, 'opencode');
(session as unknown as { _status: string })._status = 'busy';
const events: string[] = [];
session.on('idle', () => events.push('idle'));
feed(session, OC_COMPOSER_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