Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/adapters/cli/traex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,124 @@ export const TRAE_MIGRATION_DONE_MARKERS = [
'~/.trae/.coco-migrated',
] as const;

/**
* TraeX active-turn busy marker. Every anchor below is extracted verbatim
* from the traex binary's compiled-in TUI string tables and verified across
* all 9 local releases (0.201.1-alpha.5 … 0.201.2-alpha.2, both `traex` and
* `traex-code-mode-host`):
*
* - Spinner frames: the contiguous string "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
* (braille rotation compiled into every release).
* - Spinner label set 1 (thinking/working rotation): "Thinking longer…",
* "Deep in thought…", "Almost there…", "Running command…",
* "Command in flight…", "Chugging along…", "Finishing up…", "Executing…",
* "Hang tight…", "Waiting for response…", "Any second now…",
* "Poking the model…", "On its way…", "Shh, it's thinking…",
* "Thinking…", "Reasoning through it…", "Mulling it over…",
* "Pondering…", "Working it out…", "Piecing it together…".
* - Spinner label set 2 (approval/working/queue): "Reviewing approval
* request", "Working…", "Working on it…", "Queued for capacity".
* - Standalone queue notice: "Too many requests right now. You're in the
* queue."
*
* TraeX forked from Codex and DELETED the "esc to interrupt" footer hint —
* `grep -a -c "esc to interrupt"` returns 0 across every local release and
* the 94MB TUI logs — so the Codex pattern's second anchor is invalid here.
*
* Three branches:
* 1. Spinner-anchored labels: "<braille frame><space><label>". The frame
* never appears in transcript prose, so assistant output like
* "Working… on the fix" cannot revive a completed card. Covers the
* working/thinking rotation AND the spinner-prefixed queue state
* ("⠋ Queued for capacity" — the queue screen can render a frozen
* spinner frame in front of the label).
* 2. Standalone capacity-queue strings, line-anchored
* (`(?:^|[\n\r])[ \t]*…`): "Queued for capacity" and the full queue
* notice. The queue screen can render statically (no animating spinner),
* so the frame anchor must not be required for it. The line anchor keeps
* assistant prose quoting the string mid-sentence ("…says Queued for
* capacity whenever…") from registering as busy.
* 3. (staticBusyPattern below) the same queue evidence as a pre-idle latch
* inside IdleDetector — see TRAEX_STATIC_BUSY_PATTERN.
*
* Both states must be covered: the worker's busy-pattern idle probe marks
* the prompt ready as soon as the marker leaves the viewport, so matching
* only the queue strings would flash Idle the moment the queue resolves
* into a real working turn.
*/
const TRAEX_SPINNER_FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';

const TRAEX_SPINNER_LABELS = [
// Set 1 — thinking/working rotation (compiled-in spinner string table).
'Thinking longer…',
'Deep in thought…',
'Almost there…',
'Running command…',
'Command in flight…',
'Chugging along…',
'Finishing up…',
'Executing…',
'Hang tight…',
'Waiting for response…',
'Any second now…',
'Poking the model…',
'On its way…',
"Shh, it's thinking…",
'Thinking…',
'Reasoning through it…',
'Mulling it over…',
'Pondering…',
'Working it out…',
'Piecing it together…',
// Set 2 — approval/working/queue.
'Reviewing approval request',
'Working…',
'Working on it…',
'Queued for capacity',
] as const;

/** Line-anchored standalone capacity-queue strings. Shared by the active
* busy pattern and the pre-idle static latch (see below). */
const TRAEX_QUEUE_STATIC_ARMS = [
'Queued for capacity',
"Too many requests right now\\. You're in the queue",
];

const TRAEX_ACTIVE_BUSY_PATTERN = new RegExp(
[
`[${TRAEX_SPINNER_FRAMES}][ \\t]?(?:${TRAEX_SPINNER_LABELS.join('|')})`,
...TRAEX_QUEUE_STATIC_ARMS.map((arm) => `(?:^|[\\n\\r])[ \\t]*${arm}`),
].join('|'),
'i',
);

/**
* Pre-idle static-busy latch for the capacity-queue screen (ZMX gap).
*
* busyPattern/idleToBusyPattern cannot cover the FIRST static queue on ZMX:
* - busyPattern is a viewport probe; deferPromptReadyWhileBusy() and the
* idle probe bail when backendScreenEvidenceIsAuthoritativeForMutation()
* is false (ZMX history is not a trustworthy current viewport).
* - idleToBusyPattern only self-heals an ALREADY-published idle, and only
* from fresh PTY bytes; a static queue screen emits none.
*
* This pattern is consumed inside IdleDetector from the raw PTY byte stream
* (the same trust level as readyPattern/completionPattern — NOT the
* forbidden screen-capture snapshot): a chunk carrying queue evidence latches
* "static busy" and suppresses screen-derived idle until a chunk with
* readyPattern evidence but no queue string redraws (the real composer).
* Includes the spinner-prefixed queue form: a frozen braille frame in front
* of the label would otherwise only buy the 3s spinner guard, after which
* the static screen would still false-idle.
*/
const TRAEX_STATIC_BUSY_PATTERN = new RegExp(
[
`[${TRAEX_SPINNER_FRAMES}][ \\t]?Queued for capacity`,
...TRAEX_QUEUE_STATIC_ARMS.map((arm) => `(?:^|[\\n\\r])[ \\t]*${arm}`),
].join('|'),
'i',
);

export function createTraexAdapter(pathOverride?: string): CliAdapter {
const rawBin = pathOverride ?? 'traex';
let cachedBin: string | undefined;
Expand Down Expand Up @@ -329,6 +447,17 @@ export function createTraexAdapter(pathOverride?: string): CliAdapter {
},

completionPattern: undefined,
// Active-turn busy marker — spinner-anchored working/thinking labels plus
// standalone capacity-queue strings. See the TRAEX_ACTIVE_BUSY_PATTERN
// comment for the binary-extracted evidence and why both states are
// required.
busyPattern: TRAEX_ACTIVE_BUSY_PATTERN,
idleToBusyPattern: TRAEX_ACTIVE_BUSY_PATTERN,
Comment on lines +454 to +455

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle static queue state before declaring ZMX idle

When TraeX runs on the supported ZMX backend and this static queue notice arrives in the same redraw as the \d+% left ready marker, IdleDetector sees the queue text before its idle→busy edge is armed, then declares idle after quiescence. The worker cannot use busyPattern to veto that transition because deferPromptReadyWhileBusy() explicitly rejects ZMX screen evidence, and a static queue emits no later PTY data to trigger idleToBusyPattern; the Dashboard therefore still remains falsely Idle for this backend. Make the queue marker suppress the pre-idle decision without relying on an authoritative viewport.

AGENTS.md reference: AGENTS.md:L68-L68

Useful? React with 👍 / 👎.

// Pre-idle latch for the static capacity-queue screen — holds the session
// busy before the first idle on backends (ZMX) where the busyPattern
// viewport probe is forbidden from mutating state. See the
// TRAEX_STATIC_BUSY_PATTERN comment.
staticBusyPattern: TRAEX_STATIC_BUSY_PATTERN,
// TRAE has shipped both the Codex-style `›` prompt and the Claude-style
// `❯` prompt; v0.200.7 also renders a "Context 100% left" status bar.
// Startup advisory / picker screens also use `❯ 1.` as a menu cursor, so
Expand Down
14 changes: 14 additions & 0 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,20 @@ export interface CliAdapter {
* contain old busy text; existing adapters remain opt-out by default. */
readonly idleToBusyPattern?: RegExp;

/** Opt-in PRE-idle busy latch for static busy screens that emit no further
* PTY bytes after the initial render (e.g. a capacity-queue notice drawn
* alongside a readyPattern status bar). Unlike busyPattern — a viewport
* probe that only runs on backends whose screen cache is authoritative for
* mutation — this consumes raw PTY evidence inside IdleDetector, so it also
* holds on backends where screen capture must not mutate state (ZMX):
* while latched, screen-derived idle is suppressed until a PTY chunk with
* readyPattern evidence but WITHOUT this marker arrives (the queue screen
* itself matches readyPattern's status-bar arm, so readyPattern alone can
* never clear it). The latch is set/cleared from the current chunk only;
* reset() rebases it. External structured completion (fireIdle) bypasses
* the latch — it is authoritative independently of the screen observer. */
readonly staticBusyPattern?: RegExp;

/** Ready marker regex — matches when the CLI's input prompt is rendered and
* functional. When set, the idle detector suppresses quiescence-based idle
* until this pattern appears in the PTY output. Checked every cycle (reset
Expand Down
34 changes: 33 additions & 1 deletion src/utils/idle-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@ export class IdleDetector {
private busyCallback: (() => void) | null = null;
private completionPattern: RegExp | undefined;
private idleToBusyPattern: RegExp | undefined;
private staticBusyPattern: RegExp | undefined;
private busyTransitionArmed = false;
private readyPattern: RegExp | undefined;
private readySeen = false;
/** Pre-idle latch for static busy screens (capacity queue). Set from a PTY
* chunk carrying explicit static-busy evidence; suppresses screen-derived
* idle until a chunk with readyPattern evidence but no static-busy marker
* redraws. See CliAdapter.staticBusyPattern. */
private staticBusyLatch = false;

constructor(cli: CliAdapter) {
this.completionPattern = cli.completionPattern;
this.idleToBusyPattern = cli.idleToBusyPattern;
this.staticBusyPattern = cli.staticBusyPattern;
this.readyPattern = cli.readyPattern;
}

Expand Down Expand Up @@ -100,6 +107,21 @@ export class IdleDetector {
this.readySeen = true;
}

// Pre-idle static-busy latch (capacity-queue screens). Decided from the
// CURRENT chunk only: the queue text and the queue screen's `100% left`
// status bar both linger in outputTail, so tail matching could never
// clear the latch and a stale tail ready marker would clear it mid-queue.
// A chunk carrying the static-busy marker means the latest redraw still
// shows the queue; a chunk carrying fresh ready evidence without it means
// the composer is back.
if (this.staticBusyPattern) {
if (this.staticBusyPattern.test(stripped)) {
this.staticBusyLatch = true;
} else if (this.readyPattern?.test(stripped)) {
this.staticBusyLatch = false;
}
}

// Track spinner — but not if it's part of completion marker,
// and not after ready pattern is seen (status bar chars like · are not real spinners)
if (SPINNER_RE.test(stripped) && !(this.completionPattern?.test(stripped) || this.completionPattern?.test(this.outputTail)) && !this.readySeen) {
Expand All @@ -114,7 +136,9 @@ export class IdleDetector {
this.clearTimer();
this.quiescenceTimer = setTimeout(() => {
this.quiescenceTimer = null;
if (!this.isIdle) this.markIdle('screen');
// A static-busy latch outranks a completion marker: the queue screen
// can carry both, and the latch only clears on a composer redraw.
if (!this.isIdle && !this.staticBusyLatch) this.markIdle('screen');
}, 500);
return;
}
Expand All @@ -132,6 +156,7 @@ export class IdleDetector {
this.busyTransitionArmed = false;
this.outputTail = '';
this.readySeen = false;
this.staticBusyLatch = false;
this.lastSpinnerAt = Date.now();
this.clearTimer();
}
Expand All @@ -147,6 +172,7 @@ export class IdleDetector {
this.busyTransitionArmed = false;
this.outputTail = '';
this.readySeen = false;
this.staticBusyLatch = false;
this.lastSpinnerAt = 0;
this.clearTimer();
}
Expand All @@ -166,11 +192,17 @@ export class IdleDetector {
this.idleCallback = null;
this.busyCallback = null;
this.busyTransitionArmed = false;
this.staticBusyLatch = false;
}

private quiescenceCheck(): void {
this.quiescenceTimer = null;
if (this.isIdle) return;
// Explicit static-busy evidence (capacity queue): the screen is not
// quiescing into a prompt — it is parked on a queue notice. Do not mark
// idle and do not re-arm: the latch clears on the composer redraw, whose
// feed() re-arms quiescence.
if (this.staticBusyLatch) return;
const sinceSpinner = Date.now() - this.lastSpinnerAt;
if (sinceSpinner < SPINNER_GUARD_MS) {
this.quiescenceTimer = setTimeout(
Expand Down
99 changes: 99 additions & 0 deletions test/cli-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,85 @@ describe('busyPattern', () => {
expect(busy!.test('Working through the implementation')).toBe(false);
expect(busy!.test('press esc to interrupt')).toBe(false);
});

it('traex matches spinner-anchored working labels and standalone queue strings but not prose or idle composer', () => {
// Regression: a static capacity-queue screen matches readyPattern's
// `\d+% left` status-bar arm and survives the 2s quiescence window,
// flipping the card/Dashboard to Idle while the session is still waiting
// for capacity. The busyPattern must cover both the queue screen and the
// normal working indicator so the worker's deferPromptReadyWhileBusy
// backstop (and its idle probe) holds the session busy until a real
// terminal state.
//
// Every anchor below is extracted verbatim from the traex binary's
// compiled-in TUI string tables (verified across all 9 local releases,
// 0.201.1-alpha.5 … 0.201.2-alpha.2, both `traex` and
// `traex-code-mode-host`):
// spinner frames: "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
// working labels: "Working…", "Thinking…", "Pondering…",
// "Working it out…" (full rotation in traex.ts)
// queue strings: "Queued for capacity",
// "Too many requests right now. You're in the queue."
// idle composer: "Ask TraeCode CLI to do anything" + "100% context left"
// TraeX forked from Codex and DELETED the "esc to interrupt" footer hint
// (0 hits across all releases + the 94MB TUI logs), so the Codex
// pattern's second anchor is invalid here.
const busy = createTraexAdapter('/bin/traex').busyPattern;
expect(busy).toBeDefined();
// Spinner-anchored working labels: "<braille frame> <label>".
expect(busy!.test('⠋ Working…')).toBe(true);
expect(busy!.test('⠹ Thinking…')).toBe(true);
expect(busy!.test('⠸ Pondering…')).toBe(true);
expect(busy!.test('⠼ Working it out…')).toBe(true);
// Spinner-prefixed queue state: the queue screen can render a frozen
// braille frame in front of the label, and the label is part of the
// compiled-in spinner string table.
expect(busy!.test('⠋ Queued for capacity')).toBe(true);
// Standalone capacity-queue strings — the queue screen may render
// statically (no animating spinner), so no frame anchor is required.
// Line-anchored: bare line, indented line, and `at position N` suffix
// all match.
expect(busy!.test('Queued for capacity')).toBe(true);
expect(busy!.test(' Queued for capacity')).toBe(true);
expect(busy!.test('Queued for capacity at position 3.')).toBe(true);
expect(busy!.test("Too many requests right now. You're in the queue.")).toBe(true);
expect(busy!.test("Too many requests right now. You're in the queue at position 3.")).toBe(true);
// Mid-sentence prose quotes must NOT match — the line anchor is the
// discriminator for the standalone arms (the braille frame for the
// spinner arms).
expect(busy!.test('The status line says Queued for capacity right now')).toBe(false);
expect(busy!.test("It printed Too many requests right now. You're in the queue. and stopped")).toBe(false);
// Idle composer must NOT match.
expect(busy!.test('› Ask TraeCode CLI to do anything 100% context left')).toBe(false);
// Prose must NOT match — the braille frame anchor is the discriminator.
expect(busy!.test('Working… on the fix')).toBe(false);
expect(busy!.test('Working through the implementation')).toBe(false);
expect(busy!.test('press esc to interrupt')).toBe(false);
});

it('traex staticBusyPattern latches only on line-anchored queue evidence', () => {
// The pre-idle static latch (ZMX gap) consumes queue evidence straight
// from the PTY byte stream — see TRAEX_STATIC_BUSY_PATTERN in traex.ts.
// It must match every queue-screen shape (bare / indented / spinner-
// prefixed / at-position suffix / ANSI-stripped by IdleDetector) and
// must NOT match prose quotes or the idle composer.
const staticBusy = createTraexAdapter('/bin/traex').staticBusyPattern;
expect(staticBusy).toBeDefined();
expect(staticBusy!.test('Queued for capacity')).toBe(true);
expect(staticBusy!.test(' Queued for capacity')).toBe(true);
expect(staticBusy!.test('Queued for capacity at position 3.')).toBe(true);
expect(staticBusy!.test('⠋ Queued for capacity')).toBe(true);
expect(staticBusy!.test("Too many requests right now. You're in the queue.")).toBe(true);
expect(staticBusy!.test("Too many requests right now. You're in the queue at position 3.")).toBe(true);
// Mid-sentence prose quotes must NOT latch.
expect(staticBusy!.test('The status line says Queued for capacity right now')).toBe(false);
expect(staticBusy!.test("It printed Too many requests right now. You're in the queue. and stopped")).toBe(false);
// Idle composer must NOT latch.
expect(staticBusy!.test('› Ask TraeCode CLI to do anything 100% context left')).toBe(false);
// Working labels without the queue string must NOT latch — the latch is
// queue-only; ordinary working turns are covered by the spinner guard.
expect(staticBusy!.test('⠋ Working…')).toBe(false);
});
});

describe('idleToBusyPattern', () => {
Expand All @@ -1498,6 +1577,26 @@ describe('idleToBusyPattern', () => {
expect(adapter.idleToBusyPattern!.test('Working through the implementation')).toBe(false);
});

it('traex opts into idle→busy recovery with the same strict active marker as busyPattern', () => {
// The capacity-queue screen can render AFTER a false idle was already
// published (readyPattern's `\d+% left` arm matched the status bar and
// quiescence fired). idleToBusyPattern must flip the session back to
// working when the queue marker or a working spinner label appears in
// the PTY stream. Strings are the same binary-extracted anchors as the
// busyPattern test above.
const adapter = createTraexAdapter('/bin/traex');
expect(adapter.idleToBusyPattern).toBeDefined();
expect(adapter.idleToBusyPattern!.source).toBe(adapter.busyPattern!.source);
// Spinner-anchored working labels.
expect(adapter.idleToBusyPattern!.test('⠋ Working…')).toBe(true);
expect(adapter.idleToBusyPattern!.test('⠙ Pondering…')).toBe(true);
// Standalone queue strings.
expect(adapter.idleToBusyPattern!.test('Queued for capacity')).toBe(true);
expect(adapter.idleToBusyPattern!.test("Too many requests right now. You're in the queue.")).toBe(true);
// Prose without the braille frame anchor must NOT flip idle→busy.
expect(adapter.idleToBusyPattern!.test('Working… on the fix')).toBe(false);
});

it.each([
['genius', createGeniusAdapter('/bin/genius')],
['grok', createGrokAdapter('/bin/grok')],
Expand Down
Loading