context/message previously defaulted to a <context source="…">…</context>
wrapper. No model is trained on a <context> tag either, and message
framing does not belong on the session surface: the surface projects the
durable log, and a caller that wants a frame formats its own content —
which the one heavy producer (workspace-context) already does with its own
<system-reminder> frame, opting out via 'raw'. The tag only added
machinery — ContextEnvelope plus an envelope field threaded through
InjectOptions, HookContext, the context/message event, and the agent-loop
inject/additionalContexts plumbing.
context/message now projects its content verbatim as a user-role message,
sharing one deriveEventMessage case with user/message and steering/message.
ContextEnvelope and every envelope field are removed; context/message.meta
still carries durable, model-hidden JSON state. Regenerated catalogs and
website API; refreshed the three affected keyless snapshots (envelope field
only; timestamps unchanged).
Broadens and renames the steering Agent Note to cover both envelope
removals as one decision.
Agent Note: .agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
Codex review of the draft: the abort-on-dispose tests asserted only that
dispose() resolved and no warning fired — a regression back to untracked
fire-and-forget runs would pass both. The hook script now records its
shell PID before sleeping, and after dispose the test asserts
process.kill(pid, 0) throws: the drain resolves only after the killed
run settled (the child reaped), so ESRCH-by-dispose-return is
deterministic under the correct implementation, while an alive or
unreaped process fails it. Verified the discriminator by gutting drain()
locally: exactly these two tests fail.
The emit-shaped hook points (SessionStart, SubagentStart, SubagentStop)
run fire-and-forget: no seam awaits the run chain, so disposing a bridge
could strand a live hook process and let a late continuation inject into
a disposed context. The floating continuation also made the coverage
gate racy: the only coverage of the SubagentStart continuation's
no-context branch arm rode on an un-awaited .then, and on a loaded CI
runner the fork's per-file coverage snapshot beat it — master run
28798191671 failed the 100% branch gate on hooks-claude/src/index.ts at
99.03% (uncovered line 336) with the identical tree passing the PR run
three minutes earlier.
New shared primitive createDetachedRuns() in dsh-hook-protocol: a bridge
tracks each detached run chain, passes the tracker's abort signal to
runHook, and registers drain() as its effect disposer — drain aborts
still-running hook processes (a kill via the bash seam, not a wait out
to the 10-minute default hook timeout), then resolves once every chain
has settled. fiber.dispose() resolving now means the bridge's detached
work is quiescent (docs/defensive-patterns.md).
The subagent marker test disposes the bridge as its sync point, so the
formerly racy branch arm is executed deterministically before the file's
coverage snapshot; new tests pin abort-on-dispose promptness for both
bridges and the tracker's settle/drain contract in hook-protocol.
A Codex review pass on the draft caught four real gaps and two solid
suggestions; all addressed except one pushed back on the merits:
- hooks-claude/hooks-codex: stderrSummaryMaxChars was the one new knob
with NO range validation — a negative/NaN cap would silently
misbehave inside slice(). Both bridges now assert a positive integer
at the TOP of apply() (before the config-file parse's early return,
so a bad value fails the load loudly), with rejection tests.
- tool-fs: the read caps count lines/chars/bytes, so positive-FINITE
was too loose (a fractional readLimit would flow into windowing
arithmetic and the schema description). All four now require a
positive integer, matching tool-web's cap.
- Doc drift the gates cannot catch: tool-web's README tools table
still named WEB_SEARCH_MAX_RESULTS as the mechanism; compact-basic's
README/module doc and the compaction-capability-seam RFC still
described estimation as fixed char/4 rather than the charsPerToken
default.
- subagent-acp: the dispose graces were tested only at the
startAcpRun level, so a regression that stopped threading plugin
config into AcpRunSpec would have survived. A provider-path test now
drives the trap-escalation scenario through ctx.subagents.start with
small config graces and bounds dispose at 4s.
Pushed back on: converting compact-basic's charsPerToken to a
schemastery field. The package's whole config is deliberately
hand-rolled (resolveConfig, every threshold REQUIRED with no default —
a documented design posture); one schemastery field beside it would be
incoherent. The knob is cordis.yml-reachable, defaulted, and validated,
which is what the convention requires; migrating the package to
schemastery wholesale is pre-existing config-surface hygiene out of
this change's scope.
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
Implement the tighten-hook-protocol-contract RFC (moved to implemented/):
- HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero
producers (native plugins on the seams write no hook/* provenance), and the
dialect is defined as the bridge that ran the hook.
- HookOutput.suppressOutput is gone: the codec parsed it and every path
discarded it with no warn and no deferral — hook stdout never enters a
transcript, so there is nothing to suppress.
- hook/result.durationMs is gone: durable timing telemetry with no reader
that the snapshot normalizer had to scrub as replay noise. With no duration
to measure, runHook loses its injected now clock and the single-field
RunHookResult wrapper — it returns the HookOutput directly. The committed
hook fixtures had the field stripped mechanically (field-only diff); the
stdout goldens never carried it.
- The bridges' double-defaulted defaultTimeoutMs config knob is replaced by
one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the
lib's runner and applied inside runHook; per-hook timeoutSec stays the
override surface.
- The hook/result semantics move into the lib that declares the event:
HookResultRecord now carries the decoded HookOutput and appendHookResult
derives the decision string (decision ?? stop-on-continue:false ?? pass)
and the 500-char stderrSummary truncation; both bridges delete their
byte-identical private copies. The snapshot suite passes against the
existing goldens, proving the derived values are unchanged.
- Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers).
Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts,
update the lib/bridge READMEs and the session.md event tables, and retarget
the affected unit tests (including new lib-level coverage of the derivation
rules).
Codex convergence findings on the delegate-and-fold fix (code path verified
correct, prose only):
- The hook-bridges RFC claimed a downstream `block` "carries the bridge context
too" for BOTH seams. True for `tools/post-execute` (PostToolDecision.block has
an additionalContext field) but false for `agent/prompt-submit`
(PromptDecision.block is `{kind,reason}` with no context field). The code is
already correct — a blocked prompt drops the context, which is right since the
prompt never reaches the model. Reworded the RFC to state the per-seam
difference accurately.
- Two test comments narrated "Before the fix…", which the current-state-only
doc rule forbids. Reworded to describe the behavior, not its history.
- Documented on concatContext (both bridges) why the merged block carries a
single source: a HookContext holds one MessageSource and the seam cannot
represent mixed provenance; rendering distinguishes only by source.kind, so a
downstream plugin's text stays framed as plugin context.
Address review on the hook-bridges PR — two composability/compatibility bugs
in both the CC and Codex bridges:
1. A hook that only attaches additionalContext (no block/deny) returned
`allow`/`accept` WITHOUT calling next(), short-circuiting every later
agent/prompt-submit / tools/post-execute listener. A policy/sandbox plugin
registered after the bridge never saw the prompt. Now the context-only path
delegates via next() and folds its context onto the downstream decision
(concatContext): a downstream block/deny still wins and carries the bridge
context; a downstream allow/accept keeps its own content rewrite and gains
the context. Only a real hook deny/block short-circuits.
2. CLAUDE_PROJECT_DIR was empty in the default ACP wiring (no projectDir
configured), breaking common unmodified hooks that reference
$CLAUDE_PROJECT_DIR. It now defaults per-run to the agent's session
workspace (the same cwd the hook runs in); an explicit config.projectDir
still wins.
Regression tests per bridge: a later listener blocks a prompt a context-only
hook allowed; both contexts survive when the downstream also adds one; the
default CLAUDE_PROJECT_DIR reaches the hook. Each proven red on the pre-fix
code.
Address review on the bridges:
- Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks
ran in the executor default (the ACP server launch dir), not the session
cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong
tree. Both bridges now thread the agent's session `header.cwd` (the
session/new.cwd) as the hook workdir for agent-scoped points. Regression per
bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session
workspace (proven red without the workdir).
- Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at
load against the PROCESS cwd, not per-session — the comment/README now say so
explicitly (a project-local per-session hooks.json is not discovered;
TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the
distinct, separately-documented half.
- Session-start timing (blocking): agent/session-start is a synchronous emit and
the hook runs on a detached .then, so injected context is BEST-EFFORT — not
guaranteed before the first request. Downgrade the contract in code comments +
README + RFC (TODO(session-start-gating)) rather than implying "first request
sees it", and add a no-wait regression that asserts the safe properties
without pre-waiting for the inject.
- systemMessage (non-blocking): the merge collects merged.systemMessages but no
bridge surfaced it. Warn per hook (like updatedInput) and document it as
deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
Round-2 Codex review of the round-1 fixes:
- (A) The Codex plain-stdout→additionalContext fold (F1) was not gated on exit
code, so a NON-clean hook's stdout still injected: a SessionStart `echo stale;
exit 2` (an emit — cannot block) wrongly injected "stale", and a
UserPromptSubmit `exit 1` (non-blocking error → falls through to context) did
too. Gate the fold on `output.exitCode === 0`, matching the codec's own
structured-stdout rule. Guard tests for both paths, proven red without the gate.
- (B) The Codex "SessionStart no-context no-op" absence test was unsound (a
completed turn doesn't prove the detached hook finished). It now touches a
marker and waitFor()s it before asserting no context.
- (B) Both HMR tests used a no-op `true` hook, so a leaked listener would still
pass. They now use a BLOCKING (exit 2) UserPromptSubmit hook and assert the
post-dispose turn is NOT blocked and logs no hook/invoked — a leaked listener
fails loudly.
The bridge tests that drive observe-only emit listeners (session-start,
subagent/start, subagent/end) fire their hook on a detached `.then` the test
cannot await. They waited a fixed 50-80ms, which flaked under the full
test:coverage run's heavy parallel load (transform ~400s): the sleep expired
before the async hook completed, so the injected context / marker file / warn
call had not landed. Replace each fixed sleep with a `waitFor(predicate)` poll
that retries until the observable effect appears (5s deadline) — "async state is
not synchronous state": wait for the signal that actually fires, not a guessed
duration. No behavior change; the same assertions, made robust to scheduling.
Round-1 Codex review findings on the bridges:
- Stop force-continue (both bridges): a blocking Stop hook with EMPTY stderr
yielded decision 'deny' + reason undefined, and the `&& reason !== undefined`
guard let the turn STOP — the opposite of a blocking Stop hook. Force-continue
on any deny; fall back to a generic steering line when there is no reason.
- Codex payload tool_name: hardcoded "Bash" disagreed with the exec.name matcher
subject, so a real Codex `matcher:"Bash"` never fired against the harness's
lowercase `bash` tool. Use exec.name in both payload builders (matches the
matcher subject and the sibling CC bridge). Doc/RFC updated.
- Codex plain-stdout context: SessionStart/UserPromptSubmit are documented to
treat a clean hook's PLAIN (non-JSON) stdout as additionalContext, but nothing
folded it. runPoint now folds plain stdout into context for those two events,
gated on the codec's JSON gate so structured stdout is never dumped as prose.
- continue:false is deferred, not honored: the seams have no hard-halt primitive
yet. TODO(hook-continue-false) at both bridges + an RFC deferred note; the two
tests now assert the LOG records the halt request AND that the run is NOT
actually halted (no longer misleading).
- README concurrency wording: hooks run SERIALLY (deliberate — adjacent
invoked/result log pairs, order-independent fold), not concurrently. Fixed the
CC README claim + an RFC note.
Regression guards proven red on the unfixed code, then reverted. The mismatched-
hookEventName discard (also flagged) is fixed in dsh-hook-protocol and merged down.
The two bridge plugins that run a user's existing Claude Code / Codex hook
config on the harness's typed interception seams, built on the shared
dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power
tool: anything it does a native cordis plugin does more powerfully — the
bridge exists only to run UNMODIFIED external hooks.
- dsh-hooks-claude: CC dialect. Seven hook points (SessionStart,
UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart,
SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/
${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher.
- dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points,
always-regex matcher, snake_case payloads (turn_id/model, no trailing
newline), no env/substitution, block-only decisions.
Both map the neutral merged outcome onto the seam's typed Decision and stamp
an explicit {kind:'plugin'} source on injected context (so it is never
mislabeled as a user prompt). Config parse-failure is contained; only command
hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop
loop-guard is deferred (TODO).
Tests: per-file 100% — config-parse unit branches + per-seam mappings
end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted
mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot
scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt
end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a
with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash
(verified on disk). The snapshot normalizer now scrubs hook/result.durationMs.
RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md