ADR-0049: Add advisory session hooks that never block to the portable agent plugin
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-08 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | component |
| Scope | component |
| Tags | agent-plugin, copilot, hooks, governance |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Relates to | 0014, 0016, 0022, 0028, 0045, 0046, 0047, 0048 |
| Affects | path:packages/adapters/agent-plugin/extensions/**, path:docs/reference-verification-agent-plugin.md |
| Source | docs/adr/0049-add-advisory-session-hooks-that-never-block-to-the-portable-agent-plugin.md |
Status: proposed. Agent-drafted and not ratified. This record amends the component inventory of ADR-0028, ADR-0045, and ADR-0046, and the join retry ladder of ADR-0048; it supersedes none of them. Their portability, read-only, advisory, and independent-versioning constraints remain binding.
Amended in plugin 0.9.1 (a correction, no new record).
onSessionStartcollects the same union as the workflow (branch range,git diff HEAD, untracked files), so it makes four git calls (a work-tree probe and three listings) beforeadr check, not the “onegit diff” or “up to three sequential calls” stated below. The 5 s deadline and the two-process cap are unchanged, as are this record’s status and its advisory boundaries.
Context
Section titled “Context”The plugin’s Copilot extension registers the adr-review workflow
(ADR-0045) and the decision-review canvas (ADR-0046) in one joinSession.
The same call takes hooks, a SessionHooks object whose handlers the
runtime calls at fixed points in a session. The SDK typings
(@github/copilot-sdk, Copilot CLI 1.0.93, types.d.ts) define, among
others:
onSessionStart(input), which may return{ additionalContext, modifiedConfig };onPreToolUse(input), withinput.toolNameandinput.toolArgs, which may return{ permissionDecision: "allow" | "deny" | "ask", permissionDecisionReason, modifiedArgs, additionalContext, suppressOutput };onPostToolUse(input), withtoolResulttoo, which may return{ modifiedResult, additionalContext, suppressOutput }.
So a hook can do much more than inform. It can deny or rewrite a tool call,
rewrite a tool result, and, by returning permissionDecision: "allow", skip a
permission prompt the person would otherwise see. The SDK’s own example
returns "allow" as the default for every tool call.
A hook can also gate a tool call without returning anything, simply by not
answering. The Copilot CLI changelog records that preToolUse hook errors
deny the tool call (1.0.57), and that hooks once denied every tool call after
an extension restart (fixed in 1.0.85). A pre-tool hook therefore makes the
extension’s liveness a dependency of every tool call in the session.
Two gaps make hooks worth considering anyway. An agent editing a governed
file learns that a decision governs it only if it thinks to run
/adr-check or adr explain. And an open decision-review panel shows a
snapshot that goes stale as soon as the agent edits a file, until someone
presses Refresh.
ADR-0022 settles how much authority such a signal may have: markers and advisories add governance context and never gain exit-code authority. A hook that can deny or hold an edit is exit-code authority by another name, so the question is whether hooks can close those gaps while staying inside that stance.
Decision
Section titled “Decision”We will add two advisory hooks to the existing adrkit extension. They are
registered in the same joinSession, in their own guarded try in
register.mjs, and their logic lives in a new SDK-free module, hooks.mjs.
There is no onPreToolUse.
onSessionStart: a governing-decisions summary. One git diff (the same
collectChangedFiles the workflow and canvas use, with the same
origin/main...HEAD default and HEAD fallback) and one
adr check --json -- <files>. No model call. When the changed files are
governed by accepted decisions, or open proposals would also govern them, it
returns additionalContext naming the record ids, and the status for
proposals. When nothing does, it returns nothing.
onPostToolUse: a note on a governed edit, and a free canvas refresh.
After a tool the runtime classifies as an edit (edit, create,
str_replace, apply_patch, and str_replace_editor only for its create,
str_replace, and insert commands, never view), it:
- reads the target path(s), makes each relative to the hook input’s
workingDirectory, and drops anything outside the worktree; - runs one
adr check --json -- <path>per distinct path, cached for the process, and returnsadditionalContextsaying the file just edited is governed by the named accepted decision(s), once per path per session; - schedules the canvas’s
refresh(Collect and Check:git diff,adr check,adr lint; and, since ADR-0047, the freeadr queue --format jsonread) for each directory with a panel open in this process. It is debounced (1.5 s, trailing) and single-flight: while one refresh is in flight, later requests fold into one queued refresh. It reaches the canvas only through a new in-processrefreshOpenon the canvas options, whichcreateCanvasdoes not copy to the wire, and it never startsrun_review.
An edit inside the ADR corpus directory (ADRKIT_DIR or docs/adr, resolved
against the hook input’s directory) also drops the cached checks and notes,
because a changed record can change what governs anything. Any tool that is
not an edit returns before touching git, the CLI, or a timer.
Why the note moved from before the edit to after it. A note before an edit could change the edit, which is its appeal. But the measurements below show that on 1.0.93 a pre-tool hook that hangs holds the tool call unexecuted, for at least 90 s with no runtime timeout observed. The changelog also records versions that deny the call when a pre-tool hook errors. Either way, an extension that is slow, wedged, or restarting would gate the agent, which is exactly what ADR-0022 denies an advisory. A post-tool hook runs after the edit has landed. It can delay the result reaching the model, and this one bounds that delay itself, but it cannot stop or undo the edit. The note says so (“it blocked nothing”), and tells the agent to check the change it just made.
The boundary, which is the point of this record:
- No hook returns anything but
additionalContext. NeverpermissionDecision, not even"allow"(which would override anaskthe person configured), neverpermissionDecisionReason,modifiedArgs,modifiedResult,modifiedConfig, orsuppressOutput. A test asserts the key set, and was observed failing when a mutation addedpermissionDecision: "allow". - What reaches the model is ids, not text. Record ids are validated against
a record’s own id grammar (
adr.schema.ts, theidfield: four or more digits or a 26-character ULID), a closed character class. The wider cross-reference grammar, with anamespace:prefix, is deliberately not accepted: the namespace is free text a corpus could spell words in, andadr checknever emits it as arecordId. An id outside the grammar (for examplepayments:0001,001, or anything with whitespace) is dropped, not echoed. Statuses are checked againstaccepted,proposed, anddraft; the rest is counts and labels the module writes. No title is included, because a title is repository text and hook context is read by the model as instructions. Paths are not echoed back either. Titles add little here: the summary tells the agent which ids to read, andadr explaingives the title with its context. - Failure is silent to the model, and reporting it never holds a hook.
Every handler catches. A failed or timed-out call returns nothing to the
model and writes one
session.logwarning per process, fire-and-forget:session.logis an RPC with no deadline, so no hook awaits it, on any path. Review showed that awaiting it let a never-answering log holdonSessionStartpast its deadline indefinitely; a test now covers a log that never resolves with git hung. The warnings are chosen from fixed messages by explicit comparisons of the error’snameandcode, never the error’s text. An argument list too long for the OS (E2BIG) gets its own message rather than “install the CLI”. - Time and process count are capped, and here is the real worst case:
- Every
gitandadrcall the hooks make themselves carriesAbortSignal.timeout(5000). - At most two hook-spawned processes run at once; the timeout starts when a process starts, not while it waits for a slot.
onSessionStartraces its whole job against a 5 s deadline. It makes up to three sequential calls (git diff origin/main...HEAD, thegit diff HEADfallback,adr check), so without the deadline the first prompt could wait 15 s. With it, the first prompt waits at most about 5 s. Work past the deadline continues in the background, each call still bounded by its own 5 s timeout, so it ends within about 15 s. (Since plugin 0.8.1, a diff too wide for one command line runs oneadr checkper batch of about 24 KiB of paths, and once the deadline wins the hook aborts the call in flight and starts no further batch.)- The post-edit note races its checks against a 2 s deadline. Past it the note is skipped for that edit, and the checks keep running and fill the cache, so the next edit of the file is told. A tool result is held at most about 2 s.
- The hook-triggered refresh runs its calls (the queue read included) under one 15 s abort signal, and never more than one at a time. The queue keeps its own timeout as well; the two signals are combined, not replaced. If that signal fires, the panel keeps its previous result and queue, each with a fixed timeout note (“Automatic refresh timed out; showing the previous result.”), rather than committing a snapshot whose note is the abort’s exception text.
- The check runs at most once per distinct path per process (20 paths per tool call, cut while the patch is parsed). A process starts at most 500 checks in all, and re-checks after a corpus edit count against that budget, so dropping the cache never re-arms it. Concurrent edits of one path share one check and one note. A failed check is cached as “nothing”, so a missing CLI is not retried on every edit.
- Every
- An off switch.
ADRKIT_HOOKS=0(alsofalse,off, orno) makes the hooks factory returnundefined, andregisterthen joins with nohookskey at all. - The CLI is resolved as everywhere else:
$ADRKIT_CLI, then./node_modules/.bin/adronly withADRKIT_ALLOW_REPO_CLI=1, thenadronPATH, against the hook input’s working directory, neverprocess.cwd().
Registration. A hooks factory that throws costs the hooks only: the
workflow and the canvas still register, and the failure is logged after the
join, as ADR-0046 already does for the other two. If the canvas failed to
build, the hooks’ refresh is a no-op. If the runtime refuses the join itself,
register keeps one retry ladder for every optional field, shared with
ADR-0048’s tools: without hooks, then without tools, then without both,
then without canvases alone (keeping the hooks and the tools), then with the
workflow alone. onEvent is never dropped, and a rung naming an absent field
is skipped, so with all three present that is at most six joins (four with
hooks and no tools). It logs the fields the successful join dropped and quotes
each refusal. If every rung fails, it rethrows the original error, the one
about the full configuration. So a runtime that refuses hooks keeps the
workflow, the canvas, and the tools; one that refuses the tools keeps the
hooks; and one that refuses canvases keeps the hooks and the tools and
blames only the canvas.
Dropping hooks first has a side effect we accept: a one-off join failure
unrelated to any field (an RPC hiccup) is cured by the first retry, which
leaves the hooks off for the rest of the session. The log line says so
explicitly (“off for this session … they may not have caused it”, with the
original error) rather than blaming the hooks. Tests cover each rung,
including the fourth join that drops both, the rethrow, and the case where the
canvas already failed to build.
Measured facts
Section titled “Measured facts”Measured on 2026-10-08 against Copilot CLI 1.0.93 through a headless SDK host
(CopilotClient and createSession with pluginDirectories,
requestExtensions: true, and requestCanvasRenderer: true), on a two-record
fixture repository: 0001 governs src/** and 0002 governs package.json,
both accepted, with both files changed against origin/main.
-
A plugin extension joins after
session.start. With no prompt sent, a probe extension registering every hook recordedloadandjoinedand no hook call; resuming that session with no prompt fired none either.onSessionStartfired with the first prompt, afteronUserPromptSubmittedand before the model ran, withsource: "new"andinitialPromptset. ItsadditionalContextreached the model: asked to quote any line it was shown that began with a sentinel, the model quoted the session-start, prompt, and pre-tool sentinels verbatim. -
Tool names and argument shapes. With
gpt-6-luna, the edit tool wasapply_patch, andtoolArgswas the raw patch string (*** Begin Patch\n*** Update File: src/net.ts\n…), with a relative path. The runtime bundle lists the edit category asedit,create,str_replace_editor, andapply_patch. Its argument classifier also treats astr_replacetool as an edit, and switchesstr_replace_editoroncommand(view,create,str_replace,insert), so that tool reads as well as writes. 2,424 local Copilot session logs showededitwith{ path, old_str, new_str }andcreatewith{ path, file_text }, the paths absolute, andapply_patchwith relative and absolute paths. Neitherstr_replace_editornorstr_replaceappeared in any of them, so their shapes are read from the bundle, not observed. -
onPostToolUsefired after a successful edit withtoolResultcarryingresultType: "success". -
ADRKIT_*variables reach the extension withoutrequestedEnvironmentVariables.ADRKIT_HOOKS=0and a variable namedADRKIT_PROBE_SECRET_TOKEN, set in the runtime’s environment through the SDK host’sRuntimeConnection.forStdio({ env }), both appeared in the extension’sprocess.env. WithADRKIT_HOOKS=0the shipped extension still loaded (status: "running") and still listed thedecision-reviewcanvas. -
What a failing hook does to the tool call (free). These ran through
session.rpc.tools.execute, which runs a tool through the session’s native invocation pipeline with no model call; pre- and post-tool hooks fire for it. A probe extension failed on purpose, and each row is onecreatecall:Failure Tool call Later tool calls pre-tool hook throws ran (file written, success)— post-tool hook throws ran — process exits inside the pre-tool hook ran; the runtime reported the hook success: trueran; the extension showed failedprocess exits inside the post-tool hook ran ran; the extension showed failedpre-tool hook never answers not run: no file written, still pending when the host gave up at 90 s — post-tool hook never answers ran (file written); the result was still held at 90 s — So on 1.0.93, through this path, a crash fails open in both hooks, but a hang in a pre-tool hook gates the tool and a hang in a post-tool hook delays only the result. That decided the move from pre-tool to post-tool. Whether the model’s own tool loop treats a hook error the same way is not measured here; the changelog says 1.0.57 made pre-tool errors deny.
-
The first design’s hooks, one model turn (
gpt-6-luna, the canvas open, two separate edits tosrc/net.ts). Latency is the runtime’s ownhook.starttohook.endtimestamps:onSessionStartreturned “… 2 changed file(s) in this session (git diff origin/main…HEAD). Governed by accepted decision(s): 0001, 0002. …” in 111 ms.- The then pre-tool note on the first
apply_patchofsrc/net.ts(one uncachedadr check) took 117 ms; the second edit of the same path took 0 ms; six other tool calls took 0 to 1 ms. onPostToolUsetook 0 to 1 ms. The open panel’supdatedAtmoved 1.7 s after the last edit’s post hook, consistent with the 1.5 s debounce. The two edits’ post hooks were 2.3 s apart, so coalescing was not exercised live; the unit test is the evidence for it. No review was started.- The model quoted both advisories verbatim and said it read decision 0001 before editing. The edits landed.
-
The shipped post-edit note, free (
tools.execute, the canvas open):onPostToolUseaftercreateofsrc/hooks-probe.tstook 104 ms and returned “adrkit (advisory; it blocked nothing): the file(s) you just edited are governed by accepted decision(s) 0001. …”; a second governed file took 109 ms; an ungoverned file 110 ms (one check, no note);view0 ms. The panel’supdatedAtmoved, and no review started. -
Spend. Two paid turns, both
gpt-6-luna: the probe turn (totalNanoAiu208,739,500) and the first-design turn (605,392,000), about 0.81 AI credits together at 10^9 nano-AIU per credit. Rows 5 and 7, and everything else, were free.
What this record does not claim:
- Hook firing in the GitHub Copilot app is unmeasured in the Copilot app.
- So is hook firing in an interactive Copilot CLI terminal session, with
source: "startup"or"resume". - So is any model family other than
gpt-6-lunalive. - So is hook behavior in subagent child sessions: whether a child’s
post-tool hook reaches this extension, and whether
onSessionStartfires per child (which would add a check perdecision-checkerand give a single-record judge the full id list). The once-per-session note for a second session id is unit-tested only. - So is the shipped post-edit note in a model turn: row 7 used
tools.execute, not the model’s tool loop. - So is a disabled run end to end with a model turn. The off switch is unit-tested, and the variable’s arrival is measured, but the two were not observed together.
- So is the off switch’s arrival in the app: row 4 set the variable through the SDK host, and the app builds its extensions’ environment from its own launch.
Options considered
Section titled “Options considered”Option A: Advisory session-start and post-edit hooks that return only additionalContext (chosen)
Section titled “Option A: Advisory session-start and post-edit hooks that return only additionalContext (chosen)”| Dimension | Assessment |
|---|---|
| Authority | None, matching ADR-0022: context only, after the edit, no decision, no rewrite |
| Liveness | A crashed extension fails open (measured); a hung one delays a result by at most about 2 s, and the first prompt by about 5 s |
| Cost | No model spend; about 0.1 s on session start and after the first edit of each path, measured |
| Prompt-injection surface | Ids in the record-id grammar, a fixed status set, counts, and module-written labels only |
| Failure | Silent to the model; one log line per process |
| Off switch | ADRKIT_HOOKS=0 |
Option B: A pre-tool note (the first draft of this record)
Section titled “Option B: A pre-tool note (the first draft of this record)”It tells the agent before the edit, so the edit itself can follow the decision. Rejected after measurement: a hung pre-tool hook holds the tool call unexecuted (Measured fact 5), and the changelog records versions where a pre-tool hook error denies the call. An advisory plugin would gate the agent whenever its extension is slow or restarting. Bounding the hook with an internal race narrows that window but does not remove it, because a process that is wedged or gone cannot run its own race.
Option C: Block or ask on a governed edit
Section titled “Option C: Block or ask on a governed edit”permissionDecision: "deny" or "ask" on an edit to a governed file would
turn a decision into a gate inside the agent loop. Rejected: it gives a
local, advisory surface the exit-code authority ADR-0022 denies to markers
and advisories, a governing decision is not evidence that an edit conflicts
with it, and the CI Action is where a gate belongs.
Option D: Include record titles
Section titled “Option D: Include record titles”Titles would make the summary readable without a follow-up command. Rejected
for now: a title is repository text injected into the model’s context, and the
id is enough to look it up with adr explain. If titles prove necessary, they
need a length cap and a “data, not instructions” label, argued in a new record.
Option E: Deliver the summary from onUserPromptSubmitted instead
Section titled “Option E: Deliver the summary from onUserPromptSubmitted instead”That hook fires on every prompt. Measured, onSessionStart already fires with
the first prompt for a plugin extension, so the summary arrives once rather
than on every turn. Not needed.
Option F: Run adr-review from a hook
Section titled “Option F: Run adr-review from a hook”Rejected. It spends model credits, and a hook the person did not invoke must not spend them. The post-edit hook triggers only the free refresh.
Option G: Do nothing
Section titled “Option G: Do nothing”The agent can still run /adr-check, and the person can still press Refresh.
The gaps in the Context section stay open.
Trade-offs
Section titled “Trade-offs”Hook context is a prompt in all but name. Restricting it to ids keeps
repository text out, but the fixed sentences this module writes do steer the
model. Whether the person sees them in the CLI or the app is unmeasured; the
SDK host saw them only in the runtime’s hook.end events.
A note after the edit cannot shape the edit. It asks the agent to check what it just did; an agent that does not is not stopped, which is the point.
The note is given once per path per session. An agent that forgets it after compaction is not reminded; the alternative, a note on every edit, is noise in every long session. The per-session memory keeps 64 sessions and evicts the oldest first, not the least recently used, so a session that spawns many children can be re-told about paths it already knew.
The cache is per process and is dropped only when an edit tool touches the
corpus directory. It goes stale when a record changes outside the session (a
git pull in another terminal), when an edit adds or removes an @adr
marker in a source file (ADR-0022’s inbound governance), and when files are
changed through the shell (sed, git checkout), which neither clears the
cache nor triggers a refresh.
The single log line is spent by the first failure for the life of the process. A transient first timeout therefore hides a later, different failure, a failed check stays cached as “nothing” until the corpus is edited (since plugin 0.8.1, except a check that a signal, a timeout, or an abort ended, which the next edit retries), and the 500-check budget is reached silently.
As first shipped (0.8.0), a timeout killed the direct child process only: the
shared runCommand passed the signal to execFile, and an adr behind a
version-manager shim that spawns the real node as a grandchild kept running
after the hook had given up. Since plugin 0.8.1 the shared runner spawns the
child in its own process group on POSIX and signals the group (SIGTERM, then
SIGKILL after a one-second grace), so the time bounds above hold, within that
grace, for descendants that stay in the group; one that calls setsid itself
leaves it. A group still running when the extension exits normally, or is
stopped by SIGTERM, SIGINT, or SIGHUP, is killed then. On Windows the runner
still ends the direct child only, so there the bounds remain on the hook’s
wait, not on every descendant process.
onSessionStart runs before the first model call, so its 0.1 s is added to
the first prompt’s latency, and each first edit of a path waits for one
adr check before its result reaches the model. The deadlines above cap both.
The SessionHooks typings carry no @experimental tag, unlike canvases and
workflows, but tool names and argument shapes are the runtime’s and can
change; a renamed edit tool silently turns the post-edit note and refresh off
rather than failing.
Consequences
Section titled “Consequences”- Easier: an agent learns which decisions govern its change at the start of a session and right after it edits a governed file, and an open panel follows the agent’s edits, all at no model cost.
- Harder: a third registration in the extension with its own tests; the edit tool list and shapes to re-measure on each runtime upgrade; another way for the plugin to add latency to a turn.
- How we would know this was wrong: a hook returns any key other than
additionalContext; an edit or prompt is blocked, held, rewritten, or auto-approved by this plugin; a pre-tool hook is registered; a title, path, or error text reaches hook context; a hook’s failure shows up in the model’s context or throws into the runtime; a hook startsrun_reviewor spends credits;ADRKIT_HOOKS=0leaves any hook registered; a hooks failure, or a runtime refusinghooks, takes the workflow or canvas down; hook-triggered refreshes pile up; the added latency on session start or after a first edit is noticeable in practice; or agents read the advisory as a gate. - Revisit if: the runtime renames or reshapes its edit tools; the runtime gains a hook timeout that fails open, which would make a pre-tool note safe to reconsider; the app or CLI shows hook context to the person; titles prove necessary (Option D); or a later record gives advisories a gating role.
Evidence rung
Section titled “Evidence rung”Rung 1 under ADR-0014, and only that: unit and contract coverage, plus the headless SDK-host measurements above against Copilot CLI 1.0.93. Unmeasured: hook firing in the Copilot app, in an interactive CLI session, on resume, in subagent child sessions, and with any other model family live; and any external validation.
Action items
Section titled “Action items”- Implement
hooks.mjs, the canvas’srefreshOpen(with an abort signal), and the guarded hooks registration and retry ladder inregister.mjs, and wire them inextension.mjs. - Add
test/hooks.test.tsand observe its tests failing (ADR-0016). The first round: 24 of 32 failed against a stub module, and six of the eight that passed against the stub failed under targeted mutations (the no-authority key set, the unref’d debounce, the off switch, the no-hooks join, the zero-cost non-edit path, and the no-changed-files path). After review, 24 of 44 failed before the redesign. The tests that passed against the old code were observed failing under mutation: the debounce path with a throwingsession.log(fails withoutfail’s innertry), the single-flight refresh, the two-process cap, and the hooks-first retry ladder. “Exactly two hooks” is a tripwire. After the re-review, 5 new tests failed first (a never-resolving log on three paths, the narrower id grammar, the explicit hooks-off log line); the drop-both rung and the rethrow tests passed against existing code and failed under mutation. - Measure each hook’s added latency per call and the spend, and record them (Measured facts 6 to 8).
- Measure what a failing pre- and post-tool hook does to the tool call, and record the decision it drove (Measured fact 5, Option B).
- Document the hooks, the off switch, and their limits in the plugin
README and
docs/reference-verification-agent-plugin.md. - Measure hook firing in the Copilot app, in an interactive Copilot CLI
session (
startupandresume), in subagent child sessions, and the post-edit note in a model turn. - Kill the process group, not only the direct child, on a timeout in the
shared
runCommand(done in plugin 0.8.1 for POSIX; Windows still ends the direct child only). - Add reciprocal “Amended by ADR-0049 (proposed)” notes to ADR-0028, ADR-0045, ADR-0046, ADR-0047, and ADR-0048, drafted with this record.
- Ratify or reject this record before the plugin publishes the hooks.