mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 @@
|
||||
7[r8[?25h[?25l[?2004h[?2031h[?1004h[38;2;215;119;87m ▐[48;2;0;0;0m▛███▛█[12G[39m[49m[1mClaude[19GCode[24G[22m[38;2;153;153;153mv2.1.310[39m␍
|
||||
[38;2;215;119;87m▝▜[48;2;0;0;0m█████[49m█▀[12G[38;2;153;153;153mOpus[17G5.5[21G(1M[25Gcontext)[34G·[36GAPI[40GUsage[46GBilling[39m␍
|
||||
[38;2;215;119;87m ▝▝[7G▝▝[12G[38;2;153;153;153m/private/tmp/agint-repo[39m␍
|
||||
␍
|
||||
␍
|
||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||
❯ [2mTry[7G"fix[12Glint[17Gerrors"[22m␍
|
||||
[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[39m␍
|
||||
[3G[38;2;255;107;128m⏵⏵[6Gbypass[13Gpermissions[25Gon[38;2;153;153;153m (shift+tab[39Gto[42Gcycle)[49G·[51G←[53Gfor[57Gagents[101G◐[103Gmedium[110G·[112G/effort[39m␍
|
||||
[2C[3A[?25h]11;?[c[?25l[2D[3B␍[52C[1A[38;2;153;153;153m4 agents[39m ␍
|
||||
[2C[3A[?25h[>0q[?u[c[?25l[2D[3B␍[2C[1A[48;2;136;192;208m[38;2;153;153;153m [38;2;46;52;64m Opus 5.5 1M [48;2;163;190;140m[38;2;153;153;153m [38;2;46;52;64m agint-repo [48;2;180;142;173m[38;2;153;153;153m [38;2;46;52;64m - · - tokens [39m[49m ␍
|
||||
[3G[38;2;255;107;128m⏵⏵[6Gbypass[13Gpermissions[25Gon[38;2;153;153;153m (shift+tab[39Gto[42Gcycle)[49G·[51G←[53G4[55Gagents[39m␍
|
||||
[2C[4A[?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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user