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
+22 -1
View File
@@ -85,6 +85,7 @@ import {
isSustainedActivity,
isPaneQuiet,
watchingLabel,
isAwaitingWorkers,
WATCHING_TAIL_LINES,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
@@ -531,6 +532,8 @@ export class Session extends EventEmitter {
private _watchingLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -3093,7 +3096,10 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
// A turn that ended by handing off to workers the CLI waits for is work too: the
// composer is up and the pane is quiet, but the next turn starts without the user.
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
this._readWatching(text);
return this._lastPaneProbeWorking;
}
@@ -3151,6 +3157,21 @@ export class Session extends EventEmitter {
return this._watchingLineRe;
}
/**
* Whether the newest turn on this screen ended waiting for workers the CLI started
* (Claude's `✻ Waiting for 1 dynamic workflow to finish`). False for a CLI whose
* registry entry declares no `awaitingLine`. See `isAwaitingWorkers()`.
*/
private _paneAwaitsWorkers(paneText: string): boolean {
if (this._awaitingLineRe === undefined) {
const src = getCli(this.mode)?.capabilities.workDetect?.awaitingLine;
this._awaitingLineRe = src ? compileVersionRegex(src) : null;
}
if (!this._awaitingLineRe) return false;
const glyph = getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯';
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
}
/**
* The regex matching this CLI's "a turn is running" status line.
*