fleetd #788: fleet_inbox, so a pane can collect its own mail #789

Closed
agent wants to merge 0 commits from worker/788-eb7dd0-1 into main
Member

Closes part of fleet/fleetd#788.

What this adds

A Claude Code session running the fleet mod now pulls its messages from fleetd and hands
each one to Claude with $.prompt.submit, instead of having them typed into its terminal by
herdr. fleetd names a caller by its pane, not by its Claude account, so this is the hop that
works between two accounts on one host.

1. fleet_inbox (new MCP tool)

Takes no arguments. The pane is the caller's connection-resolved terminal, so no caller can read
another pane's mail. Authz gets an INBOX action, grouped with REPLY and ASK in the
only-as-itself case (caller.ownsSession(targetSession)), so every role may call it for itself
and none for another pane. Returns {sessionId, count, messages}.

2. Injector route

A pane becomes mod-served by calling fleet_inbox. PaneInbox holds the record and the
offered mail; the Injector owns it, because it is the single writer of delivery state and the
offer has to be made and taken back under the same target monitor that guards the queue.

The Injector asks "is this pane mod-served?" at the moment it is about to deliver, and offers
the message for collection instead of typing it. The message stays at the head of the
Injector's queue until the pane takes it
. That one decision gives three things:

  • delivery is recorded when the pane really has the text, not when it was offered;
  • a pane that stops polling strands nothing — see the rule below;
  • a collected message gets no Enter nudge. Nothing was typed into the pane, and an Enter in a
    lead's pane would submit whatever its operator was in the middle of writing.

cancel, drop and both grace expiries withdraw the offer as well, so a message the caller was
told never arrived can never arrive later. drain and withdrawAll run under the same monitor,
so an entry is removed exactly once — never both collected and withdrawn.

3. Ticket states

Unchanged, by construction. The message leaves the Injector queue at the same point as a typed
one, so queuedWaitMillis — and therefore fleet_poll's PENDING detail — reads
"queued, not yet delivered" while it is merely offered and flips to "worker " once
collected. The reply still arrives through fleet_reply.
MessageServiceInboxDeliveryTest runs both routes side by side in one test and asserts the
phase, the reply outcome and the reply source match.

4. Mod

A 3s timer in plugin/hooks/register.js collects through the existing fleetTool helper and
submits each message. A fleetd that is down makes the timer return, not throw: a throw would
stop every later poll.

The two decisions the ticket asked for

N = 15 seconds (PaneInbox.MOD_SERVED_WINDOW_MILLIS). The mod polls every 3s, so 15s
tolerates four missed ticks — a slow turn, or a brief daemon hiccup — while still being short
enough that a pane whose mod really stopped falls back within one 250ms Injector poll of the
window closing. The two constants are deliberately a factor of five apart rather than adjacent.

Stop-polling rule: fall back to the terminal paste route. Two reasons. First, it costs no new
machinery: because the message never leaves the Injector's queue until the pane takes it, the
fallback is the absence of a branch rather than a recovery path, so there is no window in which
a message is neither offered nor queued. Second, the pane is a real Claude Code pane in a herdr
pane, so the typed route is known to work for it — failing the ticket would throw away a delivery
route that functions and would need an operator to re-send. The alternative (fail with an explicit
reason) is only the better choice if the typed route were unavailable, and it is not.

What I did NOT change

  • The status gate still applies to this route. The box gate (#782) already did not, because
    the Injector never consulted PromptBox — only the lead push loops do — so that half of the
    ticket's note is true for free. Relaxing the status gate is a separate change and I left it
    alone on purpose: awaitingPickup/awaitingCompletion are built on sampled status edges, so
    arming them while the pane is mid-turn would let the next idle sample fire a completion for the
    previous turn (the CB-116 hazard the existing comments name). Criterion 3 asks for the same
    ticket states as a typed message, and those states are exactly what the sampled edges produce.
  • Per-message sender attribution. fleetd does not carry a sender on a queued message —
    Injector.enqueue never sees one — so the mod names the channel ("Message from the fleet, via
    fleetd") rather than inventing a peer name. Where fleetd already attributes (an observer's send
    carries its own [fleet_send from observer term_…] prefix) that rides inside the text. Adding a
    real sender field would mean widening MessageService.send and Injector.enqueue, which is
    outside this scope.
  • Lead-to-lead mail over LeadCoordLoop is a different path from the Injector and is
    untouched.
  • Docs — CLAUDE.md, wiki/, .mcp.json, fleetd.yaml are all unedited, per the brief.
    docs/MCP-Contract.md is flows-only and names no tool catalogue, so McpContractDocTest stays
    green with the extra tool; the intent→tool table and the Features entry are the lead's.

Verification

mvn clean install   (in fleetd/, unpiped, exit 0)
Tests run: 2200, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Counted independently from target/surefire-reports/TEST-*.xml after the clean: 182 XML files,
same 2200 / 0 / 0 / 0. New classes: PaneInboxTest 7, InjectorModServedDeliveryTest 6,
MessageServiceInboxDeliveryTest 2; AuthzTest 31 (one new test, one exemption list widened —
anObserverIsDeniedEverythingBeyondReadMetricsReplyAskInboxAndSend, since an observer pane
running the mod must be able to collect its own mail).

claude plugin validate plugin   → Validation passed (exit 0)
claude plugin test plugin       → 13 pass, 0 fail (exit 0)

Every negative assertion carries a positive control in the same test. Two mutations confirm the
tests bind rather than merely pass:

  • forcing isModServed to false → 9 of 15 fail;
  • deleting the stop-polling withdraw → exactly
    InjectorModServedDeliveryTest.aPaneThatStopsCollectingHasItsMailTypedInstead fails, with
    "the message falls back to the terminal route ==> expected: <[do the task]> but was: <[]>".

Both mutations were reverted and the final mvn clean install above was run after the revert.

For review

  • FleetdAssembly.assembleAndStart is unchanged — no new wiring line. The PaneInbox is
    constructed inside the Injector's two full constructors from the clock it already holds, which
    also means no existing Injector, MessageService or FleetMcp constructor gained a parameter
    and no existing test needed editing for construction.
  • An empty PaneInbox is not a silent default that disables the feature: no pane has polled, so
    no pane is mod-served, which is the correct answer rather than an off switch.
  • I could not run the CLAUDE.md ↔ wiki block sync check: wiki/ is uninitialized in a worker
    worktree (git submodule status prints a leading -). Not run, not passed.
Closes part of fleet/fleetd#788. ## What this adds A Claude Code session running the fleet mod now **pulls** its messages from fleetd and hands each one to Claude with `$.prompt.submit`, instead of having them typed into its terminal by herdr. fleetd names a caller by its pane, not by its Claude account, so this is the hop that works between two accounts on one host. ### 1. `fleet_inbox` (new MCP tool) Takes no arguments. The pane is the caller's connection-resolved terminal, so no caller can read another pane's mail. `Authz` gets an `INBOX` action, grouped with `REPLY` and `ASK` in the only-as-itself case (`caller.ownsSession(targetSession)`), so every role may call it for itself and none for another pane. Returns `{sessionId, count, messages}`. ### 2. Injector route A pane becomes **mod-served** by calling `fleet_inbox`. `PaneInbox` holds the record and the offered mail; the Injector owns it, because it is the single writer of delivery state and the offer has to be made and taken back under the same target monitor that guards the queue. The Injector asks "is this pane mod-served?" at the moment it is about to deliver, and **offers** the message for collection instead of typing it. The message **stays at the head of the Injector's queue until the pane takes it**. That one decision gives three things: - delivery is recorded when the pane really has the text, not when it was offered; - a pane that stops polling strands nothing — see the rule below; - a collected message gets no Enter nudge. Nothing was typed into the pane, and an Enter in a lead's pane would submit whatever its operator was in the middle of writing. `cancel`, `drop` and both grace expiries withdraw the offer as well, so a message the caller was told never arrived can never arrive later. `drain` and `withdrawAll` run under the same monitor, so an entry is removed exactly once — never both collected and withdrawn. ### 3. Ticket states Unchanged, by construction. The message leaves the Injector queue at the same point as a typed one, so `queuedWaitMillis` — and therefore `fleet_poll`'s PENDING detail — reads "queued, not yet delivered" while it is merely offered and flips to "worker <status>" once collected. The reply still arrives through `fleet_reply`. `MessageServiceInboxDeliveryTest` runs both routes side by side in one test and asserts the phase, the reply outcome and the reply source match. ### 4. Mod A 3s timer in `plugin/hooks/register.js` collects through the existing `fleetTool` helper and submits each message. A fleetd that is down makes the timer **return**, not throw: a throw would stop every later poll. ## The two decisions the ticket asked for **N = 15 seconds** (`PaneInbox.MOD_SERVED_WINDOW_MILLIS`). The mod polls every 3s, so 15s tolerates four missed ticks — a slow turn, or a brief daemon hiccup — while still being short enough that a pane whose mod really stopped falls back within one 250ms Injector poll of the window closing. The two constants are deliberately a factor of five apart rather than adjacent. **Stop-polling rule: fall back to the terminal paste route.** Two reasons. First, it costs no new machinery: because the message never leaves the Injector's queue until the pane takes it, the fallback is the *absence* of a branch rather than a recovery path, so there is no window in which a message is neither offered nor queued. Second, the pane is a real Claude Code pane in a herdr pane, so the typed route is known to work for it — failing the ticket would throw away a delivery route that functions and would need an operator to re-send. The alternative (fail with an explicit reason) is only the better choice if the typed route were unavailable, and it is not. ## What I did NOT change - **The status gate still applies to this route.** The box gate (#782) already did not, because the Injector never consulted `PromptBox` — only the lead push loops do — so that half of the ticket's note is true for free. Relaxing the *status* gate is a separate change and I left it alone on purpose: `awaitingPickup`/`awaitingCompletion` are built on sampled status edges, so arming them while the pane is mid-turn would let the next idle sample fire a completion for the **previous** turn (the CB-116 hazard the existing comments name). Criterion 3 asks for the same ticket states as a typed message, and those states are exactly what the sampled edges produce. - **Per-message sender attribution.** fleetd does not carry a sender on a queued message — `Injector.enqueue` never sees one — so the mod names the channel ("Message from the fleet, via fleetd") rather than inventing a peer name. Where fleetd already attributes (an observer's send carries its own `[fleet_send from observer term_…]` prefix) that rides inside the text. Adding a real sender field would mean widening `MessageService.send` and `Injector.enqueue`, which is outside this scope. - **Lead-to-lead mail over `LeadCoordLoop`** is a different path from the Injector and is untouched. - **Docs** — `CLAUDE.md`, `wiki/`, `.mcp.json`, `fleetd.yaml` are all unedited, per the brief. `docs/MCP-Contract.md` is flows-only and names no tool catalogue, so `McpContractDocTest` stays green with the extra tool; the intent→tool table and the Features entry are the lead's. ## Verification ``` mvn clean install (in fleetd/, unpiped, exit 0) Tests run: 2200, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS ``` Counted independently from `target/surefire-reports/TEST-*.xml` after the clean: 182 XML files, same 2200 / 0 / 0 / 0. New classes: `PaneInboxTest` 7, `InjectorModServedDeliveryTest` 6, `MessageServiceInboxDeliveryTest` 2; `AuthzTest` 31 (one new test, one exemption list widened — `anObserverIsDeniedEverythingBeyondReadMetricsReplyAskInboxAndSend`, since an observer pane running the mod must be able to collect its own mail). ``` claude plugin validate plugin → Validation passed (exit 0) claude plugin test plugin → 13 pass, 0 fail (exit 0) ``` Every negative assertion carries a positive control in the same test. Two mutations confirm the tests bind rather than merely pass: - forcing `isModServed` to `false` → 9 of 15 fail; - deleting the stop-polling withdraw → exactly `InjectorModServedDeliveryTest.aPaneThatStopsCollectingHasItsMailTypedInstead` fails, with "the message falls back to the terminal route ==> expected: <[do the task]> but was: <[]>". Both mutations were reverted and the final `mvn clean install` above was run after the revert. ## For review - `FleetdAssembly.assembleAndStart` is **unchanged** — no new wiring line. The `PaneInbox` is constructed inside the Injector's two full constructors from the clock it already holds, which also means no existing `Injector`, `MessageService` or `FleetMcp` constructor gained a parameter and no existing test needed editing for construction. - An empty `PaneInbox` is not a silent default that disables the feature: no pane has polled, so no pane is mod-served, which is the correct answer rather than an off switch. - I could not run the `CLAUDE.md` ↔ wiki block sync check: `wiki/` is uninitialized in a worker worktree (`git submodule status` prints a leading `-`). Not run, not passed.
agent added 2 commits 2026-10-06 05:32:26 +02:00
Turns the fleet plugin into a Claude Code mod (hooks/hooks.json +
register.js). /fleet-peers and /fleet-mail carry messages through
$.store; /fleet-whoami calls fleet_whoami on the local fleetd over
$.http.fetch.

Measured 2026-10-06 on Claude Code 2.1.290: $.store lives at
<CLAUDE_CONFIG_DIR>/plugins/store/, so the store mailbox does not cross
the ltms and work accounts (two inodes, different rows). /fleet-whoami
reached fleetd from both config dirs, so fleetd is the cross-account hop.
The fleetd pull path that finishes the adapter is #788.

claude plugin validate plugin: passed. claude plugin test plugin:
10 pass, 0 fail.
fleetd #788: fleet_inbox, so a pane can collect its own mail
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 58s
CI / build (pull_request) Failing after 2m15s
ac535503ff
A Claude Code session running the fleet mod now pulls its messages from
fleetd and submits them with $.prompt.submit, instead of having them typed
into its terminal by herdr. fleetd names a caller by its pane, so this is
the hop that works between two Claude accounts on one host.

New MCP tool fleet_inbox. It takes no arguments: the pane is the caller's
connection-resolved terminal, so no caller can read another pane's mail.
Authz gets an INBOX action, grouped with REPLY and ASK as only-as-itself.

A pane becomes mod-served by calling fleet_inbox, for a 15s window that
each call renews. The Injector asks that question at the moment it is
about to deliver, and offers the message for collection rather than typing
it. The message stays at the head of the Injector's queue until the pane
takes it, so:

  - delivery is recorded when the pane really has the text, not when it
    was offered, and fleet_poll reports the same states as for a typed
    message;
  - a pane that stops polling leaves the window and its mail is typed
    instead, with nothing stranded and nothing delivered twice. The offer
    is withdrawn under the same monitor that drains it, so an entry is
    removed exactly once;
  - a collected message gets no Enter nudge. Nothing was typed, and an
    Enter in a lead's pane would submit whatever its operator was writing.

cancel, drop and both grace expiries withdraw the offer too, so a message
the caller was told never arrived can never arrive later.

Mod side: a 3s timer collects through the existing fleetTool helper and
submits each message. A fleetd that is down returns from the timer rather
than throwing out of it, which would stop every later poll.

Build: mvn clean install in fleetd/ — Tests run: 2200, Failures: 0,
Errors: 0, Skipped: 0; BUILD SUCCESS. claude plugin validate plugin and
claude plugin test plugin both pass (13 pass, 0 fail).

Two mutations confirm the new tests bind: forcing isModServed false fails
9 of 15, and removing the stop-polling fallback fails exactly
aPaneThatStopsCollectingHasItsMailTypedInstead.
agent added 1 commit 2026-10-06 05:48:40 +02:00
fleetd #788: review fixes — cancel vs a collected offer, one MCP session
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 53s
CI / build (pull_request) Failing after 1m59s
d9fedfbc5a
Four items from the lead review of PR #789.

1. Injector.cancel touched the inbox offer unconditionally. A pane takes an
   offer on the MCP thread, so between its drain and the next poll the head
   entry is taken but still QUEUED. Cancelling it returned CANCELLED for text
   the pane already held, and cancelling a LATER entry nulled t.inboxOffer and
   so lost the only record that the head was taken, which made the next poll
   offer it a second time. cancel now touches the offer only when the entry is
   the offered head, withdraws first, and answers DELIVERED when the pane took
   it.

2. The mod sent initialize on every fleetTool call and never a DELETE, so
   fleetd's transport kept one session per call -- 20 a minute per pane at a
   3s poll. The mod now holds one MCP session and reopens it only on the 404
   "Session not found" fleetd answers for an id it no longer holds (measured
   against the live daemon, not assumed).

3. MessageService.collectInbox had been inserted between reply's javadoc and
   reply, leaving that block attached to nothing. Moved above it.

4. The timer's catch comment said a throw would kill the timer. It does not;
   the catch keeps every tick from writing an error to the debug log.

mvn clean install: Tests run: 2202, Failures: 0, Errors: 0, Skipped: 0,
BUILD SUCCESS. Counted again over 182 target/surefire-reports/TEST-*.xml:
2202/0/0/0. claude plugin validate plugin and claude plugin test plugin both
exit 0, 15 pass 0 fail.

Three mutation checks, each reverted:
- the exact pre-review cancel body: the two new cancel tests fail with
  "expected: <DELIVERED> but was: <CANCELLED>" and "expected: <true> but was:
  <false>", the other six pass;
- initialize on every call: the two session tests fail (1 vs 2 initializes);
- no 404 retry: only the retry test fails (2 vs 1 initializes).
ltms closed this pull request 2026-10-06 05:54:30 +02:00
Some checks are pending
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 53s
CI / build (pull_request) Failing after 1m59s

Pull request closed

Sign in to join this conversation.