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