mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
* 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>
100 lines
2.8 KiB
TypeScript
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
|
|
}
|