/**
* 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