Files
orca/src/shared/muse-session-log.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

141 lines
4.9 KiB
TypeScript

import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
readJsonlCursor,
record,
type JsonlCursor,
type JsonRecord
} from './codex-rollout-jsonl-cursor'
// Why: Muse stores sessions under <XDG_DATA_HOME>/muse/sessions (default
// ~/.local/share/muse/sessions), sharded by the host's local start date:
// <root>/YYYY/MM/DD/<uuid>/session.jsonl. No upstream override variable exists.
export function resolveMuseSessionsDir(override?: string): string {
if (override?.trim()) {
return override.trim()
}
const dataHome = process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share')
return join(dataHome, 'muse', 'sessions')
}
const UUID_V7 = /^([0-9a-f]{8})-([0-9a-f]{4})-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const DAY_MS = 24 * 60 * 60 * 1000
function dayParts(date: Date, utc: boolean): string[] {
const year = utc ? date.getUTCFullYear() : date.getFullYear()
const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1
const day = utc ? date.getUTCDate() : date.getDate()
return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')]
}
/** Locates a live session's log from its UUIDv7 id, whose timestamp names the date shard. */
export function findMuseSessionLogPath(
sessionId: string,
sessionsDir = resolveMuseSessionsDir()
): string | undefined {
const match = UUID_V7.exec(sessionId)
if (!match) {
return undefined
}
const startedAt = Number.parseInt(`${match[1]}${match[2]}`, 16)
const candidates = new Set<string>()
// Why: the shard uses Muse's local zone, which can differ from ours (relay, TZ env), so probe neighbors.
for (const offset of [0, -DAY_MS, DAY_MS]) {
const date = new Date(startedAt + offset)
for (const utc of [false, true]) {
candidates.add(join(sessionsDir, ...dayParts(date, utc), sessionId, 'session.jsonl'))
}
}
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return undefined
}
export type MuseUserInputQuestion = Record<string, unknown>
export type MusePendingUserInput = {
promptId: string
/** Muse run that asked; equals the hook payload's `turn_id`. */
runId?: string
questions: MuseUserInputQuestion[]
}
export type MuseSessionLogState = {
sessionId: string
cursor: JsonlCursor
pending: Map<string, MusePendingUserInput>
}
const USER_INPUT_PROMPT_MARKER = '"user_input_prompt_'
export function createMuseSessionLogState(sessionId: string): MuseSessionLogState {
return { sessionId, cursor: { offset: 0, carry: '' }, pending: new Map() }
}
/** Muse batches some records into a `retained_frame` whose `children[].record_json` hold them as strings. */
export function unwrapMuseLogRecords(line: JsonRecord): JsonRecord[] {
if (!Array.isArray(line.children)) {
return [line]
}
const records: JsonRecord[] = []
for (const child of line.children) {
const raw: unknown = record(child)?.record_json
let value: unknown = raw
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw
} catch {
value = undefined
}
const parsed = record(value)
if (parsed) {
records.push(parsed)
}
}
return records
}
function applyUserInputRecord(log: MuseSessionLogState, entry: JsonRecord): void {
const payload = record(entry.payload)
const event = record(payload?.event)
const promptId = typeof event?.prompt_id === 'string' ? event.prompt_id : undefined
if (!event || !promptId) {
return
}
if (event.kind === 'user_input_prompt_requested') {
const questions = Array.isArray(event.questions)
? event.questions.flatMap((question: unknown) => {
const item = record(question)
return item ? [item] : []
})
: []
const runId = typeof payload?.run_id === 'string' ? payload.run_id : undefined
log.pending.delete(promptId)
log.pending.set(promptId, { promptId, runId, questions })
} else if (event.kind === 'user_input_prompt_settled') {
log.pending.delete(promptId)
}
}
/** Advances the log and returns the newest unanswered `request_user_input` prompt of `turnId`'s run. */
export function readMusePendingUserInput(
log: MuseSessionLogState,
turnId: string | undefined,
sessionsDir?: string
): MusePendingUserInput | undefined {
log.cursor.filePath ??= findMuseSessionLogPath(log.sessionId, sessionsDir)
// Why: most log lines are large model/tool records; parse only the two event kinds we read.
const lines = readJsonlCursor(log.cursor, (line) => line.includes(USER_INPUT_PROMPT_MARKER))
for (const line of lines ?? []) {
for (const entry of unwrapMuseLogRecords(line)) {
applyUserInputRecord(log, entry)
}
}
// Why: a question left open by a crash or interrupt stays in the log; only the live turn's can block.
const pending = Array.from(log.pending.values())
return pending.findLast((prompt) => !turnId || !prompt.runId || prompt.runId === turnId)
}