mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
* fix(agent-status): match OSC 7501 spec length limits and progress parsing * fix(agent-status): keep a capped OSC 7501 body whose ST splits across chunks
197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
// Program Status Protocol (OSC 7501) wire grammar: a program reports idle/working/blocked/done/error
|
|
// for itself (root record) or for ids beneath it. Spec: https://www.superlogical.com/rex/docs/build/program-status
|
|
|
|
import { isUtf8ByteLengthWithinLimit } from '../utf8-byte-limits'
|
|
|
|
export const PROGRAM_STATUS_OSC_PREFIX = '\x1b]7501;'
|
|
|
|
/** Negotiate replies: probing programs change rendering (Claude drops title glyphs), so clients cannot answer for glyph-only hosts. */
|
|
export const PROGRAM_STATUS_RUNTIME_CAPABILITY = 'terminal.program-status.v1' as const
|
|
|
|
const PROGRAM_STATUS_STATES = ['idle', 'working', 'done', 'blocked', 'error'] as const
|
|
export type ProgramStatusState = (typeof PROGRAM_STATUS_STATES)[number]
|
|
|
|
const PROGRAM_STATUS_KINDS = ['permission', 'question', 'auth'] as const
|
|
export type ProgramStatusKind = (typeof PROGRAM_STATUS_KINDS)[number]
|
|
|
|
/** The only bytes a supporting terminal ever writes back. Explicit lists so a later-revision
|
|
* program can tell exactly which states and kinds this terminal accepts. */
|
|
export const PROGRAM_STATUS_QUERY_REPLY = `${PROGRAM_STATUS_OSC_PREFIX}?:states=${PROGRAM_STATUS_STATES.join(',')}:kinds=${PROGRAM_STATUS_KINDS.join(',')}\x1b\\`
|
|
|
|
/** The spec caps the whole sequence at 4096 bytes; `ESC ] 7501 ;` and `ESC \\` take 9 of them. */
|
|
export const PROGRAM_STATUS_MAX_BODY_BYTES = 4087
|
|
const MAX_KEY_BYTES = 16
|
|
const MAX_MSG_ENCODED = 2732
|
|
const MAX_MSG_DECODED = 2048
|
|
const MAX_TITLE_ENCODED = 256
|
|
const MAX_TITLE_DECODED = 192
|
|
const MAX_ID_LENGTH = 128
|
|
const MAX_ID_DEPTH = 8
|
|
const MAX_APP_BYTES = 32
|
|
|
|
export type ProgramStatusReport = {
|
|
/** Null addresses the root record. */
|
|
id: string | null
|
|
state: ProgramStatusState | 'clear'
|
|
kind?: ProgramStatusKind
|
|
progress?: number
|
|
app?: string
|
|
title?: string
|
|
msg?: string
|
|
}
|
|
|
|
export type ProgramStatusMessage =
|
|
| { type: 'query' }
|
|
| { type: 'report'; report: ProgramStatusReport }
|
|
|
|
const KEY_RE = /^[a-z]+$/
|
|
const VALUE_RE = /^[A-Za-z0-9_.,+/=-]*$/
|
|
const SEGMENT_RE = /^[A-Za-z0-9_.+-]{1,32}$/
|
|
const APP_RE = /^[A-Za-z0-9_.+-]+$/
|
|
const PROGRESS_RE = /^0*(\d{1,3})$/
|
|
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/
|
|
// oxlint-disable-next-line no-control-regex -- the spec refuses decoded C0/DEL/C1 controls
|
|
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/
|
|
|
|
const utf8Decoder = new TextDecoder('utf-8', { fatal: true })
|
|
|
|
export function isProgramStatusState(value: unknown): value is ProgramStatusState {
|
|
return PROGRAM_STATUS_STATES.some((state) => state === value)
|
|
}
|
|
|
|
export function isProgramStatusKind(value: unknown): value is ProgramStatusKind {
|
|
return PROGRAM_STATUS_KINDS.some((kind) => kind === value)
|
|
}
|
|
|
|
/** Approvals and questions belong to the running turn; a sign-in or unnamed block outlives it. */
|
|
export function isTurnScopedProgramStatusKind(kind: ProgramStatusKind | undefined): boolean {
|
|
return kind === 'permission' || kind === 'question'
|
|
}
|
|
|
|
/** Live states: the only ones whose reports may carry `progress`. */
|
|
export function programStatusStateCarriesProgress(state: ProgramStatusState | 'clear'): boolean {
|
|
return state === 'working' || state === 'blocked'
|
|
}
|
|
|
|
/** done/error outlive the program that reported them, so the user still finds the result. */
|
|
export function programStatusStateOutlivesProgram(state: ProgramStatusState): boolean {
|
|
return state === 'done' || state === 'error'
|
|
}
|
|
|
|
export function isProgramStatusQueryBody(body: string): boolean {
|
|
return body.trim() === '?'
|
|
}
|
|
|
|
function isValidId(id: string): boolean {
|
|
if (id.length > MAX_ID_LENGTH) {
|
|
return false
|
|
}
|
|
const segments = id.split('/')
|
|
return segments.length <= MAX_ID_DEPTH && segments.every((segment) => SEGMENT_RE.test(segment))
|
|
}
|
|
|
|
/** Decoded single-line text, or null when the report must be discarded whole. */
|
|
function decodeText(encoded: string, maxDecoded: number): string | null {
|
|
if (!BASE64_RE.test(encoded)) {
|
|
return null
|
|
}
|
|
const unpadded = encoded.replace(/=+$/, '')
|
|
if (unpadded.length % 4 === 1) {
|
|
return null
|
|
}
|
|
let binary: string
|
|
try {
|
|
binary = atob(unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4))
|
|
} catch {
|
|
return null
|
|
}
|
|
if (binary.length > maxDecoded) {
|
|
return null
|
|
}
|
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
|
|
let text: string
|
|
try {
|
|
text = utf8Decoder.decode(bytes)
|
|
} catch {
|
|
return null
|
|
}
|
|
return CONTROL_RE.test(text) ? null : text
|
|
}
|
|
|
|
/** Parse the body between `OSC 7501;` and ST. Null means ignore (no state, bad id, over a limit). */
|
|
export function parseProgramStatusBody(body: string): ProgramStatusMessage | null {
|
|
// Why: the scanner already caps bodies, but this is exported and must stay bounded on its own.
|
|
if (!isUtf8ByteLengthWithinLimit(body, PROGRAM_STATUS_MAX_BODY_BYTES)) {
|
|
return null
|
|
}
|
|
if (isProgramStatusQueryBody(body)) {
|
|
return { type: 'query' }
|
|
}
|
|
const pairs = new Map<string, string>()
|
|
const texts: Partial<Record<'msg' | 'title', string>> = {}
|
|
for (const pair of body.split(':')) {
|
|
const equals = pair.indexOf('=')
|
|
if (equals === -1) {
|
|
continue
|
|
}
|
|
const key = pair.slice(0, equals).trim()
|
|
const value = pair.slice(equals + 1).trim()
|
|
// Why per occurrence and before the grammar skip: a broken cap voids the report, even if a duplicate follows.
|
|
// The encoded text caps also bound decoding work.
|
|
if (
|
|
!isUtf8ByteLengthWithinLimit(key, MAX_KEY_BYTES) ||
|
|
(key === 'app' && !isUtf8ByteLengthWithinLimit(value, MAX_APP_BYTES)) ||
|
|
(key === 'msg' && !isUtf8ByteLengthWithinLimit(value, MAX_MSG_ENCODED)) ||
|
|
(key === 'title' && !isUtf8ByteLengthWithinLimit(value, MAX_TITLE_ENCODED)) ||
|
|
(key === 'id' && !isValidId(value))
|
|
) {
|
|
return null
|
|
}
|
|
// Malformed pairs are skipped; the rest of the report still applies.
|
|
if (!KEY_RE.test(key) || !VALUE_RE.test(value)) {
|
|
continue
|
|
}
|
|
if (key === 'msg' || key === 'title') {
|
|
// Every occurrence is checked, so a later duplicate cannot launder an over-limit one.
|
|
const text = decodeText(value, key === 'msg' ? MAX_MSG_DECODED : MAX_TITLE_DECODED)
|
|
if (text === null) {
|
|
return null
|
|
}
|
|
texts[key] = text
|
|
continue
|
|
}
|
|
pairs.set(key, value)
|
|
}
|
|
|
|
const state = pairs.get('state')
|
|
if (state === undefined || (state !== 'clear' && !isProgramStatusState(state))) {
|
|
return null
|
|
}
|
|
// Every id occurrence was validated above: a malformed id must not overwrite the root record.
|
|
const rawId = pairs.get('id')
|
|
const report: ProgramStatusReport = { id: rawId ?? null, state }
|
|
|
|
const kind = pairs.get('kind')
|
|
if (state === 'blocked' && kind !== undefined && isProgramStatusKind(kind)) {
|
|
report.kind = kind
|
|
}
|
|
const progressDigits = PROGRESS_RE.exec(pairs.get('progress') ?? '')?.[1]
|
|
if (programStatusStateCarriesProgress(state) && progressDigits !== undefined) {
|
|
const value = Number.parseInt(progressDigits, 10)
|
|
if (value <= 100) {
|
|
report.progress = value
|
|
}
|
|
}
|
|
const app = pairs.get('app')
|
|
if (app !== undefined && APP_RE.test(app)) {
|
|
report.app = app
|
|
}
|
|
if (texts.msg) {
|
|
report.msg = texts.msg
|
|
}
|
|
if (texts.title) {
|
|
report.title = texts.title
|
|
}
|
|
return { type: 'report', report }
|
|
}
|