mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(omp): read omp's status bar so an omp turn can end
An omp session stayed "working" for good once a turn started, the same latch pi had: omp's braille spinner trips the SPINNER_PATTERN fast path, and only a composer glyph arms the idle confirmation. omp declared none, so it fell back to Claude's `❯`, which omp never draws once its setup wizard is done. Measured on live omp 18.8.6 and 18.0.11 panes, holding a turn open against a local endpoint that never answers: the input row is `╰─ <text>` and is redrawn at submit, at the end of a turn, at launch and on reattach. While a turn runs, the status bar's leading `π` becomes a braille spinner plus the elapsed time (` ⠼ 14s > ⬢ model > ...`; 18.0.11 pads it with two spaces, past a minute it reads `1m`), with a `⎋ Working…` row above it. The registry entry now names the input row as the glyph and either working signal as the working line. The glyph also switches the submit verifier on for omp, which reads the input row the way it reads Claude's composer. A prompt sent mid-turn goes to omp's Steering queue and clears the row, so the verifier stands down. Text left in the row after an Enter is the one case it re-presses. Verified end to end on a sandboxed instance (own HOME and PATH, omp 18.8.6): session:idle at launch, session:working during a turn, session:idle about 3 s after it ended, and a restored pane settled idle about 3 s after a server restart. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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.
|
⚠️ `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.
|
||||||
|
|
||||||
|
|||||||
@@ -163,9 +163,13 @@ remote user's home, so resolving locally would pin a stranger's id. See
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **No idle/completion hook.** Idle detection falls back to output-stabilization
|
- **No idle/completion hook.** Idle detection reads the screen instead: the
|
||||||
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
|
registry entry's `workDetect` names omp's `╰─` input row as the glyph that arms
|
||||||
POSTing to `/api/hook-event` would be the highest-value follow-up.
|
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`
|
- **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
|
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
|
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ output. The other CLIs expose no equivalent.
|
|||||||
| Respawn cycling and unattended runs | Yes | Yes |
|
| Respawn cycling and unattended runs | Yes | Yes |
|
||||||
| Cron jobs | Yes | Yes |
|
| Cron jobs | Yes | Yes |
|
||||||
| Docker cases, remote SSH cases | 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 |
|
| Auto-resume when a usage limit resets | Yes | No |
|
||||||
| Plan usage chip | Yes | No |
|
| Plan usage chip | Yes | No |
|
||||||
| Approvals Inbox | Yes | DeepSeek yes; others 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.
|
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
|
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).
|
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||||
|
|
||||||
|
|||||||
@@ -1428,6 +1428,20 @@ const OMP: CliEntry = {
|
|||||||
// makes an omp conversation survive a full session kill.
|
// makes an omp conversation survive a full session kill.
|
||||||
transcript: 'omp-jsonl',
|
transcript: 'omp-jsonl',
|
||||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
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 `╰─ <text>`, 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
|
// No permission prompts and no bypass flag, so nothing config-shaped to clamp — the
|
||||||
// whole privileged surface here is env-shaped.
|
// whole privileged surface here is env-shaped.
|
||||||
privilegedParams: [],
|
privilegedParams: [],
|
||||||
|
|||||||
+1
-1
@@ -4485,7 +4485,7 @@ export class Session extends EventEmitter {
|
|||||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
// ⚠ 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
|
// 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
|
// 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
|
// 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, 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 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
|
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
* work exactly as before.
|
* work exactly as before.
|
||||||
*
|
*
|
||||||
* The status-line fixtures below are verbatim captures from live panes
|
* 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
|
* (`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.
|
* opencode 1.3.0 and omp 18.8.6 / 18.0.11.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { Session } from '../src/session.js';
|
||||||
@@ -49,7 +49,7 @@ function feed(session: Session, data: string): void {
|
|||||||
*/
|
*/
|
||||||
function withFakePane(
|
function withFakePane(
|
||||||
screen: string | (() => string),
|
screen: string | (() => string),
|
||||||
mode: 'claude' | 'codex' | 'pi' | 'opencode' = 'claude'
|
mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' = 'claude'
|
||||||
): Session {
|
): Session {
|
||||||
const read = typeof screen === 'function' ? screen : () => screen;
|
const read = typeof screen === 'function' ? screen : () => screen;
|
||||||
const mux = {
|
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[?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';
|
'\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. */
|
/** A composer repaint: the frame Claude ships roughly once a second while working. */
|
||||||
const COMPOSER_REPAINT =
|
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';
|
'\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);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user