From 62d9706c5a2affd8be8f0770ddf13b2bd20cdb03 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:23:08 -0700 Subject: [PATCH] fix(agents): start a Claude worker on its composer when its terminal title is off (#33384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude worker-start also becomes ready when Claude's empty composer (❯ + NBSP, only after Claude's DECSET 2031) draws, racing the title-based tui-idle wait, so worker-start no longer times out when Claude's terminal title is disabled. Fixes #22655. --- ...aude-2-1-310-timed-boot-no-title.meta.json | 10 ++ ...de-2-1-310-timed-boot-no-title.timing.json | 1 + .../claude-2-1-310-timed-boot-no-title.txt | 13 +++ .../claude-worker-start-readiness.test.ts | 100 ++++++++++++++++++ .../launched-agent-composer-readiness.test.ts | 7 +- .../launched-agent-composer-readiness.ts | 11 +- ...ched-agent-shell-handoff-readiness.test.ts | 5 +- src/shared/draft-paste-ready-scanner.ts | 16 +++ src/shared/tui-agent-config-types.ts | 3 + src/shared/tui-agent-config.ts | 9 +- 10 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.meta.json create mode 100644 src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.timing.json create mode 100644 src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.txt create mode 100644 src/main/runtime/claude-worker-start-readiness.test.ts diff --git a/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.meta.json b/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.meta.json new file mode 100644 index 00000000000..fb97c8af714 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.meta.json @@ -0,0 +1,10 @@ +{ + "capturedAt": "2026-10-25T03:40:00.000Z", + "platform": "darwin", + "command": ["claude"], + "env": { "CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1", "TERM": "xterm-256color" }, + "cols": 120, + "rows": 40, + "note": "Claude Code 2.1.310, macOS arm64, Python pty in a trusted folder, DA1 answered, no input sent. With its title disabled Claude writes no OSC title at all, so the composer's pointer + NBSP is its only ready signal (#22655). .timing.json holds each PTY chunk as [ms since spawn, UTF-16 length].", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.timing.json b/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.timing.json new file mode 100644 index 00000000000..aa9b129bacb --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.timing.json @@ -0,0 +1 @@ +{"chunks": [[232, 13], [298, 6], [298, 24], [316, 622], [368, 268], [368, 97], [405, 414]], "recordedUntilMs": 6000} diff --git a/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.txt b/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.txt new file mode 100644 index 00000000000..ebb21f3d359 --- /dev/null +++ b/src/main/runtime/__fixtures__/claude-2-1-310-timed-boot-no-title.txt @@ -0,0 +1,13 @@ +78[?25h[?25l[?2004h[?2031h[?1004h ▐β–›β–ˆβ–ˆβ–ˆβ–›β–ˆClaudeCodev2.1.310 +β–β–œβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€Opus5.5(1Mcontext)Β·APIUsageBilling + ▝▝▝▝/private/tmp/agint-repo + + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +❯ Try"fixlinterrors" +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +⏡⏡bypasspermissionson (shift+tabtocycle)Β·←foragents◐mediumΒ·/effort +[?25h]11;?[?25l 4 agents +[?25h[>0q[?u[?25l  ξ‰­ Opus 5.5 1M  󰉋 agint-repo  ο’› - Β· - tokens  +⏡⏡bypasspermissionson (shift+tabtocycle)Β·←4agents +[?25h \ No newline at end of file diff --git a/src/main/runtime/claude-worker-start-readiness.test.ts b/src/main/runtime/claude-worker-start-readiness.test.ts new file mode 100644 index 00000000000..2595eb223bf --- /dev/null +++ b/src/main/runtime/claude-worker-start-readiness.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForWorkerStartComposer } from './launched-agent-composer-readiness' +import { RuntimeMachineName } from './runtime-machine-name' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const NO_TITLE_BOOT = 'claude-2-1-310-timed-boot-no-title' + +describe('a Claude worker start with its terminal title turned off', () => { + beforeEach(() => { + vi.spyOn(RuntimeMachineName.prototype, 'start').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('is ready on its composer, which is the only signal it draws', async () => { + const { chunks, times } = readTimedRuntimeFixture(NO_TITLE_BOOT) + // Presence precondition: no OSC title, so tui-idle alone never settles (#22655). + expect(chunks.join('')).not.toContain('\x1b]0;') + const composerAt = times[chunks.findIndex((chunk) => chunk.includes('❯ '))] + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'claude', + launchAgent: 'claude', + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + const startedAt = Date.now() + let settledAt: number | null = null + void waitForWorkerStartComposer(runtime, handle, 'claude', 20_000).then( + (wait) => { + settledAt = wait.satisfied ? Date.now() - startedAt : null + }, + () => {} + ) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, startedAt + times[index] - Date.now())) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + } + await vi.advanceTimersByTimeAsync(5_000) + expect(settledAt).not.toBeNull() + expect(settledAt).toBeGreaterThanOrEqual(composerAt) + expect(settledAt).toBeLessThan(composerAt + 1_000) + }) +}) + +describe('the Claude composer marker', () => { + const firesOn = (fixture: string): boolean => { + const scanner = createDraftPasteReadyScanner('claude-composer-prompt') + return scanner.observe(readRuntimeFixture(fixture)).ready + } + + it('does not fire on the workspace-trust menu, which draws the same pointer', () => { + expect(readRuntimeFixture('claude-dialog-trust-workspace')).toContain('❯') + expect(firesOn('claude-dialog-trust-workspace')).toBe(false) + }) + + it('fires once that menu is answered and the composer mounts', () => { + expect(firesOn('claude-dialog-trust-workspace-answered')).toBe(true) + }) + + // A powerlevel10k/starship prompt draws the same `❯` + NBSP after the shell's own DECSET 2004. + const SHELL_PROMPT = '\x1b[?2004h~/repo \x1b[32m❯ \x1b[0m' + + it('does not fire on a shell prompt that draws the same glyph', () => { + // Presence precondition: the shell turns bracketed paste on just as Claude does. + expect(readRuntimeFixture('zsh-prompt-runs-command')).toContain('\x1b[?2004h') + const scanner = createDraftPasteReadyScanner('claude-composer-prompt') + expect(scanner.observe(readRuntimeFixture('zsh-prompt-runs-command')).ready).toBe(false) + expect(scanner.observe(SHELL_PROMPT).ready).toBe(false) + }) + + it('does not fire on a fish prompt cycle, which holds palette reports while idle', () => { + const scanner = createDraftPasteReadyScanner('claude-composer-prompt') + // fish 4+: 2031/2004 on at its prompt, off when it runs the launch line. + expect(scanner.observe('\x1b[?2031h\x1b[?2004h~/repo> claude\r\n').ready).toBe(false) + expect(scanner.observe('\x1b[?2004l\x1b[?2031l').ready).toBe(false) + expect(scanner.observe(SHELL_PROMPT).ready).toBe(false) + }) + + it('does not fire on the shell prompt drawn after Claude exits', () => { + const scanner = createDraftPasteReadyScanner('claude-composer-prompt') + const boot = readRuntimeFixture('claude-dialog-trust-workspace') + expect(scanner.observe(boot).ready).toBe(false) + // Claude drops its modes on exit, as the answered-trust capture shows mid-boot. + expect(scanner.observe('\x1b[?1004l\x1b[?2031l\x1b[?2004l\r\n').ready).toBe(false) + expect(scanner.observe(SHELL_PROMPT).ready).toBe(false) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index cf748cf5aba..a6274f21402 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -131,8 +131,9 @@ describe('which lane a freshly launched worker waits on', () => { describe('which idle-lane worker starts also answer on their composer marker', () => { // Their only rest signal is their bare name, which a launch holds to quiet output; Grok's logo - // animates for ten seconds after its composer glyph. Gemini's title needs corroboration: absent. - it('is exactly the bare-name agents whose composer draws a marker', () => { + // animates for ten seconds after its composer glyph. Claude's `✳` title can be turned off. + // Gemini's title needs corroboration: absent. + it('is exactly the bare-name or title-optional agents whose composer draws a marker', () => { expect( Object.keys(EXPECTED_LANES) .filter(isTuiAgent) @@ -142,7 +143,7 @@ describe('which idle-lane worker starts also answer on their composer marker', ( workerStartReadsComposerMarker(agent) ) .sort() - ).toEqual(['dsh', 'grok', 'mimo-code']) + ).toEqual(['claude', 'claude-agent-teams', 'dsh', 'grok', 'mimo-code']) }) }) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts index 5827b9f73a5..114f6b7b205 100644 --- a/src/main/runtime/launched-agent-composer-readiness.ts +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -97,16 +97,17 @@ export async function waitForWorkerStartComposer( } /** - * An agent whose only rest signal is its bare name, which a launch holds to quiet output, and whose - * composer draws a marker: that marker answers first. Grok draws its glyph at 0.6 s, then animates - * its logo for ten. + * An agent whose composer draws a marker, and whose only rest signal is its bare name (which a + * launch holds to quiet output) or a title the user can turn off: that marker answers first. Grok + * draws its glyph at 0.6 s, then animates its logo for ten; Claude without its title never settles. */ export function workerStartReadsComposerMarker(agent: TuiAgent): boolean { - const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal + const config = TUI_AGENT_CONFIG[agent] + const signal = config.draftPasteReadySignal return ( signal !== undefined && draftPasteReadySignalHasMarker(signal) && - !nameOnlyIdleNeedsCorroboration(agent) + (config.workerStartReadsComposerMarker === true || !nameOnlyIdleNeedsCorroboration(agent)) ) } diff --git a/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts b/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts index cace85e2859..f8296efe0d5 100644 --- a/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts +++ b/src/main/runtime/launched-agent-shell-handoff-readiness.test.ts @@ -98,10 +98,9 @@ describe('a launched agent’s ready signal, after the shell that ran it', () => await vi.advanceTimersByTimeAsync(3_000) expect(pane.settled).not.toHaveBeenCalled() + // Claude's composer marker answers once it draws, ahead of the quiet window. pane.emit(readFixture('claude-dialog-trust-workspace-answered')) - await vi.advanceTimersByTimeAsync(QUIET_WINDOW_MS - 100) - expect(pane.settled).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(200) + await vi.advanceTimersByTimeAsync(100) expect(pane.settled).toHaveBeenCalledWith('pty-1') }) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index 532de328b70..a8c1470204b 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -27,6 +27,14 @@ const GROK_COMPOSER_PROMPT = '❯' // means the composer specifically. Anchored on the alternate-screen switch for the same // reason as grok: a powerline shell prompt can also draw `β•­`. const ZCODE_COMPOSER_BOX_CORNER = 'β•­' +// Why: Claude Code's prompt-input mode indicator is `figures.pointer` + NBSP; its startup menus +// (trust, theme) draw the same pointer followed by a cursor move, so the NBSP means the composer. +const CLAUDE_COMPOSER_PROMPT = '❯\u00a0' +// Why: Claude 2.1.310 turns on palette-change reports with bracketed paste and drops both on exit. +// zsh/bash never send it, so their p10k/starship `❯` prompt cannot arm the marker; fish 4+ holds it +// only while idle at its prompt, where a `❯` + NBSP glyph is unlikely. +const DECSET_COLOR_PALETTE_UPDATES = '\x1b[?2031h' +const DECRST_COLOR_PALETTE_UPDATES = '\x1b[?2031l' const DECSET_ALT_SCREEN = '\x1b[?1049h' const DECRST_ALT_SCREEN = '\x1b[?1049l' @@ -88,6 +96,14 @@ const DRAFT_PASTE_READY_SIGNALS: Record marker: GROK_COMPOSER_PROMPT, quietAnchor: DECSET_BRACKETED_PASTE }, + // Why: Claude renders inline (no alt screen); the quiet window keeps the old delivery where the + // glyph differs (Windows `figures` fallback). + 'claude-composer-prompt': { + markerAnchor: DECSET_COLOR_PALETTE_UPDATES, + markerAnchorEnd: DECRST_COLOR_PALETTE_UPDATES, + marker: CLAUDE_COMPOSER_PROMPT, + quietAnchor: DECSET_BRACKETED_PASTE + }, 'zcode-composer-prompt': { markerAnchor: DECSET_ALT_SCREEN, markerAnchorEnd: DECRST_ALT_SCREEN, diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index 53ae4e9ea7a..dc5908b8063 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -13,6 +13,7 @@ export type DraftPasteMarkerSignal = | 'render-cursor-after-bracketed-paste' | 'grok-composer-prompt' | 'dsh-composer-prompt' + | 'claude-composer-prompt' | 'zcode-composer-prompt' /** Submit signals may also ask for a grace (`readyAfterMs`); only the paste waiters honour it. */ @@ -47,6 +48,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteMarkerSignal /** Signal before a paste that Enter follows, for agents that show their composer before it can submit; defaults to `draftPasteReadySignal`. */ submitPasteReadySignal?: DraftPasteReadySignal + /** A worker start also answers on the composer marker, for an agent whose rest title the user can turn off. */ + workerStartReadsComposerMarker?: true /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number /** Captured boots proving the composer marker used for fresh worker dispatch (the submit signal where set). */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index aa228fb2653..1dc0854d56b 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -25,7 +25,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { pasteNeedsTypedRequest: true, // Why: `claude --prefill ` seeds the input without submitting, avoiding the paste-after-ready race (PR https://github.com/stablyai/orca/pull/926). draftPromptFlag: '--prefill', - preflightTrust: 'claude' + preflightTrust: 'claude', + // Why: CLAUDE_CODE_DISABLE_TERMINAL_TITLE drops the `✳` title tui-idle waits on (#22655). + draftPasteReadySignal: 'claude-composer-prompt', + workerStartReadsComposerMarker: true }, 'claude-agent-teams': { // Why: an Orca-provided launch mode, not a separate binary; detection follows the Orca CLI. @@ -43,7 +46,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { expectedProcess: 'claude', promptInjectionMode: 'stdin-after-start', pasteNeedsTypedRequest: true, - preflightTrust: 'claude' + preflightTrust: 'claude', + draftPasteReadySignal: 'claude-composer-prompt', + workerStartReadsComposerMarker: true }, codebuddy: { detectCmd: 'codebuddy',