mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(agent-launch): wait longer for cold-boot Codex composer before dropping prompt (STA-3367)
Continue-in-new-session pastes the handoff prompt once Codex renders its
composer glyph, gated on an 8s readiness budget. A cold/first-run Codex can
take longer than 8s to mount its composer, so the wait timed out and the
prompt was silently dropped into an empty terminal.
Marker-gated ready signals (Codex glyph, opencode show-cursor) are positive
proofs: the paste fires only when the marker actually renders, so a longer
budget can never paste prematurely — it only tolerates slow cold boots. Give
those signals a 20s budget while the markerless quiet-window signal keeps 8s.
* fix(agent-launch): share the composer-readiness budget across all three delivery owners (STA-3367)
The cold-boot fix was correct but landed as a single-path exception, and it
double-spent its own budget. Three follow-ups so the behavior is a system rule:
1. Split the PTY-spawn wait from the composer wait in pasteDraftWhenAgentReady.
Both were handed the same budget, so a codex tab took up to 41s to report a
dropped prompt. "Tab has a PTY" and "composer accepts input" are separate
states: spawn keeps a fixed 8s, and the readiness budget now starts once the
PTY exists, so a slow spawn can't shorten a cold composer's window.
2. Move the per-signal budget to draftPasteReadyBudgetMs() beside the shared
readiness scanner. The budget is a property of the ready signal — only that
module knows which signals are marker-gated — so all three delivery owners
(renderer tab paste, renderer startup paste, main runtime startup paste)
consume one policy instead of three hardcoded 8s constants.
3. Give the main-runtime startup paste the process-ownership fallback both
renderer paths already have. It resolved null on budget expiry, silently
dropping the prompt on worktree-create / CLI / remote-host delivery — the
same STA-3367 failure, on the path the original fix didn't reach.
Adds coverage for the main-runtime waiter, which had none.
Test: vitest src/main/runtime src/shared src/renderer/src/lib
src/renderer/src/components/terminal-pane — all green; tsc clean.
* test(agent-launch): consume the shared readiness budget instead of restating it
Hardcoding 20000 in the runtime waiter test meant it would keep passing if
OrcaRuntimeService stopped consuming draftPasteReadyBudgetMs — the exact drift
this PR exists to prevent. The literal values stay pinned once, in the scanner
test.
* refactor(agent-launch): collapse the readiness budget to one flat timeout
The per-signal budget (marker 20s / quiet-window 8s) tied the timeout to how
readiness is DETECTED. The budget is really a property of how slowly an agent
can boot — a marker, a quiet window, and a process check all wait out the same
cold start — so one number covers all three signals.
Replaces draftPasteReadyBudgetMs() with DRAFT_PASTE_READY_TIMEOUT_MS: drops a
constant, a branch, and two tests, and removes the only reason a delivery path
needed to know which signal class it was using.
Cost: a launch that never emits DECSET 2004 now surfaces its 'prompt not sent'
toast at 20s instead of 8s. That is the failed-launch path only; successful
markerless delivery still resolves on the 1.5s quiet window as before.
* fix(agent-launch): constrain cold Codex readiness budget
* fix(agent-launch): observe Codex readiness from PTY bind
* fix(agent-launch): anchor early Codex prompt to TUI screen
255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
import type { DraftPasteReadySignal } from './tui-agent-config'
|
||
|
||
// Why: agents enable bracketed paste (DECSET 2004) before their composer is
|
||
// actually mounted/focused. These markers let the scanner detect the real
|
||
// "input is ready" moment per agent instead of guessing from output silence.
|
||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||
const CODEX_COMPOSER_PROMPT = '›'
|
||
// Why: opencode emits the DECTCEM show-cursor only once the composer row is
|
||
// mounted and the text cursor is placed in it — a "composer ready" signal,
|
||
// analogous to Codex's prompt glyph. It fires ~2s after bracketed paste is
|
||
// enabled, so gating on it (instead of a quiet window) stops the paste from
|
||
// racing the composer mount under slow/noisy startup. mimo-code uses the same
|
||
// signal by parity; the quiet-window fallback covers any agent that differs.
|
||
const DECTCEM_SHOW_CURSOR = '\x1b[?25h'
|
||
// Why: grok's composer prompt glyph (U+276F), rendered once the input box
|
||
// mounts. It is also the default glyph of popular shell prompts (starship,
|
||
// pure), so it is anchored on the alternate-screen switch below — the shell
|
||
// prompt that precedes the launch command is always in the normal buffer.
|
||
// grok swaps it for `> ` on legacy Windows consoles, which is too generic to
|
||
// match; those fall back to the quiet window and the caller's hard timeout.
|
||
const GROK_COMPOSER_PROMPT = '❯'
|
||
const DECSET_ALT_SCREEN = '\x1b[?1049h'
|
||
const DECRST_ALT_SCREEN = '\x1b[?1049l'
|
||
|
||
type DraftPasteReadySignalSpec = {
|
||
/** Bytes that must precede `marker` for it to count; null when there is no marker. */
|
||
markerAnchor: string | null
|
||
/** Bytes that revoke `markerAnchor` again, for anchors that describe a mode the agent can leave. */
|
||
markerAnchorEnd: string | null
|
||
/** Composer-ready marker, or null for signals that only use the quiet window. */
|
||
marker: string | null
|
||
/** Bytes that arm the quiet-window fallback, or null when the signal has none. */
|
||
quietAnchor: string | null
|
||
}
|
||
|
||
const DRAFT_PASTE_READY_SIGNALS: Record<DraftPasteReadySignal, DraftPasteReadySignalSpec> = {
|
||
'codex-composer-prompt': {
|
||
markerAnchor: DECSET_BRACKETED_PASTE,
|
||
markerAnchorEnd: null,
|
||
marker: CODEX_COMPOSER_PROMPT,
|
||
quietAnchor: null
|
||
},
|
||
'render-cursor-after-bracketed-paste': {
|
||
markerAnchor: DECSET_BRACKETED_PASTE,
|
||
markerAnchorEnd: null,
|
||
marker: DECTCEM_SHOW_CURSOR,
|
||
quietAnchor: null
|
||
},
|
||
'grok-composer-prompt': {
|
||
markerAnchor: DECSET_ALT_SCREEN,
|
||
// Why: leaving the alternate screen hands the terminal back to the shell, whose
|
||
// prompt may be `❯`. Without revoking the anchor, a grok that entered the alt
|
||
// screen and then died — or a pager run from the user's shell rc before grok even
|
||
// launched — would leave the glyph armed forever and paste into the shell.
|
||
markerAnchorEnd: DECRST_ALT_SCREEN,
|
||
marker: GROK_COMPOSER_PROMPT,
|
||
// Why: the quiet window stays on DECSET 2004, independent of the alt-screen
|
||
// marker anchor. grok can be configured to render inline (`--no-alt-screen`,
|
||
// `[ui] screen_mode = "minimal"`), where 1049h never arrives — anchoring the
|
||
// fallback there too would leave the draft with no delivery path at all, and
|
||
// the main-process caller drops the draft when readiness never resolves.
|
||
quietAnchor: DECSET_BRACKETED_PASTE
|
||
},
|
||
'render-quiet-after-bracketed-paste': {
|
||
markerAnchor: null,
|
||
markerAnchorEnd: null,
|
||
marker: null,
|
||
quietAnchor: DECSET_BRACKETED_PASTE
|
||
}
|
||
}
|
||
|
||
/** Longest anchor sequence minus one — the carry needed to rejoin one split across chunks. */
|
||
const ANCHOR_CARRY_CHARS = 7
|
||
|
||
export type DraftPasteReadyScanResult = {
|
||
/** The agent-specific ready signal fired — caller should deliver the paste now. */
|
||
ready: boolean
|
||
/** Caller should (re)arm the quiet-window fallback timer for this chunk. */
|
||
armQuietTimer: boolean
|
||
}
|
||
|
||
/**
|
||
* Pure, incremental scanner shared by the renderer and main-process draft-paste
|
||
* readiness waiters so the two delivery paths (desktop-local vs runtime/SSH/
|
||
* remote) cannot drift. It only parses the PTY byte stream; timers, the PTY
|
||
* subscription, and resolution stay with each caller because their transports
|
||
* and return types differ.
|
||
*
|
||
* Per agent signal:
|
||
* - `codex-composer-prompt`: ready when the `›` glyph renders after DECSET
|
||
* 2004, or when DECSET follows a glyph rendered while Codex owns the
|
||
* alternate screen; never arms the quiet window.
|
||
* - `render-cursor-after-bracketed-paste`: ready when DECTCEM show-cursor
|
||
* (`\x1b[?25h`) renders after DECSET 2004. Like Codex it does NOT arm the
|
||
* quiet window: opencode stays silent for ~1.5-2s between enabling
|
||
* bracketed paste and mounting its composer, so a quiet window would fire
|
||
* during that gap and pre-empt the marker. opencode re-emits show-cursor on
|
||
* every render frame once mounted, so the marker is effectively guaranteed;
|
||
* the caller's hard timeout is the backstop if it never appears.
|
||
* - `grok-composer-prompt`: ready when grok's `❯` glyph renders after the
|
||
* alternate-screen switch (`\x1b[?1049h`). grok shimmers its startup logo
|
||
* until the session opens, so the quiet window alone never settles and the
|
||
* draft waited out the full hard timeout (~8s). The glyph is anchored on the
|
||
* alt-screen switch rather than DECSET 2004 because the shell that runs the
|
||
* launch command emits 2004 too and its own prompt may be `❯` (starship,
|
||
* pure) — anchoring there could paste into the shell. This is the only
|
||
* signal with both a marker and a quiet window, and they use DIFFERENT
|
||
* anchors: grok can render inline (`--no-alt-screen`, `[ui] screen_mode =
|
||
* "minimal"`) and on legacy Windows consoles draws `> ` instead of `❯`, so
|
||
* the marker is best-effort and the 2004-anchored quiet window is the floor
|
||
* that keeps those launches on the pre-existing delivery path. The alt-screen
|
||
* anchor is revoked on `\x1b[?1049l`: leaving it hands the terminal back to
|
||
* the shell, so a glyph after that is the shell's prompt, not grok's composer.
|
||
* - `render-quiet-after-bracketed-paste` (default): no signal marker; arms the
|
||
* quiet window once DECSET 2004 is seen.
|
||
*
|
||
* A 512-byte ring (`recent` / `postAnchorRecent`) covers escape sequences
|
||
* split across chunk boundaries without retaining terminal scrollback.
|
||
*/
|
||
export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal): {
|
||
observe: (data: string) => DraftPasteReadyScanResult
|
||
} {
|
||
let recent = ''
|
||
let postAnchorRecent = ''
|
||
let anchorCarry = ''
|
||
let codexCarry = ''
|
||
let sawMarkerAnchor = false
|
||
let sawQuietAnchor = false
|
||
let codexAltScreen = false
|
||
let sawCodexPromptInAltScreen = false
|
||
|
||
const {
|
||
markerAnchor,
|
||
markerAnchorEnd,
|
||
marker: signalMarker,
|
||
quietAnchor
|
||
} = DRAFT_PASTE_READY_SIGNALS[readySignal]
|
||
|
||
/**
|
||
* Why: an anchor the agent can leave (the alternate screen) has to be tracked in
|
||
* stream ORDER, not as "seen once". Walk the chunk segment by segment so a marker
|
||
* only counts while the anchor is actually held, and re-entering re-arms it.
|
||
* Only reachable for signals that define `markerAnchorEnd`.
|
||
*/
|
||
const scanRevocableAnchorSegments = (window: string, anchor: string, end: string): boolean => {
|
||
let cursor = 0
|
||
while (cursor < window.length) {
|
||
if (!sawMarkerAnchor) {
|
||
const enterIndex = window.indexOf(anchor, cursor)
|
||
if (enterIndex === -1) {
|
||
return false
|
||
}
|
||
sawMarkerAnchor = true
|
||
postAnchorRecent = ''
|
||
cursor = enterIndex + anchor.length
|
||
continue
|
||
}
|
||
const leaveIndex = window.indexOf(end, cursor)
|
||
const segment = leaveIndex === -1 ? window.slice(cursor) : window.slice(cursor, leaveIndex)
|
||
if ((postAnchorRecent + segment).includes(signalMarker ?? '')) {
|
||
return true
|
||
}
|
||
if (leaveIndex === -1) {
|
||
postAnchorRecent = (postAnchorRecent + segment).slice(-512)
|
||
return false
|
||
}
|
||
sawMarkerAnchor = false
|
||
postAnchorRecent = ''
|
||
cursor = leaveIndex + end.length
|
||
}
|
||
return false
|
||
}
|
||
|
||
const scanCodexPreAnchorPrompt = (data: string): void => {
|
||
const window = codexCarry + data
|
||
codexCarry = window.slice(-ANCHOR_CARRY_CHARS)
|
||
let cursor = 0
|
||
while (cursor < window.length) {
|
||
const enterIndex = window.indexOf(DECSET_ALT_SCREEN, cursor)
|
||
const leaveIndex = window.indexOf(DECRST_ALT_SCREEN, cursor)
|
||
const promptIndex = window.indexOf(CODEX_COMPOSER_PROMPT, cursor)
|
||
const nextIndex = Math.min(
|
||
...[enterIndex, leaveIndex, promptIndex].filter((index) => index !== -1)
|
||
)
|
||
if (!Number.isFinite(nextIndex)) {
|
||
return
|
||
}
|
||
if (nextIndex === enterIndex) {
|
||
codexAltScreen = true
|
||
sawCodexPromptInAltScreen = false
|
||
cursor = nextIndex + DECSET_ALT_SCREEN.length
|
||
} else if (nextIndex === leaveIndex) {
|
||
codexAltScreen = false
|
||
sawCodexPromptInAltScreen = false
|
||
cursor = nextIndex + DECRST_ALT_SCREEN.length
|
||
} else {
|
||
if (codexAltScreen) {
|
||
sawCodexPromptInAltScreen = true
|
||
}
|
||
cursor = nextIndex + CODEX_COMPOSER_PROMPT.length
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
observe(data: string): DraftPasteReadyScanResult {
|
||
const combined = recent + data
|
||
recent = combined.slice(-512)
|
||
if (!sawQuietAnchor && quietAnchor !== null && combined.includes(quietAnchor)) {
|
||
sawQuietAnchor = true
|
||
}
|
||
if (readySignal === 'codex-composer-prompt' && !sawMarkerAnchor) {
|
||
scanCodexPreAnchorPrompt(data)
|
||
}
|
||
if (signalMarker !== null && markerAnchor !== null) {
|
||
if (markerAnchorEnd !== null) {
|
||
// Why: carry only the bytes an anchor could straddle, so already-scanned
|
||
// output is never re-walked into a second enter/leave transition.
|
||
const window = anchorCarry + data
|
||
anchorCarry = window.slice(-ANCHOR_CARRY_CHARS)
|
||
if (scanRevocableAnchorSegments(window, markerAnchor, markerAnchorEnd)) {
|
||
return { ready: true, armQuietTimer: false }
|
||
}
|
||
} else if (!sawMarkerAnchor) {
|
||
const anchorIndex = combined.indexOf(markerAnchor)
|
||
if (anchorIndex !== -1) {
|
||
sawMarkerAnchor = true
|
||
if (readySignal === 'codex-composer-prompt' && sawCodexPromptInAltScreen) {
|
||
return { ready: true, armQuietTimer: false }
|
||
}
|
||
const postAnchorChunk = combined.slice(anchorIndex + markerAnchor.length)
|
||
if (postAnchorChunk.includes(signalMarker)) {
|
||
return { ready: true, armQuietTimer: false }
|
||
}
|
||
postAnchorRecent = postAnchorChunk.slice(-512)
|
||
}
|
||
} else {
|
||
if (data.includes(signalMarker) || (postAnchorRecent + data).includes(signalMarker)) {
|
||
return { ready: true, armQuietTimer: false }
|
||
}
|
||
postAnchorRecent = (postAnchorRecent + data).slice(-512)
|
||
}
|
||
}
|
||
// Why: the Codex glyph and opencode show-cursor signals must NOT arm the
|
||
// quiet window (they carry no quiet anchor). opencode goes silent for
|
||
// ~1.5-2s between enabling bracketed paste and mounting its composer, so a
|
||
// quiet window would fire during that gap — before the composer exists —
|
||
// and pre-empt the marker. Those signals wait for their marker, bounded
|
||
// only by the caller's hard timeout (and its best-effort
|
||
// process-ownership paste after that).
|
||
return { ready: false, armQuietTimer: sawQuietAnchor }
|
||
}
|
||
}
|
||
}
|