npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

claude-broker

v0.1.39

Published

Encrypted git hub-and-spoke broker for cross-machine Claude Code messaging.

Readme

claude-broker

Encrypted git hub-and-spoke broker that lets Claude Code instances on different machines hold two-way, multi-turn conversations, with strict per-machine isolation, no inbound network reachability, and confidentiality from the transport host (GitHub).

The personal Mac is the hub; each job laptop is a spoke. The hub can dispatch work to a specific laptop's Claude and read the reply; a laptop's Claude can initiate a thread back to the hub; either side can ask a question mid-work and have the other side answer, resuming the paused session. No laptop can reach another laptop's traffic.

Why

Corporate laptops sit behind NAT with no inbound reachability, and a shared Telegram account risks a ban. The broker needs a transport that works over outbound HTTPS only and keeps engagement content unreadable to whoever hosts it. A private git repo per laptop, holding only age ciphertext, satisfies all three: reachable from anywhere, no inbound ports, and confidential end to end.

Architecture

Hub-and-spoke, one private GitHub repo per laptop used as an encrypted two-way message bus.

   Personal Mac (hub)
        |  broker-runner x3 (one per repo)   +   broker CLI
        |
        |== repo: broker-work-a  ==  work laptop A  (runner, key: work-a only)
        |== repo: broker-work-b  ==  work laptop B  (runner, key: work-b only)
        |== repo: broker-work-c  ==  work laptop C  (runner, key: work-c only)

Isolation is enforced at three independent layers, so "laptops cannot talk to each other" is a property of access control, not convention:

  1. Separate repos. No shared repo, branch, or folder spans two laptops.
  2. Per-laptop deploy keys. A laptop's git credential reaches only its repo.
  3. Per-runner config. A laptop's runner knows only its own repo URL.

The same broker-runner process runs everywhere. The hub runs one instance per repo; each laptop runs exactly one. Role differences (autonomous-with-escalation on the hub, direct execution on a laptop) are configuration, not separate code.

Encryption

age, point-to-point, one keypair per machine. Private keys never leave the machine and never enter a repo. The hub holds every laptop's public key; each laptop holds only the hub's. GitHub stores only ciphertext under opaque message ids, so content, prompts, and results are unreadable to the transport host.

Message protocol

Each repo has two directional inboxes: to-hub/<id>.age and to-spoke/<id>.age. A decrypted envelope carries a thread_id, an optional in_reply_to, and a kind:

  • task: do this work (opens or continues a thread expecting a result)
  • question: cannot proceed without an answer (the sender's session parks)
  • answer: reply to a question. Resumes the parked session, or reconstitutes a fresh one from the question and the answer when that session is gone
  • notice: one-way information, no blocking reply expected
  • result: terminal message for a thread

Multi-turn works by mapping each thread_id to a Claude Code session id: a new thread pre-mints a --session-id; a continuation resumes with --resume. The processed marker is written last, after the reply is pushed, so re-processing a completed thread is a no-op (crash-safe, idempotent).

Hub autonomy and escalation

When a laptop writes to the hub, the hub's runner handles it autonomously under a constrained permission mode. It escalates to Marc rather than guessing when it hits ambiguity it cannot resolve, or when an action is flagged sensitive (client push/merge, deletion, outbound communication, credentials). An escalation queues locally and notifies Marc; the laptop thread waits. Marc resolves it from any Mac session with broker answer <thread_id> "<decision>".

Telegram visibility

Two optional lanes, hub-only, that reach Marc through the existing personal Telegram gateway bot (reusing its token and chat id; no new bot, no Telegram user account):

  • Escalations ping the DM the moment they queue.
  • Visibility records broker events to a local activity log and, on a throttled cadence, hands the recent slice to a cheap claude -p turn that decides whether anything is worth surfacing and returns either a short note or stays silent. broker digest forces one on demand, and clears the events it summarised; broker digest --preview runs the same curation but sends nothing and clears nothing, which is the safe way to look at what the broker has been doing. The slice handed to the curator is capped, and anything past the cap is dropped oldest-first, so a curator that stays broken cannot grow the log until the prompt itself becomes the reason it keeps failing.

Delivery is best-effort over built-in HTTPS: a missing gateway config or a failed send is logged and never blocks message processing. The activity log holds only short body slices in local plaintext and is never pushed to any repo.

Kanban tracking

Optional, hub-only. When a kanban config block maps targets to board files, the broker upserts one card per thread onto the target machine's own board (an Obsidian Kanban markdown file), so every hub-and-spoke conversation is visible and tracked where Marc already tracks machine dispatches.

One card per thread, keyed by thread_id and updated in place as the thread advances: open, then waiting when it escalates, then flipped to a checked done card when a result arrives. Cards live in a tool-owned ## Broker column so they never collide with human-managed columns. A card for a target only ever writes to that target's board, preserving the same per-target isolation as the message bus.

Writes are local, atomic (temp file plus rename), and best-effort: a missing board or a failed write is swallowed and never blocks message processing. The feature is off unless kanban.boards is configured.

Observability

Optional, per machine. When an otlp config block is present and enabled, the runner exports metrics and traces to Grafana Cloud over OTLP/HTTP-JSON. There is no SDK: lib/telemetry.js speaks the wire protocol with the built-in https, preserving the zero-dependency rule. The OpenTelemetry auto-instrumentations would not help here anyway, since the broker's work is git, age and claude -p subprocesses that they cannot see.

Metrics (counters cumulative for the life of the runner):

| Metric | Type | Labels | | --- | --- | --- | | broker.messages | counter | target, kind, direction | | broker.escalations | counter | target, reason | | broker.poll_errors | counter | target | | broker.claude_turn_ms | histogram | target, outcome | | broker.tokens | counter | target, type, model | | broker.cost_usd | counter | target, model | | broker.up / broker.uptime_ms | gauge | none |

Only bounded-cardinality fields become labels. Message bodies are never exported: the closest thing is the escalation reason, which is a fixed code such as sensitive:deploy. broker.up is a per-flush heartbeat, so a stale series is the signal that the runner died, which no event-driven counter can express.

broker.tokens and broker.cost_usd come from the usage and total_cost_usd fields that claude -p --output-format json already returns on every turn. type is input, output, cache_creation, or cache_read, so cache-creation cost (usually the dominant driver) is visible on its own. model collapses the turn's modelUsage to one label: the single model id, multi, or unknown.

Traces are one broker.poll span per sweep per target, with a broker.claude_turn child span per handled message carrying the turn outcome. A failing sweep marks its span as an error before rethrowing. The span buffer is capped so an export outage drops the oldest spans instead of growing the heap, and a failed export is retried on the next flush rather than dropped.

Export is best-effort and never throws into message processing. Flush happens once per sweep, before the restart.flag check, so the sweep the runner exits on still ships. service.instance.id (from otlp.instance) becomes the Prometheus instance label, keeping hub and spokes separate in one stack.

Host metrics and log shipping are a separate concern handled by one Grafana Alloy agent per machine, not by this repo. See the runbook.

Import grafana/broker-dashboard.json into your Grafana Cloud instance for the dashboard: runner liveness (broker_up), message flow by direction/kind/target, mean claude -p turn latency, poll errors, escalations, and the runner logs panel from Loki. On import, bind the Metrics source variable to your Prometheus datasource and Logs source to Loki; the Instance and Target selectors populate from the live label values. The logs panel reads the {job="claude-broker"} stream that Grafana Alloy ships from the runner's broker.log. Metric names are OTel-normalized by Grafana's OTLP gateway (dots become underscores, counters gain _total), so the panels query broker_messages_total, broker_claude_turn_ms_*, and friends.

The runner log

launchd redirects both the runner's streams into broker.log and adds nothing of its own, so the runner stamps every line with an ISO-8601 UTC time as it writes. Alloy forwards those lines to Loki with no parsing stage, which means Loki's own timestamp is when the tail caught up, not when the line was written; after an outage the two are hours apart, and the in-line stamp is the one that is true.

Nothing else prunes that file. When the runner exits for a restart it moves the log aside to broker.log.1 if it has passed log_max_bytes (default 5 MB, 0 to disable), keeping the pair bounded at twice the cap. Rotation happens at exit rather than at startup because launchd binds the log to the process at spawn: a rename while the runner is live leaves it writing into the archive.

Fleet staleness

Exporting telemetry proves nothing about whether it arrives. A machine can stop shipping for nine hours with every dashboard still rendering, because the panels plot what exists and a dark machine simply has no series to plot. broker fleet asks the inverse question: for each machine that is expected to report, how long has it been since its last sample.

This is hub-only by construction. A spoke's Grafana credential is write-only, so a machine cannot read back whether its own series are still arriving. It now says in its own log when an export fails, but that only covers a post it attempted: a runner that has stopped, crashed or lost its config posts nothing and therefore reports nothing, which is the state this check exists to catch. The hub holds the read token, so the hub is the only place the question has an answer.

Two pipelines are checked independently, because one shipping fresh says nothing about the other: node is Alloy's host metrics (up{job="integrations/macos-node"}, keyed on instance) and broker is this repo's OTLP export (broker_up, keyed on service_instance_id). The expectation list is declared rather than derived: a machine dark past the lookback window and a machine that was never configured both produce no series, so absence is only meaningful against a list of what should be there. A reporting machine missing from that list comes back as unexpected, which is the inverse failure nobody goes looking for.

"fleet": {
  "enabled": true,
  "credentials_file": "~/.claude-broker/observability-read.env",
  "stale_after_s": 900,
  "expect": [
    { "instance": "personal-mac",         "paths": ["node", "broker"] },
    { "instance": "work-a-laptop",        "paths": ["node", "broker"] },
    { "instance": "work-b-laptop",        "paths": ["node", "broker"], "stale_after_s": 43200 }
  ]
}

credentials_file is an env-style file holding GRAFANA_READ_TOKEN, PROMETHEUS_READ_USER and PROMETHEUS_READ_URL (the URL already ends in the API version segment). A missing variable names itself rather than sending an empty auth header, and every other silent-success path throws too: a non-success query body, a non-200 response, and an enabled block with no expectations. broker fleet exits 1 when anything is stale, absent, unexpected, or unconfigured, so a LaunchAgent can key on the code instead of parsing the report. --notify sends that report over the Telegram lane, and only when the failing set has changed since the last --notify run: a machine dark for two days pages once rather than once per scheduled check, deepening from stale to absent pages again because it is a different fault, and recovery sends an all-clear naming what cleared. The last-seen set is kept in <state_dir>/fleet-state.json; a bare broker fleet neither reads nor writes it, so a human running the check by hand cannot consume a transition the scheduled run has not reported yet. An unwritable state file degrades to paging every run rather than to silence.

Install

Per machine, deployed as a launchd LaunchAgent. Full runbook, including key generation and distribution and the config.json for each role, is in deploy/KEY-DISTRIBUTION.md.

BROKER_DIR=<repo root> ./deploy/install-runner.sh

Graceful restart after a config or code change: touch ~/.claude-broker/restart.flag (never launchctl kickstart, which SIGKILLs the runner's own process group).

CLI

broker send <target> <kind> "<body>" [--cwd DIR] [--thread ID]   # dispatch a message
broker answer <thread_id> "<decision>"                            # answer a question / resolve an escalation
broker read [<target>] [--thread ID]                              # print decrypted messages / a thread
broker threads                                                    # list threads and their sessions
broker digest                                                     # force a visibility note now, then clear those events
broker digest --preview                                           # same curation, sends nothing, clears nothing
broker fleet                                                      # age of each expected machine's last sample; exit 1 if any is failing
broker fleet --notify                                             # same check, and Telegram the report when the failing set changes

<target> is a short name (work-a, work-b, ...) resolved through the hub's config. The names are yours; the hub's repos map defines them.

Configuration

Per-machine ~/.claude-broker/config.json sets role (hub|spoke), the repo map, the age identity and recipient public keys, claude_path, a cwd_allowlist, the escalation rule, the hub-only notify block for Telegram, and the hub-only kanban block for board tracking, plus the optional permission_prompt allow-list, the optional otlp block for Grafana Cloud export, and the hub-only fleet block for the staleness check. See the runbook for annotated examples of both roles.

Pinning the child MCP surface

child_mcp_servers bounds the MCP servers a spawned claude -p turn loads. The tools array leads the cached prompt prefix, so a child that lets its servers connect asynchronously grows that array mid-turn and invalidates the prefix; the conversation is then rewritten to cache at the 1h TTL, which bills at twice the base input rate.

absent        inherit the machine's full MCP surface (the behavior before this key existed)
[]            no MCP servers at all
["a", "b"]    only these, resolved by name from the pool

The pool is the top-level mcpServers object of ~/.claude.json, overridable with mcp_pool_file. A name absent from the pool is reported once at startup and skipped, never silently dropped. Note the pool bounds only what can be named: with a pinned array, --strict-mcp-config also excludes project-scoped and plugin-provided servers, which the pool never lists. This mirrors the telegram gateway's CHILD_MCP_SERVERS contract, so one mental model covers both spawn paths on a machine running both.

Bounding a thread's context

A resumed thread re-reads its whole accumulated history every turn, and that re-read is the dominant cost: cache read measured 46% of spend on the machine this was built for. max_thread_turns sets a ceiling. Past it, the next task on that thread mints a fresh session instead of resuming, which is already a proven path since an expired session takes it and the envelope carries the question.

Unset or 0 means never rotate, so a machine that has not opted in keeps exactly its previous behaviour. Every turn on the thread counts, including an answer resuming a parked session, since that turn re-reads the same history. An answer is never itself rotated: reaching the session holding the parked question is the whole point of one. Reconstituting a thread restarts the count, because the fresh session carries none of the dead one's history.

Answering permission prompts

Outside bypassPermissions, the harness gates a sensitive path (anything under ~/.claude, ~/.ssh, and similar) by asking, not denying. A headless claude -p with no way to answer that ask cannot proceed: the tool call is refused, and the turn reports work it never did. permission_prompt gives the spawned child an answer route, so a machine whose policy forbids bypassPermissions can still take broker work that touches those paths.

"permission_prompt": {
  "enabled": true,
  "audit_log": "~/.claude-broker/permission-audit.jsonl",
  "allow": [
    { "tool": "Write", "paths": ["/Users/you/.claude/settings.json"] },
    { "tool": "Edit",  "paths": ["/Users/you/.claude/**"] },
    { "tool": "Bash",  "commands": ["/Users/you/.claude/plugins/cache/*/*/*/scripts/config-sync.sh"] }
  ]
}

Deny by default. A request is allowed only when a rule names its tool exactly and the rule's own matcher accepts the request. A rule carries paths or commands, never both.

paths is matched against input.file_path. ** spans separators and a single * stops at one, so a rule written for one directory does not silently cover the tree below it. Both sides are canonicalized through realpath before matching, so neither .. nor a symlink out of an allowed directory widens a rule.

commands is matched against input.command, as a glob over the command or any space-delimited prefix of it: the named program alone, or that program followed by its own arguments. * works here too, which is what lets a rule name a path that moves, such as the version hash in an installed plugin's directory. Prefix matching a shell string is where allow-lists get walked through, so any request containing ; & | < > backtick, $, parentheses or a newline is denied outright no matter what it starts with, and a rule whose own text carries one of those is rejected at load. What a rule grants is one program with its arguments, never a chain.

Write the program path unquoted in the rule. A matched pair of quotes wrapping the whole program path comes off the request before matching, so a rule naming /opt/bin/sync.sh still covers "/opt/bin/sync.sh" --apply, which is the form a model produces when the command it is copying was written with quotes. The variable form is a different case and stays denied: "${PLUGIN_ROOT}/scripts/sync.sh" carries a $, and text whose meaning depends on the environment is not something a rule can have named. Dispatch prompts should spell the path out.

A rule with no tool, or tool: "*", is rejected when the config loads rather than compiled, because a wildcard would be bypassPermissions by another name. For the same reason a Bash rule must carry commands: for every other tool an absent matcher is a defensible "this tool, anywhere", but for Bash it would be every command on the machine.

Every decision appends one JSON line to audit_log (defaults inside state_dir), and a denial tells the model to stop and put the request to the operator, which arrives on the bus as a NEEDS-INPUT question rather than as a silent failure or an improvised workaround.

This clears the permission layer only. A seatbelt sandbox is a separate gate: where ~/.claude sits in a denyWithinAllow list, Bash there still returns Operation not permitted no matter what this allow-list says.

String fields in the otlp block expand ${VAR} references from the process environment, and the runner reads ~/.claude-broker/observability.env (0600) at startup. The access token therefore lives in exactly one file: not in this repo, not in config.json, and not in the world-readable LaunchAgent plist. An unset reference is left verbatim so a missing secret fails visibly instead of becoming an empty credential.

Development

Zero runtime dependencies. Node >= 18. Tests use the built-in node:test runner and run fully offline against local bare git remotes and throwaway age keypairs, with the Telegram and claude -p seams injected (no real network or model calls).

npm test          # node --test test/*.test.js

The age and age-keygen binaries must be on PATH to run the encryption tests.

Layout:

  • lib/: envelope, age, config, repo I/O, thread store, claude adapter, escalation, runner, the permission allow-list (permission), the visibility feature (notify, activity, visibility), and kanban board tracking (kanban).
  • bin/: broker.js (CLI), runner-main.js (launchd entrypoint / loop), and permission-server.js (the child's approval MCP server).
  • deploy/ — LaunchAgent template, install script, key-distribution runbook.
  • docs/ — design spec and implementation plan.

Status

Deployed, carrying real traffic between the hub and its spokes. The defects that traffic has surfaced, and what closed each one, are recorded in .superpowers/sdd/progress.md, along with the v1 follow-ups still open: the inert broker send --timeout and threads --open flags, and the processed list every poll scans, which nothing prunes.