Skip to content

Add managed completion hooks for Copilot, Droid, and Qoder - #725

Merged
onevcat merged 29 commits into
mainfrom
feat/agent-signal-hooks-s3b
Aug 26, 2026
Merged

onevcat merged 29 commits into
mainfrom
feat/agent-signal-hooks-s3b

Conversation

@onevcat

@onevcat onevcat commented Aug 25, 2026

Copy link
Copy Markdown
Owner

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:

Injection Repeated flag Prowl writes to disk
Copilot 1.0.80 --plugin-dir (static plugin now in the bundle) additive — no merge needed no
Droid 0.203.0 --settings (path only) last wins yes, owner-only 0600
Qoder 1.1.29 --settings (inline JSON) first wins no

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 merged
settings can contain user secrets (customModels[].apiKey), so they go to an owner-only file
reusing 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-sources degrades instead of injecting.

PermissionRequest is not "needs input"

The tempting uniform mapping is wrong. Copilot and Qoder both fire PermissionRequest while the
permission service auto-approves a tool and nobody is waiting — measured with
--allow-all-tools and --permission-mode accept_edits respectively, in headless runs where no
human could answer. Mapping it would report "waiting for input" on ordinary tool calls, so all
three derive needs-input from Notification alone.

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 reports process.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-jsonrpc engine it forks once the folder is trusted — and
every 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 print output is block-buffered under
redirection).

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 IdentifiedAgentProcess and
PaneAgentState — is what hook ancestry and replacements are judged against. Hooks descend from
both, so an engine child taking over identification, or restarting, is no longer a relaunch.
ForegroundProcess gains the parent pid the probe already fetches (no extra syscall), and the
detection diagnostic prints identified=<agent>:<pid> launch=<pid>. Two regression tests at the
manager 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's app-server now exits as soon as stdin reaches EOF and
drops any request it has not answered yet; the notifier preflight closed the pipe right after
writing, so config/read was never answered (0.149.0 tolerated this). The pipe now stays open
until 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:

  • Runtime working directory was not registered. Copilot -C, Droid --cwd, and Qoder
    -w/--cwd change directory before their hooks run, so a --cwd launch had every event
    rejected 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 --settings path still resolves against the launch directory.
  • Droid could override env-var settings. A FACTORY_RUNTIME_SETTINGS_PATH set through the
    Profile 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.
  • A stale detector session could roll back a hook-announced one. After SessionStart(S1) →
    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.
  • A transient process sample could revoke the launch generation. If one proc_listpids
    dropped 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 --settings is 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), and make build-app pass; Droid re-verified verified_live live on
the hardened build.

Static-review round 2

A second review pass found two remaining P1s; both are pinned by tests and fixed:

  • Shell-rc FACTORY_RUNTIME_SETTINGS_PATH was still overridable. Round 1 read the variable
    only from a Profile environment override, so a value exported in the user's shell rc stayed
    invisible and the injected --settings dropped it. DroidSettingsEnvironmentProbe now resolves
    it from the login shell (rc sourced), reusing the Codex shell probe. Precedence is Profile
    override > shell-resolved; the probe is skipped when a --settings flag or override already
    answers 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.
  • A resumed session could not re-verify. Retiring the superseded session on rotation (round
    1's stale-detector fix) made Claude's /resume — which re-announces an old session id with a
    fresh 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-app pass, and
Droid re-verified verified_live live (its shell probe runs, finds the variable unset, and the
hooks-only merge still verifies).

Static-review round 3

  • Delayed event for a detector-superseded session. Round 1 retired a session only when the
    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 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 is
    rejected until its SessionStart; Codex clears it). Regression test: S1 → detector S2 → delayed
    Stop(S1) must be rejected.
  • CI flake — Codex config-read timeout. CodexConfigReadProcessTests spawns a Python
    app-server fake that could be starved past its 2 s deadline under the full parallel suite. The
    suite is now .serialized and the fake-response deadlines raised to 15 s (production keeps its
    1 s deadline against the real, fast app-server). Ran the suite 5× in a row clean; full make test green.
  • Droid path normalization (P2). A path-only --settings source and the
    FACTORY_RUNTIME_SETTINGS_PATH value are trimmed, tilde-expanded, and treated as unset when
    blank, 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/sh script (set / unset / blank), not only an
    injected 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:

  • PASS — Copilot injects --plugin-dir ahead of --interactive, reaches verified_live
    with source=hook_copilot, and records turn-ended; the read-only bundled plugin loads; a
    hook without a token fails open (runtime unaffected, exit 0).
  • PASS — Droid injects --settings with a correct 0600 merged file, binds the TUI as the
    launch generation at the first scan, accepts SessionStart from the engine child forked seven
    seconds later, and records turn-ended from Stop with source=hook_droid, verified_live,
    and last_binding=current.
  • PASS — Qoder injects its inline --settings carrier and reaches verified_live with
    source=hook_qodercli, recording turn-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 they
    must 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: main launched the same Qoder
Profile three times in a row and this branch launched seven consecutive Profile surfaces. The
intermittent CREATE_FAILED seen while testing affects every runtime in a long-lived isolated
Debug instance, is unrelated to S3b, and matches the flaky
deferredProfileAppliesFontSizeAdjustmentAfterSurfaceCreation test that also fails on main.

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_live with their hook_<runtime> source and record turn-ended. Codex passed only
after the pipe fix above.

All three runtimes are complete. The investigation, the fix, and the live-verification technique
(CFFIXED_USER_HOME alone for isolation, script(1) for a line-buffered app log) are recorded in
docs-ai/064-agent-completion-signals/009-s3b-action.md.

onevcat added 14 commits August 25, 2026 09:30
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
onevcat added 15 commits August 25, 2026 22:28
…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
@onevcat
onevcat merged commit 9356aa5 into main Aug 26, 2026
1 check passed
@onevcat
onevcat deleted the feat/agent-signal-hooks-s3b branch August 26, 2026 08:01
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant