mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -338,6 +338,15 @@ const capabilitiesSchema = z
|
||||
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||
// and every row it adds is one more row the agent itself may be able to write.
|
||||
watchingLines: z.number().int().min(1).max(8).optional(),
|
||||
// Same guard again: tested against a pane row every time a session settles.
|
||||
awaitingLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
|
||||
|
||||
@@ -238,6 +238,14 @@ const CLAUDE: CliEntry = {
|
||||
// watching rather than open that door. See `watchingLabel()` in
|
||||
// `session-activity.ts`.
|
||||
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||
// When a turn ends while background agents or an ultracode workflow are still
|
||||
// running, Claude swaps its `✻ Brewed for 1m 18s` closing row for
|
||||
// `✻ Waiting for 2 background agents and 1 dynamic workflow to finish` and resumes
|
||||
// by itself when they report back. Read from the 2.1.283 bundle (the turn-duration
|
||||
// renderer) and a live pane on 2026-09-28. The row is a snapshot taken at turn end
|
||||
// and never redrawn, which is why only the newest row above the composer counts.
|
||||
// Anchored on column 0: Claude's own rows start there, the agent's prose never does.
|
||||
awaitingLine: String.raw`^✻ Waiting for \d+ (?:background agents?|dynamic workflows?)\b`,
|
||||
},
|
||||
requiresMux: false,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
|
||||
@@ -362,6 +362,19 @@ export interface CliCapabilities {
|
||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
watchingLines?: number;
|
||||
/**
|
||||
* Source of a regex matching the row this CLI closes a turn with when it ended that
|
||||
* turn to WAIT for workers it started and will resume on its own once they finish,
|
||||
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`. A pane showing it counts
|
||||
* as working, not idle: nothing is being asked of the user, and the next turn starts
|
||||
* without them.
|
||||
*
|
||||
* Unlike `workingLine` this is never searched across the pane. The CLI prints the row
|
||||
* once and never updates it, so the copy from an earlier turn is still on screen after
|
||||
* the workers are done. Only the newest transcript row directly above the composer is
|
||||
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
|
||||
*/
|
||||
awaitingLine?: string;
|
||||
};
|
||||
/**
|
||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+22
-1
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user