diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6fa13118..b445f159 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`, 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. +⚠️ **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}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, 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/omp-integration.md b/docs/omp-integration.md index 5cf5cf4c..5729dfd9 100644 --- a/docs/omp-integration.md +++ b/docs/omp-integration.md @@ -163,9 +163,13 @@ remote user's home, so resolving locally would pin a stranger's id. See ## Known gaps -- **No idle/completion hook.** Idle detection falls back to output-stabilization - like every other external CLI. If omp ever ships a hooks system, a Codeman hook - POSTing to `/api/hook-event` would be the highest-value follow-up. +- **No idle/completion hook.** Idle detection reads the screen instead: the + registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms + the idle check, and the status bar's spinner plus elapsed time (` ⠼ 14s > ⬢ …`) + or the `⎋ Working…` row as the working line, measured on omp 18.8.6 and 18.0.11. + Without it an omp session that had started a turn never left `busy`. If omp ever + ships a hooks system, a Codeman hook POSTing to `/api/hook-event` would still be + the highest-value follow-up. - **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session` before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index eb72235d..6fbe5e96 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, Pi and OpenCode: 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, OpenCode and OMP: 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 | @@ -231,7 +231,9 @@ documented default approval mode is `yolo`, so an OMP pane auto-approves tool us flag from Codeman; change that in OMP's own config, not here. OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the -same conversation with `--continue`. +same conversation with `--continue`. Codeman tells working from idle by reading OMP's status +bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.36.0 +an OMP session that had started a turn showed as working for good. Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md). diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index e1389033..f865b097 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1428,6 +1428,20 @@ const OMP: CliEntry = { // makes an omp conversation survive a full session kill. transcript: 'omp-jsonl', echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // Measured on live omp 18.8.6 and 18.0.11 panes (2026-10-09, a turn held open against + // an endpoint that never answers): the input row is `╰─ `, redrawn when a turn + // ends, at launch and on reattach. While a turn runs the status bar's leading `π` + // becomes a braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > 📁 ~/dir ▶──`; + // 18.0.11 pads it with two spaces, past a minute it reads `1m`), and a `⎋ Working…` + // row appears above it. At rest the bar starts ` π > `. + // ⚠️ Without this entry an omp session never left `busy` once marked working, like pi: + // the spinner trips SPINNER_PATTERN and omp never draws Claude's `❯` after setup. + // The glyph also switches the submit verifier on for omp. A prompt sent mid-turn goes + // to omp's `Steering` queue and clears the input row, so the verifier stands down. + workDetect: { + promptGlyph: '╰─', + workingLine: '[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [0-9hms ]+> |⎋ Working', + }, // No permission prompts and no bypass flag, so nothing config-shaped to clamp — the // whole privileged surface here is env-shaped. privilegedParams: [], diff --git a/src/session.ts b/src/session.ts index 9e76e09d..366c494b 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, pi and opencode declare a promptGlyph; the other modes would fall back + // Only claude, codex, pi, opencode and omp 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 7d032049..1dd274f4 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, pi's and opencode's. + * Working/idle detection for an interactive agent pane, Claude's, Codex's, pi's, opencode's and omp'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,8 +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, pi 1.1.0 - * and opencode 1.3.0. + * (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220, Codex CLI 0.152.1, pi 1.1.0, + * opencode 1.3.0 and omp 18.8.6 / 18.0.11. */ import { describe, expect, it, vi, afterEach } from 'vitest'; import { Session } from '../src/session.js'; @@ -49,7 +49,7 @@ function feed(session: Session, data: string): void { */ function withFakePane( screen: string | (() => string), - mode: 'claude' | 'codex' | 'pi' | 'opencode' = 'claude' + mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' = 'claude' ): Session { const read = typeof screen === 'function' ? screen : () => screen; const mux = { @@ -139,6 +139,24 @@ 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'; +/** + * omp's pane, verbatim from live omp 18.8.6 and 18.0.11 captures (rules shortened): the + * input row is `╰─`, and a running turn swaps the status bar's leading `π` for a braille + * spinner plus the elapsed time, with a `⎋ Working…` row above it. + */ +const OMP_BAR_TAIL = '⬢ hang > 📁 ~/work ▶─────────5%' + '─'.repeat(40); +const OMP_WORKING = ` say ok\n\n ⎋ Working…\n ⠼ 14s > ${OMP_BAR_TAIL}\n╰─\n`; +/** 18.0.11 pads the elapsed time with two spaces; past a minute it reads `1m`. */ +const OMP_WORKING_OLD = ` say ok\n\n ⎋ Working…\n ⠼ 2s > ${OMP_BAR_TAIL}\n╰─\n`; +const OMP_WORKING_LONG = ` ⠧ 1m > ${OMP_BAR_TAIL}\n╰─\n`; +const OMP_AT_REST = ` say ok\n\n π > ${OMP_BAR_TAIL}\n╰─\n`; +/** One spinner frame on the wire: the Working row and the bar, never the input row. */ +const OMP_SPINNER_FRAME = + '\x1b[19;1H\x1b[0m\x1b[K \x1b[38;5;248m ⎋\x1b[39m \x1b[38;5;243mWorki\x1b[39;38;5;248mng\x1b[39;1;38;5;39m…\x1b[22;39m\n' + + `\x1b[20;1H\x1b[0m\x1b[K\x1b[48;5;233;39m \x1b[38;5;39m⠼ 5s\x1b[39m \x1b[38;5;236m>\x1b[39m ${OMP_BAR_TAIL}`; +/** The input row omp redraws when a turn is submitted and when it ends. */ +const OMP_INPUT_REPAINT = '\x1b[20;1H\x1b[0m\x1b[K\x1b[38;5;239m╰─ \x1b[39;38;5;254;39m \x1b[0m'; + /** 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'; @@ -854,3 +872,77 @@ describe('wire activity stamp across recovery', () => { expect(session.lastActivityAt).toBeGreaterThanOrEqual(before); }); }); + +describe("omp's work-detection descriptor", () => { + const omp = getCli('omp')?.capabilities.workDetect; + + it('matches the status bar and Working row omp draws while a turn runs', () => { + const re = new RegExp(omp!.workingLine); + expect(re.test(OMP_WORKING)).toBe(true); + expect(re.test(OMP_WORKING_OLD)).toBe(true); + expect(re.test(OMP_WORKING_LONG)).toBe(true); + // The stream detector reads the ANSI-stripped chunk. + expect(re.test(stripAnsi(OMP_SPINNER_FRAME))).toBe(true); + }); + + it('does not match a pane at rest', () => { + expect(new RegExp(omp!.workingLine).test(OMP_AT_REST)).toBe(false); + }); + + it('names the input row omp redraws, so the idle check can arm', () => { + expect(OMP_INPUT_REPAINT).toContain(omp!.promptGlyph); + }); + + it('lets the submit verifier tell a submitted prompt from a stranded one', () => { + // Submitted (or queued for steering mid-turn): the input row is empty again. + expect(promptStillInComposer(OMP_AT_REST, 'say ok', omp!.promptGlyph)).toBe(false); + // Still sitting in the input row: the one case where pressing Enter again is right. + expect(promptStillInComposer(OMP_AT_REST.replace('╰─\n', '╰─ say ok\n'), 'say ok', omp!.promptGlyph)).toBe(true); + }); +}); + +describe('omp interactive idle detection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('marks an omp turn working, and lets it end', () => { + vi.useFakeTimers(); + let screen = OMP_WORKING; + const session = withFakePane(() => screen, 'omp'); + const events: string[] = []; + session.on('working', () => events.push('working')); + session.on('idle', () => events.push('idle')); + + // The submit redraws the input row, then eight seconds of spinner frames. + feed(session, OMP_INPUT_REPAINT); + for (let i = 0; i < 80; i++) { + feed(session, OMP_SPINNER_FRAME); + vi.advanceTimersByTime(100); + } + expect(events).toEqual(['working']); + expect(session.status).toBe('busy'); + + // Turn over: omp redraws the input row with the bar back to `π`. + screen = OMP_AT_REST; + feed(session, OMP_INPUT_REPAINT); + vi.advanceTimersByTime(20_000); + + expect(events).toEqual(['working', 'idle']); + expect(session.status).toBe('idle'); + }); + + it('settles a reattached omp pane that is at rest', () => { + vi.useFakeTimers(); + const session = withFakePane(OMP_AT_REST, 'omp'); + (session as unknown as { _status: string })._status = 'busy'; + const events: string[] = []; + session.on('idle', () => events.push('idle')); + + feed(session, OMP_INPUT_REPAINT); + vi.advanceTimersByTime(20_000); + + expect(events).toEqual(['idle']); + expect(session.status).toBe('idle'); + }); +});