diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index b01f6507..6fa13118 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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. diff --git a/docs/opencode-integration.md b/docs/opencode-integration.md index fd683a7b..31edcf61 100644 --- a/docs/opencode-integration.md +++ b/docs/opencode-integration.md @@ -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). diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index d37265c1..eb72235d 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -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 diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index f5263cae..e1389033 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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', diff --git a/src/session.ts b/src/session.ts index 3654d7d2..9e76e09d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 diff --git a/test/session-activity.test.ts b/test/session-activity.test.ts index 84c20d5f..7d032049 100644 --- a/test/session-activity.test.ts +++ b/test/session-activity.test.ts @@ -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