Skip to content

feat(herdr): builtin Herdr pane reporter and user-decision block door (phase 1) - #2415

Open
makgunay wants to merge 33 commits into
bastani-inc:mainfrom
makgunay:herdr-phase1
Open

feat(herdr): builtin Herdr pane reporter and user-decision block door (phase 1)#2415
makgunay wants to merge 33 commits into
bastani-inc:mainfrom
makgunay:herdr-phase1

Conversation

@makgunay

@makgunay makgunay commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Base: main · Head: herdr-phase1 @ 70be6eeb96356b9ee813c8b3bec95400822705c4 · 33 commits · 46 files (+5887/−50)
Related: #2210 · Herdr-side: herdrdev/herdr#2423

Problem

An Atomic session running inside a Herdr pane reports nothing. The pane shows none:unknown: Herdr cannot tell whether the agent is idle, working, or waiting on a person. Nothing in the core tracks "a user decision is pending" either — an extension dialog, a trust prompt, or any ctx.ui wait is invisible to the host, so no reporter could ever know when to say blocked.

Solution

Four layers, bottom up:

  1. User-decision block door (0184f029a): a core registry of open user blocks (user-blocks.ts, block-types.ts), the pi.awaitUserDecision API, and agent_blocked / agent_unblocked session events. Blocks are handle-scoped and released exactly once.
  2. UI-context wrapping (68a26731fcb301f634): the single get ui() accessor in runner-context.ts returns a proxy over a surrogate target that mints a block for every select / confirm / input / custom / editor wait. Wrapped members are called on the host object; getter receivers, inherited members, and dynamic properties survive (readUiMember); native, cross-realm, and structurally valid promises keep their identity and cancellation (settlement-observer.ts); the thenable probe is guarded and proxy integrity operations are refused. 70be6eeb9 closes the review gaps: interactive shortcut handlers now get the wrapped context too, and a failed turn's message stays latched across duplicate agent_settled events.
  3. Trust-prompt blocks (b17ad18de, 6095446b0): withTrustPromptBlocks in runner-project-trust.ts mints a block for an extension's own project-trust prompt, so that wait also reads blocked.
  4. Builtin Herdr pane reporter (85b691cbc and follow-ons): activates only inside a Herdr pane (env + socket gate, withheld from hosts with no terminal pane), seeds blocks already open before activation, serializes every socket write, publishes idle / working / blocked per the reducer (user blocks outrank everything), latches failures until an idle settlement, and sends exactly one release on quit (single-flight latch). Documented in docs/herdr.md and docs/extensions.md.

Rebase provenance

Freshly rebased onto origin/main @ 04e83e38e (2026-08-15): 33/33 commits replayed, one conflict (packages/coding-agent/CHANGELOG.md, entries moved under the new [Unreleased]; released sections untouched). An earlier rebase round dropped one pure .ts.js respell commit to match upstream spelling; see scrutiny point 2.

Test output

At 70be6eeb9 (post-rebase verification):

  • npm ci --ignore-scripts — clean, 0 vulnerabilities.
  • npm run typecheck — clean (root tsc --noEmit + coding-agent tsgo -p tsconfig.build.json --noEmit).
  • Branch-owned suites, all green:
    • coding-agent: 9 herdr/block-door files, 131 tests passed (activation, reducer, reporter wire, single-writer, integration, user-blocks, UI block wrapping, project-trust block, loaded-extension-paths cycle).
    • coding-agent: pi-0.83.0-direct-fixes.test.ts (branch-modified), 18 passed.
    • root unit: extension-block-door-public-api.test.ts, 1 passed.
  • Full-suite history: at the pre-rebase head e4ee77d2 the coding-agent workspace ran 3194 passed / 0 failed and root test:unit 5826 passed / 0 failed. The full suites have not yet been re-run at 70be6eeb9; run npm run check + npm run test:unit before merge.
  • Compile-time guard: AssertNotAny beside UiMemberValue; restoring the old alias fails typecheck with TS2344.

Live: the reporter wire was verified against a real Herdr socket (strictly increasing sequence numbers, exactly one release), and the full pane-state behavior of the complete stack passed a 9/9 live scenario matrix at the herdr-phase2 tip (see PR2).

Known defects in this PR, fixed in the stacked PR2

Both are phase-1-introduced and are fixed at the herdr-phase2 tip with regression tests and live verification. They stay in PR2 because the headline regression test for (A) needs PR2's lifecycle bridge, and moving the commits would invalidate the live-verified tip. If this PR is expected to land alone for any meaningful period, say so and we will split the fixes down.

  • (A) Settlement-only custom-dialog block release (runner-ui-blocks.ts): the wrapper releases a custom() dialog's block only when its promise settles. An overlay hidden via its handle without settling (a pre-existing, deliberate contract of the workflows graph overlay on main) holds the block forever, wedging the pane at blocked. Latent here; observable once any such caller runs. Fixed by 10b430bbe (hide releases, show re-mints, settlement still ends bookkeeping once).
  • (B) Herdr-asset stand-down (925e50bde's rebased twin 85b691cbc lineage; predicate at src/extensions/herdr/index.ts): the builtin stands down whenever Herdr's installed herdr-agent-state.ts merely loads — but the current Herdr server silently discards that asset's herdr:pi reports for an Atomic pane, so in the real-user default config nobody reports and the pane stays unknown. Fixed by b06ef6d43 (explicit load-time supersession, env-gated; out-of-pane behavior unchanged).

Reviewer scrutiny points

  1. runner-project-trust.ts merge — the largest semantic merge of the rebase. The branch's whole withTrustPromptBlocks block landed on upstream's drifted shape; confirm it still composes with any main-side changes to emitProjectTrustEvent callers.
  2. Mixed import-spelling convention — upstream modules (types, ui-types, session-manager-types) stay .ts; branch-owned new modules (user-blocks, block-types, runner-ui-blocks, settlement-observer, herdr siblings) use .js. module-import-specifier-consistency.test.ts enforces one spelling per module and passes, but the retained respell commit messages describe a convention the stack no longer fully carries.
  3. _buildRuntime auto-merge (agent-session-tool-registry.ts) — both hunks (the detachUserBlocks() reload guard and the runner wiring) auto-merged onto upstream's new heartbeat-scheduler context. Verified: detachUserBlocks() precedes new ExtensionRunner(...); covered by the loaded-extension-paths-cycle and single-writer tests.
  4. CHANGELOG placement — the Herdr entries now sit in [Unreleased] after upstream cut 0.9.14-alpha.1; confirm that matches the intended shipping vehicle.

Breaking changes / limitations / follow-ups

  • No breaking API changes. Additive: pi.awaitUserDecision, agent_blocked / agent_unblocked. Behavioral note: ctx.ui is now a wrapping proxy — identity differs from the raw host context, integrity operations (seal etc. through the proxy) are refused, promise identity is preserved.
  • Known limitation (documented, pinned by a test): the startup project-trust prompt runs in the host before the engine child exists, so that pre-session block is not reported to the pane. Closing it needs pre-session reporting or a host↔child ownership handoff; documented in docs/herdr.md / docs/extensions.md.
  • Follow-ups: stacked PR2 (herdr-phase2herdr-phase1) carries workflow lifecycle reporting, the Non-quit session shutdown (/reload, /new, fork) orphans in-flight workflow runs instead of pausing them like quit does #2247 session-replacement fix, and the two defect fixes above.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Greptile Summary

This change adds session-scoped tracking for extension waits on user decisions, instruments blocking UI and project-trust prompts, and publishes Herdr reporter state over its local socket lifecycle. One P2 repository-convention issue remains: the new runner UI block module uses a .ts import suffix where the project requires .js ESM specifiers. Update that import before merging.

Confidence Score: 4/5

Merge is safe once the ESM import specifier is corrected.

The final finding set contains one non-security P2 issue and no P0 or P1 findings, which maps to a score of 4.

Files Needing Attention: packages/coding-agent/src/core/extensions/runner-ui-blocks.ts

Prompt To Fix All With AI
### Issue 1
packages/coding-agent/src/core/extensions/runner-ui-blocks.ts:16
**Use the repository ESM specifier**

The new `./ui-types.ts` import conflicts with the repository requirement that TypeScript source files use `.js` ESM specifiers, adding another exception to the source-module convention and its runtime-resolution assumptions.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(herdr): close phase one review gaps" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Context used - AGENTS.md (source)

Every path that stops the agent to wait on a person now opens a block and
releases it through the handle it was given. `pi.awaitUserDecision(label,
reason)` returns a `UserBlock`; `release()` is idempotent and safe in a
`finally`. There is deliberately no release-by-id, release-by-label, or
release-all entry point, so one caller can never end another caller's wait.

Blocks are reference counted and the oldest open label is the one presented,
and `agent_blocked`/`agent_unblocked` publish on open and close as ordinary
lifecycle events.

`select`, `confirm`, `input`, `custom`, and `editor` are wrapped once, at the
single `get ui()` accessor, so an extension gets this without knowing the block
door exists. The wrapper is a memoized proxy rather than a spread: the host's
optional members stay absent when it omits them, and accessor and method
identity are unchanged. Return values, cancellation, and thrown errors pass
through untouched, and the block is released in `finally` so an abort ends it.

The project-trust prompt runs before any ExtensionRunner exists, so it mints its
own `project_trust` block around its restricted UI call.

The registry is module scope because the trust prompt predates the runner and
successor sessions in one process must share the refcount. Runners subscribe on
construction and detach on invalidation; `_buildRuntime()` detaches the outgoing
runner, since a reload replaces it without invalidating it.
Running Atomic inside a Herdr pane now makes that pane show what Atomic is
doing: `working` during a turn, `idle` once the turn has fully settled, and
`blocked` while a dialog waits on the user or when the turn ended in a provider
error. Nothing to install, nothing to configure.

Idle is decided at `agent_settled`, which the host emits only after retries,
compaction retries, and queued continuations, so the pane does not flicker
between a failed request and its retry. That is why this needs no grace or
debounce timer at all.

The reducer is pure and total. Reporting is one serialized writer whose queued
state coalesces to the newest value, over one strictly increasing sequence
shared by state, session, and release messages — Herdr silently drops a report
whose sequence is not above the last it accepted. A dead, refusing, or hung
socket degrades to silence after one 500 ms attempt and one 1500 ms retry; no
lifecycle path can be delayed or failed by it.

Only state, pane id, a short label or error string capped at 120 characters, and
a session reference cross the socket. Prompt text, tool arguments, and model
output never do.

Outside a Herdr TUI pane it is a complete no-op: no socket, no timer, no
listener. It also stands down when a file-based `herdr-agent-state` integration
loaded in the same cycle — decided from what actually loaded, not from what
exists on disk, and re-checked at activation so a file integration that lands
after this one still wins. A pane never gets two writers.

Extension loading can be deferred until after the first frame, so the reporter
binds on whichever lifecycle event it sees first and seeds its state from
`ctx.isIdle()`. It is correct whether it arrived before or after the turn it is
describing.

Quitting drains the queue and then releases the pane. Reload, new, resume, and
fork silence the outgoing instance instead; the successor re-reports, above the
predecessor's sequence.
Adds a Herdr page covering what the reporter sends, when it is active, how it
degrades when the socket is gone, and how it hands over across reload, new,
resume, fork, and quit — including the cross-process sequence caveat.

Documents `pi.awaitUserDecision()` and the `agent_blocked`/`agent_unblocked`
events under Extensions, and records the Phase 1 work in the changelog.

Also checks in the Phase 1 codebase research note.
Two defects found in review, both silent in the failure mode that matters.

The session report went straight to the transport while state reports went
through the queue, so a block opened during the session round trip started a
second concurrent write. Herdr drops the lower sequence without saying so, so
the pane simply lost a report. Session, state, and release now share one FIFO.
Each request is built and sequenced when it is enqueued, and coalescing is
limited to an adjacent pair of state entries — a session or release entry is a
barrier, because collapsing across one would strand a sequence already drawn.
Quit latches first, drains, enqueues the release through the same writer, then
drains again, so a callback racing the quit cannot land behind the release.

session_start no longer waits for the socket. Blocking it on a round trip let a
hung Herdr delay the very session it was describing.

Separately, a reporter that activated while a dialog was already open reported
idle: block handlers returned before activation, and activation seeded only the
idle/active flag. It now seeds from the block registry's snapshot before the
first state is queued. A snapshot rather than a replay buffer, because events
that fired before this extension loaded cannot be replayed, and the registry is
module scope so a block outlives the runner a reload detached. Block events also
activate now, so a wait can be the first lifecycle event a deferred load sees.

The new single-writer tests gate the transport instead of using the socket
fixture; the fixture answers immediately and hides a concurrent write behind the
speed of loopback.
The reporter registered all seven handlers as soon as the Herdr environment was
present, because ctx.mode does not exist at factory time. A headless RPC, JSON,
or print run launched from inside a Herdr pane therefore carried listeners it
could never use, which is not the complete no-op it is supposed to be.

There is no extension-only fix: pi.on() returns void and cannot be undone, and a
bootstrap listener would still leave one behind. So the row is withheld before
the factory ever runs, through builtInExtensionsForHost().

The predicate is not appMode alone, and that distinction is the whole point. The
isolated interactive engine child is spawned with piped stdio and no --mode, so
it resolves to "print" while in fact driving the terminal UI through its host —
gating on appMode would have silenced the one process that actually reports. The
engine-child flag is part of hostPresentsTerminalPane() for exactly that reason,
and a test pins it.
The block wrapper read through the proxy receiver and forwarded its own `this`,
so a host method that keys per-instance state off its own identity — a WeakMap,
a private field — saw a different receiver once wrapping was in place. Wrapping
is supposed to be invisible, and this was the one way it was not.

Reads and calls now both resolve against the host object, and the wrapper
ignores its call-time `this`. Identity stability, absent optional members,
cancellation, and error identity are unchanged, and a WeakMap-backed host method
now pins the receiver in a test.

The wrapper also typed its arguments, receiver, result, and Proxy trap as
`unknown`, against the repo rule. Those are now mapped from the real
ExtensionUIContext signatures. A source contract test greps for `any`/`unknown`
in the block door and reporter files, since tsc accepts both.
The Herdr page and changelog claimed zero listeners outside a TUI and fully
serialized writes. Both were true only after this round's fixes, so the wording
now says what the code actually does: the builtin is withheld before its factory
runs in a headless host, and state, session identity, and the release share one
writer with exactly one request in flight.

Also records that a reporter loading mid-turn or mid-dialog seeds from the host,
and that the UI wrapper preserves host receiver identity.
The previous round fixed the receiver for the five blocking dialogs and left the
rest of the surface alone, which turned out to be the same bug with a wider
blast radius. `notify`, `setStatus`, and every other pass-through member were
returned raw, so calling them through `ctx.ui` ran them with the proxy as
`this`. A UI context whose methods read a private field threw outright —
`Cannot read private member #token` — on a host that worked before wrapping
existed. `AgentSession.bindExtensions({ uiContext })` is public, so that host is
a caller's to supply.

Every callable member is now forwarded on the host object, blocking or not. The
forwarders are cached per property and rechecked against the function the host
just returned, so a getter that hands back a different function later gets a new
forwarder instead of a stale one.

This does cost raw host-function identity: a forwarder cannot be the function it
forwards to, so `ctx.ui.notify === ui.notify` no longer holds. The two cannot
both be true, and the receiver is the one that changes behavior — the identity
that callers actually rely on is stable repeated lookup, which still holds and
is now asserted for `notify`, `setStatus`, and `requestRender`. The old
assertion has been replaced rather than deleted.

No `any` or `unknown` was added. The forwarder's declared `void` return never
reaches callers, because a `get` trap's return type does not affect a proxy
typed as ExtensionUIContext — which is what carries generic methods like
`custom<T>` and the `setWidget` overloads through untouched.
shortenReportMessage collapsed whitespace and trimmed every value, including
ones already well under the 120-character cap, so a dialog titled
"  Keep\n  spacing  " reached the wire as "Keep spacing". The cap is the only
thing this function is there to enforce; rewriting a caller's label beyond that
is not its business.

A value within the cap now passes through character for character. A longer one
is its raw first 119 characters plus an ellipsis, rather than a collapsed
rendering sliced afterwards. Embedded newlines stay safe on the wire because the
transport serializes through JSON.stringify, which escapes them before the
framing newline.

Also adds a no-ack mode to the socket fixture and a latency test for it. Nothing
about the queue or the timeouts changed — the test pins behavior the docs were
describing wrongly: ordinary callbacks return without touching the socket, a
non-quit shutdown returns at once, and quit spends a bounded budget attempting
the release. The bound is expressed as FIRST_ATTEMPT_TIMEOUT_MS +
RETRY_ATTEMPT_TIMEOUT_MS so it follows the transport rather than a literal.
The page claimed no lifecycle path can be delayed by the reporter, and the
changelog repeated it. Against a Herdr that accepts connections and never
answers, quit waits about six seconds: it drains the queue and then attempts the
release, which is what L1 asks for. The implementation was right and the prose
was wrong.

The wording now separates the two cases — ordinary callbacks and non-quit
shutdown never wait for socket I/O, quit deliberately does, and that wait is
bounded by the transport budgets — and says the release is attempted rather than
sent, since it can be dropped like any other report.

Also records that a label within the cap is sent exactly as given and only a
longer one is truncated.
A proxy may not hand back anything but the exact stored value for a
non-configurable, non-writable data property on its target. The wrapper proxied
the host directly and substituted forwarders, so the moment a caller passed a
frozen UI context the first dialog threw:

  TypeError: 'get' on proxy: property 'select' is a read-only and
  non-configurable data property on the proxy target but the proxy did not
  return its actual value

Object.freeze makes every own method non-configurable, and a frozen
ExtensionUIContext is a perfectly ordinary thing to hand to
AgentSession.bindExtensions, so this broke a valid public caller.

The proxy now sits on a fresh empty object that shares the host's prototype and
resolves everything from the captured host. An extensible target carries no such
invariant, so forwarders can still be substituted while `get`, `has`, `ownKeys`,
and `getOwnPropertyDescriptor` all answer from the host. Reported descriptors are
marked configurable, which is what the invariants require of a target with no own
properties, and is what keeps Object.keys, spread, and JSON.stringify seeing what
they saw before.

Returning the raw function for frozen properties would have satisfied the
invariant too, and was rejected: it silently drops the host receiver and skips
block tracking for exactly the hosts that opted into immutability.

Reflect.apply(original, host, args) is untouched, so the receiver fix stands.
Phase 1 binds this code to the ESM `.js` import form, and the herdr directory
was the only builtin still spelling its own modules `.ts`. Its seven sibling
imports now match llama, and a source test keeps them that way — nothing in
biome or tsc distinguishes the two forms, so the rule only holds if asserted.

The four imports that reach into src/core keep `.ts` deliberately. Those modules
are spelled `.ts` everywhere else in the repository, and
test/unit/module-import-specifier-consistency.test.ts requires one spelling per
module; switching them turned that test red. llama resolves the same tension the
same way. The assertion added here is therefore scoped to `./` specifiers, and
the cross-directory half stays owned by the repository-wide test that already
enforces it.
The Herdr page used "Trust project folder?" as an example blocked label, but
that prompt is displayed by the terminal host before the interactive engine
child exists, and the reporter lives in that child. The socket sees nothing
until trust resolves, so the one label the docs named was the one label a user
could never see there.

The example is replaced with dialogs that do appear, and the limitation is now
stated plainly on the Herdr page, beside the block events in the extensions
guide, and in both changelog bullets. The events are session-scoped: a block
opened before extensions are bound has no handler to reach.

This documents the boundary rather than moving it. Reporting the startup prompt
would need either pre-session reporting or host-to-child ownership transfer for
block events, neither of which Phase 1 defines, and both of which need a writer
handover story so the two processes cannot report the same pane at once.
The runner publishes block changes with a detached emit, so the open and close
handlers for one dialog run concurrently. `ensureActivated()` was async: the
first handler marked the reporter active and then suspended awaiting
`onSessionStart()`, while the second handler saw "active" and took the fast path.
The close therefore reported openBlocks 0 before the open reported 1, and the
pane sat at blocked with an empty registry — until the next state change, which
for an idle agent may never come.

`onSessionStart()` never awaited anything; it only mutates reporter state and
queues work. Both it and `ensureActivated()` are now synchronous, so concurrent
handlers run to completion in arrival order and there is no window to overtake.
The stand-down check, the ctx.isIdle() and block-snapshot seeding, the serialized
writer, and the async shutdown drain are all unchanged.

Shutdown now latches a closed flag before anything else. `_buildRuntime()`
detaches the outgoing runner but cannot cancel an emit already in flight, so a
detached callback could otherwise activate an instance the session had finished
with. This enforces the existing non-quit silence rule rather than adding to it.

The regression tests open and release a real block with no await in between,
which is the interleaving that exposed this; the previous "block first" test
awaited each emit and could not.
`blockLabel()` substituted the method name whenever a title was empty, so
`ui.select("", [...])` was reported as "select". Nothing asks for a non-empty
label — Herdr's own schema types `message` as a nullable string — and the
contract says unspecified input is preserved rather than rewritten, so this
invented text the caller never wrote.

Titled methods now use the title verbatim, empty string included. `custom()`
keeps its fixed label because it takes a component factory and has no title
argument at all, and the method-name fallback survives only for the impossible
case of a titled method called without a string title.
Nine annotation lines across three added test files used `unknown`, against the
repo rule. The existing source-constraint scan only covered production files, so
they survived a fully green `npm run check`.

The socket fixture now parses through a named `JsonValue` and validates each
field before constructing a `RecordedRequest`, rather than casting a candidate
shape. The UI recorder types its arguments as the shapes the five dialogs
actually receive. The assertion predicates are replaced by `captureThrow` and
`captureRejection` helpers: `assert.throws`/`assert.rejects` take Node's
`AssertPredicate`, which is `(thrown: unknown) => boolean`, so writing one inline
forces the annotation — catching the error directly avoids it and still compares
the exact object by identity.

The scan now covers these files too, so the gap that hid them is closed.
Phase 1 binds this code to the ESM `.js` import form. `user-blocks` and
`loaded-extension-paths` are modules this work introduced, so every importer of
them is here to change and the whole spelling group moves at once —
`module-import-specifier-consistency` requires one spelling per module, and a
partial conversion is what turns it red.

The two remaining `.ts` specifiers in herdr point at `core/extensions/types` and
`core/session-manager-types`, which predate this work and are spelled `.ts` by
twelve-plus importers across session-manager and compaction modules. Converting
those means editing files with no connection to this feature for a purely
cosmetic gain; the llama builtin leaves them `.ts` for the same reason.
The comment on `hostPresentsTerminalPane()` said the interactive engine child is
spawned with no `--mode` and resolves to `print`. It is spawned through
`RpcClient`, which prepends `--mode rpc`, so it resolves to `rpc`.

The predicate is unchanged and was already right — it keys on the engine-child
flag precisely because the mode is misleading — but the reason given for it was
wrong, which is worse than no comment. The matching test comment is corrected in
the same spirit, and now asserts the rpc-child case explicitly.
…r thenables

Two defects in the UI wrapper, both introduced by earlier rounds of this work.

Moving the proxy onto a surrogate target fixed frozen hosts but left writes
unhandled, so every assignment landed on the surrogate instead of the host. The
host never saw the change, and the surrogate gained an own property that then
contradicted the descriptor this proxy reports — so the next read, `Object.keys`,
or `JSON.stringify` threw outright. `set`, `defineProperty`, and `deleteProperty`
now forward to the host, with the host as receiver so a host setter runs on its
own object.

One case still cannot be forwarded: defining a non-configurable property. A proxy
may only report that as succeeding if its own target has a matching
non-configurable property, and mirroring one would force `get` to hand back the
raw function instead of the host-receiver forwarder. It therefore throws rather
than being quietly normalized to configurable, because inventing a descriptor the
caller did not ask for is the worse failure. The reasoning is recorded at the
call site.

Separately, the release check asked `result instanceof Promise`. A dialog from
another realm — a vm context, an isolated host bridge — fails that check, and so
does an ordinary thenable, and both released the block before the user had
answered: the pane reported the agent free while it was still asking. The check
is now structural, and settlement goes through `Promise.resolve(...).finally`.
The synchronous path is kept so a synchronous test double behaves as before.
The stand-down answer lived in one module-scope array. Loading yields to the
event loop between inline factories, and one process can run more than one
loader — in-process subagent sessions do — so a second cycle could overwrite the
first's answer while the first was still loading. The observable result was a
pane with two Herdr reporters, or none, which is exactly what the stand-down rule
exists to prevent.

An AsyncLocalStorage handle scopes the answer to the load that produced it.
`withLoadedFileExtensionPathCycle()` reuses an enclosing cycle rather than
nesting a new one, so the pre-trust and final loads of a single reload still
share one mutable answer — a file extension the later load discovers stays
visible to a factory the earlier load already ran. A fallback handle keeps direct
callers and existing tests working.

The Herdr factory now captures its cycle instead of re-reading module scope, so
its activation-time re-check consults the load it came from rather than whichever
load happens to be current when the check runs.

No public signature, event payload, or activation synchrony changes.
…with .js

Both modules were introduced by this work, so every import site is here to move
and the whole spelling group converts at once — `module-import-specifier-consistency`
requires one spelling per module, and a partial conversion is what turns it red.

The remaining `.ts` specifiers in this feature point at `core/extensions/types`,
`core/extensions/ui-types`, and `core/session-manager-types`, which predate it.
Converting those means editing 82 import sites across the package, most of them
in files with no connection to Herdr, for a behavior-neutral spelling change.
Left alone deliberately; the llama builtin makes the same trade.
…boundary

Regression cases for this round's fixes: assignment, configurable definition and
deletion reaching the host; a host setter running on the host; a cross-realm
promise and a plain thenable each holding the block until they settle, including
the rejecting path.

Also pins the startup project-trust boundary that the Herdr docs describe. The
prompt mints its `project_trust` block, and the registry is the only observer,
because `resolveProjectTrusted()` runs before any ExtensionRunner exists — and
under isolated interactive mode, in a different process from the reporter. Prose
alone could drift; if a later phase adds pre-session reporting or a host-to-child
handoff, this is the test that should fail and be rewritten.

The new cycle test file joins the any/unknown scan.
…operations

Three ways the UI wrapper could still misbehave, all found by review.

Reading `then` to decide whether a result settles later can itself throw, because
`then` may be a getter. That read sat outside the cleanup guard, so the error
reached the caller with the block still open — the pane stayed `blocked` with
nothing left to release it. The probe is now inside the guard: release, then
rethrow the same error.

`defineProperty` forwarded to the host before the proxy invariant was checked, so
a non-configurable definition mutated the host and *then* threw. The caller saw a
failure against an object that had already changed. It is now refused before the
host is touched, so the operation fails and the host is exactly as it was.

`Object.freeze(ctx.ui)` and `Object.preventExtensions(ctx.ui)` sealed the
surrogate, and an empty non-extensible target cannot report the host's keys — so
every later `Object.keys`, spread, or `JSON.stringify` on that context threw.
`preventExtensions` now returns false: sealing a wrapped context fails
immediately and the wrapper stays usable. `isExtensible` is deliberately not
forwarded, since a frozen host behind an extensible surrogate would violate the
invariants in the other direction.

Making a wrapped context seal through to the host would need a mirrored shadow
target instead of an empty surrogate, giving up the frozen-host and host-receiver
behavior this wrapper exists to preserve. Phase 1 does not define integrity
operations on a wrapped context; refusing them is the no-widening choice, and the
supported path — freeze the host before wrapping — is tested.
… errors

Two defects, both reachable in ordinary use.

The reporter trusted the counts carried on `agent_blocked`/`agent_unblocked`.
Each change is published with its own detached emit, and each emit awaits its
handlers, so one other extension subscribing slowly to just one of the two
events is enough to deliver them out of order. The late `agent_blocked` still
claims a block is open, so the pane pinned at `blocked` over an empty registry —
and for an idle agent nothing later corrects it. Both handlers now read the live
registry, which is mutated synchronously before subscribers run. Branching on the
live count rather than on which event arrived is the point: a late open for an
already-closed block correctly reports released.

Runner dispatch is deliberately not serialized. That would change delivery for
every extension, and the pane reporter only needs the current registry state.

Separately, `turnFailureMessage()` forwarded the provider's `errorMessage`
verbatim. That field is whatever a provider or a custom streamSimple put there —
`error.message`, a normalized response body, raw request metadata — and observed
values carry authorization headers and echoed prompt and model output. Truncating
it is not redaction. There is no typed error category to key on, and no pattern
list is sound against arbitrary formats, so the reported text is now the fixed
label that was already the fallback. The pane learns the turn failed; the detail
stays in the transcript where it belongs.
…rompt

A `project_trust` handler runs before Atomic's built-in prompt and receives the
same restricted UI, so a dialog it opens stops the agent exactly like Atomic's
does — and until now nothing reported it. Handlers get a derived context whose
`select`, `confirm`, and `input` open a `project_trust` block for the duration of
the prompt, on the host's own UI object, released on synchronous throw, on
rejection, and on settlement alike.

`notify` is untouched; it waits for nobody. The fallback prompt is untouched too:
it already opens its own block, and wrapping both paths would count one wait
twice.

`ProjectTrustContext` keeps its public shape — this is a derived object with the
same members, not a signature change.

This is in-process bookkeeping. It does not make the startup trust prompt visible
on the Herdr socket: that prompt runs in the host before the isolated engine
child exists, which stays a documented Phase 1 boundary.
…c API

The existing check reads the internal user-blocks module's exported names at
runtime. That file is not in the root `tsc` program, and a name check cannot see
a type — so the clause that actually matters, that a block ends only through its
own handle, was never asserted against `ExtensionAPI` as consumers import it.

This compiles against the published surface. The `@ts-expect-error` comments are
the assertion: if a release-by-id method were added, or the reason union widened,
or `release()` given a parameter, the directive becomes unused and the root
typecheck fails. A positive assignment check would keep compiling through exactly
those changes and prove nothing. The function is never called.
…ealed

Two ways the ctx.ui wrapper still diverged from an unwrapped context.

`Promise.resolve(result).finally(cleanup)` returns a *derived* promise, so a
wrapped `ctx.ui.select()` no longer handed back the host's own object. Anything
keyed on that identity — a host that caches or cancels by what it returned, a
bridge, a test double — silently stopped matching. `observeSettlement` watches
settlement on a separate branch through the intrinsic `then` and returns the
original promise untouched; a plain thenable is adopted exactly once, because
observing the raw one while also returning it would call a non-idempotent `then`
twice. The observer's own rejection is swallowed so it cannot become a second
unhandled rejection; the caller's reason is unchanged.

The probe that decides whether a value settles later also has to consume the
promise it creates. Left alone, it turned a perfectly handled caller rejection
into an unhandled one.

Separately, `Object.freeze`, `Object.seal`, and `Object.preventExtensions` threw
on a wrapped context where they succeed on a raw one. Refusing them was the
previous round's choice and it was wrong for the same reason the original bug
was: it is a behavioural difference introduced by wrapping. Sealing now mirrors
the host's own keys onto the proxy target — holding the *wrapped* values, so the
receiver fix survives — and makes host and target non-extensible together. That
keeps `isExtensible` truthful with no trap of its own and keeps `ownKeys` legal
against a fixed key set. A host that arrives already sealed is mirrored at
construction, so the wrapper never claims to be extensible when the host is not.

The target stays empty until something actually seals, so every existing
guarantee — forwarders for a frozen host's non-configurable methods, live host
keys, later-added members — is unchanged in the ordinary case.
`ProjectTrustContext` is structurally typed and publicly exported, so a host may
hand over a class instance. Building the handler's context with `{...ui}` and
`{...ctx}` copied only enumerable own properties, which silently dropped a
prototype `notify` and any getter-backed `cwd`, `mode`, or `hasUI` — a handler
that called `ctx.ui.notify(...)` got `undefined is not a function` against a host
that worked before.

The derived context now names each member: the three prompts keep their block
wrappers, `notify` forwards to the host with the host as receiver, and `cwd`,
`mode`, and `hasUI` are accessors reading through to the original rather than
values snapshotted at wrap time.

The public shape is unchanged.
Clearing the queue and setting `silenced` left an attempt already in flight
running. Against a socket that accepts and never answers, the predecessor still
spent its 1500 ms retry and opened another connection *after* reload shutdown had
returned — so a reporter that was supposed to have gone quiet could talk over the
successor that replaced it. Sequence numbers stop a stale report being accepted
after a newer one, but they do not stop the traffic or order it against a
successor's first report.

Each reporter now owns an AbortController. A non-quit shutdown aborts it, the
attempt destroys its socket and resolves false, and the retry is skipped. Quit
deliberately does not abort: it keeps draining and then releasing, which is what
L1 asks for.

The signal is optional on `HerdrTransport`, so a substituted transport can ignore
it, and the 500 ms + 1500 ms discipline is otherwise untouched.
… sent

The page and changelog still said the socket carries "a dialog title or a
provider error string, both capped at 120 characters". Provider error text stopped
crossing the socket when the fixed `Agent turn failed` label landed; the only free
text left is a dialog title. The stale reporter comment said the same thing and is
corrected too.
…flight

`onAgentEnd()` wrote the provider failure straight into the reported state, so
any later publish surfaced it — closing a dialog mid-turn reported the pane as
`blocked: Agent turn failed` while a retry was still running and might yet
succeed. That is the settled-only rule (S2) broken by a side door. The failure is
now held pending and promoted only by an idle `agent_settled`; `agent_start`
clears it, and a non-idle settle leaves it pending.

Separately, two concurrent quits each sent a release. The silence guard ran
before the `quitting` latch, so both callers got past it, and `released` is not
set until the very end. `AgentSessionRuntime.dispose()` has no single-flight
guard of its own. Quit is now latched into one shared promise before the first
await: a second caller awaits the first rather than starting its own, and
`quitting` counts as silenced so a late lifecycle event cannot queue a report
behind the release.

L1 is unchanged: existing work drains first, the release is still the final
write, post-release reports are refused, and quit still does not abort the
transport.
`type UiMemberValue = UiCallable | ReturnType<typeof Reflect.get>` looked narrow
and was not: `Reflect.get` returns `any` in the lib types, so the union absorbed
it and the alias *was* `any`. Every member read was unchecked, and the lexical
`any`/`unknown` scan could not see it, because the word never appears.

It is now `ExtensionUIContext[keyof ExtensionUIContext]`, read through a small
`readUiMember()` helper that indexes the host directly — which keeps getter
receivers, inherited members, and dynamic properties working exactly as
`Reflect.get(host, key, host)` did.

A compile-time assertion sits beside the alias, because a lexical scan cannot
catch an inferred `any` and a regex for this one spelling would not catch the
next one. Restoring the old alias fails the typecheck with
`Type 'true' does not satisfy the constraint 'false'`, which is how it was
verified rather than assumed.
Comment thread packages/coding-agent/src/core/extensions/runner-ui-blocks.ts
@lavaman131

Copy link
Copy Markdown
Collaborator

@makgunay Thanks for this. The block door fills a real gap — we had no way for the core to say "a human is being waited on," so nothing could report blocked. The proxy work in runner-ui-blocks.ts handles the cases that usually get missed (promise identity, cancellation, getter receivers, inherited members), and verifying the wire against a real socket was the right call.

We want the feature. We need it split before we can review it.

Size

This PR is +5,887 / −50 across 46 files in 33 commits, and #2416 is +9,626 across 75 files — about 15,500 lines between them. CONTRIBUTING.md asks for changes "small enough to review," and at this size we can't give it a real review. In practice that means it sits, which doesn't help either of us.

Why your stack didn't come through as one

You clearly meant to stack these, and this part isn't your fault. #2416 says its base is herdr-phase1, but its actual base on GitHub is main. Both PRs come from your fork, and a fork PR can only target a branch that exists in the upstream repo. herdr-phase1 is in makgunay/atomic, not bastani-inc/atomic, so GitHub retargeted it to main. That's why #2416 shows 45 commits instead of 12, and why phase 1 appears twice.

So gh stack won't chain from a fork, and upstream never renders these as a stack — there's no per-layer view for us to review against. Whatever you intended, what a reviewer here actually opens is one cumulative diff.

So our clear preference is separate, smaller PRs rather than a stack. Open one focused PR against main, we review and land it, you rebase and open the next. It's more rebases for you, but it's the only shape that's actually reviewable on our side, and each one moves quickly instead of everything waiting on one giant review. Rough target: something we can read in a sitting — a few hundred to ~1,000 lines, not 6,000.

Suggested split

Your commits are already factored close to this:

  1. Block-door coreuser-blocks.ts, block-types.ts, pi.awaitUserDecision, the agent_blocked / agent_unblocked events, extensions-user-blocks.test.ts. No consumers yet. (~400 lines)
  2. UI-context wrappingrunner-ui-blocks.ts, settlement-observer.ts, extensions-ui-block-wrapping.test.ts. Subtlest code here; deserves its own review.
  3. Trust-prompt blocksrunner-project-trust.ts / withTrustPromptBlocks. Small and independent.
  4. Herdr pane reportersrc/extensions/herdr/*, transport, reducer, sequence, activation, docs/herdr.md. Needs the three above.

1–3 are useful on their own even before 4 lands.

What we'd want fixed

  • This PR isn't correct on its own. You disclose defects (A) and (B) as fixed only in fix(herdr,workflows): workflow lifecycle reporting and survival across session replacement (phase 2) #2416 — a custom() overlay hidden via its handle wedges the pane at blocked, and with Herdr's installed herdr-agent-state.ts present (the default) the pane sits at none:unknown. Good that you flagged them, but each PR has to be correct at merge, since any of them may sit at the head of main for a while. Fold 10b430bbe and b06ef6d43 into whichever split PR introduces the behavior they fix.
  • Import specifier (Greptile P2): runner-ui-blocks.ts:16 imports ./ui-types.ts; AGENTS.md requires .js. Your scrutiny point 2 notes the mixed spelling — please settle on the repo convention.
  • research/docs/2026-08-07-herdr-phase1-codebase.md (218 lines) looks like a working artifact. CONTRIBUTING.md asks to keep those out. Drop it unless it has lasting value.
  • Full suites not re-run at the tip. Your description asks us to run npm run check + npm run test:unit before merge — please run them per split PR so we're reviewing against green.
  • Required CI hasn't run (fork PRs need approval). We'll approve runs on the split PRs.

Scope note

We've moved #2247 to P0 and are taking that fix in-house, along with #2022 (the /new DBOS re-registration bug), so both ship now — details in the issue. We're cherry-picking your commits with attribution rather than rewriting them. That work drops out on rebase and shrinks #2416, so it should be less work for you here, not more.

The split is about getting this reviewed, not about the code.

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.

2 participants