From b2c2ba67b3221a4a67521f1a5b7f55cb0727f96a Mon Sep 17 00:00:00 2001 From: "jingxiong.ljx" Date: Tue, 16 Jun 2026 11:50:04 +0800 Subject: [PATCH 1/6] fix(pi): allow typeahead input --- src/adapters/cli/pi.ts | 1 + test/write-input.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/adapters/cli/pi.ts b/src/adapters/cli/pi.ts index 9cfff0a3..fccdf181 100644 --- a/src/adapters/cli/pi.ts +++ b/src/adapters/cli/pi.ts @@ -42,6 +42,7 @@ export function createPiAdapter(pathOverride?: string): CliAdapter { completionPattern: undefined, readyPattern: undefined, + supportsTypeAhead: true, systemHints: BOTMUX_SHELL_HINTS, altScreen: true, skillsDir: '~/.pi/agent/skills', diff --git a/test/write-input.test.ts b/test/write-input.test.ts index cd13d92d..d64e22cc 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -46,6 +46,7 @@ import { createOpenCodeAdapter } from '../src/adapters/cli/opencode.js'; import { createMtrAdapter } from '../src/adapters/cli/mtr.js'; import { createHermesAdapter } from '../src/adapters/cli/hermes.js'; import { createMiraAdapter } from '../src/adapters/cli/mira.js'; +import { createPiAdapter } from '../src/adapters/cli/pi.js'; import type { CliAdapter, PtyHandle } from '../src/adapters/cli/types.js'; import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { homedir, platform } from 'node:os'; @@ -171,13 +172,14 @@ const HUMAN_TYPING_ADAPTERS: AdapterEntry[] = [ ]; /** Adapters that use tmux pasteText (load-buffer + paste-buffer -d) with - * delayed Enter — CoCo / Trae CLI and Codex. See coco.ts for the Trae 0.120.31 + * delayed Enter — CoCo / Trae CLI, Codex, and Pi. See coco.ts for the Trae 0.120.31 * burst bug, and codex.ts for the per-line-submit bug bracketed paste fixes * (Codex 0.134+ handles bracketed paste correctly — the old "Codex exits on * bracketed paste" note was true only for a much earlier build). */ const PASTE_BUFFER_ADAPTERS: AdapterEntry[] = [ ['coco', createCocoAdapter('/bin/coco')], ['codex', createCodexAdapter('/bin/codex')], + ['pi', createPiAdapter('/bin/pi')], ]; /** Adapters that wrap content in bracketed-paste markers (\x1b[200~ ... \x1b[201~) @@ -491,6 +493,10 @@ describe('supportsTypeAhead flag', () => { expect(createCodexAdapter('/bin/codex').supportsTypeAhead).toBe(true); }); + it('pi: true (parks submit-while-busy in its TUI queue, avoiding missed idle detection)', () => { + expect(createPiAdapter('/bin/pi').supportsTypeAhead).toBe(true); + }); + it.each(PLAIN_ADAPTERS.filter(([name]) => name !== 'codex'))('%s: undefined (default behavior)', (_name, adapter) => { expect(adapter.supportsTypeAhead).toBeUndefined(); }); From 8d3d00eced2319bbb1b5ec1a19e8fbebea74a34e Mon Sep 17 00:00:00 2001 From: "jingxiong.ljx" Date: Tue, 16 Jun 2026 11:50:50 +0800 Subject: [PATCH 2/6] fix(worker): merge queued cli input --- src/utils/pending-input-queue.ts | 15 +++++++++++++++ src/worker.ts | 21 +++++++++++++++++---- test/worker-queue-merge.test.ts | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/utils/pending-input-queue.ts create mode 100644 test/worker-queue-merge.test.ts diff --git a/src/utils/pending-input-queue.ts b/src/utils/pending-input-queue.ts new file mode 100644 index 00000000..1ba2ea89 --- /dev/null +++ b/src/utils/pending-input-queue.ts @@ -0,0 +1,15 @@ +export interface PendingCliInput { + content: string; + turnId?: string; +} + +export function mergeQueuedCliInput( + pending: PendingCliInput[], + next: PendingCliInput, +): boolean { + const tail = pending[pending.length - 1]; + if (!tail) return false; + tail.content = `${tail.content}\n\n${next.content}`; + tail.turnId = next.turnId ?? tail.turnId; + return true; +} diff --git a/src/worker.ts b/src/worker.ts index d66af326..fd20b429 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -20,6 +20,7 @@ import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlCon import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js'; import { shouldSuppressBridgeEmit, type BridgeSendMarker } from './services/bridge-fallback-gate.js'; import { shouldWriteNow } from './utils/input-gate.js'; +import { mergeQueuedCliInput, type PendingCliInput } from './utils/pending-input-queue.js'; import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js'; import { InflightInputTracker } from './core/inflight-input-tracker.js'; import { @@ -216,7 +217,7 @@ function releaseReadyGate(reason: string): void { settleThenFlush(Date.now()); } } -const pendingMessages: Array<{ content: string; turnId?: string }> = []; +const pendingMessages: PendingCliInput[] = []; /** Inputs written to the CLI whose turn hasn't completed — re-queued across a * CLI crash so a submit-time death can't silently eat user messages. */ const inflightInputs = new InflightInputTracker(); @@ -3043,7 +3044,19 @@ async function flushPending(): Promise { function sendToPty(content: string, turnId?: string): void { if (!backend || !cliAdapter) return; - pendingMessages.push({ content, turnId }); + const next = { content, turnId }; + const shouldMergeQueued = !isFlushing && !shouldWriteNow({ + isPromptReady, + isFlushing, + supportsTypeAhead: cliAdapter.supportsTypeAhead === true, + awaitingFirstPrompt, + }); + const mergedQueued = shouldMergeQueued && mergeQueuedCliInput(pendingMessages, next); + if (mergedQueued) { + log(`Merged queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`); + } else { + pendingMessages.push(next); + } // User-override semantics: a fresh Lark message while a TUI prompt is "active" // takes precedence over the AI-detected prompt. The screen analyzer can be // wrong (false positive on a question that has no rendered options) and a @@ -3068,10 +3081,10 @@ function sendToPty(content: string, turnId?: string): void { // delivers queued messages instead. See input-gate.ts; this fixes dispatch's // brief reaching Codex before its first idle and never landing. if (shouldWriteNow({ isPromptReady, isFlushing, supportsTypeAhead: cliAdapter.supportsTypeAhead === true, awaitingFirstPrompt })) { - log(`Writing to PTY: "${content.substring(0, 80)}"`); + if (!mergedQueued) log(`Writing to PTY: "${content.substring(0, 80)}"`); flushPending(); // fire-and-forget async; no-op if already flushing } else { - log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`); + if (!mergedQueued) log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`); } } diff --git a/test/worker-queue-merge.test.ts b/test/worker-queue-merge.test.ts new file mode 100644 index 00000000..89698388 --- /dev/null +++ b/test/worker-queue-merge.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { mergeQueuedCliInput } from '../src/utils/pending-input-queue.js'; + +describe('mergeQueuedCliInput', () => { + it('returns false when there is no queued message to merge into', () => { + const pending: Array<{ content: string; turnId?: string }> = []; + + expect(mergeQueuedCliInput(pending, { content: 'next', turnId: 't2' })).toBe(false); + expect(pending).toEqual([]); + }); + + it('merges incremental queued messages into the pending tail', () => { + const pending = [{ content: 'first', turnId: 't1' }]; + + expect(mergeQueuedCliInput(pending, { content: 'second', turnId: 't2' })).toBe(true); + + expect(pending).toEqual([{ content: 'first\n\nsecond', turnId: 't2' }]); + }); +}); From b5a41600d7a2cc35c48981a856362d9f5ab17c0f Mon Sep 17 00:00:00 2001 From: "jingxiong.ljx" Date: Tue, 16 Jun 2026 13:38:22 +0800 Subject: [PATCH 3/6] fix(pi): mark reattached idle sessions ready --- src/adapters/cli/pi.ts | 1 + src/adapters/cli/types.ts | 6 ++++++ src/worker.ts | 20 ++++++++++++++++++++ test/write-input.test.ts | 6 ++++++ 4 files changed, 33 insertions(+) diff --git a/src/adapters/cli/pi.ts b/src/adapters/cli/pi.ts index fccdf181..92931093 100644 --- a/src/adapters/cli/pi.ts +++ b/src/adapters/cli/pi.ts @@ -41,6 +41,7 @@ export function createPiAdapter(pathOverride?: string): CliAdapter { }, completionPattern: undefined, + busyPattern: /Working\.\.\./, readyPattern: undefined, supportsTypeAhead: true, systemHints: BOTMUX_SHELL_HINTS, diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index 3a38766d..9f9ff89c 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -172,6 +172,12 @@ export interface CliAdapter { /** Completion marker regex (beyond generic quiescence). undefined = quiescence only. */ readonly completionPattern?: RegExp; + /** Busy marker regex — matches when the CLI is explicitly rendering a + * still-running state. Used for re-attached persistent sessions where there + * may be no new PTY output: if the current screen does NOT match this marker, + * the worker may safely let quiescence mark the session idle. */ + readonly busyPattern?: 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 diff --git a/src/worker.ts b/src/worker.ts index fd20b429..ebfb7c85 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -3320,6 +3320,25 @@ function seedBackendScreen(source: string, be: Pick): void { + setTimeout(() => { + if (!awaitingFirstPrompt || isPromptReady || pendingMessages.length > 0) return; + try { + const content = be.captureViewport?.() ?? be.captureCurrentScreen?.() ?? ''; + if (!content) return; + if (cliAdapter?.busyPattern) { + if (cliAdapter.busyPattern.test(content)) return; + log(`${source} idle probe: busy marker absent, marking prompt ready`); + markPromptReady(); + return; + } + onPtyData(content); + } catch (err: any) { + log(`${source} idle probe captureCurrentScreen failed: ${err.message}`); + } + }, 3_500).unref?.(); +} + function spawnCli(cfg: Extract): void { // Re-deliver inputs that were in-flight when the previous CLI died (see // backend.onExit). killCli() already wiped pendingMessages, so these go to @@ -3995,6 +4014,7 @@ function spawnCli(cfg: Extract): void { if (isPipeMode && backend && 'isReattach' in backend && backend.isReattach) { log(`Re-attached to existing ${effectiveBackendType} session via pipe backend: ${persistentSessionName}`); seedBackendScreen(`${effectiveBackendType} reattach`, backend); + scheduleReattachIdleProbe(`${effectiveBackendType} reattach`, backend); } // Fallback: if the CLI takes too long to show its prompt (e.g. slow diff --git a/test/write-input.test.ts b/test/write-input.test.ts index d64e22cc..c1961d9f 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -497,6 +497,12 @@ describe('supportsTypeAhead flag', () => { expect(createPiAdapter('/bin/pi').supportsTypeAhead).toBe(true); }); + it('pi: exposes Working... as the explicit busy marker', () => { + const adapter = createPiAdapter('/bin/pi'); + expect(adapter.busyPattern?.test('⠙ Working...')).toBe(true); + expect(adapter.busyPattern?.test('已完成,等待下一条输入')).toBe(false); + }); + it.each(PLAIN_ADAPTERS.filter(([name]) => name !== 'codex'))('%s: undefined (default behavior)', (_name, adapter) => { expect(adapter.supportsTypeAhead).toBeUndefined(); }); From b7bb33b03d069953df8c21b2ff0ddb108404c21b Mon Sep 17 00:00:00 2001 From: "jingxiong.ljx" Date: Tue, 16 Jun 2026 17:10:02 +0800 Subject: [PATCH 4/6] fix(pi): detect idle after busy submit --- src/adapters/cli/pi.ts | 1 + src/adapters/cli/types.ts | 5 ++ src/worker.ts | 73 +++++++++++++++---- test/worker-pipe-initial-screen-order.test.ts | 11 +++ test/write-input.test.ts | 10 +++ 5 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/adapters/cli/pi.ts b/src/adapters/cli/pi.ts index 92931093..8fcc45f1 100644 --- a/src/adapters/cli/pi.ts +++ b/src/adapters/cli/pi.ts @@ -44,6 +44,7 @@ export function createPiAdapter(pathOverride?: string): CliAdapter { busyPattern: /Working\.\.\./, readyPattern: undefined, supportsTypeAhead: true, + mergeQueuedInput: true, systemHints: BOTMUX_SHELL_HINTS, altScreen: true, skillsDir: '~/.pi/agent/skills', diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index 9cfb9994..9e2db363 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -216,6 +216,11 @@ export interface CliAdapter { * correct for both shapes. */ readonly supportsTypeAhead?: boolean; + /** When true, worker may squash additional queued Lark messages into the + * pending tail instead of preserving one botmux turn per queued message. + * Keep this opt-in: most adapters rely on distinct turnId / card routing. */ + readonly mergeQueuedInput?: boolean; + /** Whether CLI uses alternate screen buffer */ readonly altScreen: boolean; diff --git a/src/worker.ts b/src/worker.ts index 2e746df5..e2b40c98 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -110,6 +110,9 @@ let consecutiveInWorkerRestarts = 0; * lifecycle so a 4× crash loop does not spam the Lark thread with 4 copies * of the same warning. */ let resumeFallbackNotified = false; +const IDLE_PROBE_INTERVAL_MS = 3_500; +const IDLE_PROBE_MAX_ATTEMPTS = 24; +let busyPatternIdleProbeTimer: ReturnType | null = null; /** The effectiveResume flag used by the most recent spawnCli call. Written * immediately after the two-tier fallback check so late-attach timers * (hermes, cursor, etc.) can read THE SAME semantics the spawn used, @@ -2762,6 +2765,7 @@ function onPtyData(data: string): void { function markPromptReady(): void { if (isPromptReady) return; // guard against duplicate calls + stopBusyPatternIdleProbe(); // Ready-gate: a startup selector's ❯ (cjadk et al.) falsely matches // readyPattern → the IdleDetector fires idle while the CLI is NOT actually at // its input box. Hold off declaring ready until the SessionStart hook signal @@ -3023,6 +3027,7 @@ async function flushPending(): Promise { let result: Awaited> | undefined; try { result = await cliAdapter.writeInput(backend, msg); + scheduleBusyPatternIdleProbe(`${cliName()} post-submit`); } catch (err: any) { log(`writeInput threw: ${err?.message ?? err}`); // If the CLI exited mid-write the backend already fired onExit (which @@ -3071,7 +3076,7 @@ function sendToPty(content: string, turnId?: string): void { isFlushing, supportsTypeAhead: cliAdapter.supportsTypeAhead === true, awaitingFirstPrompt, - }); + }) && cliAdapter.mergeQueuedInput === true; const mergedQueued = shouldMergeQueued && mergeQueuedCliInput(pendingMessages, next); if (mergedQueued) { log(`Merged queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`); @@ -3341,23 +3346,62 @@ function seedBackendScreen(source: string, be: Pick): string { + return be.captureViewport?.() ?? be.captureCurrentScreen?.() ?? ''; +} + +function probeBusyPatternIdle( + source: string, + be: Pick, +): boolean { + try { + const content = captureBackendScreen(be); + if (!content) return false; + if (cliAdapter?.busyPattern) { + if (cliAdapter.busyPattern.test(content)) return false; + log(`${source} idle probe: busy marker absent, marking prompt ready`); + markPromptReady(); + return true; + } + onPtyData(content); + } catch (err: any) { + log(`${source} idle probe captureCurrentScreen failed: ${err.message}`); + } + return false; +} + function scheduleReattachIdleProbe(source: string, be: Pick): void { setTimeout(() => { if (!awaitingFirstPrompt || isPromptReady || pendingMessages.length > 0) return; - try { - const content = be.captureViewport?.() ?? be.captureCurrentScreen?.() ?? ''; - if (!content) return; - if (cliAdapter?.busyPattern) { - if (cliAdapter.busyPattern.test(content)) return; - log(`${source} idle probe: busy marker absent, marking prompt ready`); - markPromptReady(); - return; - } - onPtyData(content); - } catch (err: any) { - log(`${source} idle probe captureCurrentScreen failed: ${err.message}`); + probeBusyPatternIdle(source, be); + }, IDLE_PROBE_INTERVAL_MS).unref?.(); +} + +function stopBusyPatternIdleProbe(): void { + if (busyPatternIdleProbeTimer) { + clearTimeout(busyPatternIdleProbeTimer); + busyPatternIdleProbeTimer = null; + } +} + +function scheduleBusyPatternIdleProbe(source: string): void { + stopBusyPatternIdleProbe(); + if (!cliAdapter?.busyPattern || !backend?.captureCurrentScreen && !backend?.captureViewport) return; + + let attempts = 0; + const tick = () => { + busyPatternIdleProbeTimer = null; + if (!backend || isPromptReady || pendingMessages.length > 0) return; + attempts += 1; + if (probeBusyPatternIdle(source, backend)) return; + if (attempts < IDLE_PROBE_MAX_ATTEMPTS && !isPromptReady) { + busyPatternIdleProbeTimer = setTimeout(tick, IDLE_PROBE_INTERVAL_MS); + busyPatternIdleProbeTimer.unref?.(); } - }, 3_500).unref?.(); + }; + + busyPatternIdleProbeTimer = setTimeout(tick, IDLE_PROBE_INTERVAL_MS); + busyPatternIdleProbeTimer.unref?.(); } function spawnCli(cfg: Extract): void { @@ -4141,6 +4185,7 @@ function spawnCli(cfg: Extract): void { function killCli(): void { idleDetector?.dispose(); idleDetector = null; + stopBusyPatternIdleProbe(); // Cancel any pending ready-gate fallback / settle timers; spawnCli re-arms on respawn. if (readySignalTimer) { clearTimeout(readySignalTimer); readySignalTimer = null; } if (readyFlushSettleTimer) { clearTimeout(readyFlushSettleTimer); readyFlushSettleTimer = null; } diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index f872baaa..93625f61 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -14,4 +14,15 @@ describe('worker pipe initial screen ordering', () => { const idleIdx = source.indexOf('// Set up idle detection'); expect(captureIdx).toBeGreaterThan(idleIdx); }); + + it('runs a busy-pattern idle probe after each submitted input', () => { + const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + const writeIdx = source.indexOf('result = await cliAdapter.writeInput(backend, msg);'); + const probeIdx = source.indexOf('scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);'); + const helperIdx = source.indexOf('function scheduleBusyPatternIdleProbe(source: string): void'); + + expect(helperIdx).toBeGreaterThan(-1); + expect(writeIdx).toBeGreaterThan(-1); + expect(probeIdx).toBeGreaterThan(writeIdx); + }); }); diff --git a/test/write-input.test.ts b/test/write-input.test.ts index c1961d9f..a325f746 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -503,6 +503,16 @@ describe('supportsTypeAhead flag', () => { expect(adapter.busyPattern?.test('已完成,等待下一条输入')).toBe(false); }); + it('pi: opts into queued-input merging', () => { + expect(createPiAdapter('/bin/pi').mergeQueuedInput).toBe(true); + }); + + it('non-pi type-ahead adapters do not squash queued botmux turns', () => { + expect(createClaudeCodeAdapter('/bin/claude').mergeQueuedInput).toBeUndefined(); + expect(createCocoAdapter('/bin/coco').mergeQueuedInput).toBeUndefined(); + expect(createCodexAdapter('/bin/codex').mergeQueuedInput).toBeUndefined(); + }); + it.each(PLAIN_ADAPTERS.filter(([name]) => name !== 'codex'))('%s: undefined (default behavior)', (_name, adapter) => { expect(adapter.supportsTypeAhead).toBeUndefined(); }); From 42a99cd1e1b8970b442d036333166269cd13aba3 Mon Sep 17 00:00:00 2001 From: "jingxiong.ljx" Date: Wed, 17 Jun 2026 17:34:52 +0800 Subject: [PATCH 5/6] fix(worker): scope busy idle probes --- src/worker.ts | 24 ++++++++++++++----- test/worker-pipe-initial-screen-order.test.ts | 12 ++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index e2b40c98..76c8a93d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -113,6 +113,7 @@ let resumeFallbackNotified = false; const IDLE_PROBE_INTERVAL_MS = 3_500; const IDLE_PROBE_MAX_ATTEMPTS = 24; let busyPatternIdleProbeTimer: ReturnType | null = null; +let reattachIdleProbeTimer: ReturnType | null = null; /** The effectiveResume flag used by the most recent spawnCli call. Written * immediately after the two-tier fallback check so late-attach timers * (hermes, cursor, etc.) can read THE SAME semantics the spawn used, @@ -3363,7 +3364,6 @@ function probeBusyPatternIdle( markPromptReady(); return true; } - onPtyData(content); } catch (err: any) { log(`${source} idle probe captureCurrentScreen failed: ${err.message}`); } @@ -3371,10 +3371,21 @@ function probeBusyPatternIdle( } function scheduleReattachIdleProbe(source: string, be: Pick): void { - setTimeout(() => { - if (!awaitingFirstPrompt || isPromptReady || pendingMessages.length > 0) return; + stopReattachIdleProbe(); + if (!cliAdapter?.busyPattern || (!be.captureCurrentScreen && !be.captureViewport)) return; + reattachIdleProbeTimer = setTimeout(() => { + reattachIdleProbeTimer = null; + if (backend !== be || !awaitingFirstPrompt || isPromptReady) return; probeBusyPatternIdle(source, be); - }, IDLE_PROBE_INTERVAL_MS).unref?.(); + }, IDLE_PROBE_INTERVAL_MS); + reattachIdleProbeTimer.unref?.(); +} + +function stopReattachIdleProbe(): void { + if (reattachIdleProbeTimer) { + clearTimeout(reattachIdleProbeTimer); + reattachIdleProbeTimer = null; + } } function stopBusyPatternIdleProbe(): void { @@ -3386,12 +3397,12 @@ function stopBusyPatternIdleProbe(): void { function scheduleBusyPatternIdleProbe(source: string): void { stopBusyPatternIdleProbe(); - if (!cliAdapter?.busyPattern || !backend?.captureCurrentScreen && !backend?.captureViewport) return; + if (!cliAdapter?.busyPattern || (!backend?.captureCurrentScreen && !backend?.captureViewport)) return; let attempts = 0; const tick = () => { busyPatternIdleProbeTimer = null; - if (!backend || isPromptReady || pendingMessages.length > 0) return; + if (!backend || isPromptReady) return; attempts += 1; if (probeBusyPatternIdle(source, backend)) return; if (attempts < IDLE_PROBE_MAX_ATTEMPTS && !isPromptReady) { @@ -4185,6 +4196,7 @@ function spawnCli(cfg: Extract): void { function killCli(): void { idleDetector?.dispose(); idleDetector = null; + stopReattachIdleProbe(); stopBusyPatternIdleProbe(); // Cancel any pending ready-gate fallback / settle timers; spawnCli re-arms on respawn. if (readySignalTimer) { clearTimeout(readySignalTimer); readySignalTimer = null; } diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index 93625f61..6f60f5c8 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -25,4 +25,16 @@ describe('worker pipe initial screen ordering', () => { expect(writeIdx).toBeGreaterThan(-1); expect(probeIdx).toBeGreaterThan(writeIdx); }); + + it('limits the reattach idle probe to adapters with a busy marker', () => { + const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); + const helperStart = source.indexOf('function scheduleReattachIdleProbe'); + const helperEnd = source.indexOf('function stopReattachIdleProbe'); + const helper = source.slice(helperStart, helperEnd); + + expect(helperStart).toBeGreaterThan(-1); + expect(helper).toContain('if (!cliAdapter?.busyPattern || (!be.captureCurrentScreen && !be.captureViewport)) return;'); + expect(helper).toContain('if (backend !== be || !awaitingFirstPrompt || isPromptReady) return;'); + expect(helper).not.toContain('pendingMessages.length > 0'); + }); }); From b7dfa0c06981af2d94910c9d69a437b5af778c76 Mon Sep 17 00:00:00 2001 From: "jingxiong.ljx" Date: Wed, 17 Jun 2026 17:57:22 +0800 Subject: [PATCH 6/6] fix(pi): rely on busy marker for queued input --- src/adapters/cli/pi.ts | 2 -- test/write-input.test.ts | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/adapters/cli/pi.ts b/src/adapters/cli/pi.ts index 8fcc45f1..9f3709bd 100644 --- a/src/adapters/cli/pi.ts +++ b/src/adapters/cli/pi.ts @@ -43,8 +43,6 @@ export function createPiAdapter(pathOverride?: string): CliAdapter { completionPattern: undefined, busyPattern: /Working\.\.\./, readyPattern: undefined, - supportsTypeAhead: true, - mergeQueuedInput: true, systemHints: BOTMUX_SHELL_HINTS, altScreen: true, skillsDir: '~/.pi/agent/skills', diff --git a/test/write-input.test.ts b/test/write-input.test.ts index a325f746..23fa2151 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -493,8 +493,8 @@ describe('supportsTypeAhead flag', () => { expect(createCodexAdapter('/bin/codex').supportsTypeAhead).toBe(true); }); - it('pi: true (parks submit-while-busy in its TUI queue, avoiding missed idle detection)', () => { - expect(createPiAdapter('/bin/pi').supportsTypeAhead).toBe(true); + it('pi: undefined (uses busy marker probes instead of type-ahead)', () => { + expect(createPiAdapter('/bin/pi').supportsTypeAhead).toBeUndefined(); }); it('pi: exposes Working... as the explicit busy marker', () => { @@ -503,8 +503,8 @@ describe('supportsTypeAhead flag', () => { expect(adapter.busyPattern?.test('已完成,等待下一条输入')).toBe(false); }); - it('pi: opts into queued-input merging', () => { - expect(createPiAdapter('/bin/pi').mergeQueuedInput).toBe(true); + it('pi: does not squash queued botmux turns', () => { + expect(createPiAdapter('/bin/pi').mergeQueuedInput).toBeUndefined(); }); it('non-pi type-ahead adapters do not squash queued botmux turns', () => {