TermAl

Feature Brief: Agent Delegation Sessions

Delegated Codex children run on the shared Codex app-server; its identity model and orphan-thread behavior are documented in shared-codex-app-server.md.

Status

Proposed.

This brief defines an ad hoc delegation model for spawning bounded child agent sessions from a parent conversation. It is intentionally smaller than the existing orchestration-template system: delegation sessions are for immediate parallel work, review lenses, focused investigations, and small isolated patches that a lead agent can later resume from.

Related:

Problem

TermAl already supports many ordinary agent sessions, but a lead agent cannot directly create a bounded side session, wait for it, and consume a compact result without relying on the human to manually create tabs, copy prompts, and paste summaries back.

That makes parallel work awkward in exactly the places where it is safest: review lenses, read-only codebase exploration, targeted test additions, and small patches with explicit file ownership.

Goals

Non-goals for v1

Core Idea

A delegation is a parent-child relationship between two normal sessions:

Parent session
  |
  +-- delegation-1 -> child session: "React review"
  +-- delegation-2 -> child session: "Rust review"
  +-- delegation-3 -> child session: "Implement tests"

The child session receives a bounded prompt, runs independently, then records a structured result. The parent can either continue local work or explicitly yield on a backend-owned wait. When the wait condition is met, TermAl queues a resume prompt and wakes the parent through the normal queued-prompt dispatcher.

Delegations are not special agent runtimes. They are metadata and control surfaces around ordinary sessions.

The same control surface generalizes past the parent-child tree. A delegation is really a directed message to a session plus a reply and a backend fan-in; if the target already exists instead of being spawned, the identical machinery becomes a peer conversation between two top-level sessions. See Peer Session Connections below.

Value To Parent Agents

Delegation is useful to a parent agent even when that agent already has an internal subagent mechanism. Internal subagents are scoped to one runtime and usually disappear into that runtime’s transcript. TermAl delegations make the parallel work a durable application feature:

Terminology

Product Model

Delegation Card

The parent transcript should show a delegation card when child sessions are spawned.

The card contains:

For multiple parallel children, the parent can show a grouped card:

Delegated Work
3 sessions running

React Review       running    02:14
Rust Review        complete   01:32    1 finding
Security Review    complete   01:45    clean

Child Session

The child is a normal session with:

The child remains independently openable from the parent delegation card while the parent exists. TermAl omits delegated children from default session lists so reviewer fan-out does not clutter the sidebar.

Retention And Cleanup

Delegation children are durable ordinary session records while their parent exists, which preserves restart recovery and lets users reopen child transcripts from the parent card. The parent owns the child tree: deleting a parent session cascades deletion to its delegated child sessions and any delegated descendants, and tears down their runtimes.

This intentionally allows reviewer fan-out to accumulate child sessions during a long parent session. The cleanup boundary is the parent session, not each individual delegation result. That keeps child transcripts available for later human inspection or follow-up prompts without making delegated reviewers visible in default session lists.

Delegation tasks are one-shot records even though their child sessions remain openable. A user may open a child transcript and continue it manually, but MCP review automation should create a fresh child delegation for each bounded task instead of reusing an earlier child session. Reuse-by-default would make result packets ambiguous and would blur the parent-owned audit trail.

The delegation record and result summary remain in backend state for lifecycle bookkeeping after parent deletion. Child transcripts and full result retrieval through parent-scoped routes end with the owning parent. Open item: define a later archive/export policy if long-lived installations need child transcripts or full result packets after the owning parent is deleted.

Result Packet

When a child finishes, TermAl records a compact result packet:

{
  "delegationId": "delegation-123",
  "childSessionId": "session-456",
  "status": "completed",
  "summary": "Reviewed the virtualized transcript changes. One test gap remains.",
  "findings": [
    {
      "severity": "Low",
      "file": "ui/src/panels/VirtualizedConversationMessageList.test.tsx",
      "line": 612,
      "message": "Programmatic release path lacks a post-idle unmount assertion."
    }
  ],
  "changedFiles": [],
  "commandsRun": [
    {
      "command": "npx vitest run src/panels/VirtualizedConversationMessageList.test.tsx",
      "status": "success"
    }
  ],
  "notes": []
}

The compact packet is deliberately bounded because it is copied into broad state, SSE, and fan-in prompts. It is not the transport for byte-complete child artifacts. The authoritative final assistant output remains in the persisted child transcript and is available in UTF-8-safe pages from GET .../result/output. MCP callers use the same termal_get_session_result tool with outputOffset and outputLimit, then repeat with nextOffsetBytes until complete is true. This keeps every tool response bounded without requiring children to write ad hoc temporary files.

For every newly spawned mode: reviewer delegation, the compact packet is no longer reconstructed from human-readable output. TermAl appends the versioned submission contract to the child bootstrap prompt after the repository-owned task text. This is host behavior: a repository may keep its own /review-code, /review-local, or other review command and does not need a TermAl marker or a submission step in that command. The child calls the child-only termal_submit_review_result tool with schema version 1. TermAl validates the complete typed payload, derives the delegation/child/parent identity, and appends a self-describing envelope to the parent-child mailbox under topic delegation-review-result/v1. The mailbox append is durable and idempotent; it intentionally does not wake the parent ahead of the normal delegation fan-in. The validated payload is held provisionally until the child turn independently becomes terminal, then promoted to the delegation result. Once accepted, that payload is the authoritative terminal truth: a later runtime error, idle teardown, or missing child cannot overwrite it. TermAl promotes the payload with every field unchanged and records any later transport failure separately in postSubmissionTransportError. An explicit user cancellation remains authoritative over lifecycle status and retains, but does not promote, an already accepted result. Recovery of the durable mailbox envelope is deliberately resilient: malformed JSON or mismatched envelope metadata is quarantined once for the current submission attempt and exposed as reviewResultRecoveryError. Parent status/result/output/cancel/follow-up APIs continue through the ordinary fail-closed lifecycle instead of returning a permanent recovery error. Rearming increments the attempt and clears the probe, so a later valid submission is not suppressed.

This separates protocol from presentation: the final assistant Markdown stays available through paged full-output reads, but its headings and bullets are not parsed to decide whether findings exist. If the required tool submission is missing, the review result fails closed as unavailable and includes an explicit Unavailable finding. It is never represented as a clean empty result. Only older persisted delegations that predate the required structured protocol retain the versioned prose parser as a legacy fallback.

New reviewer delegations currently support Claude and Codex. Their native permission protocols let TermAl authenticate the injected MCP server/tool before granting the narrow result-submission exception. ACP v1 session/request_permission does not expose a portable authenticated MCP tool identity, and Cursor, Gemini, and OpenCode emit incompatible presentation-only fields. TermAl rejects mode: reviewer for those ACP adapters before child creation instead of guessing from names or leaving a headless child blocked on approval. ACP agents remain available in mode: explorer where the requested write policy is supported.

The composer makes this boundary visible. Claude and Codex preselect Reviewer. Cursor and Gemini preselect Explorer and keep Reviewer visible but disabled with an explanation. OpenCode also preselects Explorer and uses an isolated worktree, because its delegated children do not support the shared read-only policy.

The packet is a summary for resumption, not a replacement for the child transcript. Structured review submissions accept only terminal command status labels: success or error.

Lifecycle

1. Spawn

The parent requests a child with:

TermAl creates:

Phase 1 REST spawn does not park records in queued: it either creates a running delegation immediately or rejects with 409 when the per-parent active limit is full. queued is reserved for a future scheduler/throttle layer that would own the queued-to-running transition and emit delegationUpdated when dispatch actually starts.

2. Run

The child runs like any other session.

The parent is not blocked unless it explicitly waits. The UI should keep the child status visible without forcing the parent transcript to hydrate the child transcript.

3. Complete

Completion happens when the child session is idle and has produced a final assistant response. TermAl extracts or requests a result packet from the final response.

For v1, the result can be derived from the child final response using a clear prompt contract. Later, TermAl can add a native structured result message type.

4. Resume / Yield

The parent can consume the result in one of three ways:

Automatic parent prompting is opt-in through a delegation wait. A wait records a parent session, one or more delegation ids, and a fan-in mode:

When the wait is scheduled, the parent can yield the current turn instead of polling. TermAl persists the wait and exposes it through /api/state and SSE so the UI can show “waiting for delegations” even after reload.

When the wait is satisfied, TermAl queues a synthesized prompt to the parent session and removes the wait from the pending-wait list. If the parent is idle, the prompt dispatches immediately. If the parent is still in a turn, the prompt waits behind the current turn and resumes the parent through the existing queued-prompt path.

This means a caller must choose between synchronous polling and backend resume waits. After scheduling a backend resume wait, the parent turn should end; a shell or HTTP polling loop in that same turn keeps the queued resume prompt behind the active turn and can make the fan-in look stuck even though TermAl has already queued the result.

If the parent session is removed or becomes unavailable before a wait can resume it, TermAl consumes the parent’s pending waits with reason: "parentSessionRemoved" or reason: "parentSessionUnavailable" and does not queue a resume prompt. This keeps /api/state from retaining orphan waits and lets SSE clients distinguish normal fan-in completion from parent loss. Boot-time reconciliation applies the same cleanup to persisted waits whose parent session is already missing.

The resume prompt is deliberately close to orchestration’s consolidated transition prompt: it includes the wait id, mode, watched delegation statuses, and one result section per terminal child. all waits produce a full fan-in bundle; any waits produce the first terminal result plus the current status of the remaining children.

Delegation waits reuse the orchestration scheduling model conceptually: child delegations are completion sources and the parent is the destination session. all corresponds to orchestration’s Consolidate input mode; any corresponds to ordinary queued transition delivery. The delegation API keeps this ad hoc so users do not need to author a reusable orchestration template for one-off reviewer batches.

Example flow:

spawn_delegation(agent="Claude", prompt="Review backend resolver") -> delegation-a
spawn_delegation(agent="Codex", prompt="Review frontend composer") -> delegation-b
resume_after_delegations(parentSessionId, [delegation-a, delegation-b], mode="all")

...parent yields; TermAl shows a pending all-mode delegation wait...

...both children finish...

TermAl queues a parent resume prompt containing both results and starts the
parent if it is idle.

For reviewer fan-out, callers can combine spawn and fan-in scheduling:

spawn_reviewer_batch(parentSessionId, requests, { mode: "all", title: "Review fan-in" })

...TermAl creates child sessions, stores one delegation wait for successful spawns...

...the parent yields...

TermAl queues the parent resume prompt when all successful children finish.

The reviewer-batch path is the preferred API for “spawn several reviewers and wait for all of them.” It creates all successful child sessions first, then stores one all wait covering those delegation ids. Partial spawn batches still schedule the wait for successful children; failed spawn items are returned in the batch result so the parent can decide whether to retry.

5. Cancel

Cancel stops the child session and marks the delegation canceled. The parent card should preserve partial transcript access and any partial result summary.

Cancel responses return the server’s latest delegation status. The UI treats a failed response as an error because the cancel was a no-op against an already errored delegation. completed and canceled are idempotent terminal no-ops, while queued and running can occur while the cancel request has been accepted but follow-up state is still arriving through SSE.

UI messages that mention an unavailable child session derive their wording from the same wire status: terminal states use “already …” (completed, failed, canceled) and in-flight states use “still …” (queued, running). The phrases are display text only; callers should branch on the wire status, not the rendered message text.

In the current REST runtime, running delegations are expected to have a childSessionId; a running response without one is treated as an unexpected unavailable-child state. Childless queued records are reserved for the future scheduler/throttle layer described above.

6. Delegate Agent Commands

Delegating a slash command or future skill must not bypass command-template resolution. The regular-send path and delegation path should both call the backend command resolver described in agent-slash-commands.md.

Required contract:

This keeps /fix-bug, future trusted /review-code commands, and future Claude skills consistent whether the user sends them in the parent session or delegates them to a child.

Command And Tool Surface

Internal Commands

TermAl should expose internal commands that can be used from the UI and from an MCP wrapper:

Implementation: ui/src/delegation-commands.ts; wait-error packet sanitization lives in ui/src/delegation-error-packets.ts.

spawn_delegation(parentSessionId, request) -> SpawnDelegationCommandResult
spawn_reviewer_batch(parentSessionId, requests, resumeAfter?) -> SpawnReviewerBatchCommandResult
get_delegation_status(parentSessionId, delegationId) -> DelegationStatusCommandResult
get_delegation_result(parentSessionId, delegationId) -> DelegationResultPacket
cancel_delegation(parentSessionId, delegationId) -> DelegationStatusCommandResult
wait_delegations(parentSessionId, delegationIds, options?) -> WaitDelegationsResult
resume_after_delegations(parentSessionId, delegationIds, options?) -> DelegationWaitResponse

spawn_reviewer_batch is the first Phase 3 helper. It fans out several read-only reviewer spawns in parallel through the same Phase 1 REST create route and returns successful child ids plus per-item failures. completed means every spawn succeeded on one backend instance; partial means at least one spawn succeeded, at least one item failed, and every successful response came from the same backend instance. error means every item failed or any successful responses crossed backend instances during restart. Mixed-instance spawn errors set error.kind === "mixed-server-instance", null top-level revision metadata, and include diagnostic error.recoveryGroups. The current command surface does not accept a server-instance selector, so wrappers should treat mixed-instance errors as non-recoverable through these helpers until a server-aware transport is added. wait_delegations returns error.kind === "mixed-server-instance" when a successful or timed-out status batch observes a backend restart between polling cycles or within one parallel status batch. Status-fetch failures have priority: if any status request rejects, the result is status-fetch-failed even when collected responses already include another serverInstanceId. In that priority path, collected responses from a different instance are ignored while building the retained partial state, so the error can hide the concurrent restart and omit mixed-instance recoveryGroups. Wrappers should treat status-fetch-failed as “poll again or fall back to backend resume wait” rather than as evidence that no restart happened. Mixed-instance error.recoveryGroups are diagnostic only: groups identify which backend instance produced each observed delegation/status pair, and a previous-instance group is scoped to delegations fetched in the current poll. A single delegationId can appear in multiple groups within one error packet: once for the previous-instance baseline and once for the current instance response. If the first poll crosses instances, the previous-instance group is omitted because there is no baseline to report. Within each group, delegationIds and childSessionIds are ordered by each delegation id’s position in the original wait_delegations request. Groups are ordered by the earliest requested delegation id they contain, with serverInstanceId as the tie-breaker. Revisions are per server instance and must not be compared across groups.

spawn_reviewer_batch can also take a third resumeAfter argument with the same shape as resume_after_delegations options. When supplied, successful spawns are followed by a backend resume wait for those delegation ids. Partial spawn batches schedule the wait for only the successful child sessions and keep the failed items in the batch result. Mixed-server-instance batches do not schedule a wait because their successful ids came from different backend instances.

resume_after_delegations does not poll in the caller. It schedules a durable backend delegation wait for the parent session and returns the created wait record. When the selected any or all condition is satisfied, the backend queues a synthesized resume prompt to the parent through the normal queued-prompt dispatcher. The default mode is all. Callers should treat a successful scheduled wait as a yield point: do not poll in the same parent turn unless the user explicitly asks for synchronous status. If the caller needs same-turn results, use wait_delegations instead of resume_after_delegations. TermAl will re-activate the parent when the wait completes after the current turn has yielded.

Spawn commands return client-side validation failures as outcome: "error" with error.kind === "validation-failed". Wait commands are different: invalid parent/delegation ids or wait options throw TypeError/RangeError before polling or scheduling starts.

Spawn validation packet messages are intentionally allow-listed. Unknown spawn validation exceptions collapse to "Invalid delegation request."; wrapper UX should not depend on spawn packet messages outside this list:

Wait validation throws TypeError or RangeError before polling starts. These throws are not spawn validation packets and are not sanitized by delegation-error-packets.ts. Wrappers should catch by error type for UX. The current runtime message templates are pinned by delegation command tests for wrapper diagnostics:

Backend-scheduled resume wait failures may surface these sanitized backend messages through DelegationResumeWaitFailurePacket:

Use the exported constants from ui/src/delegation-commands.ts as the numeric source of truth. Angle-bracket placeholders above interpolate these values in runtime strings:

MCP Tools

Current direction:

Delegation tools are parent-scoped. The first local implementation injects the bridge into TermAl-launched agent runtimes by default, relying on the implicit parent id, backend ownership checks, read-only default write policy, and concurrency/depth limits as the safety boundary. Do not add a separate namespace or capability-token layer for this local per-process bridge unless a concrete agent integration requires it. Add project/workspace opt-in or capability tokens before exposing the bridge over a shared, remote, or long-lived reusable transport.

The parent session id is the namespace for v1. That is intentionally weaker than a Linux-style namespace: child sessions are still ordinary TermAl sessions in storage, and humans can open them from the parent card. The MCP caller, however, only receives delegation ids created under its implicit parent and has no tool that can enumerate unrelated sessions or delegations. This is the minimum boundary needed for local delegated review automation without adding a capability system prematurely.

Visibility is scoped at the tool boundary, not at the storage layer. The local bridge is not expected to hide child sessions from TermAl itself, nor to make children reusable by unrelated parent sessions. A delegated child may remain openable from the parent UI for follow-up prompts while the parent lives. When the parent session is deleted, TermAl owns cascade cleanup of its delegation records and child sessions. That keeps review sessions useful during the parent workflow while making long-term accumulation a parent-lifecycle concern instead of a per-review cleanup requirement.

Do not implement a stronger namespace abstraction until there is a concrete reason to do so. For the local per-process bridge, parentSessionId plus backend ownership checks is the boundary. If a future transport is shared across projects, exposed remotely, or reused across parent sessions, add an explicit scope/capability layer at that point rather than weakening the v1 tool contract.

The first implementation is a TermAl-owned local MCP bridge spawned for one parent agent session:

termal delegation-mcp --parent-session-id <session-id> --base-url <http-origin>

The bridge is configured with the TermAl base URL and the current parentSessionId; tool calls do not accept an arbitrary parent id. This keeps the first security boundary simple: the bridge can only act under the parent session that TermAl used to launch it, and the backend still validates that every requested delegation belongs to that parent.

Do not add a broad “list all sessions” or “list all delegations” tool in the first MCP slice. The bridge may return ids it created, and callers may pass those ids back to status/result/cancel/wait tools. Broader visibility can be added later behind an explicit human-granted scope if it proves useful. This is the practical visibility boundary for v1: delegated children remain normal sessions in storage, but parent-scoped MCP callers can only reach children by delegation id through parent-owned routes.

v2 update — durable peer mailboxes ratify a broader boundary. The delegation tools above stay parent-scoped exactly as described. The root peer tools (termal_send_to_session, termal_list_sessions, termal_list_mailboxes, termal_read_mailbox, termal_read_mailbox_message, and termal_acknowledge_mailbox) deliberately cross this boundary. They ship the broad root-session discovery and durable neutral mailbox access the v1 slice deferred: a bridge MAY enumerate and target root sessions across projects — and, on the roadmap, across machines — because the point is long-running specialist sessions on different projects consulting each other (for example, a Kadry coding agent requesting changes from a LegalSystem coding agent). Delegation children remain unreachable as peers: discovery and ordinary mailbox participant validation filter to root sessions (parentDelegationId == null). On top of that root-only filter, termal_send_to_session refuses to target the caller itself — on both id and name references — so a bridge cannot message itself; termal_list_sessions applies only the root-only filter and so still lists the caller. That root-only filter, plus send’s self-rejection, is what the peer guard tests now pin, and it is the actual v2 visibility boundary.

The exclusion is symmetric on the caller side: a bridge serving a delegation child (a reviewer, explorer, or worker, which may be processing untrusted content) is not given any of the six general peer/mailbox tools. tools_list_for_caller removes them from that child’s advertised tools, and the invocation path rejects them even if called directly, so a child cannot reach root sessions or their ordinary mailboxes through the bridge. That check fails closed: an unreachable backend or an unresolvable caller is treated as a child and denied the peer tools. The sole exception is termal_submit_review_result: it has no target argument, and the backend accepts it only from a linked, current reviewer child, routing one strict result envelope to that child’s own coordinator. Only a root-session caller sees the general peer tools, so the note above that termal_list_sessions lists the caller is itself scoped to a root caller.

The bridge caches a successful caller classification for its lifetime. That is safe because root eligibility is conjunctive: the session must have no parentDelegationId and its id must be absent from the durable delegation child index. A session that is root by both sources is never converted into a child under the same id by any production lifecycle path. Startup repair may restore a missing parent marker only for a session that the durable index already classified as a child, so it cannot follow a cached root grant. Hidden prewarmed sessions are no longer created; transient lookup failures remain uncached and fail closed. Any future root-to-child adoption, conversion, or id-reuse feature must replace the lifetime cache with revalidation before it ships.

This containment is a tool-layer guardrail, not process isolation. TermAl’s loopback HTTP API is unauthenticated under the single-user, local-only trust model — GET /api/state and POST /api/sessions/{id}/messages answer any local caller — so a child able to issue raw HTTP could enumerate or message sessions directly, bypassing the bridge. Hiding and rejecting the peer tools keeps a well-behaved agent within the boundary by governing the tools it is offered and will run; a hard cross-session boundary would need caller-scoped REST auth, which is deferred with the capability-token work (see Phase 3).

Keep tool names explicit:

termal_spawn_session
termal_list_delegations
termal_get_session_status
termal_get_session_result
termal_cancel_session
termal_wait_delegations
termal_resume_after_delegations
termal_followup_session
termal_submit_review_result
termal_send_to_session
termal_list_sessions
termal_list_mailboxes
termal_read_mailbox
termal_read_mailbox_message
termal_acknowledge_mailbox

The MCP tools map to the existing command/API semantics:

termal_spawn_session(request) -> SpawnDelegationCommandResult
termal_list_delegations() -> DelegationListResponse
termal_get_session_status({ delegationId }) -> DelegationStatusCommandResult
termal_get_session_result({ delegationId }) -> DelegationResultPacket
termal_get_session_result({ delegationId, outputOffset, outputLimit? }) -> DelegationResultOutputPage
termal_cancel_session({ delegationId }) -> DelegationStatusCommandResult
termal_wait_delegations({ delegationIds, pollIntervalMs?, timeoutMs? }) -> WaitDelegationsResult
termal_resume_after_delegations({ delegationIds, mode?, title? }) -> DelegationWaitResponse
termal_followup_session({ delegationId, message }) -> DelegationStatusResponse
termal_submit_review_result({ schemaVersion, status, summary, findings, commandsRun, filesInspected, notes, suggestedTrackerUpdates }) -> MailboxAppendReceipt
termal_send_to_session({ sessionId, message, idempotencyKey, topic?, stateStamp?, class? }) -> { sessionId, resolvedFrom, mailboxId, messageId, sequence, unreadDepth, notificationDisposition, duplicate }
termal_list_sessions() -> { sessions: [{ sessionId, name, agent, status, workdir, preview }] }
termal_list_mailboxes() -> { mailboxes: [{ id, participants, latestSequence, unreadCount, latestMessagePreview, latestMessageAt }] }
termal_read_mailbox({ mailboxId, afterSequence?, limit? }) -> { mailboxId, messages }
termal_read_mailbox_message({ messageId }) -> MailboxMessage
termal_acknowledge_mailbox({ mailboxId, expectedProcessedThrough, processedThrough }) -> MailboxSummary

termal_list_delegations is the recovery path when a spawn result or parent conversation context was truncated. It lists only the bridge’s implicit parent, keeps same-title children as separate records, and returns compact summaries with exact delegation and child-session ids plus current status. Those ids can be reused directly with status, result, cancel, follow-up, wait, or resume; no respawn or direct persistence-database access is needed.

termal_followup_session re-arms a completed or failed delegation for another turn — a still-running, canceled, or child-removed delegation is rejected (see the /followup route). The peer tools use a durable neutral mailbox rather than placing the message body directly into the receiver’s turn queue:

Receipt notificationDisposition is the immutable original dispatch outcome: deliveredToIdleSession, queuedBehindActiveTurn, or durableButNotWoken; durability does not depend on successful wake delivery. Mailbox reads expose the evolving wake lifecycle separately as notificationState, which can additionally be recoveredWake and can advance to deliveredToIdleSession. A duplicate retry with the same stable intent returns the original receipt with duplicate: true, regardless of the current notification state. If a transport failure prevents the receipt from arriving, the append outcome is explicitly reported as unknown and the same stable intent and idempotency key are the recovery path. Backend 409 and 503 responses retain their status and error detail. See Durable agent mailboxes for the complete storage, recovery, and acknowledgement contract, plus the shipped-vs-proposed note under Peer Session Connections below.

termal_wait_delegations is a bounded synchronous wait for short waits and smoke tests. termal_resume_after_delegations schedules the durable backend wait and should be preferred for long-running delegated review flows because it lets the parent yield and be resumed by TermAl when the wait is terminal. Agents must not combine a backend resume wait with shell polling, raw HTTP polling, or session-log scraping in the same parent turn. Once a resume wait is scheduled, the parent should yield so the queued fan-in prompt can run as the next turn.

Tool results should include enough information for a parent agent to continue without opening the child transcript:

Safety limits for agent-facing tools:

Capability tokens are not required for the first local bridge as long as TermAl spawns it per agent process, passes an implicit parent session id, and does not expose it remotely. Treat capability tokens as remote/shared-transport work, not as a prerequisite for local delegated review automation.

Non-goals for the local v1 bridge:

Agent integration hooks:

/review-changes depends on this MCP surface. Its final form should:

Implementation order:

  1. Close existing delegation correctness bugs first, especially terminal status/result refresh and backend resume wait behavior after restart.
  2. Finish the local MCP bridge contract and regression coverage around parent-scoped spawn/status/result/cancel/wait tools.
  3. Wire the same bridge descriptor into Codex, Claude, Cursor, Gemini, and OpenCode startup/resume hooks.
  4. Rewrite /review-changes to use only the TermAl MCP tools. The command must not fall back to raw HTTP, shell polling, Claude Task agents, Codex platform subagents, or manual session-log scraping.

API Sketch

POST /api/sessions/{parentSessionId}/delegations
GET  /api/sessions/{parentSessionId}/delegations/{delegationId}
GET  /api/sessions/{parentSessionId}/delegations/{delegationId}/result
GET  /api/sessions/{parentSessionId}/delegations/{delegationId}/result/output?offsetBytes=0&limitBytes=4096
POST /api/sessions/{parentSessionId}/delegations/{delegationId}/cancel
POST /api/sessions/{parentSessionId}/delegations/{delegationId}/followup
POST /api/sessions/{parentSessionId}/delegation-waits

The delegation-waits endpoint schedules backend-owned parent resume prompts. The polling wait_delegations helper remains client-side and does not create backend wait records. Its status/result reads may still refresh a completed child delegation, persist the terminal delegation record, and consume any already-satisfied backend wait watching that delegation.

GET /api/state includes pending delegationWaits so reloads and other tabs can render the parent waiting state. Wait records are removed from the snapshot when they are consumed. The synchronous DelegationWaitResponse still returns the created wait even if it is instantly satisfied and consumed by a follow-up revision. resumePromptQueued means TermAl queued a parent resume prompt. resumeDispatchRequested is separate and only means that the parent was idle enough for TermAl to dispatch that queued prompt immediately.

Delegation lifecycle changes should be revisioned delta events so normal SSE gap detection and /api/state repair keep working:

type DelegationDeltaEvent =
  | { type: "delegationCreated"; revision: number; delegation: DelegationSummary }
  | { type: "delegationWaitCreated"; revision: number; wait: DelegationWaitRecord }
  | {
      type: "delegationWaitConsumed";
      revision: number;
      waitId: string;
      parentSessionId: string;
      reason: "completed" | "parentSessionUnavailable" | "parentSessionRemoved";
    }
  | {
      type: "delegationWaitResumeDispatchFailed";
      revision: number;
      parentSessionId: string;
      error: string;
    }
  | {
      type: "delegationUpdated";
      revision: number;
      delegationId: string;
      status: DelegationStatus;
      updatedAt: string;
    }
  | {
      type: "delegationCompleted";
      revision: number;
      delegationId: string;
      result: DelegationResultSummary;
      completedAt: string;
    }
  | {
      type: "delegationCanceled";
      revision: number;
      delegationId: string;
      canceledAt: string;
      reason?: string;
    };

/api/state must include enough delegation summary data to recover missed lifecycle deltas after reconnect.

Data Model

type AgentType = "Claude" | "Codex" | "Cursor" | "Gemini";
type SessionStatus = "active" | "idle" | "approval" | "error";
// UI recovery category, not a strict HTTP status-class discriminator.
// Preserved parseable gateway JSON errors may report 502/503/504 as
// "request-failed"; branch on status/restartRequired when status-class
// behavior matters.
type ApiRequestErrorKind = "backend-unavailable" | "request-failed";

type DelegationMode = "reviewer" | "explorer" | "worker";
type DelegationStatus =
  | "queued"
  | "running"
  | "completed"
  | "failed"
  | "canceled";

type DelegationWritePolicy =
  | { kind: "readOnly" }
  | { kind: "sharedWorktree"; ownedPaths: string[] }
  | { kind: "isolatedWorktree"; ownedPaths: string[]; worktreePath: string };

type DelegationWritePolicyRequest =
  | { kind: "readOnly" }
  | { kind: "sharedWorktree"; ownedPaths: string[] }
  | {
      kind: "isolatedWorktree";
      ownedPaths: string[];
      // Optional in requests/defaults; the backend generates a TermAl-owned
      // worktree path before persisting the delegation record.
      worktreePath?: string;
    };

type DelegationRecord = {
  id: string;
  parentSessionId: string;
  childSessionId: string;
  mode: DelegationMode;
  status: DelegationStatus;
  title: string;
  prompt: string;
  cwd: string;
  agent: AgentType;
  model?: string | null;
  writePolicy: DelegationWritePolicy;
  createdAt: string;
  startedAt?: string | null;
  completedAt?: string | null;
  result?: DelegationResult | null;
};

type DelegationResult = {
  delegationId: string;
  childSessionId: string;
  status: DelegationStatus;
  summary: string;
  findings?: DelegationFinding[];
  changedFiles?: string[];
  commandsRun?: DelegationCommandResult[];
  notes?: string[];
};

type DelegationFinding = {
  severity: string;
  file?: string | null;
  line?: number | null;
  message: string;
};

type DelegationCommandResult = {
  command: string;
  status: string;
};

type DelegationResultSummary = {
  delegationId: string;
  childSessionId: string;
  status: DelegationStatus;
  summary: string;
};

type DelegationSummary = Omit<DelegationRecord, "prompt" | "cwd" | "result"> & {
  result?: DelegationResultSummary | null;
};

type DelegationChildSessionSummary = {
  id: string;
  name: string;
  emoji: string;
  agent: AgentType;
  model: string;
  status: SessionStatus;
  parentDelegationId: string | null;
};

type SpawnDelegationFailurePacket =
  | {
      kind: "spawn-failed";
      name: string;
      message: string;
      apiErrorKind: ApiRequestErrorKind | null;
      status: number | null;
      restartRequired: boolean | null;
    }
  | {
      kind: "validation-failed";
      name: string;
      message: string;
    };

type SpawnDelegationCommandSuccessResult = {
  outcome: "completed";
  delegationId: string;
  childSessionId: string;
  delegation: DelegationSummary;
  childSession: DelegationChildSessionSummary;
  revision: number;
  serverInstanceId: string;
  error?: never;
};

type SpawnDelegationCommandResult =
  | SpawnDelegationCommandSuccessResult
  | {
      outcome: "error";
      revision: null;
      serverInstanceId: null;
      error: SpawnDelegationFailurePacket;
    };

type CreateDelegationRequest = {
  prompt: string;
  title?: string;
  cwd?: string;
  agent?: AgentType;
  model?: string;
  mode?: DelegationMode;
  writePolicy?: DelegationWritePolicyRequest;
};

type SpawnReviewerBatchItem = Omit<CreateDelegationRequest, "mode" | "writePolicy">;

type SpawnReviewerBatchFailure = {
  kind: "spawn-failed";
  index: number;
  title: string | null;
  name: string;
  message: string;
  apiErrorKind: ApiRequestErrorKind | null;
  status: number | null;
  restartRequired: boolean | null;
};

type DelegationWaitRecord = {
  id: string;
  parentSessionId: string;
  delegationIds: string[];
  mode: "any" | "all";
  createdAt: string;
  title?: string | null;
};

type StateResponse = {
  // Other fields omitted.
  delegations?: DelegationSummary[];
  delegationWaits?: DelegationWaitRecord[];
};

type DelegationWaitResponse = {
  revision: number;
  wait: DelegationWaitRecord;
  resumePromptQueued: boolean;
  resumeDispatchRequested: boolean;
  serverInstanceId: string;
};

type SpawnReviewerBatchResumeWaitResult =
  | {
      outcome: "scheduled";
      wait: DelegationWaitRecord;
      resumePromptQueued: boolean;
      resumeDispatchRequested: boolean;
      revision: number;
      serverInstanceId: string;
    }
  | {
      outcome: "skipped";
      reason: "mixed-server-instance" | "no-successful-spawns";
      message: string;
    }
  | {
      outcome: "error";
      error: {
        kind: "resume-wait-failed";
        name: string;
        message: string;
        apiErrorKind: ApiRequestErrorKind | null;
        status: number | null;
        restartRequired: boolean | null;
      };
    };

type SpawnReviewerBatchBaseResult = {
  spawned: SpawnDelegationCommandSuccessResult[];
  failed: SpawnReviewerBatchFailure[];
  delegationIds: string[];
  childSessionIds: string[];
  revision: number | null;
  serverInstanceId: string | null;
  resumeWait?: SpawnReviewerBatchResumeWaitResult;
};

type SpawnReviewerBatchCommandResult =
  | (SpawnReviewerBatchBaseResult & {
      outcome: "completed" | "partial";
      error?: never;
    })
  | (SpawnReviewerBatchBaseResult & {
      outcome: "error";
      error:
        | MixedServerInstanceErrorPacket
        | {
            kind: "all-spawns-failed";
            name: string;
            message: string;
          }
        | Extract<SpawnDelegationFailurePacket, { kind: "validation-failed" }>;
    });

type DelegationStatusCommandResult = {
  delegationId: string;
  childSessionId: string;
  status: DelegationStatus;
  delegation: DelegationSummary;
  revision: number;
  serverInstanceId: string;
};

type DelegationResultPacket = {
  delegationId: string;
  childSessionId: string;
  status: DelegationStatus;
  summary: string;
  findings: DelegationFinding[];
  changedFiles: string[];
  commandsRun: DelegationCommandResult[];
  notes: string[];
  revision: number;
  serverInstanceId: string;
};

type MixedServerInstanceErrorPacket = {
  kind: "mixed-server-instance";
  name: string;
  message: string;
  serverInstanceIds: string[];
  recoveryGroups: {
    serverInstanceId: string;
    revision: number;
    delegationIds: string[];
    childSessionIds: string[];
  }[];
};

type WaitDelegationErrorPacket =
  | {
      kind: "mismatched-delegation-id";
      name: string;
      message: string;
      requestedId: string;
      receivedId: string;
    }
  | MixedServerInstanceErrorPacket
  | {
      kind: "status-fetch-failed";
      name: string;
      message: string;
      apiErrorKind: ApiRequestErrorKind | null;
      status: number | null;
      restartRequired: boolean | null;
    };

type WaitDelegationsBaseResult = {
  delegations: DelegationSummary[];
  completed: DelegationSummary[];
  pending: DelegationSummary[];
  revision: number | null;
  serverInstanceId: string | null;
};

type WaitDelegationsSuccessResult = WaitDelegationsBaseResult & {
  outcome: "completed" | "timeout";
  error?: never;
};

type WaitDelegationsErrorResult = WaitDelegationsBaseResult & {
  outcome: "error";
  error: WaitDelegationErrorPacket;
};

type WaitDelegationsResult =
  | WaitDelegationsSuccessResult
  | WaitDelegationsErrorResult;

Persist delegation records alongside sessions. A child session should also carry parentDelegationId metadata so the relationship is recoverable after reload.

Isolation Rules

Delegation write policies are runtime contracts, not just prompt text. V1 should be explicit about which guarantees are enforced and which are advisory.

Enforcement Model

readOnly:

sharedWorktree:

isolatedWorktree:

Path Validation

All path boundaries must be enforced server-side:

Reviewer And Explorer

Default to read-only. They may inspect files and run non-mutating commands. They should not edit, stage, commit, or push. If the selected agent runtime cannot enforce this, TermAl must say so in the child header and result packet.

Worker

Worker delegation requires explicit ownership:

Preferred worker mode is isolatedWorktree, especially if multiple workers run in parallel. Shared-worktree workers are allowed only for small, explicitly disjoint file sets.

Commits And Pushes

Delegated sessions must not commit or push unless the human explicitly asks. This mirrors the top-level TermAl safety policy.

Prompt Contract

Spawner prompt should tell the child:

Example reviewer final shape:

## Result

Status: completed

Summary:
Reviewed the virtual-list patch. No blocking issues found.

Findings:
- None

Commands Run:
- npx vitest run src/panels/VirtualizedConversationMessageList.test.tsx: passed

Files Inspected:
- ui/src/panels/VirtualizedConversationMessageList.tsx
- ui/src/panels/VirtualizedConversationMessageList.test.tsx

UI Placement

V1 can be minimal:

Later:

Relationship To Existing Orchestration

Delegation sessions are ad hoc. Orchestration templates are reusable graphs.

They should share primitives where possible:

Do not force ad hoc delegation through a template graph in v1. That would make quick review/explorer tasks too heavy.

Peer Session Connections

Proposed extension. Delegation always spawns its target and owns its lifecycle, so the relationship is a tree. A common need does not fit that tree: two sessions that already exist, started independently, that the user now wants to let talk to each other — hand off context, ask a focused question, compare notes.

Status — what shipped diverges from this proposal. The peer feature that actually ships is the durable neutral mailbox flow described under MCP Tools above. termal_send_to_session commits the body, while the four mailbox tools provide pull-based reading and explicit acknowledgement; termal_list_sessions discovers eligible roots. It has no connection object, no hop budget, no expectsReply, no termal_reply_to_session, and no human-only /connect-sessions — any root session may append to another root’s shared mailbox by id or name, and a reply is another durable mailbox message. In particular the Provenance Is Mandatory and hop-budget subsections below describe the proposed protocol and are not implemented: the shipped wake contains compact backend-resolved sender/mailbox metadata, and the agent fetches the durable sender-attributed body explicitly. It does not prepend the proposed connection/hop provenance header. Treat everything below as future design, not current behaviour.

This is the same primitive with one thing removed. A delegation is a directed message to a session plus a reply and a backend fan-in. Take away the spawn — let the target be a session that already exists — and the delegation record becomes a peer conversation. The underlying primitives are reused as-is — the status lifecycle, the all/any wait, the queued-prompt resume, and SSE deltas. The peer-facing surfaces intentionally differ: a reply is freeform prose rather than the reviewer ## Result packet, and both endpoints render a connection card rather than one parent-owned card (see Data Model and Command And Tool Surface).

The single structural difference drives the whole design: neither peer is a child, so no one owns the other’s lifecycle, either side may initiate, and termination cannot come from “the child finished.” It comes from an explicit hop budget instead.

Turn-Taking Constraint

Sessions are turn-taking agents, not servers. A session can only consume input between turns — the same fact that already forces a delegation parent to yield its turn before a resume prompt can run. Peer messaging inherits this: there is no synchronous “ask and block” call. Asking a peer means enqueue a prompt, end your turn, and be resumed with the answer, exactly like resume_after_delegations.

This makes the default interaction a bounded ask/reply:

Two degenerate shapes fall out for free. A one-way handoff is an exchange with expectsReply: false (A pushes context and does not yield on it). An open back-and-forth is bounded ask/reply iterated until the connection’s hop budget is spent. v1 ships bounded ask/reply and one-way handoff; it does not add a separate “channel” abstraction, because iterated exchanges already cover it with a built-in stopping condition.

Provenance Is Mandatory

A delivered exchange must announce that it came from a peer agent, not the human. Without it, the receiver treats the message as a user prompt — it addresses “you”, asks clarifying questions into a transcript no human is reading, and may take human-directed actions like committing. TermAl prepends the header; the sender cannot forge or omit it:

[from session-2787 · Claude · via connection-abc · hops left: 3]
<message body>

This message is from a peer agent session, not the user. Do not commit, push, or
ask the user questions on its behalf. To respond, call
termal_reply_to_session(exchange-xyz).

For a one-way ask (expectsReply: false) TermAl omits the reply instruction and states that no reply is expected, so the recipient does not answer into an exchange that is already terminal.

Authority And Guardrails

Connections are created by the human in v1, through a /connect-sessions command or the UI. The agent MCP surface exposes no connection-creation tool, so agents may use edges that exist but cannot mint them. This keeps topology, blast radius, and cost under human control, mirroring how delegations require an explicit spawn. Agent-requested connections with human approval are a later addition, noted in Open Questions.

The “human-only” property is enforced at the tool surface and UX, not as a hard boundary. The create route lives on the same unauthenticated loopback API as the rest of TermAl (see the security note below), so a shell-enabled session could still POST it directly. Making human-only authoritative requires the create route to demand a UI-scoped capability the agent process never receives — part of the tracked loopback-auth work, not v1.

Security note: like the delegation bridge, this is an accounting and UX boundary, not a sandbox. Any session with shell access can already reach POST /api/sessions/{id}/messages on the unauthenticated loopback API. A connection makes peer messaging first-class, auditable, and bounded; it does not by itself stop an out-of-band session from injecting a prompt. Closing that gap is loopback-auth work, tracked separately, and is not a prerequisite for v1.

Command And Tool Surface

Internal commands and MCP tools mirror the delegation set, renamed for the peer relationship. resume_after_exchanges is very close to resume_after_delegations: it schedules a durable backend wait and yields rather than polling in-turn.

connect_sessions(sessionA, sessionB, options?) -> SessionConnection   // human/UI only
ask_session(connectionId, prompt, options?)    -> SessionExchange
reply_to_session(exchangeId, reply)            -> SessionExchange
resume_after_exchanges(exchangeIds, options?)  -> ExchangeWaitResponse
list_connections(sessionId)                    -> SessionConnection[]  // own edges only
close_connection(connectionId)                 -> SessionConnection

MCP tool names stay explicit and peer-scoped. Connection creation is deliberately absent from the agent MCP surface in v1; edges come from the human.

termal_list_connections
termal_ask_session
termal_reply_to_session
termal_resume_after_exchanges

Routes mirror the delegation endpoints:

POST   /api/sessions/{sessionId}/connections          # create edge (UI/human)
GET    /api/sessions/{sessionId}/connections          # own edges
DELETE /api/connections/{connectionId}                # close edge
POST   /api/connections/{connectionId}/exchanges      # ask -> exchange id
POST   /api/exchanges/{exchangeId}/reply              # reply
POST   /api/sessions/{sessionId}/exchange-waits       # backend fan-in resume

exchange-waits reuses the delegation-wait mechanics: it schedules a backend-owned resume prompt for the asker and is surfaced in /api/state and SSE so a reload still shows a session waiting on a peer.

Data Model

Connections are a separate record that reuses the wait/resume machinery rather than overloading DelegationRecord. The two can be unified later behind a shared targetSessionId + ownsTarget field (see Open Questions); keeping them distinct for v1 avoids rewriting the delegation data model.

type SessionConnectionStatus = "open" | "closed";

type SessionConnection = {
  id: string;
  // Unordered pair of existing top-level session ids; no self-edges.
  sessionIds: [string, string];
  hopsRemaining: number;            // shared budget, decremented per delivery
  status: SessionConnectionStatus;
  createdAt: string;
  createdBy: "human";               // v1: connections are human-created
};
// No `mode` field: a single mode on an unordered pair cannot identify which
// endpoint is restricted. Whether a given session may initiate an exchange is
// derived at ask time from that session's own write policy (a read-only/reviewer
// session may reply but not initiate).

type SessionExchangeStatus =
  | "queued"      // delivered to the target, awaiting its next idle turn
  | "delivered"   // consumed; a reply-expecting turn is in flight
  | "answered"    // reply captured; asker resumable (expectsReply: true)
  | "completed"   // one-way ask (expectsReply: false) delivered; no reply owed
  | "failed"      // target gone, hops exhausted, or connection closed
  | "expired";    // a delivery or reply deadline elapsed, or a reply-expecting
                  // peer turn ended with the exchange still unanswered

type SessionExchange = {
  id: string;
  connectionId: string;
  fromSessionId: string;
  toSessionId: string;
  prompt: string;
  expectsReply: boolean;            // false = one-way handoff
  status: SessionExchangeStatus;
  // Freeform prose. Deliberately NOT the reviewer `## Result` packet: peers
  // talk to each other, they do not file machine-parsed findings.
  reply?: string | null;
  createdAt: string;
  answeredAt?: string | null;
};

Edge Cases

Non-goals for v1

Implementation Phases

Phase 1: Read-only Delegation Records

Phase 2: Internal Tool Surface

Phase 3: Agent MCP Bridge

Phase 4: Reviewer Batch UX

Phase 5: Worker Delegation

Phase 6: Integration With Orchestration

Phase 7: Peer Session Connections

Depends on Phase 1 records and the Phase 3 MCP bridge.

Testing Plan

Backend:

Frontend:

MCP/internal commands:

Agent MCP bridge:

Isolation:

Peer connections:

Acceptance Criteria

Open Questions