/** * Adopt-bridge turn attribution state machine. * * Pure (no fs / IPC / timers) so the worker can wrap it with watchers and * tests can drive it deterministically. The worker feeds it transcript * events (already drained from JSONL) and Lark-message markers; this class * decides which assistant uuids belong to which Lark turn. * * Attribution rule: * - mark() — pushes a new pending turn entry (state: not started) * - ingest(events) — for each new user/assistant event: * * user event → the earliest unstarted pending turn whose fingerprint * matches becomes 'started' (its assistantUuids will collect from * now on). A user event that does NOT match any pending fingerprint * (or arrives with no pending Lark turn at all) is treated as * **local terminal input**: a synthetic local turn is created on * the spot, started immediately, and inserted ahead of any * still-unstarted Lark turns so emit ordering reflects when the * user event actually landed in the transcript. The local turn is * emitted with `isLocal: true` so the worker can format it with a * "user typed in the terminal" marker for the Lark thread. * * assistant text event (non-sidechain) → appended to the * currently-collecting turn (Lark or local), if any. * - drainEmittable() — pops any leading turn that has been started AND has * accumulated at least one visible assistant-text uuid. Started turns with no text * yet (Claude is mid-tool-use) stay queued for the next idle. * * Baseline (`absorb()`) takes a batch of historical events and registers * their uuids as already-seen so future ingest doesn't double-attribute. */ import { normaliseForFingerprint, type ClaudeTerminalOutcome, type TranscriptEvent } from './claude-transcript.js'; export { normaliseForFingerprint }; export interface BridgePendingTurn { turnId: string; dispatchAttempt?: number; started: boolean; assistantUuids: string[]; /** An authoritative transcript boundary closed this turn. Durable turns may * never settle from screen-idle alone; worker terminal emission requires * this bit (or an explicit failure/exit path outside the queue). */ terminalObserved?: boolean; /** Structured execution result. This is independent from whether transcript * fallback text is visible or suppressed by a prior `botmux send`. */ terminalOutcome?: Exclude; /** Structured 429 observed for this turn. It remains owned by the existing * limited/reset path and must not be converted into ordinary ambiguity when * the following turn_duration closes the transcript boundary. */ rateLimited?: boolean; /** Set when this turn was synthesised from a local-terminal user event * (no matching Lark fingerprint). Causes the worker emit path to format * the Lark message with both user text and assistant text under a * "🖥️ 终端本地对话" header — otherwise the user would see an orphan * reply with no prompt for context. Lark-driven turns keep this unset. */ isLocal?: boolean; /** Transcript uuid of the user event that started this turn. Stored for * local turns so emit can fetch the user-typed content from the source * jsonl alongside the assistant uuids. Lark turns don't need it because * the user content is already known on the daemon side. */ userUuid?: string; /** A short substring of the Lark message that we expect to find inside * the next matching `user` event's content. When set, only a user event * whose stringified content contains this fingerprint is allowed to * start the turn. Local-terminal input (whose content won't contain * the Lark fingerprint) leaves the turn unstarted. */ contentFingerprint?: string; /** Full normalised content of the Lark message. Used by the rotation * fallback's recovery path to gate a switch into an UNKNOWN sessionId * on exact equality with a user/queue event in that file — much * stronger than the substring fingerprint check, which can't tell * "test" from "run tests" across sibling panes. Stored in addition to * `contentFingerprint` (not instead of) because in-pane known-sid * candidates still benefit from the cheaper substring path. */ contentNormalized?: string; /** JSONL file the turn's user event was first seen in. Stamped by ingest() * when the turn transitions to started. Lets the emit step re-read text * from the original transcript even after a sessionId rotation has * pointed bridgeJsonlPath at a *different* file — without this stamp, * uuid → text resolution would fail and the reply would be silently * dropped. */ sourceJsonlPath?: string; /** Wall-clock millis when mark() was called. Lets the fingerprint-based * rotation fallback bound its scan to events written after we marked * the turn — short fingerprints ("hello", "test") would otherwise risk * matching pre-existing user lines in unrelated sibling jsonls. */ markTimeMs?: number; /** Set when this mark was re-created from the durable turn journal after a * worker/daemon restart interrupted the turn. The emit path prefixes the * delivered fallback with an "interrupted by restart" notice so the user * can tell a recovered partial answer from a live one. */ restoredFromJournal?: boolean; } /** Trim a Lark message into a stable fingerprint. Keeps a leading window * of non-whitespace-collapsed content; long enough to disambiguate, short * enough that minor formatting differences (newlines, attachment hints * appended below) don't break the match. */ export declare function makeFingerprint(message: string, len?: number): string | undefined; /** Capability-agnostic proof that a recorded transcript user line is THIS * turn's user event even though its head-substring fingerprint didn't match. * * claude-code TRUNCATES the leading envelope lines (`` + * ``) when persisting the user turn, so the head fingerprint is * gone — but the surviving text is exactly the TAIL of what we sent, i.e. a * contiguous SUFFIX of the mark's full normalised content. We anchor the proof * to that observed invariant with `endsWith`, NOT a loose `includes`: an * interior substring (a command like `run pnpm test --project unit`, or the * bare closing tags ` `) that happens to appear * in the middle of the task body is NOT a suffix, so a Web Terminal operator * typing such a phrase can't spoof this and steal the pending durable mark * (the interior-substring false-match codex demonstrated on PR #724). Length * (16) is a floor, not the proof — the suffix anchor is. Proof is by CONTENT * shape, not session type (apiOnly/adopt), so it holds regardless of whether * the session can mint a Web Terminal write token. * * `recordedNorm` and `markContentNorm` are both already normaliseForFingerprint'd. * Guards: require a real mark content, a recorded tail of at least * TRUNCATION_MATCH_MIN_CHARS, and a strict suffix match. * * NOTE: if a future claude-code build stops truncating at the head (recorded * line no longer a suffix of what we sent), this correctly returns false and * the turn falls back to local-synth — never a wrong-mark bind. Re-proving a * non-suffix truncation would need a truncation-surviving turn nonce/closing * marker, not a relaxed substring test. */ export declare function isTruncatedMatch(recordedNorm: string, markContentNorm?: string): boolean; export declare class BridgeTurnQueue { private seen; private queue; private collecting; /** Register events as historical — their uuids are now considered seen * but no attribution happens. Used at attach time to baseline. */ absorb(events: TranscriptEvent[]): void; /** Push a new pending turn for the next Lark message. `contentFingerprint` * (when set) restricts which user event can start this turn — only a * user event whose content contains the fingerprint qualifies. Pass * `undefined` to start on the next user event regardless (legacy). * * `markTimeMs` is captured here so the rotation fallback can bound its * fingerprint scan to events written after this point — protects short * fingerprints from matching old history in unrelated sibling jsonls. */ mark(turnId: string, contentFingerprint?: string, markTimeMs?: number, contentNormalized?: string, dispatchAttempt?: number, opts?: { restoredFromJournal?: boolean; }): string; /** Drop all pending turns. Used when the worker discovers it can't * reliably attribute future events (e.g. baseline raced with a turn * already in flight) and wants to clear the slate. */ clearPending(): BridgePendingTurn[]; /** Drop one exact pending delivery attempt iff it has not yet started * collecting assistant text. A durable retry reuses turnId with a higher * dispatchAttempt, so matching only turnId would let attempt N's delayed * submit-failure timer delete the live mark for retry N+1. Returns the * dropped turn or null if the exact attempt is not found / already started. * Used by the worker when a writeInput's deferred recheck conclusively * fails — the user has been notified the message was lost, so keeping a * fingerprint-bearing mark around only fuels the per-tick rotation-fallback * scan that already spammed 99% CPU once (no jsonl line will ever match). */ dropPendingTurn(turnId: string, dispatchAttempt?: number): BridgePendingTurn | null; /** Sweep pending (unstarted) turns whose mark is older than `maxAgeMs`. * Returns the dropped turns for logging. Belt-and-braces backstop for * any future code path that leaves an unstarted mark stranded — without * it, `maybeSwitchBridgeJsonl` would keep doing full-directory jsonl * scans every poll tick until the worker restarts. Started turns are * never expired here: once Claude actually wrote the user line, the * turn is collecting assistant text and we want to wait however long * the model takes. */ pruneExpired(maxAgeMs: number, now?: number): BridgePendingTurn[]; /** Process newly-appended events. Idempotent on uuid: events with seen * uuids are skipped, so callers can safely replay. * * `sourceJsonlPath` (when provided) is stamped onto a turn at the moment * it transitions from "pending" to "started" — so that emit-time text * resolution reads the same transcript file the user/assistant uuids * were originally observed in. Without this, a sessionId rotation * between ingest and emit would silently drop the reply, since the * global current jsonl path would no longer contain those uuids. */ ingest(events: TranscriptEvent[], sourceJsonlPath?: string): void; /** Shared turn-start handler. Called for both `role:user` and * `attachment(queued_command)` events once meaningfulness has been * established by the caller. Encapsulates: * 1. HOL-block drop of the previous collecting turn when it got no * assistant text (Claude moved on). * 2. Fingerprint-gated start of the earliest unstarted Lark turn, * falling through to local-turn synthesis on mismatch. * 3. markTimeMs override to the transcript event's own timestamp — * critical for type-ahead, where the original markTimeMs (set when * the worker wrote to PTY) can be many seconds earlier than the * moment Claude actually dequeues and starts processing the turn. * The bridge-fallback gate's [markTimeMs, nextBoundaryMs) window * MUST anchor on the latter, otherwise a `botmux send` from the * previous turn can leak into the next turn's window and the * suppression decision flips to the wrong turn (real reply * suppressed, fallback shown — exactly what the type-ahead-disable * in commit b2d9791 was protecting against). */ private handleTurnStart; /** Pop FIFO any leading turn that's started and normally has visible text. * The worker calls with terminalBoundary=true only after the CLI's prompt * detector reports idle; that explicit boundary also releases an empty or * tool-only turn so durable delivery can settle without fabricating output. * Returns the popped turns in order; the caller is responsible for * rebuilding the optional visible payload from the assistant uuids. */ drainEmittable(opts?: { terminalBoundary?: boolean; /** Pop only turns carrying an authoritative transcript boundary. */ explicitTerminalOnly?: boolean; /** Screen idle may release ordinary fallback turns, but never a durable * receiver turn whose receipt depends on an exact terminal contract. */ requireExplicitTerminalForDurable?: boolean; }): BridgePendingTurn[]; /** Number of queued (not-yet-emitted) Lark turns. */ size(): number; /** Test helper — peek the queue without mutating. */ peek(): readonly BridgePendingTurn[]; } //# sourceMappingURL=bridge-turn-queue.d.ts.map