mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
* fix(runtime): agent-neutral wait-blocked reasons and non-Gemini Antigravity readiness Reported by a user via the in-app help menu (report "not captured", 1.4.198). The trust/interactive/update/cwd prompt matchers are agent-agnostic - they match on dialog wording and never inspect the pane's agent - yet emitted hardcoded codex-* reasons. Those reached users verbatim in worker receipts (local-worker-start, federation), two automation surfaces, and raw CLI output, so an Antigravity user was told they had a Codex problem. findAntigravityReadyPromptIndex also required the model line to start with the literal "gemini". Antigravity CLI is not Gemini-only, so a non-Gemini session never registered as ready, stale trust text was never superseded, and the pane stayed blocked - which is why dispatch --inject answered agent_prompt_blocked. Add agent-neutral reasons additively (codex-* members kept on the wire per docs/reference/remote-wire-compatibility.md, with a legacy alias for older hosts) and decide Antigravity readiness structurally: header, then model/account rows, then the prompt caret. codex-model-migration-prompt and codex-hooks-review-prompt stay Codex-named - both key on Codex's own wording. * fix(runtime): finish the agent-neutral rename, revert the Antigravity readiness rewrite Review follow-up on this branch. Splits the two halves of the original commit: the reason rename lands, the Antigravity readiness detector goes back to merge-base until someone captures a real transcript. Rename half: - 'hooks need review' + 'press enter to confirm' inspects no agent, so it now publishes agent-hooks-review-prompt. That was the last agent-agnostic codex-* emission left, and it is the one the original report was about: a Claude Code user hitting a hooks dialog still read "codex-hooks-review-prompt". - The legacy alias is applied at all three surfaces that render a raw reason, not just the CLI. describeTerminalWaitBlockedReason() is the single formatter; the worker and federation "Agent startup blocked:" receipts use it too. Kept one-directional: nothing consumes agent-* -> codex-*, since an old client renders with its own shipped code. - Restores the compat note deleted at the permission-choices site. The Rule 1 citation is correct - remote-wire-compatibility.md names this enum by name. Antigravity half, reverted: findAntigravityReadyPromptIndex goes back to merge-base (header + a 'gemini' model line + a lone '>' caret) and antigravity-ready-prompt-index.ts is removed. Executing both builds against constructed tails, the rewrite read a live startup dialog as ready. Adding the account row from this repo's own ready-screen fixture to five silent startup dialogs (sign-in, model picker, theme picker, privacy notice, update banner) flipped all five from unready to ready; so did any narration line containing an email address, with no account row at all. Readiness is what gates typing the task prompt into the pane, so that path types a task prompt into a live authentication dialog. Merge-base returns unready for all ten. The rewrite also did not reliably fix the wedge it targeted: with no account row and a non-Gemini model - a personal or API-key user - it still returns unready. No real Antigravity transcript exists in this repo. The cursor-agent rules are derived from captures under src/main/runtime/__fixtures__; Antigravity has no equivalent, and every attempt so far has been tuned against a hand-written 5-line fixture. A false negative (the agent waits) is safer than a false positive (we type into an auth dialog), so this ships the known behaviour. Reverting restores a pre-existing gap, not a regression: a non-Gemini Antigravity session wedges on merge-base too. Closing it needs a captured ready screen and a captured dismissed-dialog screen, for a personal/API-key account as well as a Business one. Tests: - Ten ratchet fixtures pin the shapes any replacement detector must refuse - the five silent dialogs with an account row, and each with a narrated email. All ten fail against the reverted rewrite. - Vacuous tests rewritten so they fail without the code they cover: the CLI alias tests asserted only the absence of a suffix, and the worker receipt test asserted the raw token. Tests that are characterization rather than a guard now say so on the line above. --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <neil@stably.ai>
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict'
|
|
import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy'
|
|
import { describeTerminalWaitBlockedReason } from '../shared/terminal-wait-blocked-reason-legacy-alias'
|
|
import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors'
|
|
import type {
|
|
RuntimeTerminalClose,
|
|
RuntimeTerminalCreate,
|
|
RuntimeTerminalFocus,
|
|
RuntimeTerminalListResult,
|
|
RuntimeTerminalVisualLayout,
|
|
RuntimeTerminalVisualLayoutNode,
|
|
RuntimeTerminalVisualPaneNode,
|
|
RuntimeTerminalVisualTab,
|
|
RuntimeTerminalRead,
|
|
RuntimeTerminalRename,
|
|
RuntimeTerminalSend,
|
|
RuntimeTerminalShow,
|
|
RuntimeTerminalSplit,
|
|
RuntimeTerminalWait
|
|
} from '../shared/runtime-types'
|
|
|
|
export function formatTerminalList(
|
|
result: WithAnnotatedHostScope<RuntimeTerminalListResult>
|
|
): string {
|
|
const scope = formatListingHostScope(result.hostScope)
|
|
if (result.terminals.length === 0) {
|
|
return `No terminals listed.\n${scope}`
|
|
}
|
|
const body = result.terminals
|
|
.map(
|
|
(terminal) =>
|
|
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
|
|
)
|
|
.join('\n\n')
|
|
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
|
|
const bodyWithLayout = visualLayout ? `${body}\n\nvisual layout:\n${visualLayout}` : body
|
|
const bodyWithScope = `${bodyWithLayout}\n\n${scope}`
|
|
return result.truncated
|
|
? `${bodyWithScope}\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
|
|
: bodyWithScope
|
|
}
|
|
|
|
function formatTerminalVisualLayouts(
|
|
layouts: readonly RuntimeTerminalVisualLayout[] | undefined
|
|
): string | null {
|
|
if (!layouts || layouts.length === 0) {
|
|
return null
|
|
}
|
|
return layouts
|
|
.map((layout) =>
|
|
[
|
|
`worktree: ${layout.worktreePath || layout.worktreeId}`,
|
|
...formatVisualLayoutNode(layout.root, 0)
|
|
].join('\n')
|
|
)
|
|
.join('\n\n')
|
|
}
|
|
|
|
function formatVisualLayoutNode(node: RuntimeTerminalVisualLayoutNode, depth: number): string[] {
|
|
const indent = ' '.repeat(depth)
|
|
if (node.type === 'split') {
|
|
return [
|
|
`${indent}split ${node.direction}`,
|
|
...formatVisualLayoutNode(node.first, depth + 1),
|
|
...formatVisualLayoutNode(node.second, depth + 1)
|
|
]
|
|
}
|
|
return [
|
|
`${indent}group ${node.groupId ?? '(default)'}`,
|
|
...node.tabs.flatMap((tab) => formatVisualTab(tab, depth + 1))
|
|
]
|
|
}
|
|
|
|
function formatVisualTab(tab: RuntimeTerminalVisualTab, depth: number): string[] {
|
|
const indent = ' '.repeat(depth)
|
|
return [
|
|
`${indent}tab ${tab.tabId} ${tab.title ?? '(untitled)'}`,
|
|
...formatVisualPaneNode(tab.panes, depth + 1)
|
|
]
|
|
}
|
|
|
|
function formatVisualPaneNode(node: RuntimeTerminalVisualPaneNode, depth: number): string[] {
|
|
const indent = ' '.repeat(depth)
|
|
if (node.type === 'pane-split') {
|
|
return [
|
|
`${indent}pane split ${node.direction}`,
|
|
...formatVisualPaneNode(node.first, depth + 1),
|
|
...formatVisualPaneNode(node.second, depth + 1)
|
|
]
|
|
}
|
|
const marker = node.active ? '* ' : ' '
|
|
return [
|
|
`${indent}${marker}${node.handle} ${node.title ?? '(untitled)'} tab=${node.tabId} leaf=${node.leafId}`
|
|
]
|
|
}
|
|
|
|
export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {
|
|
const terminal = result.terminal
|
|
return [
|
|
`handle: ${terminal.handle}`,
|
|
`title: ${terminal.title ?? '(untitled)'}`,
|
|
`worktree: ${terminal.worktreePath}`,
|
|
`branch: ${terminal.branch}`,
|
|
`leaf: ${terminal.leafId}`,
|
|
`ptyId: ${terminal.ptyId ?? 'none'}`,
|
|
`connected: ${terminal.connected}`,
|
|
`writable: ${terminal.writable}`,
|
|
// Why listed above the preview: the preview is where a reader would otherwise have to
|
|
// spot the prompt by eye, which is the work this line exists to remove.
|
|
`agentWait: ${formatAgentWait(terminal.agentWait)}`,
|
|
`preview: ${terminal.preview || '<empty>'}`
|
|
].join('\n')
|
|
}
|
|
|
|
function formatAgentWait(agentWait: RuntimeTerminalShow['agentWait']): string {
|
|
if (agentWait === undefined) {
|
|
return 'unknown (not evaluated)'
|
|
}
|
|
if (!agentWait) {
|
|
return 'none'
|
|
}
|
|
if (!agentWait.reason) {
|
|
return `interactive prompt (via ${agentWait.source})`
|
|
}
|
|
return `${describeTerminalWaitBlockedReason(agentWait.reason)} (via ${agentWait.source})`
|
|
}
|
|
|
|
export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string {
|
|
const terminal = result.terminal
|
|
const oldestCursor =
|
|
typeof terminal.oldestCursor === 'string' ? [`oldest cursor: ${terminal.oldestCursor}`] : []
|
|
const latestCursor =
|
|
typeof terminal.latestCursor === 'string' ? [`latest cursor: ${terminal.latestCursor}`] : []
|
|
const limitedWarning = formatTerminalReadLimitedWarning(terminal)
|
|
const header = [
|
|
`handle: ${terminal.handle}`,
|
|
`status: ${terminal.status}`,
|
|
...(terminal.source ? [`source: ${terminal.source}`] : []),
|
|
...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []),
|
|
...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []),
|
|
...oldestCursor,
|
|
...latestCursor,
|
|
...(terminal.truncated ? ['warning: older output is no longer retained'] : []),
|
|
...(limitedWarning ? [limitedWarning] : []),
|
|
// Why: the caller asked for the rendered screen; say plainly that this is not it rather
|
|
// than let repaint fragments be read as what the terminal displayed.
|
|
...(terminal.source === 'screen-unavailable'
|
|
? [
|
|
'warning: no rendered screen was available, so this is accumulated output; repainted lines may appear as stacked fragments'
|
|
]
|
|
: [])
|
|
]
|
|
return [...header, '', ...terminal.tail].join('\n')
|
|
}
|
|
|
|
function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string | null {
|
|
if (!terminal.limited) {
|
|
return null
|
|
}
|
|
if (
|
|
typeof terminal.nextCursor === 'string' &&
|
|
typeof terminal.latestCursor === 'string' &&
|
|
terminal.nextCursor !== terminal.latestCursor
|
|
) {
|
|
return `warning: output limited; continue with --cursor ${terminal.nextCursor}`
|
|
}
|
|
if (
|
|
typeof terminal.oldestCursor === 'string' &&
|
|
typeof terminal.latestCursor === 'string' &&
|
|
terminal.oldestCursor !== terminal.latestCursor
|
|
) {
|
|
// A tail preview's next cursor is already latest, so oldestCursor is the retained history entry point.
|
|
return `warning: output limited; page retained output with --cursor ${terminal.oldestCursor} --limit <count>`
|
|
}
|
|
return 'warning: output limited'
|
|
}
|
|
|
|
export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
|
|
if (result.send.agentSessionRefusal) {
|
|
const copy = structuredChatPtyWriteRefusalCopy(result.send.agentSessionRefusal, 'terminal-send')
|
|
if (copy) {
|
|
return copy
|
|
}
|
|
}
|
|
if (!result.send.accepted) {
|
|
const reason = result.send.refusedReason ? `: ${result.send.refusedReason}` : ''
|
|
return `Input refused by ${result.send.handle}${reason}.`
|
|
}
|
|
const prompt = result.send.prompt
|
|
if (!prompt) {
|
|
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
|
|
}
|
|
return [
|
|
`Prompt ${prompt.requestId} on ${result.send.handle}: ${prompt.stages.join(' -> ')}.`,
|
|
`provider: ${prompt.provider}`,
|
|
`delivery observation: ${prompt.observation}`,
|
|
...terminalSendWarnings(result.send).map((warning) => `warning: ${warning}`)
|
|
].join('\n')
|
|
}
|
|
|
|
/** The same warnings the text formatter prints, so a --json caller sees them too. */
|
|
export function terminalSendWarnings(send: RuntimeTerminalSend): string[] {
|
|
const warning = send.accepted && send.prompt ? promptObservationWarning(send.prompt) : null
|
|
return warning ? [warning] : []
|
|
}
|
|
|
|
function promptObservationWarning(
|
|
prompt: NonNullable<RuntimeTerminalSend['prompt']>
|
|
): string | null {
|
|
if (prompt.observation === 'permission') {
|
|
return `delivery was not observed because the provider requires permission. Resolve the permission prompt in the terminal, then reissue the exact command with --retry-request ${prompt.requestId} and --wait-submit <seconds>.`
|
|
}
|
|
if (prompt.observation === 'incarnation_replaced') {
|
|
return 'delivery was not observed because the terminal process was replaced. Inspect the current terminal before sending a new prompt; do not retry with this request ID.'
|
|
}
|
|
// Ordered before the unsupported arm: an agent provider that never reached turn_started
|
|
// needs the swallowed-Enter recovery even if this host could not observe the submit.
|
|
if (prompt.provider !== 'unsupported' && prompt.provider !== 'old-host') {
|
|
return prompt.stages.includes('turn_started')
|
|
? null
|
|
: `input was accepted but no turn start was observed, so the Enter may have been swallowed. Confirm delivery by reissuing the exact command with --retry-request ${prompt.requestId} --wait-submit <seconds>; the same request ID replays the receipt instead of sending the prompt again.`
|
|
}
|
|
if (prompt.observation === 'unsupported') {
|
|
return prompt.provider === 'old-host'
|
|
? 'this host predates durable prompt receipts. Update Orca on the execution host, and inspect the terminal before retrying an ambiguous send.'
|
|
: 'input was accepted, but this provider cannot report delivery. Inspect the terminal before retrying.'
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string {
|
|
return result.rename.title
|
|
? `Renamed terminal ${result.rename.handle} to "${result.rename.title}".`
|
|
: `Cleared title for terminal ${result.rename.handle}.`
|
|
}
|
|
|
|
export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string {
|
|
const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : ''
|
|
const surfaceNote = result.terminal.surface ? ` [${result.terminal.surface}]` : ''
|
|
const warningNote = result.terminal.warning ? `\nwarning: ${result.terminal.warning}` : ''
|
|
return `Created terminal ${result.terminal.handle}${titleNote}${surfaceNote}${warningNote}`
|
|
}
|
|
|
|
export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string {
|
|
return `Split pane ${result.split.handle} in tab ${result.split.tabId}`
|
|
}
|
|
|
|
export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string {
|
|
if (result.focus.navigated === false) {
|
|
return `Focus request for terminal ${result.focus.handle} was superseded or host navigation was skipped (tab ${result.focus.tabId}).`
|
|
}
|
|
return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).`
|
|
}
|
|
|
|
/** "PTY killed." is a claim of observed death, so only a confirmed kill earns it. */
|
|
function describePtyStop(close: RuntimeTerminalClose): string {
|
|
if (close.ptyKilled) {
|
|
return ' PTY killed.'
|
|
}
|
|
if (close.ptyStopVerdict === 'live') {
|
|
return ` ${PTY_LIVE_NOTE}`
|
|
}
|
|
if (close.ptyStopVerdict === 'unverifiable') {
|
|
return ` ${describeUnconfirmedStop(close.ptyStopReason ?? 'its host could not be reached')}`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string {
|
|
if (result.close.closeMode === 'tab') {
|
|
return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).`
|
|
}
|
|
return `Closed terminal ${result.close.handle}.${describePtyStop(result.close)}`
|
|
}
|
|
|
|
export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
|
|
const lines = [
|
|
`handle: ${result.wait.handle}`,
|
|
`condition: ${result.wait.condition}`,
|
|
`satisfied: ${result.wait.satisfied}`,
|
|
`status: ${result.wait.status}`,
|
|
`exitCode: ${result.wait.exitCode ?? 'null'}`
|
|
]
|
|
if (result.wait.blockedReason) {
|
|
lines.push(`blockedReason: ${describeTerminalWaitBlockedReason(result.wait.blockedReason)}`)
|
|
}
|
|
return lines.join('\n')
|
|
}
|