mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
ZCode's CLI persists an OpenCode-v1-shaped history in its own SQLite store (~/.zcode/cli/db/db.sqlite). The Agent Session History scan already lists those sessions, but opening one in the native chat found nothing: the chat transcript path had no ZCode awareness. Resolve a zcode transcript against the ZCode database (native home, then WSL homes) before any OpenCode install, tag every read (signal, page, subscribe) with the agent, and apply the same hidden-transcript filter the scan uses so ZCode's hidden bookkeeping rows stay out of chat renders.
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { UNFLUSHED_SETTLE_MS } from './transcript-watch-contract'
|
|
import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe'
|
|
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
|
|
import { extname } from 'node:path'
|
|
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
|
import {
|
|
needsWslHostResolution,
|
|
toHostReadableTranscriptPath,
|
|
type WslTranscriptResolutionSnapshot
|
|
} from './host-readable-transcript-path'
|
|
import { resolveSessionFilePath } from './session-file-resolver'
|
|
import { installTranscriptWatcher } from './transcript-watch-engine'
|
|
import type {
|
|
NativeChatTranscriptSubscription,
|
|
SubscribeNativeChatTranscriptArgs
|
|
} from './transcript-watch-contract'
|
|
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
|
|
import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate'
|
|
import { observeRunningWslDistros } from './wsl-transcript-running-observer'
|
|
|
|
export { readNativeChatTranscriptTail } from './transcript-tail-reader'
|
|
export { getActiveNativeChatWatcherCount } from './transcript-watcher-count'
|
|
export type {
|
|
NativeChatTranscriptSubscription,
|
|
SubscribeNativeChatTranscriptArgs
|
|
} from './transcript-watch-contract'
|
|
|
|
/** One resolve+install attempt. Returns null while the transcript file itself
|
|
* is unresolved; native-watch failure degrades to reconciliation-only mode. */
|
|
async function attemptInstall(
|
|
args: SubscribeNativeChatTranscriptArgs,
|
|
decode: (line: string, fallbackId: string) => NativeChatMessage | null,
|
|
signal?: AbortSignal
|
|
): Promise<NativeChatTranscriptSubscription | null> {
|
|
const filePath =
|
|
args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal))
|
|
signal?.throwIfAborted()
|
|
if (!filePath) {
|
|
return null
|
|
}
|
|
const installed = await installTranscriptWatcher(filePath, decode, args, signal)
|
|
if (signal?.aborted) {
|
|
installed?.unsubscribe()
|
|
signal.throwIfAborted()
|
|
}
|
|
return installed
|
|
}
|
|
|
|
// Why: Claude Code (and other agents) can take from ~3s to minutes to flush a
|
|
// brand-new session's first JSONL line (#8401) — resolveSessionFilePath
|
|
// genuinely has nothing to find yet. Poll for it instead of going deaf. Exact
|
|
// hook paths are probed on every retry; the recursive
|
|
// session-id fallback runs less often because a large Claude tree is expensive.
|
|
const INITIAL_RESOLVE_POLL_MS = 500
|
|
const MAX_RESOLVE_POLL_MS = 5_000
|
|
const FALLBACK_RESOLVE_POLL_MS = 5_000
|
|
// Why: with no frame at all a client shows a bare spinner for the whole flush
|
|
// delay — a fresh session that has yet to be prompted never flushes, so the
|
|
// spinner is permanent. Long enough that a merely slow resolve still wins the
|
|
// race and paints history directly.
|
|
|
|
function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null {
|
|
const path = args.transcriptPath?.trim()
|
|
return path && extname(path) === '.jsonl' ? path : null
|
|
}
|
|
|
|
/**
|
|
* Background retry loop for a transcript that hasn't been resolvable yet.
|
|
* Returns a subscription immediately (per subscribeNativeChatTranscript's
|
|
* contract); the loop keeps retrying resolve+install until it succeeds or
|
|
* unsubscribe() cancels it. Reports watching:true — the engine's first drain
|
|
* delivers the initial snapshot once the file appears, so subscribers must not
|
|
* settle a merely not-yet-flushed transcript into a permanent error (#8401).
|
|
* A short grace period in, it reports the transcript as pending once so the
|
|
* view can stop spinning while it waits.
|
|
*/
|
|
function subscribeViaResolvePoll(
|
|
args: SubscribeNativeChatTranscriptArgs,
|
|
decode: (line: string, fallbackId: string) => NativeChatMessage | null
|
|
): NativeChatTranscriptSubscription {
|
|
let closed = false
|
|
let installed: NativeChatTranscriptSubscription | null = null
|
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS
|
|
let lastFallbackResolveAt = Date.now()
|
|
const exactPath = exactTranscriptPath(args)
|
|
const exactPathNeedsWslResolution = exactPath !== null && needsWslHostResolution(exactPath)
|
|
// Why: WSL hooks report guest Linux paths the Windows host cannot open; the
|
|
// UNC twin is resolved lazily (the distro may still be cold) and memoized so
|
|
// the exact-path install doesn't wait on the slower id-glob (#10326).
|
|
let hostReadableExactPath: string | null = null
|
|
// Latches only once a frame was actually emitted, so a subscriber without the
|
|
// callback can't suppress it for a later one.
|
|
let gateErrorEmitted = false
|
|
// Whether the subscriber already has an initial frame to render.
|
|
let settled = false
|
|
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
|
const resolveController = new AbortController()
|
|
let stopWslObservation = (): void => {}
|
|
|
|
function stopSettleTimer(): void {
|
|
if (settleTimer) {
|
|
clearTimeout(settleTimer)
|
|
settleTimer = null
|
|
}
|
|
}
|
|
|
|
/** Report a still-unresolved transcript so the view can leave 'loading' and
|
|
* invite a first message instead of spinning. Deliberately not a snapshot:
|
|
* an empty window presented as a settled read would capture over retained
|
|
* history and unblock consumers that require a trustworthy transcript. */
|
|
function settleUnflushed(): void {
|
|
settleTimer = null
|
|
if (closed || settled || installed || !args.onTranscriptPending) {
|
|
return
|
|
}
|
|
settled = true
|
|
args.onTranscriptPending()
|
|
}
|
|
|
|
if (args.onTranscriptPending) {
|
|
settleTimer = setTimeout(settleUnflushed, UNFLUSHED_SETTLE_MS)
|
|
settleTimer.unref?.()
|
|
}
|
|
|
|
function scheduleAttempt(): void {
|
|
if (closed || exactPathNeedsWslResolution) {
|
|
return
|
|
}
|
|
const untilFallbackResolve = exactPath
|
|
? Math.max(0, FALLBACK_RESOLVE_POLL_MS - (Date.now() - lastFallbackResolveAt))
|
|
: delay
|
|
pollTimer = setTimeout(
|
|
() => {
|
|
pollTimer = null
|
|
void runAttempt()
|
|
},
|
|
Math.min(delay, untilFallbackResolve)
|
|
)
|
|
// Why: never hold the event loop open (headless `orca serve` shutdown) for
|
|
// a session that may genuinely never resolve.
|
|
pollTimer.unref?.()
|
|
// Only back off in production; a test-supplied interval stays fixed so
|
|
// tests resolve in bounded, predictable time.
|
|
if (args.resolvePollIntervalMs === undefined) {
|
|
delay = Math.min(delay * 2, MAX_RESOLVE_POLL_MS)
|
|
}
|
|
}
|
|
|
|
async function runAttempt(wslSnapshot?: WslTranscriptResolutionSnapshot): Promise<void> {
|
|
if (closed || installed) {
|
|
return
|
|
}
|
|
let result: NativeChatTranscriptSubscription | null
|
|
const now = Date.now()
|
|
const fallbackDue = !exactPath || now - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS
|
|
try {
|
|
if (exactPath && !hostReadableExactPath) {
|
|
if (!exactPathNeedsWslResolution) {
|
|
// Non-WSL paths stay raw: installTranscriptWatcher already handles a
|
|
// not-yet-created file, so don't spend an extra probe per tick.
|
|
hostReadableExactPath = exactPath
|
|
} else if (wslSnapshot) {
|
|
// The raw guest path is never installed on Windows — it would resolve
|
|
// against the current drive (`C:\home\…`) and bind a look-alike file.
|
|
hostReadableExactPath = await toHostReadableTranscriptPath(exactPath, {
|
|
signal: resolveController.signal,
|
|
wslSnapshot
|
|
})
|
|
}
|
|
}
|
|
result = hostReadableExactPath
|
|
? await attemptInstall(
|
|
{ ...args, filePath: hostReadableExactPath },
|
|
decode,
|
|
resolveController.signal
|
|
)
|
|
: null
|
|
if (!result && exactPathNeedsWslResolution) {
|
|
// A distro may stop after resolution; never retry a stale UNC root.
|
|
hostReadableExactPath = null
|
|
}
|
|
if (!result && fallbackDue && !exactPathNeedsWslResolution) {
|
|
lastFallbackResolveAt = now
|
|
result = await attemptInstall(args, decode, resolveController.signal)
|
|
}
|
|
} catch (error) {
|
|
if (exactPathNeedsWslResolution) {
|
|
hostReadableExactPath = null
|
|
}
|
|
// Why: a transient resolve failure (EACCES/EIO during the glob) must not
|
|
// kill the poll loop with an unhandled rejection — retry like a miss. A
|
|
// stalled WSL distro would otherwise poll silently forever, leaving the
|
|
// client at 'loading'; emit its retryable message once and keep polling,
|
|
// so a later tick's real snapshot still replaces it. Narrowed with a bare
|
|
// instanceof, never wslTranscriptFsRefusal — that helper rethrows, and
|
|
// runAttempt is invoked as `void runAttempt()`.
|
|
if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) {
|
|
gateErrorEmitted = true
|
|
// Its retryable message outranks the empty settle; don't overwrite it.
|
|
settled = true
|
|
stopSettleTimer()
|
|
args.onInitialSnapshot([], false, 0, error.message)
|
|
}
|
|
result = null
|
|
}
|
|
if (closed) {
|
|
// unsubscribe() ran while this attempt was in flight.
|
|
result?.unsubscribe()
|
|
return
|
|
}
|
|
if (result) {
|
|
installed = result
|
|
stopWslObservation()
|
|
stopWslObservation = () => {}
|
|
stopSettleTimer()
|
|
return
|
|
}
|
|
scheduleAttempt()
|
|
}
|
|
|
|
if (exactPathNeedsWslResolution) {
|
|
stopWslObservation = observeRunningWslDistros((runningDistros) =>
|
|
runAttempt({ runningDistros: [...runningDistros] })
|
|
)
|
|
} else {
|
|
scheduleAttempt()
|
|
}
|
|
|
|
return {
|
|
watching: true,
|
|
unsubscribe: () => {
|
|
if (closed) {
|
|
return
|
|
}
|
|
closed = true
|
|
resolveController.abort()
|
|
stopWslObservation()
|
|
stopWslObservation = () => {}
|
|
stopSettleTimer()
|
|
if (pollTimer) {
|
|
clearTimeout(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
installed?.unsubscribe()
|
|
installed = null
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to live appends on an agent's transcript file. Returns an
|
|
* unsubscribe fn that tears the watcher down completely.
|
|
*
|
|
* Handles file rotation/replacement: when the file shrinks (a new session id
|
|
* resolved to a smaller/newer file, or the file was truncated), the offset is
|
|
* reset to 0 so the replacement's content is read from the top.
|
|
*
|
|
* When the transcript isn't resolvable yet (a just-created session whose
|
|
* agent hasn't flushed its first JSONL line, #8401), returns the subscription
|
|
* immediately and keeps retrying resolve+install in the background rather
|
|
* than returning a no-op that never recovers.
|
|
*/
|
|
export async function subscribeNativeChatTranscript(
|
|
args: SubscribeNativeChatTranscriptArgs,
|
|
setupSignal?: AbortSignal
|
|
): Promise<NativeChatTranscriptSubscription> {
|
|
setupSignal?.throwIfAborted()
|
|
if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') {
|
|
return subscribeOpenCodeNativeChatTranscript(
|
|
args,
|
|
setupSignal,
|
|
{},
|
|
args.agent === 'zcode' ? 'zcode' : undefined
|
|
)
|
|
}
|
|
const decode = nativeChatLineDecoderForAgent(args.agent)
|
|
if (!decode) {
|
|
// Nothing watchable — return a no-op teardown so callers can unconditionally
|
|
// unsubscribe without null-checks.
|
|
return { unsubscribe: () => {}, watching: false }
|
|
}
|
|
// Why: a blank session id (and no explicit file) can never resolve — bail out
|
|
// instead of resolve-polling an unresolvable target forever.
|
|
if (!args.filePath && !args.sessionId.trim()) {
|
|
return { unsubscribe: () => {}, watching: false }
|
|
}
|
|
|
|
let installed: NativeChatTranscriptSubscription | null
|
|
try {
|
|
installed = await attemptInstall(args, decode, setupSignal)
|
|
} catch (error) {
|
|
setupSignal?.throwIfAborted()
|
|
// Why: a gate-refused resolve (stalled WSL distro) must degrade to the
|
|
// resolve-poll fallback below, not fail the subscribe outright.
|
|
void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal
|
|
installed = null
|
|
}
|
|
if (installed) {
|
|
return installed
|
|
}
|
|
setupSignal?.throwIfAborted()
|
|
return subscribeViaResolvePoll(args, decode)
|
|
}
|