Files
orca/src/shared/osc-title-extraction.ts
T
Neil e3c9370da4 feat(agent-status): read OSC 7501 program status beside hooks (#30261)
Programs can tell Orca what they are doing through the Program Status
Protocol (OSC 7501); Claude Code, Codex, Grok, pi and oh-my-pi already do.
Orca answers the protocol's feature-detection probe (visible xterm and the
hidden-pane emulator), parses reports from PTY output on the execution host
(src/shared/program-status/), and keeps two lanes:

- Lifecycle state keeps one owner per pane. Hooks win; OSC 7501 drives the
  row when hooks are off, missing, or silent on a state the program has
  left (server/program-status/hook-precedence).
- The program's own report (progress, its one-line message, blocks such as
  sign-in) is a live-only `programStatus` facet kept per pane
  (server/program-status/facet-store), stitched onto rows at publish time
  and carried to the renderer store, mobile, paired clients and
  `worktree ps`. Facet-only changes patch just the facet.

Title-driven readers (idle edges, delivery, tui-idle, exit inference)
classify by the program's OSC 7501 activity while it reports one and fall
back to title glyphs only for agents that report nothing explicit, so they
keep working when Claude stops painting its title glyph after the probe.
2026-10-18 22:12:37 -07:00

145 lines
4.3 KiB
TypeScript

const ESC_CODE_UNIT = 0x1b
const BEL_CODE_UNIT = 0x07
const RIGHT_BRACKET_CODE_UNIT = 0x5d
const BACKSLASH_CODE_UNIT = 0x5c
const SEMICOLON_CODE_UNIT = 0x3b
const OSC_TITLE_COMMANDS = new Set([0x30, 0x31, 0x32])
export const MAX_OSC_TITLE_CHARS = 1024
export const MAX_OSC_TITLES_PER_CHUNK = 4096
type OscTitleParseResult =
| { kind: 'title'; title: string; nextIndex: number }
| { kind: 'invalid'; nextIndex: number }
| { kind: 'incomplete' }
function isOscIntroducerAt(data: string, index: number): boolean {
return (
data.charCodeAt(index) === ESC_CODE_UNIT &&
data.charCodeAt(index + 1) === RIGHT_BRACKET_CODE_UNIT
)
}
function parseOscTitleAt(data: string, index: number): OscTitleParseResult {
if (!isOscIntroducerAt(data, index)) {
return { kind: 'invalid', nextIndex: index + 1 }
}
if (
!OSC_TITLE_COMMANDS.has(data.charCodeAt(index + 2)) ||
data.charCodeAt(index + 3) !== SEMICOLON_CODE_UNIT
) {
return { kind: 'invalid', nextIndex: index + 2 }
}
const titleStart = index + 4
for (let cursor = titleStart; cursor < data.length; cursor += 1) {
const code = data.charCodeAt(cursor)
if (code === BEL_CODE_UNIT) {
return {
kind: 'title',
title: readBoundedOscTitle(data, titleStart, cursor),
nextIndex: cursor + 1
}
}
if (code !== ESC_CODE_UNIT) {
continue
}
if (data.charCodeAt(cursor + 1) === BACKSLASH_CODE_UNIT) {
return {
kind: 'title',
title: readBoundedOscTitle(data, titleStart, cursor),
nextIndex: cursor + 2
}
}
return { kind: 'invalid', nextIndex: cursor }
}
return { kind: 'incomplete' }
}
function readBoundedOscTitle(data: string, titleStart: number, titleEnd: number): string {
// Why: PTY output can contain pasted or remote-controlled OSC titles; keep
// downstream title detection bounded while preserving trailing status words.
const length = titleEnd - titleStart
if (length <= MAX_OSC_TITLE_CHARS) {
return data.slice(titleStart, titleEnd)
}
const prefixLength = Math.ceil(MAX_OSC_TITLE_CHARS / 2)
const suffixLength = MAX_OSC_TITLE_CHARS - prefixLength
return (
data.slice(titleStart, titleStart + prefixLength) +
data.slice(titleEnd - suffixLength, titleEnd)
)
}
export function extractLastOscTitle(data: string): string | null {
if (!data.includes('\x1b]')) {
return null
}
let last: string | null = null
let searchStart = 0
// Why: raw PTY chunks can include large pasted content. Parse OSC titles
// directly instead of running a global regex over the whole chunk.
while (searchStart < data.length) {
const start = data.indexOf('\x1b]', searchStart)
if (start === -1) {
break
}
const parsed = parseOscTitleAt(data, start)
if (parsed.kind === 'incomplete') {
break
}
if (parsed.kind === 'title') {
last = parsed.title
searchStart = parsed.nextIndex
continue
}
searchStart = parsed.nextIndex
}
return last
}
export function extractAllOscTitles(data: string): string[] {
return scanAllOscTitles(data, (title) => title)
}
/** Every complete OSC title with the offset just past it, for ordering titles against other events. */
export function extractAllOscTitleEntries(data: string): { title: string; end: number }[] {
return scanAllOscTitles(data, (title, end) => ({ title, end }))
}
function scanAllOscTitles<T>(data: string, make: (title: string, end: number) => T): T[] {
if (!data.includes('\x1b]')) {
return []
}
const titles: T[] = []
let oldestTitleIndex = 0
let searchStart = 0
while (searchStart < data.length) {
const start = data.indexOf('\x1b]', searchStart)
if (start === -1) {
break
}
const parsed = parseOscTitleAt(data, start)
if (parsed.kind === 'incomplete') {
break
}
if (parsed.kind === 'title') {
const entry = make(parsed.title, parsed.nextIndex)
if (titles.length < MAX_OSC_TITLES_PER_CHUNK) {
titles.push(entry)
} else {
titles[oldestTitleIndex] = entry
oldestTitleIndex = (oldestTitleIndex + 1) % MAX_OSC_TITLES_PER_CHUNK
}
searchStart = parsed.nextIndex
continue
}
searchStart = parsed.nextIndex
}
return oldestTitleIndex === 0
? titles
: [...titles.slice(oldestTitleIndex), ...titles.slice(0, oldestTitleIndex)]
}