/** * The v2 host's own sub-agents, made collectable by `tm_join` (decision 4, 2026-09-26). * * What was broken: on v2 every dispatch goes through the host's `subagent` tool (v1's * `tm_dispatch` is gone and the plugin forces `background:true`), so the child session * was never entered into `tm_join`'s registry. `tm_join` then answered 没有待收集的派发 * about work that was running on screen, a wait could never end, and the lead had to * re-read the round from the injected message like a person paging through mail. The * event feed added in 1.7.x made this worse in the specific way goal #6 refuses: the * dispatcher ignores a session id it has no record of, so a settled child produced * silence that read as "nothing to report". * * Why these two hooks and not a session query: the v2 plugin ctx has no * `session.children` and our `ctx.session.get` bridge has never resolved a shape on a * live host, so a tree walk is not available. What IS available is measured, from the * user's own desktop session (`docs/research/host-subagent-injection.md`): * * execute.before subagent {agent, background, description, prompt} * execute.after result.metadata = {sessionID:"ses_…", status:"running", truncated:false} * result text "The subagent is working in the background * (sessionID: ses_…). You will be notified…" * later, as a message the plugin never sees as a tool result: * metadata {source:"subagent", childID:"ses_…", agent, state} * text body * * So the before hook supplies who and what-for, the after hook supplies the child's id, * and the pairing is per CALLER session — a lead that fires three dispatches in one round * gets three pending rows and pairs them oldest-first as the acks come back. * * The reply BODY is deliberately not promised: it reaches the parent as an injected * message, not through this seam, so a registered child reports where its answer comes * from instead of pretending we can fetch it. Claiming to hold a report we never read * is the defect this product exists to refuse. * * Privacy: the trajectory records the child id and the agent name only — never the * description or the prompt (R6 binds a diagnostic as much as a guard). */ import type { V2Registration } from "./v2-types.js"; import type { TeamScope } from "./v2-scope.js"; /** A dispatch we saw being made, waiting for its ack. */ export interface PendingDispatch { agent: string; label: string; at: number; /** The host's own tool-call id (`execute.before` carries `id`), when present. It is * what makes the pairing survive a replayed `execute.after`: the same call cannot * hand its slot back twice. */ callId?: string; /** The cap admission this call took, so its slot can be released BY ID instead of * "the first slot this caller holds" (0 = the cap took no slot, or not paired). */ admissionId?: number; } /** What `tm_join` needs to open a row for a child it did not dispatch itself. */ export interface HostChildInput { sessionID: string; parentSessionID: string; agent: string; label: string; /** The cap admission that admitted this dispatch (see PendingDispatch.admissionId). */ admissionId?: number; } /** How many unclaimed acks one caller may have in flight before we stop guessing which * `description` belongs to which child. Past this the child is registered with a * generic label rather than a possibly-wrong one. */ export declare const PENDING_PER_CALLER = 8; /** Read the dispatch out of the tool input (the host's `execute.before` field is * `input`, and it IS the argument object). Returns null for anything that is not a * recognisable `subagent` call — a non-object input is left alone, never invented. */ export declare function pendingDispatchOf(raw: unknown, at?: number): PendingDispatch | null; /** The child's session id, from the two places the host puts it (measured). A * synchronous child DOES have a `metadata.sessionID` — the host always sets * `{sessionID, status}` — but its `status` is already `completed` and its content IS the * reply, so there is nothing to * collect and null is the correct answer, not a failure. */ export declare function hostChildIdOf(result: unknown): string | null; /** True when the ack says the child is still open — i.e. worth registering. Read from * the host's own code (2.0.18): the `subagent` result always carries * `metadata:{sessionID, status}`, and a SYNCHRONOUS child arrives with * `status:"completed"` and the body itself, so there is nothing left to collect. A * `status` we do not recognise registers anyway, because a missed child costs the lead a * whole round while a spurious row only costs a line in a table. */ export declare function hostChildIsOpen(result: unknown): boolean; export interface SubagentRegistryReport { /** `subagent` calls seen on a Team session */ seen: number; /** children actually opened in tm_join's registry */ registered: number; /** acks that carried no child id at all */ noChildId: number; /** acks that arrived with the child ALREADY completed — a synchronous `subagent` call, * whose result is the body itself, so there is nothing for tm_join to collect */ settledAtOnce: number; /** acks that arrived with nothing pending to pair them with */ unpaired: number; /** children registered with a generic label because the caller had >PENDING_PER_CALLER * in flight, so pairing would have been a guess */ labelGuessed: number; /** `execute.after` events carrying a call id we had already processed — a host that * replays hooks in-process. Counted because a replay that released a slot would * silently loosen the ceiling. */ replayed: number; } export interface V2SubagentRegistry { registrations: Promise[]; report: SubagentRegistryReport; active: boolean; } /** The host's own completion envelope, in either generation's spelling. Returns the child * id and the state the host asserted, or null when the text is not an injection. * `[B]` 2.0.18 builds the wrapper as ``; v1 * used ``. Both are matched because a plugin reload can * serve a session whose history contains either. */ export declare function completionFromText(text: string): { sessionID: string; state: string; } | null; export interface CompletionWatchReport { /** times the seam fired on a Team session */ contextFired: number; promptFired: number; /** envelopes recognised */ seen: number; /** children actually settled through this path */ settled: number; /** scans skipped because nothing was open (the cheap path) */ skipped: number; } /** * The settle path for a child the plugin never created (#31, 2026-09-26). * * A live 2.0.18 round proved the two obvious paths do not work: the host does NOT forward a * child session's `session.idle` to a plugin subscriber, and the parent-idle presumption * cannot fire mid-turn because the parent is by definition busy while it works. So a * background child whose full report had ALREADY been injected stayed 运行中 — goal #6's * overstated claim wearing the opposite face. * * A second round then proved a hook can exist and still be the wrong one: `session.prompt` * fired 0 times and `session.model.request` fired 6 but carried no messages at all (the * message list is assembled separately from that payload). The seam that DOES carry the * parent's history is `session.hook("context")` — the same one the request trim and the * probe read, and the one whose `messages` the probe counted at 8 in that round. * * So: watch `context`, and only when something is actually open. The scan is O(messages) * per request, which is a real cost on a long-lived lead session, so an idle registry skips * it entirely and the skip is counted — "we did not look because there was nothing to look * for" stays distinguishable from "we looked and saw nothing". * * `session.prompt` is kept registered and counted as well: it costs nothing, and if a future * host routes the injection through it, the counter is where we would learn that. */ export declare function applyV2CompletionWatch(ctx: unknown, deps: { /** Settle the named child if this process knows it. Returns false when the id is not * registered (somebody else's session, or already settled) — never an error. */ settle: (sessionID: string, state: string) => boolean; /** Anything still open? When this says no, the scan is skipped. */ hasOpen?: () => boolean; scope?: TeamScope; }): { registrations: Promise[]; report: CompletionWatchReport; active: boolean; }; export declare function applyV2SubagentRegistry(ctx: unknown, deps: { /** Open a row in tm_join's registry. Returns false when the child is already known * (a plugin reload replays hooks) or the id is the caller's own session. */ register: (child: HostChildInput) => boolean; /** A dispatch that produced NO child row still has to give its concurrency * slot back — otherwise the cap counts phantom in-flight work and tightens * itself until the TTL saves it. `why` says which case it was, and * `admissionId` names the EXACT slot (see `PendingDispatch.admissionId`). */ onCallEnd?: (callerSessionID: string, why: "no-child" | "settled-at-once" | "unusable-child", admissionId?: number) => void; /** Pair this call with one of the caller's cap admissions, returning its id. * Called from `execute.before`, i.e. after `permission.evaluate` admitted it. */ pairAdmission?: (callerSessionID: string) => number; scope?: TeamScope; now?: () => number; }): V2SubagentRegistry; //# sourceMappingURL=v2-subagent.d.ts.map