/** * The event stream: one core, many reporters. * * Display is an observer, never a participant. No workflow may depend on a UI * being there: unplug every reporter and the result is identical. Which is why * nothing here ever writes to the terminal. */ import type { Lifetime } from "./agent.ts"; import type { Post } from "./board/index.ts"; import type { CheckedNode, FlowError } from "./flow/index.ts"; import type { Result } from "./result.ts"; import type { Usage } from "./usage.ts"; /** What a subagent is doing right now, as seen from the outside. */ export type SubagentStatus = "working" | "idle" | "blocked" | "done"; /** Everything the core emits. Reporters subscribe, and only read. */ export type SubagentEvent = | { type: "spawn"; id: string; agent: string; lifetime: Lifetime; /** * Whether this subagent asked for its own herdr split. * * It travels on the event rather than being read back from the core, * because a reporter is a pure observer: it never queries anything, * it only listens. */ openInHerdr: boolean; /** `provider/id` as pi resolved it. Absent when pi could not say. */ model?: string; /** * Its definition says `mustCallTool: true`: a turn of it that called * no tool fails, and the displays say it called none. */ mustCallTool?: true; /** * Where this subagent came in the launch, counting from 1. * * The event cannot be emitted until the session exists, because it * carries the model pi resolved - and sessions come up in whatever * order they come up in. Measured: a fan-out of three drew as * `scout#2, scout#1, scout#3`. A reader that wants the order the * branches were launched in sorts on this. */ order: number; /** * The subagent that had this one spawned, when one did. * * Absent at the top level, which is what makes a root a root. It * travels on `spawn` alone: every later event about this subagent * carries its `id`, and a reporter that saw the spawn already knows * where to hang it. Sending it again would be a second copy of one * fact, and two copies drift. */ parentId?: string; /** * The flow visit it was spawned for, `deliver#2/work[1]/code`, when a * flow's runner spawned it. A subagent a memory scope keeps names the * first visit that asked for it. It is how a plan line finds its * subagents. */ visit?: string; /** * Where a flow keeps it: its memory scope's path when it has one, * its visit's otherwise, `""` for `memory: flow`. It is what a * herdr split is named after. */ home?: string; /** * Where its transcript is written when it closes, without the * extension: `/`. Absent when it exports nothing. */ transcript?: string; } | { type: "status"; id: string; status: SubagentStatus; /** * The task this turn is about, on the `"working"` transition only. * * A reporter has no other way to learn it: `spawn` happens before * anyone knows what the subagent will be asked, and a persistent * subagent is asked several different things over its life. */ task?: string; } | { type: "text"; id: string; delta: string } /** A tool call; `call` is pi's id for it, when pi gave one. */ | { type: "tool"; id: string; name: string; args: unknown; call?: string } /** * A tool call that came back an error: refused before it ran, as a tool * the agent does not have is, or failed while running. `error` is the first * line pi said, which names the refusal. */ | { type: "tool_error"; id: string; name: string; error: string; call?: string } /** * A member said something on the board. * * `id` is the member, as on every other event; the post carries who it was * for and what kind of thing it was. `record.ts` writes it down, which is * what makes the traffic of a run readable afterwards instead of * reconstructed, and the console reporter prints it as it happens. */ | { type: "post"; id: string; post: Post } /** * A member was handed what it had not seen. * * The posts alone say who said what, and that is the smaller half: what an * investigation asks is who *knew* what, and knowing comes from being handed * something. Measured on three members dividing one job: the claims were * spread over two seconds, so the later ones could have read the earlier, * and nothing in the record could say whether they had. * * Only the ids: the text is already in the record, under the `post` that put * it there. A read that was handed nothing is recorded too, and is the * strongest thing the record holds about what a member could not have known. */ | { type: "read"; id: string; posts: readonly string[]; waiting: number } /** * A member asked for a thing, or gave one back. * * `ok` is whether it got what it asked for, and `heldBy` names the holder * when a take was refused. Recorded for the same reason a read is: the * question afterwards is who held what and when, and a refusal is as much a * fact of the run as a grant. */ | { type: "claim"; id: string; key: string; action: "take" | "release"; ok: boolean; heldBy?: string } /** * A person spoke to a working subagent, through its pane. * * On the stream so the record holds it: a run somebody steered is not the * run they would have got by watching, and two identical `events.jsonl` * must not describe two different runs. */ | { type: "steer"; id: string; text: string } | { type: "usage"; id: string; usage: Usage } | { type: "close"; id: string; result: Result } | VisitEvent; /** * A flow's runner entering and leaving one visit of a node. * * They carry no subagent id: a visit is a node's, and a `choice` has no * subagent at all. Both are also what the journal writes down, so a reader * folds the journal and the stream alike. */ export type VisitEvent = | { type: "visit_start"; /** The visit: `deliver#2/work[1]/code`. */ path: string; /** The node's address, without iterations: `deliver/work/code`. */ node: string; kind: CheckedNode["kind"]; /** The agent an `agent` visit runs, when its node names one; one `agent-from:` picks is named on the `visit_end`. */ agent?: string; } | { type: "visit_end"; path: string; /** As on its `visit_start`, so an end read alone, from the journal, says what it was. */ node: string; kind: CheckedNode["kind"]; ok: boolean; /** What the node handed on, when it ran. */ output?: unknown; /** Why not, when it did not. */ error?: FlowError; /** The case a `choice` ran: `"1"` for the first, or `"default"`. */ case?: string; /** Whether a `loop` that ended stopped on its condition. */ converged?: boolean; /** The agent an `agent` visit ran, the one `agent-from:` picked included. */ agent?: string; /** The subagent an `agent` visit ran on: the last one, when a timeout renewed it. */ subagent?: string; /** The model its subagent ran on, as pi resolved it. */ model?: string; wallMs: number; /** Every attempt's tokens, and every nested visit's: the delta of pi's cumulative stats. */ usage: Usage; }; /** * Whether an event is a flow's visit rather than a subagent's. * * A reader that follows subagents by id skips these: a visit is the plan's * business, and the subagent it ran on reports on its own events. */ export function isVisit(event: SubagentEvent): event is VisitEvent { return event.type === "visit_start" || event.type === "visit_end"; } /** A subscriber. Throwing from here must never break the workflow. */ export type EventListener = (event: SubagentEvent) => void; /** The one channel between the core and every reporter. */ export type EventBus = { /** Delivers to every listener; a listener that throws is swallowed. */ emit(event: SubagentEvent): void; /** Returns the unsubscribe function. */ subscribe(listener: EventListener): () => void; }; /** * A minimal bus, no dependencies. * * A listener that throws is swallowed: a broken reporter must not take a * workflow down with it. That is the whole point of "an observer, never a * participant". */ export function createEventBus(): EventBus { const listeners = new Set(); return { emit(event) { for (const listener of listeners) notify(listener, event); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, }; } /** * Calls a listener and swallows what it throws. * * The bus does this for every reporter; a progress hook a workflow calls by * hand is an observer too, and gets the same treatment - a caller's * bookkeeping that fails must not take the run down with it. */ export function notify(listener: ((...args: A) => void) | undefined, ...args: A): void { try { listener?.(...args); } catch { // an observer's problem is never the workflow's problem } } /** * The one stream a run's events travel on: the caller's bus, or a new one * carrying the caller's listener. * * `bus` is set by a caller that already had one, which is the extension's case * and nobody else's; everyone else passes `onEvent`. Anything a workflow emits * beside its subagents - a board's traffic, a claim granted - has to go on the * same one, or an observer sees a run's subagents and none of what passed * between them. */ export function busFor(options: { bus?: EventBus; onEvent?: EventListener }): EventBus { const bus = options.bus ?? createEventBus(); if (options.onEvent) bus.subscribe(options.onEvent); return bus; } const counters = new Map(); let launched = 0; /** * A stable id for a subagent, and where it came in the launch. * * The id is the key the TUI, herdr and the export use to follow one subagent * across its whole life. Counters are per-process and per-agent-name, which is * enough to read a fan-out of the same agent side by side. * * `order` counts every subagent of the process instead, and the two are handed * out together because they are one fact: the moment this subagent was asked * for. A second counter incremented somewhere else would drift from the id the * day one of the two calls moved. */ export function nextSubagentId(agentName: string): { id: string; order: number } { const n = (counters.get(agentName) ?? 0) + 1; counters.set(agentName, n); launched += 1; return { id: `${agentName}#${n}`, order: launched }; } /** Resets the id counters. For tests that assert on exact ids. */ export function resetSubagentIds(): void { counters.clear(); launched = 0; }