/** * §3.1 — the Claude Code session JSONL → AG-UI event mapping. * * This is the record-shaped half of the cutover. It lives HERE and not in `connector-core` on * purpose: the JSONL entry shape is Claude's, and letting it into the shared layer would be exactly * the leak AGENTS.md forbids ("never let an adapter's concepts leak into the shared layers"). What * IS shared — the vocabulary, the frame, the bracket machine, the durable read discipline — is * imported from `connector-core` and nothing about Claude goes back the other way. * * --------------------------------------------------------------------------------------------- * THREE THINGS THE PLAN DOES NOT SETTLE, RECORDED AS GAPS RATHER THAN DECIDED HERE. * * **(A) The run brackets have no vehicle, and this is a real plan defect, not a detail.** §3.1 * sources `RUN_STARTED` from the `UserPromptSubmit` hook and `RUN_FINISHED` from the `Stop` hook * (`mcp.ts:72`, `:96`). But `[P6]`/§5.4 makes the durable plane READ A FILE, and the emitter's * mapper is `(record) => events` — a hook fires in a different process at a different time and * produces NO JSONL record, so there is no vehicle by which a hook-sourced event can enter a * record-sourced stream. The two halves of the design were specified against different inputs. * * What this file does instead is stated plainly so a ruling can replace it in one function: * **brackets are derived from the record stream.** A human prompt opens a run; the NEXT human * prompt closes the previous one. Two consequences, both of which a reader must know: * 1. `RUN_FINISHED` lags by one turn. It is emitted when the next turn starts, not when the turn * ends, so a consumer sees the finish later than the `Stop` hook would have said it. * 2. **The last run of a session never closes.** There is no record after it to close it on. * Neither is silently absorbed: {@link closeOpenRun} exists so the connector's `Stop` hook can shut * the run at the real boundary once a vehicle is ruled, and until then the lag is honest. * * **(B) `origin.kind === "human"` OPENS NO RUN IN ANY AGENT-DRIVEN SESSION — MEASURED on three, and * this is why the mapping smoke reads a real session rather than a fixture.** §3.1's rule is right * about what it excludes (peer/mesh injections, task notifications, resumed-session summaries). The * problem is what is left to select. Partitioned by CONTENT SHAPE, not just counted: * * | session | user entries | `tool_result` | mesh (`origin.kind:"channel"`) | compact summary | human | * | --- | --- | --- | --- | --- | --- | * | interactive, 5938 rec | 892 | 824 | 67 | 1 | **0** | * | headless `claude -p`, 30 rec | 5 | 3 | 0 | 0 | **0** (2 prompts, `promptSource:"sdk"`) | * | agent session, 1088 rec | 90 | 86 | 4 | 0 | **0** | * * **`kind:"human"` occurs zero times — but so does a human.** `~/.claude/history.jsonl`, which * records typed prompts through a different mechanism entirely, reports **0** for all three sessions * and 0 for this worktree. The two sources agree, so zero matches is the CORRECT result on these * captures and NOT evidence the rule is wrong. **The rule is unexercised here, not disproven.** * * **AND IT IS EXERCISED ELSEWHERE — read the mapping rationale below before reading these numbers as * a defect.** That section measured a real session a person was driving and counted `kind:"human"` * **44 times**, with `promptSource: "typed"`/`"queued"`, beside 3068 `kind:"channel"` injections. So * the predicate does select, on a session that contains the thing it selects. The three captures * here simply contain none. **Both numbers belong together; either alone misleads.** * * **THIS WAS A COVERAGE GAP, IT WAS RULED, AND THE RULING IS IMPLEMENTED BELOW.** It read: on * agent-driven sessions no run is ever opened and the connector emits nothing, because the mapping table * sent every non-`human` origin to *nothing*. The session it was derived from had a human typing 44 times alongside * its 3068 mesh messages; a spawned lane seat has **0 and 67**, so the open question was **what * opens a run when nobody types**. * * **THE RULING: run-opening and attribution are two predicates, and that row was one predicate * doing both jobs.** A run opens on * `origin.kind ∈ { human, channel }`, ENUMERATED and never inferred; `task-notification` is named as * known-and-not-a-turn; absent `origin` gets its own enumeration over `promptSource`. Attribution * rides as `cotal.turnSource` — **a field on the run, never a gate on it**. The privacy argument is * untouched: a `RUN_STARTED` attributed to a peer republishes no message body, so a peer-initiated * turn can be a turn without re-emitting the peer's content. See {@link ORIGIN_RULE} and * {@link ABSENT_ORIGIN_RULE}, which are where this now lives. * * **KEEP THIS PARAGRAPH HONEST.** Its earlier form said "no run is ever opened and the connector * emits nothing" and "escalated as a plan defect rather than decided here" — describing the state * before the ruling, directly above code that had already implemented it. A successor read it, * believed it over the code, and escalated a closed question as a live blocker; the measurement that * corrected it took one run of the real mapper (**67 runs / 5217 events** on the 5938-record * session, `diagnose()` → `null`). **A stale header is not a documentation defect, it is a false * claim about the function beneath it.** If the rule changes again, this paragraph changes with it. * * **DO NOT "FIX" THIS BY TREATING ABSENT `origin` AS HUMAN.** In a Claude session `user` is also the * role of a TOOL RESULT: that predicate selects **825** of the interactive session's 892 user * entries, and the single non-tool-result among them is a **context-compaction summary** * (`isCompactSummary`), so the true human count is 0 and the predicate over-matches by 825. It would * not emit nothing — it would emit a flood, each entry opening a run, which looks like the connector * working. An earlier revision of this comment recorded (B) as HEADLESS-ONLY and asserted a human * turn is "a `user` entry with no `origin`"; both halves were wrong. * * **The rule is implemented exactly as RULED, and still not guessed at.** `promptSource` was * proposed as the selector and REJECTED: it is bounded by the partition it was inferred from, and * "sdk" also covers programmatic injection. It survives only inside {@link ABSENT_ORIGIN_RULE}, * where there is no `origin.kind` to enumerate — a second table rather than a synthetic member, * because an enumeration over `origin.kind` cannot classify a record that has none. Every value * outside either table **fails loud** rather than being silently treated as not-a-turn. * * **(C) `TOOL_CALL_RESULT.messageId` is unstated in §3.1's table** (the row names only * `toolCallId`) while the real schema REQUIRES it. It is keyed the same way every other message * identity here is — entry `uuid` plus block index — so it is unique, stable, and derived rather * than invented at a call site. Raised as a gap in `connector-core`'s constructor doc as well. * --------------------------------------------------------------------------------------------- * * `messageId` is `${uuid}#${blockIndex}` and NOT `message.id`. `message.id` is a provider request * id: measured over a real session, 67% of them appear in more than one entry and 59% carry more * than one block type, so spending it as an AG-UI message identity opens and closes one id * repeatedly and collapses text and reasoning into a single message in the reference reducer. The * provider id is preserved as `cotal.providerMessageId`, which is what it is good for. */ import { type AguiEvent, type RecordMapper } from "@cotal-ai/connector-core"; /** * One JSONL entry, typed to what the mapping actually reads and no further. * * Every field is optional because a session file carries at least seven entry types * (`user`, `assistant`, `attachment`, `queue-operation`, `ai-title`, `last-prompt`, `mode`, and * more will be added by a harness release we do not control). Declaring them required would make * the mapper's own type a lie about a file it does not own. */ export interface ClaudeEntry { type?: string; uuid?: string; sessionId?: string; timestamp?: string; isSidechain?: boolean; origin?: { kind?: string; }; /** * Present on every submitted prompt and absent on tool results. **Not the run-opening gate** — * it is `"system"` on task-notifications and caveats too. Read ONLY where `origin` is absent, and * only for the value `"sdk"`. See `ABSENT_ORIGIN_RULE`. */ promptSource?: string; /** The harness's own compaction record. A string-content `user` entry that is not a turn. */ isCompactSummary?: boolean; isVisibleInTranscriptOnly?: boolean; /** * The session-level invocation marker — `"cli"` or `"sdk-cli"`, uniform across a session file. * **Declared and deliberately NOT read.** It is here so the field's existence is recorded rather * than rediscovered, and so a suite can drive both values against a rule that must ignore them. */ entrypoint?: string; message?: { id?: string; stop_reason?: string | null; content?: string | ClaudeBlock[]; }; } /** A content block. Same reasoning as above: shape-tolerant, read narrowly. */ export interface ClaudeBlock { type?: string; text?: string; thinking?: string; /** `tool_use` */ id?: string; name?: string; input?: unknown; /** `tool_result` */ tool_use_id?: string; content?: unknown; is_error?: boolean; } export interface ClaudeMapperOptions { /** The native session id — `threadId` for every event. §3 forbids anything else claiming it. */ threadId: string; /** Mints a `runId`. Connector-minted by §3.1, so every `RUN_STARTED` carries `runIdSource`. */ mintRunId: () => string; /** * Emit `REASONING_*` for `thinking` blocks. **Off by default (§7 Q1).** The `signature` is never * emitted at any setting (§3.5) and is not read by this module at all. */ reasoning?: boolean; /** * Arrival clock, for the entries whose `timestamp` is missing or unparseable. Injectable so the * mapping is deterministic under test; those events are labelled `cotal.tsSource: "arrival"` * rather than being given a real-looking number. */ now?: () => number; } /** What {@link createClaudeMapper} returns: the mapper plus the out-of-band run close. */ export interface ClaudeMapper { map: RecordMapper; /** * Close the open run, if there is one, at a boundary the record stream cannot see — the `Stop` * hook. Returns `null` when no run is open, so calling it twice is not an error and cannot * manufacture a second `RUN_FINISHED` the bracket machine would refuse. * * It exists because gap (A) above is a gap: the durable plane has no way to hear a hook today, * and this is the seam a ruling plugs into rather than a rewrite. */ closeOpenRun: (timestamp: number, stopReason?: string) => { runId: string; events: AguiEvent[]; } | null; /** The run currently open, or `null`. Read-only view for a caller that needs to know. */ openRun: () => string | null; /** * Forget a run the EMITTER closed out of band, so this mapper stops treating it as open. * * Two things close a run now, and they do not share state. The mapper closes one when the record * stream shows a new turn beginning. The emitter closes one when a lifecycle hook says the turn * ended, which is a boundary no record describes. After the second kind, the mapper still believes * a run is open: it would attribute the next records to a run the stream has already finished, and * the bracket machine would refuse the second terminal it eventually emitted. The emitter reports * what it closed, and this is where that report lands. * * KEYED ON THE ID, and that is the whole safety of it. The report arrives asynchronously, so by * the time it does the mapper may already have opened a NEWER run from a record that landed in * between. Clearing unconditionally would orphan that one: its events would emit under no run at * all, which the brackets refuse, and the session would halt on a frame it had every right to * send. A run that is not the one named is left exactly as it is. */ forgetOpenRun: (runId: string) => void; /** * **WHY THIS SESSION OPENED NO RUNS** — a sentence, or `null` once any run has opened. * * A mapper that opens zero runs is byte-indistinguishable from a session nobody prompted, and * from a mapper that is simply broken. Refusing a record is a legitimate outcome; refusing it * SILENTLY is not, and the silence is the defect, not the refusal. This is the production * statement of that — it lives in the shipped mapper, not in a smoke summary, so the connector * and any operator reading it get the same sentence the suite does. * * It is deliberately NOT a throw. Some sessions genuinely contain no prompt yet, and a mapper * that threw on one would take down a live connector over an empty file. */ diagnose: () => string | null; } export declare function createClaudeMapper(opts: ClaudeMapperOptions): ClaudeMapper; //# sourceMappingURL=agui-map.d.ts.map