fix(agents): start a Claude worker on its composer when its terminal title is off (#33384)

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.
This commit is contained in:
Neil
2026-10-24 22:23:08 -07:00
committed by GitHub
parent 95df4c74ed
commit 62d9706c5a
10 changed files with 162 additions and 13 deletions
@@ -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). <name>.timing.json holds each PTY chunk as [ms since spawn, UTF-16 length].",
"exitCode": null
}
@@ -0,0 +1 @@
{"chunks": [[232, 13], [298, 6], [298, 24], [316, 622], [368, 268], [368, 97], [405, 414]], "recordedUntilMs": 6000}
@@ -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
@@ -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)
})
})
@@ -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'])
})
})
@@ -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))
)
}
@@ -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')
})
+16
View File
@@ -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<SingleSignal, DraftPasteReadySignalSpec>
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,
+3
View File
@@ -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). */
+7 -2
View File
@@ -25,7 +25,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
pasteNeedsTypedRequest: true,
// Why: `claude --prefill <text>` 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<TuiAgent, TuiAgentConfigSource> = {
expectedProcess: 'claude',
promptInjectionMode: 'stdin-after-start',
pasteNeedsTypedRequest: true,
preflightTrust: 'claude'
preflightTrust: 'claude',
draftPasteReadySignal: 'claude-composer-prompt',
workerStartReadsComposerMarker: true
},
codebuddy: {
detectCmd: 'codebuddy',