Add managed completion hooks for Copilot, Droid, and Qoder - #725
Merged
Merged
Conversation
Record the measured tier-A runtime baseline and the S3b design before implementation. Local re-attestation showed Copilot and Qoder emit PermissionRequest even when the permission service auto-approves and no human is waiting, so both derive needs-input from Notification alone; the same matrix confirmed Claude 2.1.243 does not, leaving the shipped S3a mapping correct. Also capture the injection asymmetry each runtime imposes: additive --plugin-dir for Copilot, last-wins path-only --settings for Droid, and first-wins --settings for Qoder that --setting-sources disables outright. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Extend the S3a managed-hook foundation to the remaining tier-A runtimes so a Profile launch of Copilot, Droid, or Qoder reports native lifecycle events into the existing signal bus with verified_live coverage. All three emit Claude-shaped payloads, so they share one decoder plus a per-runtime event table. Their injection differs and is asymmetric in ways that were measured, not assumed: Copilot loads every --plugin-dir additively (a static plugin now ships in the bundle and resolves the CLI through $COPILOT_PLUGIN_ROOT), Droid takes a path-only --settings with last-wins precedence (its merged object goes to an owner-only 0600 file because user settings can carry API keys), and Qoder takes an inline --settings with first-wins precedence and disables flag hooks entirely under --setting-sources. PermissionRequest is excluded for all three. Copilot and Qoder were both measured emitting it while the permission service auto-approved and no human was waiting, so needs-input comes from Notification alone. Copilot's Notification is also the one mixed-case payload, carrying sessionId rather than session_id, so the shared decoder accepts either spelling. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Runtimes disagree on how they report their working directory. Copilot echoes the shell's logical path (/tmp/...), while Droid reports process.cwd(), which the kernel has already resolved (/private/tmp/...). Both name the same directory, but hook validation compared them with standardizedFileURL, which does not resolve symlinks, so a correctly launched agent's events were silently rejected. Resolve symlinks before comparing, and log the first failing precondition of a rejected hook in debug builds: a rejected hook is silent by design, which made this indistinguishable from a runtime that never fired one. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Cover the three added runtimes in the agent-detection and CLI manuals, the agents-signal contract, and the bundled prowl-cli skill: which events each one reports, why PermissionRequest is excluded, how injection differs per runtime, and when a launch degrades instead of injecting. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Capture what shipped for Copilot, Droid, and Qoder, the symlink-resolution defect the work exposed in S3a's cwd validation, and an honest live-acceptance table: Copilot verified end to end, Droid's channel and Qoder's launch still open, with the measurements that rule out the obvious causes. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
A stub-socket test shows the CLI transmits a well-formed agentsHook frame for droid exactly as it does for claude and copilot, so the runtime argument, stdin read, decoder, and transport are all fine. That leaves resolveCaller / recordHook, and explains the empty diagnostics: a resolveCaller miss returns SOURCE_REQUIRED before the store's rejection logging runs. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Qoder's failure was not surface creation: on main the same Profile launches
repeatedly, and on this branch seven consecutive Profile surfaces succeeded.
Qoder refuses flag-supplied hooks in an untrusted folder ("Security: Blocked
execution of hook (system) in untrusted folder"); with the launch directory
trusted it reaches verified_live with source=hook_qodercli.
The research matrix claimed Qoder's flag hooks were not trust-gated. The
original probe ran in an already-trusted directory, which hid the gate; that
entry is corrected. Droid was retested with its folder pre-trusted and still
produces no channel, so it is a different cause and stays open.
Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Session takeover was restricted to Codex and Claude, so once a Copilot, Droid, or Qoder channel bound a session id, no other session could ever be accepted. Starting a fresh session in the same pane (Droid's /new) therefore left the channel permanently unable to record its own agent's events. All of these runtimes announce a new session with SessionStart, so allow the takeover on that event for any of them, matching Claude. Also extend the rejection diagnostics to the generation, retired-session, and session-change branches, which previously returned silently and left nothing to debug with. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
A real Droid run against a stub socket produces a correct 339-byte agentsHook frame, so Droid, the command rendering, stdin, decoding, and transport are all correct and the frame reaches the app. Records the two receiving-side gaps fixed while narrowing this and names the remaining candidate. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
The session-rotation rules were written as a Codex/Claude allowlist back when those were the only two managed runtimes. The actual distinction is whether a runtime announces a new session with its own start event: one that does hands the channel over only on that event and can reject late events outright, while one that does not (Codex, whose only native event is a turn edge) rotates on ordinary events and must retire superseded sessions explicitly. Express that directly as a property of the registration, replacing all four runtime checks. Runtimes added later inherit the correct behavior from their declared coverage instead of needing another allowlist entry. Claude-Session: https://claude.ai/code/session_0177kPWFE6qPt2fkmGDJmagS
Droid runs as two processes: the interactive TUI and a `droid exec` engine it forks once the folder is trusted, with every hook spawned by the engine. The process probe lists the newer engine first and both score equally, so the detector identified the TUI at the first scan and the engine as soon as it existed. The evidence epoch keyed its process generation on that identified pid, read the flip as a replaced process, revoked the managed hook registration, and rejected every later hook. Record the launch process — the topmost job ancestor of the identified process — beside it and match hook ancestry and replacements against that instead. State and sessions are still read from the identified process; an engine child taking over identification, or restarting, is no longer a relaunch. `ForegroundProcess` carries the parent pid the probe already fetches, and the detection diagnostic prints both pids. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
Codex 0.149.1's app-server shuts down as soon as stdin reaches EOF and drops any request it has not answered yet. The notifier preflight wrote its three messages and closed the pipe at once, so `config/read` was never answered, every Codex Profile launch degraded its managed hook, and the user's effective notifier went unpreserved. Leave the pipe open until the read loop has the response; the deferred stop still ends the server and closes it. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
…settings
Managed-hook preparation ignored the working-directory options the runtimes
accept. Copilot `-C`, Droid `--cwd`, and Qoder `-w`/`--cwd` change directory
before their hooks run, so the hooks report the changed path while the launch
registered the inherited one — every event from a `--cwd` launch was rejected
on the cwd guard. A shared working-directory scanner now resolves the effective
directory per runtime (last-wins for Copilot/Droid, first-wins for Qoder,
`=`/joined forms accepted) and degrades on a malformed option, while a relative
`--settings` path is still resolved against the launch directory.
Droid settings gain three fixes: a `FACTORY_RUNTIME_SETTINGS_PATH` set through
the Profile environment is read and merged as the base rather than silently
overridden by the injected flag (the flag still wins; unreadable degrades); the
path-only `--settings` no longer treats a `{`-prefixed value as inline JSON; and
the merge is capped at the owner-only private-file limit so an oversize merge
degrades with the Droid reason instead of failing opaquely at write time.
Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
Two ways a lagging detector sample could tear down a live managed-hook channel: A stale session read could roll back a newer one. After SessionStart(S1) then SessionStart(S2), a detector still reporting S1 drove a session-change that revoked the freshly verified S2 channel and rolled record.sessionID backwards. An announced rotation now retires the superseded session for every runtime, so a detector read of it is ignored, while a genuinely new session the hook has not announced still distrusts the channel until it re-announces. A transient process sample could revoke the launch generation. If one proc_listpids dropped the launcher while keeping the forked engine child, the launch root flipped to the child and read as a process replacement, permanently revoking the registration and its private settings file. The launch process is now kept while it is still a live ancestor of the identified process. Also restores the symlink cwd-comparison test dropped during the session takeover change. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
…e Profile FACTORY_RUNTIME_SETTINGS_PATH is a Droid settings source the injected --settings flag outranks, so overriding it silently drops the user's models, keys, and hooks. The previous fix only read the variable from a Profile environment override, leaving a value exported in the user's shell rc invisible. A new DroidSettingsEnvironmentProbe resolves it from the login shell (rc sourced), reusing the Codex shell probe process. Precedence is Profile override over shell-resolved; the probe is skipped when a --settings flag is present (it wins) or an override already answers the question, and a probe that cannot run degrades rather than override. Consistent with Codex, this adds a login-shell spawn per Droid launch that has no flag or override. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
Retiring the superseded session on rotation — the fix that stopped a stale detector read from rolling a channel back — made Claude's /resume unusable: it re-announces an old session id with a fresh SessionStart, which hit the retired-session guard and was rejected, so every event of the resumed session died. An announcing runtime's SessionStart now reactivates a retired id (un-retire it, retire the current one, rotate and re-verify); Codex, which has no SessionStart, stays strict. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
The stale-session guard retired a session only when the hook rotated it. When the detector moved to an exact new session while the hook's last session was still the old one, the old session stayed live, so a delayed Stop for it re-verified the channel and rolled record.sessionID back. A detector-driven session change now retires the superseded session for every runtime; announcing runtimes keep managed.sessionID so a non-SessionStart event for the new session stays rejected until its SessionStart, and Codex clears it. Covered by an S1 -> detector S2 -> delayed Stop(S1) regression test. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
A path-only --settings source and the FACTORY_RUNTIME_SETTINGS_PATH value are now trimmed, tilde-expanded, and treated as unset when blank, matching Droid 0.203: a Profile override and the shell probe resolve to the same file the runtime would, and `~/settings.json` names the user's home. Adds a real-shell test of the probe (set / unset / blank) alongside the injected-resolver cases. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
…allel CI The suite spawns a Python app-server fake; under the full parallel test run the fake could be starved past the injected 2s deadline, failing intermittently in CI. Serialize the process-spawning suite and give the fake-response cases 15s of headroom. Production keeps its 1s deadline against the real, fast app-server. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
SwiftLint's optional_data_string_conversion rule flagged String(decoding:as:) in the real-shell probe test helper; switch to String(bytes:encoding:). Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
Round 3 retired the hook session on any detector-driven session change using the permanent retired-session set, so a transient detector false positive killed the real session forever: even after the detector corrected back, the old session was discarded and its events stayed rejected. Detector-driven supersession is now a distinct, reversible marker separate from hook-authoritative retirement. It still blocks a delayed hook edge for the superseded session, but the detector can self-correct by reporting the session again, which clears it. A SessionStart is the final arbiter: it clears the supersession and suppresses the conflicting detector candidate so a repeating false positive cannot re-supersede the affirmed session. Codex, with no SessionStart, keeps its permanent retirement. Claude-Session: https://claude.ai/code/session_01AQ4X2bx8DnU74wwThhV9D8
shanegao
pushed a commit
to shanegao/Prowl
that referenced
this pull request
Aug 28, 2026
…tation Record the 2026-08-26 measurements of Pi 0.84.3, Oh My Pi 18.0.6, and OpenCode 1.18.23 (extension injection, lifecycle sequences, session identity, sub-agent shape, trust, cwd forms, `--pure`/`--auto` traps) and the resulting S3c plan: extensions relay native event names inside the Claude-shaped envelope, OMP maps `session_stop`, OpenCode is non-announcing with a sub-agent filter, and the Active Agents exact badge is dropped without commitment. Mark S3b merged (onevcat#725) in the release plan, the wave-1 plan, the S3b record, and the 064 plan. Claude-Session: https://claude.ai/code/session_016P9EWPCSZBP3HoaEqvBdUL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extends the S3a managed-hook foundation (#721, #723) to the remaining tier-A runtimes, so a
Profile launch of Copilot, Droid, or Qoder reports native lifecycle events into the existing
signal bus. Slice 064-S3b; S3 wave 1 completes with S3c.
What each runtime needed
All three emit Claude-shaped payloads, so they share one decoder plus a per-runtime event
table. Their injection is asymmetric in ways that were measured rather than assumed:
--plugin-dir(static plugin now in the bundle)--settings(path only)0600--settings(inline JSON)Copilot's plugin ships as
Resources/agent-hooks/copilot/and resolves the CLI through$COPILOT_PLUGIN_ROOT, so the directory stays read-only inside a signed bundle. Droid's mergedsettings can contain user secrets (
customModels[].apiKey), so they go to an owner-only filereusing S3a's lock/retirement/orphan-sweep. Qoder inserts its merged object before the user's,
because inserting after would leave Prowl's hooks dead and inserting unmerged would disable the
user's settings;
--setting-sourcesdegrades instead of injecting.PermissionRequest is not "needs input"
The tempting uniform mapping is wrong. Copilot and Qoder both fire
PermissionRequestwhile thepermission service auto-approves a tool and nobody is waiting — measured with
--allow-all-toolsand--permission-mode accept_editsrespectively, in headless runs where nohuman could answer. Mapping it would report "waiting for input" on ordinary tool calls, so all
three derive
needs-inputfromNotificationalone.The same matrix re-checked Claude 2.1.243 across six non-interactive scenarios, including an
auto-allowed Bash call that actually executed: it stays silent, so the shipped S3a mapping is
correct as-is.
Bug found while verifying
Hook validation compared the launch directory with the hook's reported cwd using
standardizedFileURL, which does not resolve symlinks. Copilot echoes the shell's logical/tmp/...path while Droid reportsprocess.cwd(), already resolved to/private/tmp/...—the same directory, silently rejected. Now compared by resolved path, with a debug log naming
the first failing precondition (a rejected hook is otherwise indistinguishable from one that
never fired).
Droid: two processes, one launch
Droid's hook ran and the CLI provably transmitted a valid frame, yet no channel appeared. The
cause was in code every runtime shares. Droid ≥ 0.202 is two processes: the interactive TUI, and
a
droid exec --input-format stream-jsonrpcengine it forks once the folder is trusted — andevery hook is a child of the engine. The process probe lists the newer engine first and both
score equally, so the detector identified the TUI at the first scan and the engine a few seconds
later. The evidence epoch keyed its process generation on that identified pid, read the flip as
a replaced process, dropped the managed registration, and rejected every later hook at a
guard whose diagnostic had never surfaced (the app's
printoutput is block-buffered underredirection).
The fix separates two concepts that single-process runtimes had let coincide: the identified
process is still what state and sessions are read from, while the launch process — the
topmost job ancestor of the identified one, now recorded in
IdentifiedAgentProcessandPaneAgentState— is what hook ancestry and replacements are judged against. Hooks descend fromboth, so an engine child taking over identification, or restarting, is no longer a relaunch.
ForegroundProcessgains the parent pid the probe already fetches (no extra syscall), and thedetection diagnostic prints
identified=<agent>:<pid> launch=<pid>. Two regression tests at themanager level fail on the old generation subject.
Codex 0.149.1: keep the config-read pipe open
Re-verifying against the latest releases turned every Codex Profile launch into
managed_hook_degraded. Codex 0.149.1'sapp-servernow exits as soon as stdin reaches EOF anddrops any request it has not answered yet; the notifier preflight closed the pipe right after
writing, so
config/readwas never answered (0.149.0 tolerated this). The pipe now stays openuntil the response arrives. A stub that exits on EOF like 0.149.1 covers it, and the live
contract test was re-attested against the real binary.
Static-review hardening
A static review of the branch found four contract gaps the tests missed (the stubs encoded the
same assumptions). Each is now pinned by a test and fixed:
-C, Droid--cwd, and Qoder-w/--cwdchange directory before their hooks run, so a--cwdlaunch had every eventrejected on the cwd guard. A shared scanner resolves the effective directory per runtime
(last-wins for Copilot/Droid, first-wins for Qoder;
=/joined forms; degrade on malformed),while a relative
--settingspath still resolves against the launch directory.FACTORY_RUNTIME_SETTINGS_PATHset through theProfile environment is now merged as the base rather than dropped by the injected flag (flag
still wins; unreadable degrades). The shell-rc / globally exported case needs a shell probe and
is a noted follow-up; the plan doc's "unused" claim was corrected.
SessionStart(S2), a lagging detector reporting S1 revoked the verified S2 channel. An announced
rotation now retires the superseded session for every runtime, so a read of it is ignored while
a genuinely new session still distrusts the channel until the hook re-announces.
proc_listpidsdropped the launcher while keeping the engine child, the launch root flipped and read as a
replacement, permanently revoking the registration and private file. The launch process is now
kept while it is still a live ancestor of the identified process.
Also: Droid
--settingsis treated as path-only (a{-value is a missing path, not inline JSON);the Droid merge is capped at the private-file limit so an oversize merge degrades cleanly; and the
symlink cwd-comparison test dropped during the session-takeover change was restored.
make check,make test(zero failures), andmake build-apppass; Droid re-verifiedverified_livelive onthe hardened build.
Static-review round 2
A second review pass found two remaining P1s; both are pinned by tests and fixed:
FACTORY_RUNTIME_SETTINGS_PATHwas still overridable. Round 1 read the variableonly from a Profile environment override, so a value exported in the user's shell rc stayed
invisible and the injected
--settingsdropped it.DroidSettingsEnvironmentProbenow resolvesit from the login shell (rc sourced), reusing the Codex shell probe. Precedence is Profile
override > shell-resolved; the probe is skipped when a
--settingsflag or override alreadyanswers the question, and a probe that cannot run degrades rather than override. Adds a
login-shell spawn per Droid launch with no flag/override, consistent with Codex.
1's stale-detector fix) made Claude's
/resume— which re-announces an old session id with afresh SessionStart — get rejected by the retired-session guard, killing every event of the
resumed session. An announcing runtime's SessionStart now reactivates a retired id (un-retire,
retire the current one, rotate); Codex, with no SessionStart, stays strict. Covered by a
S1 → S2 → S1 regression test.
Both P1s red→green-verified;
make check,make test(zero failures),make build-apppass, andDroid re-verified
verified_livelive (its shell probe runs, finds the variable unset, and thehooks-only merge still verifies).
Static-review round 3
hook rotated it; when the detector moved to an exact new session S2 while the hook's last was
S1, S1 stayed live, so a delayed
Stop(S1)re-verified the channel and rolledrecord.sessionIDback. A detector-driven session change now retires the superseded session for every runtime
(announcing runtimes keep
managed.sessionIDso a non-SessionStart event for the new session isrejected until its SessionStart; Codex clears it). Regression test: S1 → detector S2 → delayed
Stop(S1) must be rejected.
CodexConfigReadProcessTestsspawns a Pythonapp-server fake that could be starved past its 2 s deadline under the full parallel suite. The
suite is now
.serializedand the fake-response deadlines raised to 15 s (production keeps its1 s deadline against the real, fast app-server). Ran the suite 5× in a row clean; full
make testgreen.--settingssource and theFACTORY_RUNTIME_SETTINGS_PATHvalue are trimmed, tilde-expanded, and treated as unset whenblank, matching Droid 0.203, so a Profile override and the shell probe resolve to the same file.
The probe is now exercised through a real
/bin/shscript (set / unset / blank), not only aninjected resolver.
Verification
make check(35 script tests),make test(2567 tests, zero failures),make build-cli,make test-cli-integration(97 tests),make build-app.Live, in an isolated Debug instance with its own home, socket, and copied credentials:
--plugin-dirahead of--interactive, reachesverified_livewith
source=hook_copilot, and recordsturn-ended; the read-only bundled plugin loads; ahook without a token fails open (runtime unaffected, exit 0).
--settingswith a correct0600merged file, binds the TUI as thelaunch generation at the first scan, accepts
SessionStartfrom the engine child forked sevenseconds later, and records
turn-endedfromStopwithsource=hook_droid,verified_live,and
last_binding=current.--settingscarrier and reachesverified_livewithsource=hook_qodercli, recordingturn-ended. This required correcting a research error:Qoder's flag hooks are trust-gated (
Security: Blocked execution of hook (system) in untrusted folder), so exact coverage begins once the user trusts the worktree — which theymust do anyway before the agent can work there. The earlier probe had run in an
already-trusted directory, which hid the gate.
Profile surface creation is not broken for any runtime:
mainlaunched the same QoderProfile three times in a row and this branch launched seven consecutive Profile surfaces. The
intermittent
CREATE_FAILEDseen while testing affects every runtime in a long-lived isolatedDebug instance, is unrelated to S3b, and matches the flaky
deferredProfileAppliesFontSizeAdjustmentAfterSurfaceCreationtest that also fails onmain.Latest-version re-verification (2026-08-25), same isolated sweep for each Profile in turn:
Claude Code 2.1.245, Codex 0.149.1, Copilot 1.0.80, Droid 0.203.0, and Qoder 1.1.29 each reach
verified_livewith theirhook_<runtime>source and recordturn-ended. Codex passed onlyafter the pipe fix above.
All three runtimes are complete. The investigation, the fix, and the live-verification technique
(
CFFIXED_USER_HOMEalone for isolation,script(1)for a line-buffered app log) are recorded indocs-ai/064-agent-completion-signals/009-s3b-action.md.