Files
orca/src/shared/codex-rollout-jsonl-cursor.ts
T
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00

100 lines
2.8 KiB
TypeScript

import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from 'node:fs'
const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024
const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024
const TRANSCRIPT_DIRECTORY_MAX_ENTRIES = 4096
/** Resume point for an incremental read of one Codex rollout file. */
export type JsonlCursor = {
filePath?: string
offset: number
carry: string
}
export type JsonRecord = Record<string, unknown>
export function record(value: unknown): JsonRecord | undefined {
return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined
}
/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines.
* `lineFilter` skips JSON.parse for raw lines the caller can reject by substring. */
export function readJsonlCursor(
cursor: JsonlCursor,
lineFilter?: (line: string) => boolean
): JsonRecord[] | undefined {
if (!cursor.filePath) {
return undefined
}
let stats: Stats
try {
stats = statSync(cursor.filePath)
} catch {
return undefined
}
if (!stats.isFile()) {
return undefined
}
if (stats.size < cursor.offset) {
cursor.offset = 0
cursor.carry = ''
}
if (stats.size === cursor.offset) {
return []
}
const bytesToRead = Math.min(stats.size - cursor.offset, TRANSCRIPT_READ_MAX_BYTES)
const start = stats.size - cursor.offset > bytesToRead ? stats.size - bytesToRead : cursor.offset
const buffer = Buffer.allocUnsafe(bytesToRead)
let bytesRead = 0
let fd: number | undefined
try {
fd = openSync(cursor.filePath, 'r')
bytesRead = readSync(fd, buffer, 0, bytesToRead, start)
} catch {
return undefined
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
const skippedPrefix = start !== cursor.offset
const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}`
const lines = content.split('\n')
cursor.offset = start + bytesRead
cursor.carry = lines.pop() ?? ''
if (skippedPrefix) {
lines.shift()
}
const records: JsonRecord[] = []
for (const line of lines) {
if (
(lineFilter && !lineFilter(line)) ||
Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES
) {
continue
}
try {
const parsed = record(JSON.parse(line) as unknown)
if (parsed) {
records.push(parsed)
}
} catch {
// A malformed rollout line must not block later lifecycle events.
}
}
return records
}
export function readTranscriptDirectory(directory: string): string[] {
let entries: string[]
try {
entries = readdirSync(directory)
} catch {
return []
}
if (entries.length > TRANSCRIPT_DIRECTORY_MAX_ENTRIES) {
entries = entries.slice(-TRANSCRIPT_DIRECTORY_MAX_ENTRIES)
}
return entries
}