Four defects that all reduce to the same thing: the engine trusted a name it had no business trusting. _broadcast is now a reserved namespace, not a repo. coord-send guarded retraction with a sender check, but that guard only covered the door it was nailed to: `coord-done --repo _broadcast <file>` walked in the side entrance and archived a broadcast out of the queue - a full unauthenticated retract of an announcement for every repo that had not read it yet. The rule reserves the whole `_` prefix rather than one literal, so a later `_seen` or `_config` cannot reopen the hole, and it is enforced in every CLI: a rule held in three of four places is not a rule. The pwd fallback is gone. It existed so the CLI would work anywhere, but "anywhere" includes every global surface: a session in ~/repos is not a repo, and basename(pwd) silently handed it the identity "repos". That is not hypothetical - mail was delivered under exactly that name. git toplevel or an explicit --from/--repo are now the only two sources. The write paths refuse and say so; the read path declines silently, because the hook runs it at every session start and must never fail a session. Two checkouts with the same directory name still share one mailbox - re-keying identity would break every existing mailbox and the readable `--to <repo>` addressing. Instead the first git-derived read records the claiming path in <repo>/.origin, and a read from elsewhere is warned about in the injection. A warning, not a refusal: the same repo moved or re-cloned is the ordinary case. The warning goes in the injection because the hook discards stderr, and a warning nobody can see is not a warning. The collision is live in this tree: claude-code-100x is nested inside a repo of the same name. Broadcast delivery is recorded only after the injection is written. Marking inside the read loop left a window where the seen set said "delivered" while the operator saw nothing, and the hook runs under `timeout: 10`, so the window was reachable. A lost broadcast is unrecoverable by design - the seen set is delivery history and retraction deliberately leaves it alone - so the failure mode has to be redelivery, never loss. The hook stops resolving identity altogether. It was the fourth copy of the rule and the only one that runs in production, so passing --repo bypassed the engine's guards exactly where they mattered and suppressed the collision check along with them. It is now the pure wrapper the boundary rule always claimed it was, pinned by two behavioral tests rather than by reading the source. Selftest 93 -> 116; three node tests cover the hook. BREAKING CHANGE: coord-send and coord-done exit 2 outside a git repo instead of naming themselves after the working directory. Pass --from/--repo to choose an identity explicitly. Repo names beginning with _ are refused everywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
48 KiB
| type | created | question | confidence | dimensions | mcp_servers_used | local_agents_used | external_agents_used | gemini_bridge | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| trekresearch-brief | 2026-07-25 | How can a local-only inter-repo message mailbox deliver to repositories that are not currently being worked in, and what is this design missing? | 0.82 | 7 |
|
|
|
skipped (deep-research not requested) |
Delivering to repositories nobody has open
Generated by trekresearch v1.0 on 2026-07-25
Snapshot note. This brief describes the engine as of v0.4.0 and is left as written. Since then v0.5.0 shipped the Rule 7 priority contract, and the selftest moved from 82 to 93 checks — read every "82 checks" below as the count at research time. Recommendation 4 (
reply-expected) is now coupled to that release rather than merely desirable: the obligation shipped on a format that still cannot express "this one needs no answer."
Research Question
Delivery in repo-mailbox is 100% recipient-initiated: a message is only ever
seen when a human starts a Claude Code session in that specific repository. A
repository untouched for weeks never learns it owes a reply, and round-trip
latency is both unbounded and invisible.
What existing solutions address this? Specifically: can a sweeper running outside any repository surface pending mail across all inboxes, and can a stale repository answer autonomously without a human opening it?
Executive Summary
The cross-repo digest is well-founded, cheap, and blocked by one concrete
engine defect nobody had noticed: reading is a mutating operation, so a naive
--all would consume every repository's broadcast backlog on their behalf. The
autonomous reply should not be built as specified — not on probabilistic
grounds but architectural ones: the reply is itself the consequential action, it
is unrecallable by design, and the ingestion path (a hook) sits outside the
only isolation Claude Code's built-in sandbox provides, which is Anthropic's own
documented position. Confidence is high on the mechanism inventory and the
security analysis (vendor docs and CVEs, fetched directly), lower on the
operational-threshold numbers, which are largely absent from the literature; the
key caveat is that four verified defects in the current engine surfaced during
this research and are worth fixing regardless of which direction is chosen.
Dimensions
1. Delivery initiation — what can act outside a repository at all -- Confidence: high
Local findings:
SessionStartin the recipient repository is the sole automatic delivery trigger. VERIFIED:hooks/hooks.json:3-12declares exactly one hook, andhooks/scripts/session-start.mjs:35-39is the only caller of the read path. No daemon, no cron, no other hook, no statusline integration exists.- The plugin's hook has no
matcher(VERIFIEDhooks/hooks.json:4-5), so it is registered for allSessionStartsources. - A statusline already exists as an integration surface at the user level
(
statusLine.commandin user settings). It currently has no mailbox awareness. VERIFIED by settings inspection.
External findings:
-
No hook in Claude Code 2.1.220 fires without a Claude Code process starting in that project. All 30 hook events were enumerated from https://code.claude.com/docs/en/hooks; the earliest (
Setup) is triggered by starting Claude Code. VERIFIED by enumeration; note the doc contains no explicit sentence stating the requirement, so this rests on trigger semantics. -
FileChanged+watchPathslooks like an exception but is not confirmed to be one:watchPathsis an output of theSessionStarthook, and whether the watcher survives outside a running process is not documented — not verified. -
The mechanism inventory splits cleanly on local-vs-cloud:
Mechanism Local? Unattended? Verdict claude -p(headless)Yes Yes, if something invokes it Viable; no documented cron/launchd pattern exists Routines / /scheduleNo — Anthropic cloud Yes Disqualified. "Access to local files: No (fresh clone)"; requires claude.ai login + GitHub; research preview; no permission-mode picker CronCreate//loopYes No — "Tasks only fire while Claude Code is running and idle" Cannot solve a stale repo Desktop scheduled tasks Yes Yes, while the app runs and the machine is awake Viable; per-task permission mode; macOS build is Universal (Intel OK) claude --bg/ agent viewYes Yes — "Background sessions don't need any terminal open" Viable; research preview; conflicts with -pStatusline Yes No — session-only display Surface, not a trigger Notifications Hook+ osascriptlocal; push is cloudNo "Claude Code has no mechanism that notifies you when no session exists anywhere." All rows VERIFIED against https://code.claude.com/docs/en/ pages cited in Sources.
Contradictions: none. Local code and vendor docs agree that nothing in either layer reaches a repository nobody has open. The only local-and-unattended substrates are OS-level (launchd) or Desktop scheduled tasks.
2. The missing engine primitive — and the blocker inside it -- Confidence: high
Local findings:
- There is no cross-repo read.
coord-inbox.shresolves exactly one$REPOand constructs exactly two paths from it:INBOX="$COORD/$REPO/inbox"(:45) andSEEN_FILE="$SEEN_DIR/$REPO"(:72). No loop over$COORD/*exists. VERIFIED. - The blocker: reading consumes.
coord-inbox.sh:87appends each emitted broadcast to the repository's seen file as a side effect of reading:mkdir -p "$SEEN_DIR" && printf '%s\n' "$fname" >> "$SEEN_FILE". A sweeper that calledcoord-inbox.sh --repo Xfor every X merely to count pending mail would mark every repository's broadcast backlog as delivered, and those broadcasts would never be shown to anyone. VERIFIED. This is the single largest structural obstacle to reusing the existing script, and it makes a cross-repo digest a refactor plus a new mode, not a one-line flag. - Sizing: gating
:87behind a delivery flag is ~5-8 changed lines; a separatecoord-status.shenumerating$COORD/*/is ~50-70 new lines; an in-place--allis ~35-50 lines plus the seen-gating refactor. New selftest checks: ~10-14. ASSUMED (estimate), derived from VERIFIED structure. - Enumeration is not trivial: the mailbox root also contains regular files
(
README.md,register.md), and_broadcastmust be excluded explicitly. VERIFIED. - Age is free. The filename prefix is fixed-width UTC
(
coord-send.sh:154-156,%Y%m%dT%H%M%SZ), andLC_ALL=C(coord-inbox.sh:16) makes the glob chronological. Per-repo oldest-pending-age is computable from a directory listing with no file I/O and no parsing. VERIFIED.
External findings:
- Maildir solved the same shape 30 years ago and its answer is directly
transferable: delivered-vs-unseen is encoded by directory (
newvscur), while seen / replied / flagged are filename flags withincur. Critically,R(replied-to) is a distinct flag fromS(seen) — maildir does not collapse "I looked at it" and "I answered it." VERIFIED, https://man.freebsd.org/cgi/man.cgi?query=maildir&sektion=5 (Courier formulation; djb's original at cr.yp.to was unreachable and is not cited). - The prior art says the reply-owed state is a flag on an already-delivered message, not a separate queue and not a property of the mailbox.
Contradictions: none.
3. Notification surfaces -- Confidence: high on mechanics, medium on the choice
Local findings: the existing SessionStart injection is the only surface
wired up. Extending it to carry a cross-repo line is the smallest possible
change and adds no new failure mode. ASSUMED (design inference from §1 VERIFIED
facts).
External findings:
- zsh startup semantics, VERIFIED (
zsh(1), zsh 5.9, read on macOS 26.5.1):~/.zshenvis sourced by every zsh invocation including non-interactive scripts — everyzsh -c, every shebang, every tool that shells out pays the cost.~/.zshrcis interactive-only;~/.zprofile/~/.zloginare login-only. A pending-count line belongs in.zshrcor.zprofile, never.zshenv. The documented idiom for guarding is[[ -o interactive ]]/[[ -o login ]]. osascript -e 'display notification', partially VERIFIED:osascript(1)ships on macOS 26.5.1, butdisplay notificationis documented only in an archived Apple guide (2016-06-13). Attribution goes to the script or to Script Editor — there is no documented parameter to name it otherwise. There is no documented way to attach a click action; the only documented click behavior is "opens the app that displayed the notification." It is silenceable by Focus and by Script Editor's per-app toggle. Whether it works at all from a LaunchAgent context is not verified — no official source addresses it.- Claude Code's own documented macOS path is the
Notificationhook invokingosascript, with a documented gotcha: if Script Editor lacks notification permission "the command fails silently, and macOS won't prompt you to grant it." VERIFIED, https://code.claude.com/docs/en/hooks-guide. - A plugin cannot ship the main statusline. VERIFIED,
https://code.claude.com/docs/en/plugins-reference: a plugin's
settings.jsonsupports "only theagentandsubagentStatusLinekeys." Installing a statusline requires a user-scope settings edit outside the plugin manifest. - Habituation generalizes across surfaces. Habituation to warnings sets in after only 2-3 exposures, measured by fMRI and eye-tracking, and — the finding that matters for a four-surface plan — it carries over to novel stimuli similar in appearance, so frequent non-essential notifications degrade response to important warnings the user has never seen before. VERIFIED, https://www.usenix.org/conference/soups2019/presentation/vance (SOUPS 2019), http://library.usc.edu.ph/ACM/CHI%202017/1proc/p2215.pdf (CHI 2017).
- A count is not actionable. Google SRE: "Every page should be actionable… If a page merely merits a robotic response, it shouldn't be a page." VERIFIED, https://sre.google/sre-book/monitoring-distributed-systems. Applicability caveat stated by the source itself: this is interrupt-driven paging, not async notices; the transferable part is the tests, not the numbers.
- Convergent prior art on ambient surfacing: todo.txt prompt integrations, independently reinvented in 2012 (zsh) and 2022 (fish), both render an integer, never content, and both render nothing at zero. VERIFIED, https://blog.xargs.io/2012/05/27/todo-txt-count-in-rprompt-zsh, https://www.seanh.cc/2022/11/04/todo.txt.
Contradictions: the operator's stated preference is all four surfaces; the evidence says ship one. This is a genuine tension and is resolved in the Recommendation, not here.
4. Autonomous reply — the trust boundary -- Confidence: high
This is the decisive dimension.
Local findings:
- The project's own boundary rule states the mailbox is "transport, not state."
An auto-reply makes the mailbox generate state. VERIFIED,
CLAUDE.md. coord-doneexists so a message can be marked handled without replying — no-reply is a documented terminal state, not a defect. VERIFIED,CLAUDE.md,scripts/coord-done.sh.- Retraction is documented as "un-send and never recall." A delivered reply
cannot be withdrawn from its recipient. VERIFIED,
CLAUDE.md,coord-send.sh:95-96. - The propagation vector needs no exploit. An unattended agent in any
repository is already an authorized writer to every other repository's
mailbox via
coord-send. Spreading requires no race, no symlink, no permission bug — only the intended happy path. A broadcast reaches all repositories in one write. VERIFIED by code structure.
External findings:
- Anthropic's own documentation is unambiguous. All VERIFIED by direct fetch
of https://code.claude.com/docs/en/sandbox-environments and
https://code.claude.com/docs/en/agent-sdk/secure-deployment:
- "The sandboxed Bash tool on its own constrains only Bash, so it is not sufficient for fully unattended runs in either mode."
- "Always run
--dangerously-skip-permissionssessions inside a container, a VM, or the sandbox runtime, so that file tools, MCP servers, and hooks are also inside the boundary." - Under the built-in Bash sandbox, "MCP servers and hooks are separate processes that run unconstrained on the host." The mailbox's ingestion path is a hook — i.e. the untrusted content enters through the one path the built-in sandbox does not cover.
- Auto mode's classifier "is a per-action control, not an isolation boundary"; permission rules are "a permission gate, not a sandbox."
- "
allowed_toolsdoes not constrainbypassPermissions."
- The governing principle from the peer-reviewed literature: "once an LLM agent has ingested untrusted input, it must be constrained so that it is impossible for that input to trigger any consequential actions." VERIFIED, https://arxiv.org/abs/2506.08837 (Beurer-Kellner et al., Design Patterns for Securing LLM Agents against Prompt Injections, 2025). In this design the auto-reply is itself the consequential action — it writes under the operator's identity into another repository's trust boundary.
- OWASP LLM01:2025 Prompt Injection is the #1 entry in the current version; named preventions include privilege control and "human approval for high-risk actions." VERIFIED, https://genai.owasp.org/llmrisk/llm01-prompt-injection/.
- NIST AI 100-2 E2025 takes the same position: assume injection succeeds, constrain by architecture. VERIFIED (metadata directly; https://csrc.nist.gov/pubs/ai/100/2/e2025/final), body passage retrieved via search index and flagged as such by the researching agent.
- The recurring precondition in Anthropic's own advisories is "the ability to
add untrusted content into a Claude Code context window." Eleven Claude Code
advisories were fetched individually from NVD/GHSA; the closest analogues:
- CVE-2026-55607 (High, 7.7) — sandbox escape via git worktree path confusion,
overwriting
~/.zshenv; required the user to run Claude Code against a repo containing prompt-injection content. - CVE-2026-54316 (Moderate, 6.0) — out-of-band exfiltration through a pre-approved domain, no prompt shown.
- CVE-2025-54794 (High, 7.7) — path restriction bypass via prefix matching instead of canonical comparison.
- CVE-2025-55284, CVE-2025-64755, CVE-2025-54795 — all gated on injecting untrusted content into the context window. A mailbox that injects cross-repo content into an agent's context is that precondition, automated.
- CVE-2026-55607 (High, 7.7) — sandbox escape via git worktree path confusion,
overwriting
- The closest published analogue is "Comment and Control" (Apr 2026): PR titles and issue bodies hijacking agents in GitHub Actions, confirmed against Claude Code Security Review, Gemini CLI Action and GitHub Copilot Agent, auto-triggered by workflow events with no victim action. The researcher's own generalization: "The pattern likely applies to any AI agent that ingests untrusted data and has access to execution tools in the same runtime as production secrets." VERIFIED.
- The lethal trifecta is fully closed here: private data (repo contents,
~/.claude, credentials) + untrusted content (the message, which this project's own docs label untrusted) + egress (git push, network commands, andcoord-senditself). VERIFIED framing, https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/. - Published mitigation patterns that actually apply (arXiv:2506.08837; CaMeL https://arxiv.org/abs/2503.18813; dual-LLM https://simonwillison.net/2023/Apr/25/dual-llm-pattern/): Action-Selector (untrusted content selects among N fixed non-consequential actions and cannot author output), Plan-Then-Execute (the reply's shape is fixed before ingestion), Dual LLM (a tool-less quarantined component processes the untrusted text and produces a draft). CaMeL quantifies the cost: 77% of AgentDojo tasks solved with guarantees vs 84% undefended — security costs utility, measurably.
- The one gate that always runs: hooks precede every other permission step,
and "a hook deny applies even in
bypassPermissionsmode." VERIFIED, https://code.claude.com/docs/en/agent-sdk/permissions. APreToolUsehook is therefore the only reliable choke point and the natural place to emit an audit record.
Contradictions: none between sources. The contradiction is between the evidence and the stated goal, and it is stated plainly in the Recommendation.
5. Loop and fan-out control -- Confidence: high
Email learned this the hard way, and the countermeasures are specified, not folklore. This is the section that makes a safe auto-acknowledgement possible at all.
External findings — all VERIFIED:
-
RFC 3834 (Standards Track, 2004, https://www.rfc-editor.org/rfc/rfc3834.txt) exists precisely because of "mail loops or 'sorcerer's apprentice mode'." It permits automatic responders subject to conditions:
# Requirement Strength 1 Not the same response to the same sender more than once in several days; 7 days RECOMMENDED SHOULD 2 Do not respond to any message carrying Auto-Submittedwith any value other thannoSHOULD 3 Include Auto-Submitted: auto-repliedon your own responsesSHOULD 4 MUST NOT respond where the destination would be a null address MUST NOT Requirements 2 and 3 are the pairing that makes the scheme terminate: mark your output as machine-originated, and refuse to reply to anything so marked.
-
vacation(1)'s actual mechanism is unglamorous: a keyed database of senders already responded to, default interval one week;-r 0means at most one reply ever. VERIFIED, https://man.freebsd.org/cgi/man.cgi?query=vacation&sektion=1. -
RFC 5230 formalizes it and adds the sleeper detail: the dedupe key is (sender, response-identity), not sender alone, and the minimum suppression window is clamped up, not bypassable — the spec anticipated that implementors would set it too low. VERIFIED, https://www.rfc-editor.org/rfc/rfc5230.txt.
-
The counting valve — four independent systems, 1982→2020, all with two layers: header-based suppression plus an unconditional counter. RFC 5321 §6.3 ("servers MUST contain provisions for detecting and stopping trivial loops"), Mailman
max_autoresponses_per_daydefault 10, Zendesk 20/hr then reject at 40, Exchange Online reply-all storm protection. Mailman's docs state the rationale outright: "Mailman already inhibits automatic replies to any message labeled… This is a fallback safety valve." That is a maintainer saying header suppression is not sufficient. -
A counter is only as strong as the identity it keys on. Zendesk documents its own failure mode: "This limitation won't work if the other system doesn't use the same email address every time." This lands directly on this project:
--fromredefines identity here, andCLAUDE.mdalready records the retract sender check as "an accident guard, not a security boundary." A per-sender counter under a caller-declarable identity is an accident guard too. The mechanism that does not depend on peer identity is a hop/generation counter carried in the message — which is what SMTP chose. -
Broadcast is fan-out, not recursion — multiplication, which a per-pair dedupe window does not bound. Documented storms: Reuters 2015 (~23M messages in 7 hours), Atos 2015 (379 messages → >34.5M), NHS England 2016 (~186M). VERIFIED via https://en.wikipedia.org/wiki/Email_storm with contemporaneous press citations. A named autoresponder-initiated postmortem is NOT VERIFIED — vendors document the defenses, not the incidents.
6. Operational durability of a background job -- Confidence: high
External findings:
StartIntervalandStartCalendarIntervalbehave oppositely on a laptop that sleeps, documented only in the man page. VERIFIED,launchd.plist(5)read on macOS 26.5.1:StartInterval— "If the system is asleep during the time of the next scheduled interval firing, that interval will be missed";StartCalendarInterval— "launchd will start the job the next time the computer wakes up. If multiple intervals transpire… those events will be coalesced into one event." Consequence: the sweeper cannot infer elapsed time from the fact that it ran.WatchPathsis discouraged by Apple, in Apple's own words. VERIFIED, verbatim: "Use of this key is highly discouraged, as filesystem event monitoring is highly race-prone, and it is entirely possible for modifications to be missed." Whether it recurses into subdirectories is not documented in either official source — reported as undocumented, not as non-recursive.QueueDirectoriesis the wrong primitive for a retained mailbox. VERIFIED: it "keeps the job alive as long as the directory… [is] not empty." It models a drain queue; a mailbox that deliberately retains messages until they are marked handled satisfies the keep-alive condition permanently.- A launchd job's PATH is not the login-shell PATH. VERIFIED by composition:
/etc/zprofilerunspath_helper, andzsh(1)states/etc/zprofileis read only by login shells. Note the widely-repeated claim "launchd jobs do not inherit the interactive shell environment" is NOT VERIFIED in any Apple source. Practical consequence either way: absolute paths for every binary. Also VERIFIED: launchd redirects stdio to/dev/nullunlessStandardOutPath/StandardErrorPathare set. - TCC: not resolved.
~/.claude/is not among Apple's enumerated protected locations (Documents, Downloads, Desktop, iCloud Drive, network volumes) — VERIFIED as an absence. But no official source states that a home path outside those locations needs no approval, andlaunchd.plist(5)CAVEATS warns that "privacy sensitive files and folders in a launchd plist may not have the desired effect, and may prevent the job from running." Reported as an open gap, not filled. - Jobs stop firing after OS upgrades — five independent reports across four macOS releases (Catalina, Monterey, Ventura, plus a 2025 comment). VERIFIED as a pattern: "After upgrading, that LaunchDaemon started getting 'Operation not permitted' errors… Full Disk Access for the daemon got disabled by the upgrade." The common shape is that the job does not report that it stopped.
- The canonical way a background job's failure becomes invisible is that cron mails its output to a local mailbox nobody opens — structurally the identical failure to "a message delivered to a repo nobody opens." A sweeper whose errors go to an unread channel reproduces the very bug it was built to fix, one level up. VERIFIED as a consistent claim across four independent sources (individually low-authority monitoring blogs; the convergence is the evidence).
- The established countermeasure is inverted alerting (dead man's switch): alert on the absence of a success signal. "It keeps silent as long as pings arrive on time." VERIFIED, https://healthchecks.io/docs/. Note the tension: the switch's own alert must land somewhere structurally different from where the job's ordinary output lands, or it returns to the unread-channel problem.
Local findings:
- The project's only correctness evidence is 82 synchronous selftest checks
against a throwaway mailbox via
CLAUDE_COORD_DIR(VERIFIED,coord-selftest.sh:13-16). That harness can pin filename grammar, frontmatter, delivery and archiving because they are pure and file-local. It cannot pin whether launchd fired, whether TCC granted access this boot, or whether two runs overlapped. A dead sweeper leaves all 82 checks green. ARGUMENT, derived from VERIFIED structure.
Contradictions: none.
7. Message model gaps -- Confidence: high
Local findings — all VERIFIED:
- The frontmatter schema is exactly four fields, written at
coord-send.sh:165-173:from,to,subject,date. The read side parses onlyfrom:andsubject:(coord-inbox.sh:54-55).date:andto:are never read by anything. - No priority field, no deadline, no reply-expected field, no age or expiry
handling anywhere. The
-> reply:hint atcoord-inbox.sh:60is emitted unconditionally for every directed message, so it carries zero signal about whether a reply is actually wanted. - No reply linkage. There is no
in-reply-to, no thread id, no message id beyond the filename.--reply-toarchives the original but writes nothing into the reply that references it; the only trace of a thread is theRe:subject convention, which is a heuristic and is overridable. Consequence: given an archived message you cannot mechanically determine whether it was replied to. - The only signal that a message is still owed something is that it sits in
inbox/rather thanarchive/— and that cannot distinguish "expects a reply and hasn't got one" from "purely informational and nobody rancoord-done". This is precisely the field a pending-reply digest most needs and does not have. - Repo identity is
basenameof the git root, derived independently in four places (coord-inbox.sh:34-37,coord-send.sh:48-51,coord-done.sh:31-34, and again in JS atsession-start.mjs:28-36) with no shared helper. Override is per-invocation only (--repo/--from); there is no identity env var.
Local Context
Four defects found in the current engine
These surfaced during architecture analysis and are independent of the sweeper
decision. Defect 1 is VERIFIED BY EXECUTION in a throwaway mailbox
(CLAUDE_COORD_DIR); defects 2-4 are VERIFIED by code path.
-
_broadcastis reserved only on the send side — reproduced.coord-send.sh:130rejects--to _broadcast, butcoord-inbox.shhas no such guard. Reproduced end to end against an isolated mailbox:coord-inbox.sh --repo _broadcastreads the broadcast queue as a directed inbox. The same message is rendered twice in one output — once under--- message: … ---with a-> reply:/coord-doneaffordance, and again under--- broadcast: … ---— and the header reports "2 unread/unhandled" for what is one message. Exit 0.coord-done.sh --repo _broadcast <file>reports "1 message(s) archived", exits 0, and the file is gone from_broadcast/inbox/and present in_broadcast/archive/. A subsequent read from a fresh repository identity no longer receives it.
That is a complete, unauthenticated retract — the same end state
coord-send --retractproduces, reached without the sender check atcoord-send.sh:85-88. Any enumerating sweeper must exclude_broadcast, and the engine should reject it on the read side regardless. Note this is an accident surface of the same class the project already documents for--from, not a privilege boundary being broken — but unlike--fromit is undocumented and reachable by a plausible typo. -
The pwd-fallback silently captures broadcasts under a bogus identity. With no git root, identity falls back to
basename(pwd)(coord-inbox.sh:36). The live mailbox contains a seen-file for a non-repository parent directory — a session started there consumed broadcasts under a name no repository will ever use again. Those broadcasts are burned. A sweeper launched from$HOMEwould do this systematically. -
Basename collisions share one mailbox. Two repositories at different absolute paths with the same basename share
$COORD/<basename>/inboxand one seen file. A directed message goes to whichever starts a session first; a broadcast consumed by one is marked seen for the other. Nothing detects it — there is no registration and no path recorded anywhere. At least one such collision exists in the current working set. -
Broadcasts are marked seen before output is printed.
coord-inbox.sh:87marks inside the emit loop;:93prints only at the end. The hook has a hard"timeout": 10(hooks/hooks.json:9). If the process is killed mid-loop, broadcasts already iterated are permanently marked seen while nothing was ever emitted. (That Claude Code kills the hook process at timeout is ASSUMED.)
Concurrency
Delivery is atomic against crash — mktemp inside the destination directory,
dot-prefixed to stay out of the reader's *.md glob, then a same-filesystem
mv (coord-send.sh:160-176, pinned by selftest §14). VERIFIED, and rename(2)
on macOS guarantees "an instance of new will always exist, even if the system
should crash in the middle of the operation."
But there is no concurrency control at all: no flock, no lockfile, no
noclobber, no mkdir-as-mutex. VERIFIED by grep. Every existing guarantee
assumes one reader per identity at a time — true when the only reader was a
SessionStart hook, and violated by a sweeper by construction. The concrete
race is a TOCTOU on the seen set (coord-inbox.sh:77 tests, :87 appends, with
message formatting in between), which for a sweeper is the common case rather
than an edge case. Note also that mkdir-as-mutex is NOT VERIFIED as atomic
in either POSIX or macOS mkdir(2) — both document only EEXIST.
External Knowledge
Best practice
Assume injection succeeds and constrain by architecture (NIST, OWASP, arXiv:2506.08837). For automatic responders specifically, the standards are explicit and old: mark your own output as machine-generated, refuse to respond to anything so marked, keep a per-(sender, response-identity) suppression window with a non-bypassable minimum, and add an unconditional counter as a fallback because header suppression is known to be insufficient.
Alternatives
The genuinely cheap alternatives, none of which requires a scheduler:
- Sender-side responsibility. The operator's continuity convention already has a mechanism whose entire job is to re-raise what must not be forgotten: the STATE.md next-step block, injected at every session start. If a sending repository needs a reply, the sender records that dependency in its own STATE.md. This targets the active repository the operator is actually visiting, rather than chasing the stale one they are not. ARGUMENT.
- A manually-invoked read-only digest. One command, synchronous, in front of the operator, inside the existing bash boundary, no scheduler, no autonomy, no silent-death mode.
- Draft-not-send. An unattended component prepares a reply but does not deliver it; the operator releases it. This removes the egress leg and breaks the trifecta while keeping most of the latency benefit.
Security
Covered in Dimension 4. The compressed version: an allowlist alone is
insufficient by Anthropic's own documentation; the ingestion path is a hook and
hooks run outside the built-in Bash sandbox; the propagation vector is intended
functionality rather than an exploit; and --from means any per-sender rate
limit is an accident guard, not a boundary.
Known issues
- Automation bias: participants followed wrong automated recommendations in ~65% of cases, with a 41% omission rate versus 3% unaided; training reduced commission errors but not omission errors, and two-person crews were no better than individuals. VERIFIED for the mechanism; the percentages come from flight-simulation tasks and must not be transplanted as a rate for this setting. https://d-nb.info/1223023044/34, https://pubmed.ncbi.nlm.nih.gov/11543300/. The relevance: an auto-reply does not land in a vacuum — it is injected into the receiving repository's next session as context, pre-formatted and carrying machine authority.
- Self-assessment of agent-assisted productivity is unreliable in this exact population: METR's RCT with 16 experienced developers on their own repositories found them 19% slower while believing they were 20% faster. VERIFIED, https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/. N=16, early-2025 tooling, wide CI — cited for the perception gap, not as proof that agents are net-negative. Consequence: "coordination feels better now" cannot be the success criterion.
- Both git-native options fail as silent non-delivery:
git notesare "not fetched or pushed by default" and require an explicit refspec; githooks are not transferred by clone and "hooks that don't have the executable bit set are ignored." The receiver observes an empty state indistinguishable from "nothing was sent" — the transport reproduces the very problem. VERIFIED, https://git-scm.com/docs/git-notes, https://git-scm.com/docs/githooks. - Taskwarrior's maintainers name exactly these risks — "race conditions, infinite loops and runaway cascading effects" — and then ship only JSON validation. VERIFIED, https://taskwarrior.org/docs/hooks.
Gemini Second Opinion
Not used. The gemini-bridge agent was deliberately skipped: the session's
harness policy is that deep-research tooling is used only on explicit request,
and the operator asked for research generally, not for Gemini specifically. This
removes one independent triangulation path; the four external agents and the
directly-fetched vendor documentation are the substitute. Noted as a limitation
rather than silently omitted.
Synthesis
The problem splits at the word "reply," and the two halves have opposite verdicts. The digest reduces ignorance — the operator does not know a message is waiting. The autonomous reply reduces latency — the operator knows but has not answered. Only the first is the stated complaint. That distinction is what makes the recommendation asymmetric rather than a compromise.
The most useful new fact is local, not external. Every plausible sweeper
design assumed coord-inbox.sh could be called per-repo to count pending mail.
It cannot: reading consumes broadcasts (:87). This was invisible from the
outside and would have been discovered after the first sweep silently ate every
repository's backlog. The seen-gating refactor is a precondition for anything in
family (c), digest or agent.
The security objection is architectural, not probabilistic, and it composes badly with this project's own design choices. Three of them:
- The mailbox's ingestion path is a hook, and Anthropic documents hooks as running outside the built-in Bash sandbox. The one thing the sandbox does not cover is exactly the thing this design uses to ingest untrusted content.
coord-sendis itself an egress channel, so the trifecta closes without the agent ever touching the network. Removing network access does not help.- Propagation requires no exploit. An injected agent is an authorized writer to every mailbox, and one broadcast reaches all of them. The worm shape is the happy path.
The 20-year-old email standards are the missing engineering, and they are
missing on the read side too. RFC 3834's terminating pair — mark your output
Auto-Submitted: auto-replied, refuse to reply to anything marked — is exactly
what this message format cannot express, because the format has four fields and
none of them says "machine-generated." Note the sharper point: the same absent
field, a reply-expected marker, is what the digest needs to count pending debt.
One schema addition unlocks both halves, and it is additive and backward
compatible because unknown frontmatter keys are simply not parsed today.
But the counting valve must not key on from. Zendesk documents the failure
("won't work if the other system doesn't use the same email address every time"),
and this project's own CLAUDE.md already concedes that --from redefines
identity and that the sender check is "an accident guard, not a security
boundary." A per-sender limit inherits that weakness exactly. SMTP's choice — a
hop counter carried in the message, independent of peer identity — is the one
that survives here.
Finally, the failure mode of the fix is the failure mode being fixed. A dead sweeper and a quiet mailbox produce byte-identical output: nothing. The 82 selftest checks stay green. cron's canonical silent-death mode is that its output goes to a mailbox nobody reads — structurally identical to a message delivered to a repository nobody opens. Any background component must therefore carry a dead man's switch whose alarm lands on a different surface from its ordinary output, or it will reproduce the original bug one level up while the operator believes it is solved.
Open Questions
- Does the operator actually have unanswered messages that mattered? No measurement exists. The discriminating counts are cheap and read-only: messages sent per week; how many genuinely required a reply; how many never got one; and how many caused a downstream consequence. If the last number is zero, the premise itself is unsupported and the correct action is to build nothing. This is the single highest-value thing to establish before writing code.
- Would a stale repository even have enough context to answer correctly? The agent would compose from that repository's STATE.md and git HEAD, which cannot know about decisions the operator has since made elsewhere. This is not fixable by running the sweeper more often. Cheap test: take the last few messages that needed replies and check whether the receiving repository's state at that moment contained enough to answer. If it did not, the autonomous half is unbuildable as specified regardless of every other consideration.
- Does
osascript display notificationwork from a LaunchAgent context? Undocumented; not tested. Determines whether the macOS surface is available at all without a third-party dependency. - Does a LaunchAgent reading
~/.claude/need a TCC grant? Apple's enumeration does not include it, but absence from an enumeration is not a grant, and the man page warns privacy protections "may prevent the job from running." - Does
FileChanged/watchPathssurvive outside a running Claude Code process? Not documented. If it does, it changes the answer in Dimension 1. - Whether Desktop scheduled tasks work with no account/network — not documented, and it decides whether the one first-party local-and-unattended scheduler is usable under the privacy constraint.
Recommendation
RECOMMEND
-
Fix the four engine defects (Local Context). Independent of everything else.
_broadcastrejected on the read side is a correctness and safety fix; the pwd-fallback should refuse rather than invent an identity; basename collisions should at minimum warn; and the seen-mark should move after emit or the timeout window should be documented. Cost: small, TDD-shaped, entirely inside the existing boundary. This is the part with no downside. -
Split reading from consuming (
coord-inbox.sh:87behind a delivery flag). Precondition for everything else, ~5-8 lines plus checks. -
Add a read-only cross-repo digest, manually invoked. It is synchronous, inspectable, testable by the existing harness, has no scheduler and no autonomy, and it addresses the actual complaint — not knowing something is waiting. Age is already free from the filename prefix. Prefer a separate script so the injection path the 82 checks pin hardest stays untouched.
-
Add a reply-expected marker and an
auto-submitted-equivalent field to the frontmatter. Additive, backward compatible, and it unlocks both halves: the digest can finally count debt rather than unarchived messages, and any future automation has the terminating pair RFC 3834 specifies. Follow maildir's lesson and keep replied-to distinct from seen. -
Ship exactly one notification surface first: extend the existing
SessionStartinjection with a cross-repo line. Zero new code paths, zero new failure modes, and it fires when the operator is in a position to act. The habituation evidence says additional surfaces degrade the one that works, so add a second only after naming a specific occasion when the first was insufficient. If a second is added,~/.zshrc(an integer, nothing at zero, per the todo.txt convention) is the best-evidenced candidate — never~/.zshenv.Do not implement this one standalone. It makes the hook read N inboxes under the hard
"timeout": 10, which multiplies the exposure window of defect 4 (broadcasts marked seen before output is printed) by the number of repositories. It is gated behind item 2 for that reason.
RECOMMEND AGAINST, as specified
- Do not build unattended autonomous sending. Three independent lines converge, and two of them come from this project's own documentation rather than outside opinion: it makes the mailbox generate state, violating the stated transport boundary; its errors are unrecallable by design while its only benefit is lower latency (wrong is permanent, slow is temporary); and it closes the lethal trifecta on the host in the configuration Anthropic documents as container-only, through a hook — the one ingestion path the built-in sandbox does not cover.
- Do not add a background scheduler yet, independently of the agent. It converts an inspectable synchronous tool into a distributed system whose failure is byte-identical to success, on a platform with five documented cases of jobs silently losing permission across OS upgrades, and it moves the interesting behavior out from under the only correctness evidence the project has. If one is added later, it needs a dead man's switch reporting on a different surface from its ordinary output.
The viable middle, if latency really is the problem
- Draft, do not send. An unattended component may prepare a reply into a
staging area that the operator releases. This removes the egress leg — the
trifecta does not close — and keeps most of the latency benefit. Combined
with:
- Action-Selector for anything that does send: a fixed vocabulary of pre-written, non-semantic acknowledgements ("received, queued, a human will answer"), selected by classification but never authored from message content. Untrusted content then cannot compose output.
- The RFC 3834 terminating pair: mark machine-generated output; never auto-respond to anything so marked.
- A hop counter carried in the message, not a per-sender counter — because
--fromredefines identity and per-sender counting degrades to no counting. - A hard propagation cap: a message that arrived from another repository must never cause a send to a third, plus a per-sweep budget. This is the anti-worm rule, and it is the one that matters most.
- A
PreToolUsehook as the single always-running gate (it denies even underbypassPermissions) and the natural place to write the audit record. - Real isolation if it ever sends unattended: container/VM or the sandbox runtime, because hooks and MCP servers run unconstrained under the built-in Bash sandbox.
Sequencing: items 1-2 are unconditionally worth doing. Item 3 is the 90% solution. Items 4-5 are cheap and make everything after them possible. Item 8 is a design to hold in reserve, and it should not start until the measurement in Open Questions shows a non-zero downstream cost from missed replies.
Sources
Explicitly not verified
Carried forward rather than filled in: WatchPaths recursion; launchd runtime
PATH (the widely-repeated claim is unsourced; the narrower composed claim is
verified); TCC requirements for ~/.claude/; whether display notification
works from a LaunchAgent; whether FileChanged/watchPaths survives outside a
session; whether Desktop scheduled tasks work without an account; a named
autoresponder-initiated storm postmortem; a developer-tooling notification
volume threshold; mkdir-as-mutex atomicity; APFS-specific rename guarantees
beyond rename(2)'s own contract; djb's original maildir link() formulation.