/**
* tm_join — the COLLECT side of sub-agent work (issue #7 of 2026-09-18; the
* dispatch side was removed in 1.5.16).
*
* What is gone and why: this module used to own a plugin-side dispatcher,
* `tm_dispatch`, which bought the lead real overlap by creating child sessions
* through `client.session.create` + `promptAsync`. It was removed on the
* user's instruction, because a child a plugin creates is a session the user
* can neither open from the tool card nor stop from the interface — the host
* renders only its own built-in tools, and only its own `task` card links to a
* live child session. "The lead can `cancel:true` it" is not the same thing as
* the user being able to. Delegation therefore goes back to the host's `task`
* (and `task { background: true }`, which the installers switch on), and what
* stays here is the half that is still ours to do: observing and collecting
* children, which is also how the lead reads back a reply that
* `src/task-offload.ts` replaced with a pointer.
*
* Contract kept from T3 (no sub-agent spawns a sub-agent):
* - `tm_join` executes ONLY for the team lead (ctx.agent check here, plus an
* explicit deny for the five specialists in agents.ts — the tm_* wildcard
* would otherwise hand it to everyone);
* - the children it knows about are the five named specialists, never "team";
* - everything it touches is parented to the calling session (parentID), and
* parentage is READ BACK from the host before an id is claimed — this module
* never assumes a session belongs to the caller.
*
* Context economy (design goal #4): tm_join renders each child's reply
* through the SAME governance pipeline as every other tm_* tool, so a long
* sub-agent report arrives as a handle + an 80-token preview the lead can page
* through with tm_fetch — not kilotokens dumped inline.
*
* Steering (2026-10-04): the same tool is also the lead's INTERJECTION channel —
* `steer` puts a line into a running child's inbox (`Session.Inbox.Delivery =
* "steer" | "queue"`, "Steering wakes session execution."), `unread` lists that
* child's undelivered items by id and shape, `unsend` withdraws one that has not
* been delivered yet. No new tool was registered for it: a new tool name would
* have to move the whitelist in agents.ts, the permission triples in
* v2-permissions.ts and the request-layer deletion logic at once, and three
* places that can drift are worse than one argument set on a tool the lead
* already calls. What the host does NOT give is tool-level cancellation — the
* only thing stoppable is the whole active execution (`interrupt`) — so nothing
* here pretends that granularity exists.
*
* Waking (2026-10-23, measured in another project): steering a session that has
* already settled is not an interjection, it is a RESURRECTION — the host's own
* sentence is "Steering wakes session execution", so the line starts a second run
* that overlaps whatever the lead dispatched meanwhile (two processes on one
* device, in the incident that produced this). A steer therefore refuses unless
* the registry says `running`, and the explicit `wake:true` opt-in is the only way
* to deliver into a settled/unknown child. The same incident showed the mirror
* bug on the stop path: a settled row made `cancel:true` answer "没有运行中的
* 子代理可停" WITHOUT asking the host, while the woken child was in fact running.
* An explicitly named id is always asked of the host now.
*
* The body fallback has one more source: a settled host sub-agent's report also
* lives in the PARENT transcript as `…`; when
* the child session's context yields no assistant text, that envelope is read
* (exact id match, last one wins) instead of reporting `child_body_missing`.
*/
import type { HostEvent, ToolDefinition } from "../types.js";
import type { TmPipelines } from "./pipelines.js";
import { type LedgerStore } from "./ledger.js";
/** The five dispatchable specialists — "team" is deliberately NOT in the
* list: a lead spawning a lead is the nesting T3 closed. */
export declare const DISPATCH_TARGETS: readonly ["architect", "implementer", "reviewer", "tester", "researcher"];
/** A dispatch lives for the plugin process. A child that outlives the
* process is the host's session to clean up, not ours to forget silently,
* which is why tm_join re-adopts it from `session.children` instead of
* reporting "nothing to collect" (see `adoptFromHost`). */
export interface ChildRecord {
sessionID: string;
agent: string;
label: string;
parentSessionID: string;
startedAt: number;
finishedAt?: number;
state: "running" | "idle" | "error";
error?: string;
/** Reconstructed from the host's session tree, not observed live: the
* wall-clock elapsed and the settle verdict come from the host, so the
* lead must know this row was rebuilt. */
adopted?: boolean;
/** This child was dispatched by the HOST's own `subagent` tool, which we observed
* being called (v2). Its reply reaches the parent as an injected message, not
* through this tool — so the row reports state and the settle moment and must never
* be rendered as if we held the text. */
via?: "host-injection";
/** How this row reached its settled state. `event` is the host naming the child
* session in a `session.idle`; `parent-idle` is the inference that a still-running
* host child of a session that just went idle has been collected by the host — the
* v2 plugin ctx gives no way to read a child's state directly, and a row that stayed
* 运行中 forever would be the same overstated claim in the opposite direction. A
* presumption is printed as one. */
settleSource?: "event" | "parent-idle" | "injection";
/** Whether the body tm_join actually READ was a finished turn: true only when the last
* assistant message carried `time.completed`. Absent/false is NOT "the child failed" —
* it is the measurement that lets a presumed settle avoid presenting the text written
* before the child's last tool call as a delivery (a live lead read one such fragment as
* "修复没落地" and re-dispatched work that had already landed). Refreshed on every read. */
bodyComplete?: boolean;
/** The host's own completion moment for that body, when it recorded one. */
bodyCompletedAt?: number;
/** #33: the verdict of a stop attempt on THIS row, in the tool's own words. Printed by
* `renderChildLine` so a lead skimming the table cannot read five different outcomes as
* one "已取消". Absent = no stop was attempted on this child. */
cancelNote?: string;
}
/** A child seen being dispatched by the host's own tool, turned into a registry row.
* Null when the ids are unusable: a child whose id equals the caller's would make
* tm_join wait on the session it is running in, and an anonymous child cannot be
* settled by an event we match on session id. */
export declare function hostChildRecord(child: {
sessionID: string;
parentSessionID: string;
agent: string;
label: string;
}, now?: () => number): ChildRecord | null;
export interface DispatchDeps {
client: unknown;
pipelines: TmPipelines;
/** The lead agent's name as injected by agents.ts (keyed "Team"). */
leadAgent?: string;
targets?: readonly string[];
/** Let the approval gate register the child session so a sub-agent's own
* protected read opens the official dialog instead of hard-throwing. */
onChildSession?: (sessionID: string, agent: string) => void;
/** Injectable clock for tests. */
now?: () => number;
/** Ceiling on how long tm_join will wait on a round of children. */
maxWaitMs?: number;
/** What the v2 `ctx.session` bridge saw on its last attempt — reported so a failed
* claim can name the shapes it tried instead of printing "no pending dispatch". */
sessionReaderReport?: () => {
attempted: number;
resolved: number;
usedShape?: string;
failedShapes: string[];
lastError?: string;
};
/** The plugin's own LEDGER (v2 has no host `session.todo` to read). Present =
* the goal tripwire has a second thing it can actually check; absent = the
* tripwire says it could not check, which is a different answer from "clean". */
ledgerStore?: LedgerStore;
}
interface SessionApi {
messages?: (opts: unknown) => Promise;
get?: (opts: unknown) => Promise;
status?: (opts: unknown) => Promise;
abort?: (opts: unknown) => Promise;
/** #33 — v2's stop seam, wrapped by `v2-session-client.ts` around the host's own
* `ctx.session.interrupt`. Present on this personality, absent on the v1 client. */
interrupt?: (opts: unknown) => Promise;
/** GET /session/{id}/children -> Session[] — the host's own session tree,
* i.e. the recovery source when this process never saw the dispatch. */
children?: (opts: unknown) => Promise;
/** GET /session/{id}/todo -> Todo[] — READ-ONLY (the SDK gives no write
* body; writing is the built-in `todowrite` tool's job). We use it as the
* goal tripwire below. */
todo?: (opts: unknown) => Promise;
/** #steer — the v2 bridge's INTERJECTION seams, built only by
* `createV2SessionReader` (`src/host/v2-session-client.ts`). `prompt` carries the
* documented `{sessionID, text, delivery}` contract and folds `session.synthetic`
* into itself as the fallback seam; `inboxList` / `inboxCancel` wrap
* `session.inbox.list` / `session.inbox.cancel`. */
prompt?: (opts: unknown) => Promise;
inboxList?: (opts: unknown) => Promise;
inboxCancel?: (opts: unknown) => Promise;
/** THE MARKER, not a capability probe: the v1 SDK client also has a method named
* `prompt`, but it takes `{path:{id}, body:{parts}}` and has no `delivery` field, so
* gating the steer path on `typeof api.prompt === "function"` would fire a v1
* endpoint with a v2 contract and report whatever came back as a steering verdict.
* Only the object that SPEAKS the v2 flat contract carries this flag, and a client
* without it answers `no-seam` without calling anything (pinned by a test:
* promptReached === 0 on an abort-capable v1 client). */
v2SteerSeam?: boolean;
}
/** Open items on the host's own todo list, in the shape tm_join reports. */
export interface HostTodo {
content: string;
status: string;
}
export declare function openHostTodos(todos: unknown): HostTodo[];
/** Resolve the host session API defensively — `messages` is what tm_join
* cannot work without (the collected reply); a host without it means the
* caller must degrade, and `create`/`promptAsync` are deliberately NOT part
* of this contract any more: nothing here spawns sessions. */
export declare function sessionApiOf(client: unknown): SessionApi | null;
/** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
* takes `background: true`, and because that card is rendered by the host's
* own `task` renderer it links to the live child session — which a plugin tool
* card structurally cannot do. Gated behind a runtime flag (verified in the
* desktop binary): `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`, falling back
* to the umbrella `OPENCODE_EXPERIMENTAL`. We read the SAME env the host
* reads — our plugin runs inside that process — so this is a fact, not a
* guess. */
export declare function hostBackgroundSubagentsEnabled(env?: NodeJS.ProcessEnv): boolean;
/** What a tm_join wait may actually cost. The FIRST bounded wait gets the
* requested budget (clamped to TM_JOIN_MAX_WAIT_MS); a wait that follows a
* wait which settled nothing gets cut to REPEAT_WAIT_MS, because the second
* one is the shape of a lead parking instead of working — measured live at
* 2 × 300 s of nothing while six children ran. */
export declare const REPEAT_WAIT_MS = 10000;
export declare function joinBudget(waitMs: number, maxWaitMs: number, prev: {
stillRunning: number;
streak: number;
} | undefined): {
budget: number;
repeat: boolean;
streak: number;
};
/** Recognise the title a `tm_dispatch` child used to carry — the tool is no
* longer registered (a plugin-spawned child is a session the user can neither
* open from a card nor stop from the UI), but children created before the
* change are still live in the host's session tree and tm_join must still
* collect them.
*
* The shape was the host's OWN subagent convention — the built-in task tool
* titles its children `${description} (@${agent} subagent)` (verified in the
* desktop binary), which is what the renderer's `taskSession()` heuristic
* reads to colour a child row. Ours added a ` ·tm` marker inside that shape,
* and the marker is LOAD-BEARING in both directions: it is what tells a
* leftover dispatch apart from a host `task` child, so an automatic tree walk
* never claims a reply that belongs to the host's dispatcher. The pre-1.5.15
* shape (`tm::