Co-authored-by: Kandev Agent <agent@kandev.dev>
7.7 KiB
0052 — Plugins reconcile pending agent interactions through a dedicated Host contract
- Status: accepted
- Date: 2026-08-22
- Area: backend, protocol
- Related: 0043 — Plugin host data API (the pattern this extends), 0047 — Plugins read conversation content, 2026-08-14 — Current turn owns active clarification (the authority this reuses), docs/specs/plugins/spec.md
Context
A cross-workspace operator plugin can already subscribe to task, session, message, permission and clarification events, and can read session metadata through the Host data API. It still cannot answer the one question an attention or inbox plugin exists to answer: does a human owe this session a response right now?
Kandev settled this internally long ago. WAITING_FOR_INPUT also describes an
ordinarily completed turn, so session state is not evidence of an owed
response; the durable pending_action projection is. A live compatibility run
on Kandev 0.90.0 with @agentclientprotocol/codex-acp 1.6.2 produced the
concrete case: the turn completed successfully, its only permission row was
durably approved, and the session snapshot still read WAITING_FOR_INPUT. A
state-only plugin claims attention is owed; kandev's own list surfaces
correctly do not.
The public plugin surface exposed none of that:
pluginsdk.Taskandpluginsdk.Sessioncarry no pending-action projection.pluginsdk.Messageomits request metadata —pending_id, status, the options or questions needed to render a valid response, resolution state.Host.Messages().Sendcan send a new prompt, but there is no capability-gated action corresponding to the permission-response or clarification answer/cancel paths.
Events cannot close the gap on their own. A plugin may start after the request was made, restart, or drop an event; without a durable read it can never reconcile its cache against the truth. The remaining escape hatch — reading the SQLite file directly — is the exact failure ADR 0043 exists to close.
Decision
Add a dedicated interaction contract to service Host, gated by its own
api_read:interactions / api_write:interactions capabilities.
-
A separate
Interactions()accessor, not a field on existing DTOs. An additive pending-interaction field onSessionorMessagewould drag raw transcript metadata into DTOs that deliberately avoid it, and would leave the write authorization without a boundary to attach to. A dedicated accessor keeps both concerns in one gated place. It is also deliberately not folded into the plugin-managed-conversation extension: an ordinary attention or inbox plugin must not have to own an agent conversation lifecycle to find out that a user owes an answer.It is an OPTIONAL Host extension (
pluginsdk.InteractionHost), likeExecutorProfileHostandPluginOwnedTaskTreeHost, so every existing Go-nativeHostimplementation stays source-compatible. -
One durable read, one resolve.
ListPendingInteractions(filter, page)returns every interaction still owed a response.Interactioncarries the pending id, kind (permission|clarification), task/session/turn ids, normalized status, timestamps, and the permission options or structured questions a caller needs to render a valid response.GetInteraction(id)resolves ANY interaction, terminal ones included. That is what makes an event-driven cache reconcilable: a replayed or missed event converges on the current durable result instead of disappearing intoNotFound.
-
The read reuses the pending-action authority rather than re-deriving it.
ListPendingInteractionsis built on the same predicates asGetPendingActionsBySessionIDs: only the session's current durable turn counts, terminal sessions quarantine pending history, and only the newest permission row of that turn is answerable. A pinning test asserts the two reach the same verdict per session. A plugin that disagrees with kandev's own kanban card is a bug in one of them, and this makes that a test failure rather than a support thread. -
Writes route through the first-party services the native UI drives.
RespondToPermissiongoes through the orchestrator method the WSpermission.respondaction calls.AnswerClarificationandCancelClarificationgo through the sameclarification.Handlersinstance the REST route serves, including its durable exclusive claim, live-waiter delivery, and detached-resume fallback. Nothing is reimplemented, so a plugin's response is indistinguishable from a user's and every surface converges through the normal events.Cancelis routed to the handler's reject path rather than its in-memoryCancelRequest: cancellation needs the original waiter to still be parked, while rejection is durable-claim based and therefore also settles a bundle whose waiter went away in a restart. That is precisely the bundle a reconciling plugin is holding. -
The host derives the outcome; the plugin only names a choice. A permission response must name one of the options the agent actually offered, and kandev derives approve-versus-deny from that option's recorded ACP kind. A plugin therefore cannot report an outcome that was never on the menu. The target session comes from the durable record, never from the request.
-
Terminal-once, with two distinguishable failures. The first response wins. A write against an already-resolved interaction returns
FailedPreconditioninstead of dispatching a second response; an unknown id returnsNotFound. A reconciling cache needs to tell "someone else answered first" apart from "my id is stale", and those two codes are that distinction. The clarification handler's own conflict outcome maps onto the sameFailedPrecondition, so a plugin branches on one code regardless of which layer caught the race.
Consequences
- An attention or inbox plugin can declare
api_read:interactionsalone and never gain the ability to answer on a user's behalf. - The clarification response sequence gained an exported
Handlers.Respond/Handlers.Cancelseam; the REST route is now a thin shell over it. Behavior is unchanged — the HTTP status vocabulary moved into a typedRespondErrorthe REST layer renders and the plugin adapter maps to gRPC codes. - Interaction reads and the compact
pending_actionprojection are two queries over the same durable rows. They are pinned to agree, but a future change to one must update both; the shared predicates (turnAuthorityPredicate,nonTerminalSessionPredicate) are the single source for the parts that matter most. - Only the newest permission of a turn is listed, matching the projection. If ACP ever issues genuinely concurrent permission requests in one turn, both this contract and the projection need widening together.
Alternatives considered
- An additive pending field on
Session/Message. Rejected: it leaks transcript metadata into DTOs that avoid it, and leaves the write path without a capability boundary of its own. - Reuse the plugin-managed-conversation extension. Rejected: it forces an inbox plugin to take on a conversation lifecycle it has no use for.
- Events only, with no durable read. Rejected: it cannot serve a plugin that started late, restarted, or dropped an event, which is the entire reported problem.
- Let plugins write message rows directly. Rejected for the same reason ADR 0043 rejects repository access: the agent would never unblock, and no surface would converge.