fix(session): keep a tab working while Claude waits for its own workers

When Claude hands work to an ultracode workflow or background agents, it
ends its own turn and closes it with `✻ Waiting for 1 dynamic workflow to
finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when the
workers report back. The pane sits quiet with the composer up, so the idle
probe called the session idle for the whole wait. At phone width the
workflow's progress row also drops its ticking timer, so nothing on screen
changes for minutes.

A new optional registry field, `capabilities.workDetect.awaitingLine`,
names that closing row, and `_probePaneWorking()` counts it as work.
Claude renders the row once from a snapshot and never redraws it, so the
same words stay on screen after the workers finish. `isAwaitingWorkers()`
therefore tests only the newest column-0 row directly above the composer,
never the whole pane and never the PTY stream; a follow-up turn always
puts rows of its own there. The column-0 anchor also keeps an agent from
holding its own tab busy by printing the sentence.

Verified against the live Mac mini pane that reported the bug (2.1.283),
and end to end on an isolated instance: an ultracode session running a
90 s workflow at 46 columns stayed busy through the wait and the
follow-up turn, then went idle 6 s after that turn closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-28 16:18:27 +02:00
parent 45ea2e1d32
commit 7659ca8b44
10 changed files with 381 additions and 6 deletions
+56
View File
@@ -158,3 +158,59 @@ export function watchingLabel(
}
return null;
}
/**
* How many rows above the composer the turn's closing row may sit. Between the two Claude
* draws only its composer border and, sometimes, a right-aligned hint
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
* transcript to find an old turn's row.
*/
export const AWAITING_SEARCH_ROWS = 6;
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
const COMPOSER_FRAME_ROW = /^[─-╿]/;
/**
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
*
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
* nothing is being asked of the user: the CLI resumes by itself when the workers report
* back. That is why a session in this state counts as working.
*
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
* updates it, so after the workers finish the same words are still on screen above the
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
* long as they stay visible. Only the newest transcript row counts: the walk starts at
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
* its own there, so the stale copy is never the one tested.
*
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
* @returns false when the screen shows no composer, which is no evidence either way
*/
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
if (!paneText) return false;
const rows = stripAnsi(paneText)
.split('\n')
.map((row) => row.trimEnd());
let composer = -1;
for (let i = rows.length - 1; i >= 0; i--) {
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
composer = i;
break;
}
}
if (composer < 0) return false;
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
const row = rows[i];
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
pattern.lastIndex = 0;
return pattern.test(row);
}
return false;
}