Merge pull request #648: fix(antigravity): show the working indicator while an agy turn runs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:16:43 +02:00
commit d55267a07c
11 files changed
+219 -29

No files matched your search

@@ -0,0 +1,5 @@
---
'aicodeman': patch
---
An Antigravity (`agy`) tab now shows that it is working. Its spinner animates a braille set Codeman did not recognise and the entry declared no work detection, so a running turn never marked the tab working and the activity indicator stayed off. The entry now declares what a turn draws (`⣯ Generating...`, `Working...`, `Loading...`) and its footer hint (`? for shortcuts`) as the idle mark, so a finished turn reads as idle again. A shell call waiting for approval keeps showing as working until you answer it.
+3 -3
View File
@@ -217,7 +217,7 @@ runs no agent, and the external CLIs render their own TUIs and post no hooks
is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off
its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode
only `idle`, `working` and `exit` are accepted. `exit` is dependable everywhere; `idle` and
`working` are screen-checked for `opencode`, `gemini`, `pi` and `omp` (as for `claude` and
`working` are screen-checked for `opencode`, `gemini`, `pi`, `omp` and `antigravity` (as for `claude` and
`codex`) and not guaranteed elsewhere: see the caveats under [Signals](#signals) before
building on `idle`.
Requesting a signal the session can never emit **explicitly** is a `400` (`stop` on
@@ -246,8 +246,8 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
| Signal | Source | Actually fires for |
|--------|--------|--------------------|
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`). `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`antigravity`, `grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi` and `omp` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph |
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph |
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only |
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below |
| `exit` | no process is behind the session | every mode |
+1 -1
View File
@@ -272,7 +272,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
⚠️ **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, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, 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, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, 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.
+3 -3
View File
@@ -122,11 +122,11 @@ the remote host, `grok login --device-auth` exists for exactly this.
## Known gaps
- **No idle/completion hook yet.** The registry entry declares no `workDetect` prompt
marker or working line, which is what gives Codex, OpenCode, Gemini, Pi and OMP a
screen check. So Codeman calls a new grok pane idle 3 seconds after launch, and after
marker or working line, which is what gives Codex, OpenCode, Gemini, Pi, OMP and
Antigravity a screen check. So Codeman calls a new grok pane idle 3 seconds after launch, and after
that it has only generic cues: Claude's `❯` marker, which grok does not print (see the
cron item below), and a braille spinner glyph. Working and idle are unreliable for
grok, as for Antigravity. Grok has a hooks system, so a Codeman hook POSTing to
grok. Grok has a hooks system, so a Codeman hook POSTing to
`/api/hook-event` is the highest-value follow-up.
- **No response viewer.** Grok writes ACP JSONL sessions under
`~/.grok/sessions/<encoded-cwd>/<session-id>/updates.jsonl`; nothing reads them yet.
+12 -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, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Antigravity and Grok: generic cues only, unreliable |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
@@ -184,6 +184,17 @@ Google's successor to the consumer Gemini CLI, invoked as `agy`. It keeps all of
in `~/.gemini/antigravity-cli/`, so the credential handling that applies to Gemini applies
to it as well.
- **Work detection reads its spinner row.** While a turn runs, `agy` animates an 8-dot braille
frame and a label one row above the composer (`⣯ Generating...`; `Working...` and
`Loading...` too), and its footer reads `esc to cancel`. Codeman matches that row, which is
what puts the activity indicator on the tab. The frames are not the braille set Claude and
Gemini use, which is why the tab used to look idle through a whole turn.
- **At rest the footer hint is the idle mark.** The composer's `>` is no good for that, since
the submitted prompt is echoed as `> <text>` as well. `? for shortcuts` is drawn only at
rest, and the turn's last repaint carries it. A shell call that waits for approval replaces
the composer and the footer hint is not drawn there, so the tab keeps reading as working
until you answer.
### Pi
Pi needs the opposite instincts from every other CLI here.
+7 -8
View File
@@ -28,14 +28,13 @@ There are several layers stacked on that: a completion message from the CLI, an
output silence, and token stability.
**For the other CLIs** it depends on what the CLI tells Codeman. Codex, OpenCode, Gemini
CLI, Pi and OMP each declare their own prompt marker and working line, so they get the same
screen check Claude does (before 1.26.1 every Codex session reported idle for its whole
life). DeepSeek Harness reports its end of turn and its dialogs to Codeman itself, which is
as precise as hooks; its working and idle display still comes from its output. Antigravity
and Grok Build declare neither. Codeman calls a new pane of theirs idle 3 seconds after
launch, and after that it has only generic cues to go on (Claude's `❯` prompt marker, a
braille spinner glyph), which these CLIs may not draw, so their working and idle status is
unreliable.
CLI, Pi, OMP and Antigravity each declare their own prompt marker and working line, so they
get the same screen check Claude does (before 1.26.1 every Codex session reported idle for
its whole life). DeepSeek Harness reports its end of turn and its dialogs to Codeman itself,
which is as precise as hooks; its working and idle display still comes from its output.
Grok Build declares neither. Codeman calls a new grok pane idle 3 seconds after launch, and
after that it has only generic cues to go on (Claude's `❯` prompt marker, a braille spinner
glyph), which grok may not draw, so its working and idle status is unreliable.
## The Respawn Controller
+2 -2
View File
@@ -39,8 +39,8 @@ hooks, but its dialogs are distinctive enough to read off the screen, so a Codex
waiting on an approval, a question or the "Trust this folder?" prompt turns red too, and
clears a few seconds after you answer. For the other CLIs there are no hooks and no dialog
reading, so you get only the coarser working and idle state: read off the screen for
OpenCode, Gemini CLI, Pi and OMP, and unreliable for Antigravity and Grok Build, which
declare nothing Codeman can read it from.
OpenCode, Gemini CLI, Pi, OMP and Antigravity, and unreliable for Grok Build, which
declares nothing Codeman can read it from.
## Window title and OS notifications
+8 -6
View File
@@ -116,12 +116,14 @@ seconds in. Current versions confirm against the actual screen before believing
### A session is stuck showing busy
For Antigravity and Grok Build the busy and idle status is unreliable: those CLIs expose no
hooks and declare no working line, so after launch Codeman has only generic cues to go on
(Claude's `❯` prompt marker, a braille spinner glyph), which they may not draw. Codex,
OpenCode, Gemini CLI, Pi and OMP get the same screen check Claude does, so a session of
theirs that has genuinely gone quiet will settle. If it never does, interrupt it (`Ctrl+C`
with nothing selected).
For Grok Build the busy and idle status is unreliable: it exposes no hooks and declares no
working line, so after launch Codeman has only generic cues to go on (Claude's `❯` prompt
marker, a braille spinner glyph), which it may not draw. Codex, OpenCode, Gemini CLI, Pi,
OMP and Antigravity get the same screen check Claude does, so a session of theirs that has
genuinely gone quiet will settle. An Antigravity session waiting on a shell-command approval
("Run this command?") shows busy until you answer it: `agy` draws nothing Codeman reads as
"at rest" while that list is up. If a session never settles, interrupt it (`Ctrl+C` with
nothing selected).
### The agent asks about bypass permissions every time
+24
View File
@@ -996,6 +996,30 @@ const ANTIGRAVITY: CliEntry = {
...AGENTS_DIR_SKILL,
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live `agy` 1.3.3 pane (capture-pane every second through a prose turn
// and a shell-call turn, plus the raw PTY stream, 2026-10-11). A running turn animates
// `⣯ Generating...` (the label also reads `Working...` and `Loading...`) one row above
// the composer, and the footer's left end reads `esc to cancel`; at rest the footer
// reads `? for shortcuts` and nothing else on screen matches. A shell call replaces the
// composer with a `Run this command?` list, the spinner stops, and the pane goes
// silent. Nothing arms the idle check while the list is up (the footer hint is drawn only
// at rest), so a pending approval reads as working until it is answered. That differs from
// gemini, whose `▀` bar comes back on every spinner repaint.
// ⚠️ The frames are the 8-dot braille set (`⣯ ⡿ ⣻ ⣾ ⣷ ⣟ ⢿ ⣽`), none of which is in
// SPINNER_PATTERN's Claude set, and this entry used to declare nothing, so no frame
// ever marked an antigravity tab working and it never showed the activity spinner.
// The composer's `>` is no arming mark: the submitted prompt is echoed as `> <text>`
// too. The footer hint is drawn only at rest, and the turn's last repaint carries it.
// The schema caps a glyph at 8 characters, so this is the head of `? for shortcuts`. It also
// keeps the submit verifier inert (no row starts with it and holds prompt text).
// The working line is anchored to the start of a row, like gemini's braille branch: the
// idle probe tests it against the whole pane, and an answer that QUOTES the spinner row
// (an agent explaining this entry) would otherwise hold the tab busy while it is on screen.
// Every `agy` capture draws the spinner at column 0.
workDetect: {
promptGlyph: '? for sh',
workingLine: String.raw`(?:^|\n)[⠀-⣿] [A-Z][A-Za-z ]{2,30}(?:\.\.\.|…)`,
},
// Like codex: an ABSENT config already defaults safe (no bypass flag), so only a
// SENT config needs the flag forced off — nothing is materialized.
privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }],
+1 -1
View File
@@ -4800,7 +4800,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, opencode, omp and gemini declare a promptGlyph; the other modes would fall back
// Only claude, codex, pi, opencode, omp, gemini and antigravity 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
+153 -4
View File
@@ -1,6 +1,6 @@
/**
* Working/idle detection for an interactive agent pane, Claude's, Codex's, pi's, opencode's, omp's
* and Gemini CLI's.
* Gemini CLI's and Antigravity'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
@@ -15,8 +15,9 @@
*
* 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,
* opencode 1.3.0, omp 18.8.6 / 18.0.11 and Gemini CLI 0.63.0 (its turns driven by a local
* stand-in for the Gemini API, since the CLI's look does not depend on the backend).
* opencode 1.3.0, omp 18.8.6 / 18.0.11, Gemini CLI 0.63.0 (its turns driven by a local
* stand-in for the Gemini API, since the CLI's look does not depend on the backend) and
* Antigravity `agy` 1.3.3.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
@@ -51,7 +52,7 @@ function feed(session: Session, data: string): void {
*/
function withFakePane(
screen: string | (() => string),
mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' = 'claude'
mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' | 'antigravity' = 'claude'
): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
@@ -978,6 +979,154 @@ describe('gemini interactive idle detection', () => {
});
});
/**
* Antigravity's (`agy` 1.3.3) pane, verbatim from live captures (rules shortened). A running
* turn animates an 8-dot braille frame and a label one row above the composer, and the
* footer reads `esc to cancel`; at rest the footer reads `? for shortcuts`. The composer
* row is `>`, which the echoed prompt starts with as well.
*/
const AGY_RULE = '─'.repeat(48);
const AGY_FOOTER_REST = '? for shortcuts Gemini 3.8 Flash · medium\n';
const AGY_FOOTER_WORKING = 'esc to cancel Gemini 3.8 Flash · medium\n';
const AGY_WORKING = `> say ok\n⣷ Generating...\n${AGY_RULE}\n>\n${AGY_RULE}\n${AGY_FOOTER_WORKING}`;
const AGY_AT_REST = ` ok\n${AGY_RULE}\n>\n${AGY_RULE}\n${AGY_FOOTER_REST}`;
/** A shell call replaces the composer with a confirmation list and the spinner stops. */
const AGY_CONFIRM =
'> Run the shell command: sleep 6 && echo done\n sleep 6 && echo done\nRun this command?\n' +
"> 1. Yes, run command\n 2. Yes, and always allow in this conversation for commands that start with 'sleep'\n" +
' 4. No, cancel\n ↑/↓ Navigate · tab Amend · ctrl+g edit/expand command\n';
/** One spinner frame on the wire, verbatim: the glyph is not one of SPINNER_PATTERN's. */
const AGY_SPINNER_FRAME = '\r\n\x1b[34;1m> say ok\x1b[m\x1b[K\r\n⣷ \x1b[90mGenerating...\x1b[m\x1b[K\r\n';
/** The turn's last repaint, verbatim (shortened): the footer hint comes back. */
const AGY_TURN_END = `${AGY_RULE}\x1b[m\r\n\x1b[90m? for shortcuts\x1b[m\x1b[80X\x1b[80C\x1b[2mGemini 3.8 Flash · medium\r`;
describe("antigravity's work-detection descriptor", () => {
const agy = getCli('antigravity')?.capabilities.workDetect;
const working = () => new RegExp(agy!.workingLine);
it('matches every frame of the spinner and every label, on screen and on the wire', () => {
expect(working().test(AGY_WORKING)).toBe(true);
expect(working().test(stripAnsi(AGY_SPINNER_FRAME))).toBe(true);
for (const frame of ['⣯', '⡿', '⣻', '⣾', '⣷', '⣟', '⢿', '⣽']) {
for (const label of ['Generating...', 'Working...', 'Loading...']) {
expect(working().test(`${frame} ${label}`)).toBe(true);
}
}
});
it('does not match a pane at rest, a pending confirmation, or prose', () => {
expect(working().test(AGY_AT_REST)).toBe(false);
expect(working().test(AGY_CONFIRM)).toBe(false);
expect(working().test('Loading... is what the spinner label reads\n')).toBe(false);
// An answer that quotes the spinner row mid-line must not hold the tab busy.
expect(working().test(` ok\n it matches \`⣯ Generating...\` in the pane\n${AGY_RULE}\n>\n`)).toBe(false);
});
it('names the footer hint the turn end carries, and no working repaint does', () => {
expect(AGY_TURN_END).toContain(agy!.promptGlyph);
expect(AGY_AT_REST).toContain(agy!.promptGlyph);
expect(AGY_WORKING).not.toContain(agy!.promptGlyph);
expect(AGY_SPINNER_FRAME).not.toContain(agy!.promptGlyph);
});
it('leaves the submit verifier unable to press Enter on an antigravity pane', () => {
// The composer row starts with `>`, but the glyph is the footer hint, so a prompt
// typed into the composer (or echoed above it) is never read as "still unsubmitted".
const typed = AGY_AT_REST.replace('>\n', '> say ok\n');
expect(promptStillInComposer(typed, 'say ok', agy!.promptGlyph)).not.toBe(true);
expect(promptStillInComposer(AGY_WORKING, 'say ok', agy!.promptGlyph)).not.toBe(true);
expect(promptStillInComposer(AGY_CONFIRM, 'Run the shell command', agy!.promptGlyph)).not.toBe(true);
});
});
describe('antigravity interactive activity', () => {
afterEach(() => {
vi.useRealTimers();
});
function spin(session: Session, ms: number): void {
for (let t = 0; t < ms; t += 100) {
feed(session, AGY_SPINNER_FRAME);
vi.advanceTimersByTime(100);
}
}
it('shows a turn as working, and lets it end instead of latching busy', () => {
vi.useFakeTimers();
let screen = AGY_WORKING;
const session = withFakePane(() => screen, 'antigravity');
(session as unknown as { _status: string })._status = 'idle';
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
// Before antigravity declared its spinner line no frame ever marked the tab working.
spin(session, 4000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = AGY_AT_REST;
feed(session, AGY_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('keeps a pending shell confirmation working until it is answered, and ends the resumed turn too', () => {
vi.useFakeTimers();
let screen = AGY_WORKING;
const session = withFakePane(() => screen, 'antigravity');
(session as unknown as { _status: string })._status = 'idle';
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 3000);
expect(events).toEqual(['working']);
screen = AGY_CONFIRM;
feed(session, 'Run this command?\r\n');
vi.advanceTimersByTime(60_000);
// The footer hint is not drawn under the confirmation, so nothing arms the idle check:
// the pane stays working (unlike gemini, whose bar returns on every repaint) until the
// user answers and the turn ends below.
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = AGY_WORKING;
spin(session, 3000);
screen = AGY_AT_REST;
feed(session, AGY_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events[0]).toBe('working');
expect(events[events.length - 1]).toBe('idle');
expect(session.status).toBe('idle');
});
});
describe('antigravity reattach', () => {
afterEach(() => {
vi.useRealTimers();
});
// Declaring workDetect turns off the 3 s settle for a restored pane, so a reattached agy pane
// depends on the footer hint arriving in tmux's attach repaint to leave `busy`.
it('settles a reattached antigravity pane that is at rest', () => {
vi.useFakeTimers();
const session = withFakePane(AGY_AT_REST, 'antigravity');
(session as unknown as { _status: string })._status = 'busy';
const events: string[] = [];
session.on('idle', () => events.push('idle'));
feed(session, AGY_TURN_END);
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