A local coordination hub for Claude Code. One always-on Node process that lets multiple Claude Code instances on the same machine talk to each other, be monitored and controlled from your phone, and automatically resume work after a usage-limit window resets.
Three things it does:
- Inter-instance chat + Athen, the shared know-how store — Claude Code instances working in different project directories can message each other (direct or broadcast) and share searchable know-how notes ("here's how to set up mobile CI on GitHub") via MCP tools. Athen (from Athenaeum) searches by meaning, not exact words (local embeddings + sqlite-vec, fused with FTS5 full-text), so an instance asked "does athen know about shipping iPhone apps" finds the note titled "Building iOS apps" — and can check whether another instance already solved a problem before solving it from scratch. See Athen — the shared know-how store below.
- Remote monitoring / control API — a REST + WebSocket surface for a companion mobile app: watch sessions live, send prompts to a session from your phone, answer permission requests remotely. LAN by default; an optional Cloudflare Worker relay (see below) extends the same API to the open internet without opening a port on your firewall.
- Usage-limit watcher + auto-continue — polls Claude's usage API, detects "you've hit your usage limit — resets at HH:MM" windows, and automatically resumes the interrupted sessions once the limit resets (with safety caps).
It also raises desktop toast notifications (Windows/macOS/Linux) for the moments you'd actually want to glance over — a permission request waiting on you, a session that's gone idle needing input, the usage limit being hit or clearing — see Desktop notifications below.
Scope & platform: built for a trusted personal LAN — one bearer token, normally over plain HTTP, not designed for internet exposure out of the box. The optional relay (see Remote access) carries that same single-bearer-token trust model out onto the internet, with the token checked at Cloudflare's edge over TLS rather than on your home network. Developed and tested on Windows (Node ≥ 22); the limit watcher reads Claude credentials via
%USERPROFILE%, so other platforms need minor path adjustments.
┌────────────┐ MCP (streamable HTTP, localhost) ┌─────────────────┐
│ Claude Code │────────────────────────────────────▶│ │
│ instance A │ hooks (SessionStart/Stop/…) │ │
│ instance B │────────────────────────────────────▶│ cc_hub │──── SQLite + FTS5
│ instance … │◀────context / block-decisions───────│ (one process) │
└────────────┘ │ │
▲ spawns `claude --resume <id> -p` │ │
└─────────────────────────────────────────────│ │
└───────┬─────────┘
REST + WebSocket (LAN, bearer) │
┌───────▼─────────┐
│ mobile app │
└─────────────────┘
- Hooks installed in
~/.claude/settings.jsonreport session lifecycle events to the hub (turn-level only by default). The hook script is fail-silent by contract: if the hub is down it prints nothing and exits 0, so Claude Code behaves exactly as if no hook were installed. - Chat delivery rides on those hooks while an instance is active: unread messages are injected as context at the start of the next turn; urgent messages and remotely queued prompts are delivered through a
Stop-hook block, so the instance acts on them at the end of its current turn. For anything not currently active, a separate hub-side tick takes over — see Chat delivery to non-active instances below. - Remote prompts to an idle session are delivered by spawning
claude --resume <session-id> -p "<prompt>"headlessly — the turn lands in the same session transcript, and its hooks stream activity back to the hub (and your phone) in real time. - Permission requests long-poll the hub for up to
permissionWaitMs(default 30 s); answer from your phone, or let it fall through to the normal terminal prompt. - The limit watcher is a tick-driven state machine (
ok → limited → waiting_reset → continuing → ok) that survives machine sleep, backs off on API errors, and never auto-continues unless a fresh poll confirms the limit actually reset. - Desktop notifications subscribe to the same event bus that drives everything above — a permission request, a session needing input, hitting/clearing the usage limit, and (off by default) every turn ending each raise an OS toast, independently toggleable — see Desktop notifications below.
- Windows 10/11, Node.js ≥ 22
- Claude Code CLI installed and logged in
git clone https://github.com/righttechsoft/cc_hub.git
cd cc_hub
npm install
npm run setup # writes config.json (fresh authToken) + installs Claude Code hooks
npm start # runs until Ctrl+Cnpm run setup runs two idempotent steps (each can be run standalone):
node scripts/gen-token.mjs— copiesconfig.example.jsontoconfig.jsonwith a fresh randomauthToken. No-ops ifconfig.jsonalready exists.node scripts/install-hooks.mjs— appends cc_hub's hook commands into~/.claude/settings.json. It only ever appends: existing hook groups are left untouched, a timestamped backup is written before any change, and re-running it skips events that already have a cc_hub entry.
For development, npm run dev runs the same entrypoint under tsx watch.
With the hub running, register it once per machine (--scope user makes it available in every project):
claude mcp add --scope user --transport http cc-hub http://127.0.0.1:4270/mcp
The exact command (with your configured port) is printed to the log on every startup.
Hooks and MCP config are snapshotted when a session starts. New sessions pick everything up automatically; for sessions already running, exit and resume with history intact:
claude --continue
By default, a prompt sent from the mobile app or an inter-instance chat message reaches an idle session as a separate headless claude turn — it runs, but your open terminal never repaints (see Limitations). To make those prompts appear in your real terminal exactly as if you typed them, launch Claude Code through the wrapper instead of claude:
One-time: add cc_hub\bin to PATH (PowerShell, persistent), then restart your terminal:
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";F:\rts\cc_hub\bin", "User")Then, with the hub running (npm start), launch through the wrapper in any project dir:
cc-attach # == `claude`, but hub-attached
cc-attach --continue # any claude args pass straight through (--resume <id>, -r, etc.)cc-attach spawns claude inside a hub-owned pty and passes your terminal through unchanged (colors, cursor, Ctrl-C, resize all work normally). It launches the same claude your hub uses — it reads claudePath from config.json (and PATH-resolves a bare claude to the real executable). While it's running, mobile prompts and chat messages for that directory are injected into the live session (bracketed paste + Enter) instead of spawned headless — idle-gated, so nothing lands mid-turn. When no wrapper is attached, delivery falls back to today's headless behavior. A shell alias claude=cc-attach makes every session transparent with no habit change. Disable entirely with "attach": { "enabled": false } in config.json.
On Windows it drives claude through ConPTY by default (preserves terminal scrollback). If you hit an intermittent first-keystroke garble (a typed line landing with a stray gap), set CC_HUB_USE_WINPTY=1 to switch to the winpty backend — it fixes the input glitch but loses scrollback (you can only scroll ~one page up), so it's opt-in.
It also loads the project's .env (from the launch directory) into the session's environment, so launched apps, bash-tool commands, and MCP servers all inherit those variables. The pty engine (@homebridge/node-pty-prebuilt-multiarch, a prebuilt fork of node-pty — no compiler needed) is an optional dependency pulled by npm install; if it's unavailable on your platform, cc-attach prints a notice and you just run claude directly. The hub itself never loads it, so a missing pty binary never affects the always-on hub.
Live terminal on your phone. While a session runs under cc-attach, the mobile app can mirror its real terminal in read-only view: sessions with a live wrapper show a LIVE badge, and opening one streams the actual terminal (colors, spinners, output) as it happens — rendered with a real VT emulator, not the parsed transcript. Output rides the same authenticated /ws connection (and the relay, if enabled), streamed only to the phone while you're watching. Input still goes through the normal prompt box (which injects into the live session), so the mirror stays view-only.
Smart paste (Windows). Windows Terminal's native Ctrl+V pastes clipboard text but bypasses the pty, so pasting an image does nothing. Under cc-attach you can rebind Ctrl+V so the wrapper reads the clipboard itself: an image is saved to a temp PNG and its path is dropped into the prompt, a copied file gives its path, and text pastes as usual (all non-submitting, so you review before Enter). Rebind in Windows Terminal settings (keep Ctrl+Shift+V for normal paste elsewhere):
{ "command": { "action": "sendInput", "input": "\u0016" }, "id": "User.smartPaste" }then bind "keys": "ctrl+v" to "id": "User.smartPaste", and "keys": "ctrl+shift+v" to Terminal.PasteFromClipboard. Set CC_HUB_PASTE_DEBUG=1 to trace paste handling to %TEMP%\cc-attach-debug.log.
Paste hygiene. Smart paste also cleans up what it injects before you ever see it: by default, well-known secret shapes (API keys, GitHub/Slack/Google tokens, JWTs, PEM private-key blocks) are masked as «REDACTED:kind» ("attach": { "redactSecrets": false } to turn that off), and multi-line code pastes can optionally be wrapped in ``` fences for readability ("attach": { "fenceCodePastes": true }, off by default — the heuristic is conservative and leaves prose alone). Because smart paste never auto-submits, you always see the redacted/fenced result before pressing Enter.
Snippets. Press Ctrl+G then a single character to expand a canned bit of text into the prompt (non-submitting, same as smart paste) — handy for a signature line, a standing instruction, anything you type often. Configure them under attach.snippets in config.json:
{ "attach": { "snippets": { "s": "— Damien", "r": "Please review and suggest improvements." } } }Ctrl+G is only intercepted while attach.snippets has at least one entry; the default {} means no behavior change.
Fewer false "needs input" notifications. Under cc-attach, the wrapper watches for claude's own "esc to interrupt" running indicator in the terminal output and reports live working/idle state to the hub. Desktop and push notifications use that alongside session status, so an idle-input alert is suppressed while a subagent is still working in the background — previously that could fire mid-turn because the top-level session already looked idle.
Notices from your terminal. cc-attach also watches the session's output for build/test failures (npm errors, BUILD FAILED, TypeScript/Rust/Go/Python errors, failing jest/pytest runs) and local dev-server URLs, and raises a desktop/push notification for them (notifications.outputTriggers, on by default) — the same event is also forwarded to the mobile app over the live connection. Repeats are deduplicated and rate-limited, so a failing watch loop or a noisy dev server can't spam you.
| Tool | What it does |
|---|---|
hub_register |
Bind this instance to the hub (call once per session, before other tools). Returns your instance name, unread count, and known peers |
chat_send |
Message another instance by name, or broadcast to all. urgent: true interrupts the recipient at its next turn end |
chat_inbox |
Fetch (and by default mark read) your unread messages |
chat_peers |
List known instances: name, project dir, last seen, active flag |
athen_save |
Save a reusable know-how note (title, body, tags) to Athen, the shared know-how store. Notes are embedded locally for semantic search |
athen_search |
Search Athen by meaning, not exact words — hybrid of vector KNN (sqlite-vec + local MiniLM embeddings) and full-text (FTS5 + BM25), fused by reciprocal rank. Degrades to full-text-only if embeddings are unavailable |
athen_get |
Fetch a note's full body by id |
Instance identity is derived from the project directory (basename, with automatic disambiguation on collisions).
Athen (short for Athenaeum — a library of collected knowledge) is a single, machine-wide memory shared by every Claude Code instance the hub knows about. Anything one instance learns, every other instance can find later — across projects, across sessions, across weeks.
Purpose. Instances keep re-solving the same problems: how to set up CI for iOS, which Postgres grant incantation survives a nightly table rebuild, what flag makes some CLI behave. Athen turns those one-off discoveries into durable, searchable instructions. Tell any instance "save how to build an iOS app to athen" and it stores the write-up; ask any other instance "check if athen knows about shipping iPhone apps" and it finds that note — by meaning, not exact words. "Shipping iPhone apps" matches "Building iOS apps" even though they share no keywords.
How it works:
- Notes are plain rows (title, body, tags, author) in the hub's SQLite database — nothing leaves your machine.
- On save, the note is embedded by a local ONNX model (
Xenova/all-MiniLM-L6-v2by default, ~25 MB, downloaded once intodata/models/, runs on CPU — no API keys, no cloud). - Search runs two legs and fuses them by reciprocal rank: vector KNN over the embeddings (sqlite-vec) for meaning, FTS5 + BM25 for exact terms. Either leg alone can surface a note; appearing in both boosts it.
- Notes written before the feature existed (or while embeddings were unavailable) are picked up by a background backfill shortly after hub start, so the whole store stays semantically searchable.
- Everything is fail-soft: if the ONNX runtime or the sqlite-vec extension can't load on a machine, saves and searches keep working in full-text-only mode — no note is ever lost or unreachable.
athen.embeddings: falsein the config forces that mode explicitly. - Swapping
athen.modelfor a different embedding model rebuilds the vector table and re-embeds every note automatically on the next start.
Typical flow — instance A (in ~/proj-alpha) figures out code signing after an hour of pain, and is told: "save that to athen" → athen_save {title: "Building iOS apps", body: "...", tags: "xcode signing"}. A week later instance B (in ~/proj-beta) is asked to ship an iPhone build; its session-start banner reminds it Athen exists, it calls athen_search "ship an iphone app", gets the note, and athen_get pulls the full instructions.
Athen is also reachable over the mobile REST API (/api/v1/kb/* routes — the paths keep the historical kb name for client compatibility).
node scripts/install-hooks.mjs # install (append-merge)
node scripts/install-hooks.mjs --dry-run # show what would change, write nothing
node scripts/install-hooks.mjs --uninstall # remove cc_hub's entries onlyInstalled events: SessionStart, UserPromptSubmit, Notification, Stop, PermissionRequest, SessionEnd. PostToolUse (per-tool-call activity) is not installed by default — see hooks.postToolUse in the config to opt in.
The hook script (hooks/cc-hub-hook.mjs) has zero dependencies and never breaks Claude Code: any error — hub down, timeout, unexpected response — results in no output and exit 0. Environment overrides: CC_HUB_URL for a non-default host/port, CC_HUB_DEBUG=1 to log to %LOCALAPPDATA%\cc_hub\hook.log.
Base URL: http://<lan-ip>:<port>/api/v1. Every request needs Authorization: Bearer <authToken> (from config.json); there is no unauthenticated endpoint.
| Method & path | Body | Notes |
|---|---|---|
GET /health |
— | {status, uptimeMs, limit} |
POST /sessions |
{cwd, prompt, permissionMode?} |
Spawns a brand-new headless session (claude -p) in cwd; fire-and-forget, {spawned:true}; 409 if the runner is at max concurrent |
GET /sessions?status= |
— | status is a comma-separated list (active,idle,...) |
GET /sessions/:id |
— | Session + instance_name, pendingPrompts, last 20 events |
GET /sessions/:id/events?afterId&limit |
— | Paginate forward from afterId (default 0), limit default 100, max 500 |
GET /sessions/:id/transcript?afterByte&tailBytes |
— | Parsed CC transcript ({entries, byteOffset, truncatedHead}); afterByte for incremental reads, tailBytes default 262144 (clamp 16384–1048576); 409 no_transcript if the session has none or it can't be read |
POST /sessions/:id/prompt |
{prompt} |
{delivery:"spawned"|"queued", pendingPromptId}; 409 if session has ended |
POST /sessions/:id/auto-continue |
{enabled} |
Toggle auto-continue for one session |
POST /sessions/:id/image |
{imageBase64, ext?} |
Saves the decoded image to a temp file on the hub's machine and injects its path into the session's live cc-attach terminal (non-submitting) so claude can read it off disk; ext defaults to png. 409 not_attached if that session has no cc-attach wrapper currently open; 413 if the decoded image exceeds ~4 MB |
GET /permissions?status= |
— | status one of pending|allowed|denied|timeout |
POST /permissions/:id/decision |
{behavior:"allow"|"deny", message?} |
409 if already decided (someone else / timeout got there first) |
GET /messages?limit&beforeId |
— | Chat history, newest first |
POST /messages |
{to?, body, urgent?} |
from_name is always forced to "mobile"; omit to to broadcast |
GET /kb/search?q=&limit= |
— | Search Athen (hybrid semantic + full-text, same as athen_search) |
GET /kb/:id |
— | Full note body |
POST /kb |
{title, body, tags?} |
Author is forced to "mobile" |
GET /limit |
— | Current limit_state row + last 20 limit_events |
POST /push/register |
{token} |
Registers an APNs device token (hex, lowercased); called by the mobile app on launch |
POST /debug/limit |
{state, resetsAtMs?} |
Dev-only (gated on logLevel:"debug"); forces the watcher's state for testing |
ws://<lan-ip>:<port>/ws — auth via Authorization: Bearer <token> header or ?token=<token> query param (for WS clients that can't set headers).
Server → client frames ({type, data}):
| type | data |
|---|---|
hello |
Sent once on connect: {sessions, limit} snapshot |
session_event |
{sessionId, eventType, payload, createdAt} |
session_status |
{sessionId, status} |
message |
A chat message row |
permission_request |
A newly pending permission request |
permission_decided |
Allowed / denied / timed out |
limit_state |
Limit watcher state change |
Client → server: {"type":"ping"} → {"type":"pong","data":null}.
/hooks/* and /mcp are not part of this API — they are restricted to localhost by socket address, used by the hook script and claude mcp add respectively.
When the watcher sees five-hour utilization cross limitedThresholdPct (default 100), it snapshots which sessions were mid-work ("interrupted"). Once resets_at passes (plus resetJitterMs) and a fresh poll confirms utilization dropped, the hub additionally scans the transcripts of all idle sessions for a fresh "usage limit reached / waiting for limit to reset" marker (within autoContinue.transcriptScanWindowMinutes, default 360) — so a session that hit the limit hours ago, or while the hub itself was down, is still picked up. Every interrupted session is then resumed headlessly with autoContinue.prompt. Guard rails:
maxPerSessionPerDay(default0= unlimited; set >0 to cap how often one session may auto-continue per day)maxConcurrent(default 1) — resumes are serialized- per-session opt-out via
POST /sessions/:id/auto-continueor theauto_continueflag - any watcher error degrades to an
unknownstate that never auto-continues blind - the transcript scan only trusts markers on API-error/system lines, not ordinary conversation text that merely mentions limits
Without this, a message reaches its recipient at one of three moments: injected as context at its next prompt, pushed through the Stop hook if it's urgent, or summarized in the banner at session start. That leaves a gap for an instance with nothing currently running — idle, ended, or Claude Code never actually run there in the first place — no turn in flight to inject context into. To close it, the hub ticks every chatDelivery.tickMs (default 30 s) and is poked immediately whenever a message is sent (via chat_send or the mobile API), so a reachable recipient normally gets its mail within seconds: for every instance sitting on unread messages that has no session currently active, it starts a brand-new headless session in that instance's project directory (claude -p "..." — never --resume) carrying those messages plus an instruction to act on them or reply via chat_send. Messages are marked read once that new session exits successfully. The new session self-registers with the hub via the normal SessionStart hook.
An idle or ended session doesn't change any of this — it neither blocks the fresh spawn nor gets reused by it. Earlier versions of this mechanism only handled idle sessions, and did so by --resumeing them; that's gone; every delivery is now a fresh spawn, because an idle terminal never repaints for a --resume turn either, so there was no benefit to reusing its session id over just starting a clean one.
Guard rails:
chatDelivery.maxSpawnsPerInstancePerHour(default 4) caps how many new sessions the hub will start for the same instance per hour (counted per attempt, not per success), so two chatty instances can't bounce messages back and forth into an unbounded delivery loop.chatDelivery.enabled: falseturns the tick off entirely; context-injection andStop-hook delivery to active instances keep working as before.
A headless turn spawned this way consumes usage like any other turn, and — same caveat as auto-continue — it won't repaint an interactive terminal left open in that project directory; the turn lands in its own new transcript and streams to the hub in real time, but the visible terminal (if one is open there) doesn't refresh (see Limitations). Because of that, if a human returns to the terminal and starts typing before ever noticing the earlier delivery, the hub re-surfaces it: the next UserPromptSubmit checks for messages delivered this way and, if any are found, injects a brief FYI note alongside the normal context ("a background turn already handled/replied to these while you were away") and marks them as surfaced so the same note is never shown twice.
The hub raises OS toast notifications (node-notifier, which bundles SnoreToast for Windows and covers macOS/Linux the same way) for a handful of moments worth glancing at, each with its own on/off toggle:
| Moment | Toggle (default) | Toast |
|---|---|---|
| A tool call is waiting on your permission decision | notifications.permissionRequests (true) |
<instance> — permission with the tool name and a preview of its input |
A session goes idle needing input (Claude Code's own "waiting" notification) — suppressed while the session is mid-turn, including subagent work a cc-attach-attached wrapper can see but the top-level session status can't |
notifications.needsInput (true) |
<instance> needs input |
| — filtered by an AI classifier, if enabled | notifications.aiIdleFilter (false) |
Reads the session's last assistant message from its transcript and asks a small model (notifications.aiIdleFilterModel, default claude-haiku-4-5) whether it needs your action now; suppresses the toast/push only on a clean "no" (status update / completion report / background work continuing). Fails open — any error (no token, network, timeout) still notifies |
| A turn ends | notifications.turnEnd (false — noisy if left on) |
<instance> finished a turn |
| The usage limit is hit, or clears back to normal | notifications.limit (true) |
one toast entering the limited state, one toast on recovery — never a toast per poll tick |
| Chat delivery spawns a headless session to process unread inter-instance mail | notifications.chatDelivery (true) |
<instance> — incoming chat with a count and the sender name(s) |
A cc-attach session's terminal shows a build/test failure or a local dev-server URL |
notifications.outputTriggers (true) |
<instance> — build failed / <instance> — server ready, with the matched line or URL |
notifications.enabled: false turns the whole feature off. Every toast call is wrapped so a notification failure (no notification daemon running, SnoreToast missing, etc.) is logged at debug level and never affects the hub itself — v1 is fire-and-forget, no click actions.
When you're away from the desktop (no keyboard/mouse input for push.awayThresholdMinutes, default 3), the same moments that raise a desktop toast also send an APNs push to your registered iOS device(s) — no separate toggles, notifications.* drives both.
Setup:
- In the Apple developer portal, go to Certificates, Identifiers & Profiles → Keys → +, enable Apple Push Notifications service (APNs), and create the key.
- Download the
.p8file — Apple only lets you download it once, so keep it somewhere safe. - Fill in the
pushblock inconfig.json:"push": { "enabled": true, "awayThresholdMinutes": 3, "apns": { "keyPath": "C:\\path\\to\\AuthKey_XXXXXXXXXX.p8", "keyId": "XXXXXXXXXX", "teamId": "<your Apple team id>", "bundleId": "com.righttechsoft.ccHubMobile", "environment": "production" } }
- Restart the hub. The mobile app registers its device token automatically on launch (
POST /api/v1/push/register); no further action is needed on the desktop side.
There's no away-detection mechanism outside Windows — on any other platform the hub treats the user as always away (a warning is logged once at startup), so pushes fire for every toggled-on event rather than never.
config.json (gitignored; generated from config.example.json by npm run setup):
| Key | Meaning |
|---|---|
port |
HTTP port (default 4270) |
bindAddress |
Interface to bind (0.0.0.0 = all interfaces, needed for LAN access) |
authToken |
Bearer token for /api/v1/* and /ws. Generated by gen-token.mjs; treat as a secret |
claudePath |
Path to the claude executable (bare claude resolves via PATH) |
hooks.postToolUse |
Record PostToolUse events (off by default — turn-level only) |
hooks.postToolUseThrottleMs |
Min gap between recorded PostToolUse events per session, if enabled |
hooks.permissionWaitMs |
How long the PermissionRequest hook long-polls for a remote decision before falling back to the terminal prompt |
limitWatcher.enabled |
Turn the usage-limit poller on/off |
limitWatcher.pollIntervalMs |
Normal poll cadence |
limitWatcher.retryIntervalMs |
Poll cadence after a transient (network) failure |
limitWatcher.limitedThresholdPct |
Five-hour utilization % that counts as "limited" |
limitWatcher.resetJitterMs |
Extra delay after resets_at before trusting the reset |
autoContinue.enabled |
Master switch for auto-resuming interrupted sessions |
autoContinue.prompt |
The prompt sent to resume an interrupted session |
autoContinue.maxPerSessionPerDay |
Cap per session per local calendar day (0 = unlimited, the default) |
autoContinue.maxConcurrent |
How many sessions to auto-continue at once |
autoContinue.eligibleWindowMinutes |
How recently a session must have been active to count as "interrupted" at detection time |
autoContinue.transcriptScanWindowMinutes |
How fresh a transcript's limit marker must be for the continue-time scan to count that idle session as interrupted (default 360) |
autoContinue.permissionMode |
--permission-mode passed to the headless claude --resume call |
retention.sessionEventsDays |
session_events rows older than this are purged daily |
retention.messagesDays |
Read messages older than this are purged daily (unread messages are never auto-deleted) |
relay.enabled |
Turn on the Cloudflare Worker relay for remote (off-LAN) access (default false) |
relay.url |
The deployed worker's URL, e.g. https://cc-hub-relay.<account>.workers.dev |
relay.secret |
Shared secret the hub authenticates to the worker with (the HUB_SECRET set via wrangler secret put) |
chatDelivery.enabled |
Master switch for chat delivery to non-active instances (default true) |
chatDelivery.tickMs |
How often the hub checks instances with unread messages (default 30000) |
chatDelivery.maxSpawnsPerInstancePerHour |
Cap on brand-new sessions the hub will start per instance per hour (default 4) |
attach.enabled |
Master switch for the cc-attach transparent-console endpoint (default true). false unmounts /attach entirely, so delivery is pure headless fallback |
attach.heartbeatMs |
Wrapper→hub ping cadence; the hub prunes an attached terminal that goes silent for > 2.5× this (default 30000) |
attach.redactSecrets |
Smart paste masks well-known secret shapes (API keys, tokens, PEM blocks) before injecting clipboard text (default true) |
attach.fenceCodePastes |
Smart paste wraps multi-line code pastes in ``` fences via a conservative heuristic (default false) |
attach.snippets |
Map of single-char key → canned text; Ctrl+G then the key injects it as a non-submitting paste (default {} — Ctrl+G untouched until at least one entry is set) |
athen.embeddings |
Semantic search for Athen notes via local embeddings (default true). Kill switch: set false if the ONNX runtime or sqlite-vec can't load on your machine — search degrades to full-text-only |
athen.model |
Embedding model id (default Xenova/all-MiniLM-L6-v2, ~25 MB, downloaded on first use into data/models/). Changing it rebuilds the vector table and re-embeds every note automatically |
notifications.enabled |
Master switch for desktop toast notifications (default true) |
notifications.permissionRequests |
Toast when a tool call is waiting on your permission decision (default true) |
notifications.needsInput |
Toast when a session goes idle needing input (default true) |
notifications.turnEnd |
Toast when a turn ends (default false — noisy if left on) |
notifications.limit |
Toast on entering/recovering from the usage-limited state (default true) |
notifications.chatDelivery |
Toast/push when a hub-spawned headless session starts processing unread chat mail (default true) |
notifications.aiIdleFilter |
Ask a small model whether a surviving idle_prompt notification actually needs your action, suppressing it if not (default false) |
notifications.aiIdleFilterModel |
Model id used for that classification (default claude-haiku-4-5) |
notifications.outputTriggers |
Toast/push when cc-attach detects a build/test failure or a local dev-server URL in a session's terminal output (default true) |
push.enabled |
Master switch for APNs push notifications to registered iOS devices (default false) |
push.awayThresholdMinutes |
Minutes of no keyboard/mouse input before the desktop user counts as "away" and pushes start firing (default 3) |
push.apns.keyPath |
Absolute path to the APNs auth key (.p8) downloaded from the developer portal |
push.apns.keyId |
The key's 10-character id from the developer portal |
push.apns.teamId |
Your Apple team id |
push.apns.bundleId |
The mobile app's bundle id (default com.righttechsoft.ccHubMobile) |
push.apns.environment |
production or sandbox (default production) |
logLevel |
debug|info|warn|error |
Allow LAN devices to reach the hub:
netsh advfirewall firewall add rule name="cc_hub" dir=in action=allow protocol=TCP localport=4270 remoteip=localsubnetRun from login via Task Scheduler (or use any process manager you prefer):
schtasks /create /tn cc_hub /sc onlogon /tr "cmd /c cd /d C:\path\to\cc_hub && npm start" /rl limitedEverything above assumes your phone is on the same LAN as the hub. The optional relay lifts that restriction: the hub dials out to a small Cloudflare Worker (backed by a Durable Object) and holds that connection open, and your mobile client talks to the worker instead of talking to the hub directly. There's nothing to open on your home firewall — no port forward, no inbound rule — because the connection is always initiated from inside your network outward.
The worker only knows how to forward two things, /api/v1/* and /ws; it has no route for /hooks or /mcp, so those stay reachable only from localhost exactly as before. The hub's relay client enforces the same boundary independently, allowlisting /api/v1/ on its own side of the tunnel — so even a misconfigured or compromised worker deployment can't get the hub to relay anything else.
┌──────────────┐ dials out: persistent WebSocket ┌───────────────────┐
│ cc_hub │─────────────────────────────────────▶│ │
│ (behind your │ │ Cloudflare Worker │
│ firewall, │◀─────────────────────────────────────│ + Durable Object │
│ no inbound │ REST + WS tunneled over that socket │ (cc-hub-relay) │
│ rules) │ │ │
└──────────────┘ └─────────┬─────────┘
HTTPS / wss:// (bearer token) │
anywhere │
┌─────────▼─────────┐
│ mobile app │
└───────────────────┘
cd worker && npm installnpx wrangler login— one-time OAuth login to your Cloudflare account.npx wrangler deploy— note the URL it prints,https://cc-hub-relay.<account>.workers.dev.npx wrangler secret put AUTH_TOKEN— paste in the sameauthTokenvalue from your hub'sconfig.json.- Generate a second, independent secret and set it too:
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))", thennpx wrangler secret put HUB_SECRETand paste in the output. - Point the hub at the deployed worker by adding a
relayblock toconfig.json:"relay": { "enabled": true, "url": "https://cc-hub-relay.<account>.workers.dev", "secret": "<the HUB_SECRET value>" }
- Restart the hub — the log should show
relay: connected.
To iterate on the worker itself without deploying, run wrangler dev against a worker/.dev.vars file (gitignored) containing AUTH_TOKEN and HUB_SECRET, and point the hub's relay.url at http://127.0.0.1:8787 instead of the deployed URL.
Mobile clients use the same /api/v1/* paths and the same bearer token whether they're talking to the hub directly or through the relay. The recommended pattern is to probe the LAN hub's /api/v1/health with a short (1-2 s) timeout first, and fall back to the worker URL if that probe fails or times out. /ws behaves identically over the relay, including ?token= query-param auth for clients that can't set headers, and the {"type":"ping"} keepalive — the edge answers it directly when the connection is relayed, without a round trip to the hub. Query-string tokens can be captured in Cloudflare request logs (observability/tail), so prefer the Authorization header for /ws when the client supports setting WebSocket headers; ?token= remains for clients that cannot.
- Request and message bodies are capped at roughly 950 KB, the practical ceiling under the Durable Object WebSocket's 1 MiB frame limit. That's well above what's ever needed in practice — prompts sent through cc_hub are already capped at 8000 characters.
- The free Workers tier is plenty for single-user use; WebSocket hibernation keeps the Durable Object's idle cost at zero between messages.
- The worker returns
503 hub_offlineif no hub is currently connected, and504 hub_timeoutif a connected hub doesn't answer within 30 s. - Run exactly one hub per worker deployment — the relay assumes a single connected hub and doesn't multiplex requests across several.
npm run dev # tsx watch
npm run typecheck # tsc --noEmit
npm test # vitest (limit watcher state machine, continuation caps, usage parsing)No build step — the server runs from TypeScript sources via tsx. SQLite database lives in data/, logs in logs/ (both gitignored).
- Instance identity is per-directory, not per-terminal. Two terminals open in the same project directory share one inbox and one instance identity.
- A headless
--resumeturn does not repaint an open interactive terminal. The turn lands in the session transcript and hooks stream it to the hub in real time, but the visible terminal won't refresh — Claude Code exposes no supported way to type into a running interactive terminal remotely. Workaround: launch viacc-attach(see Transparent console), which owns the pty and injects remote/chat prompts into the live session as if typed. Without the wrapper, the headless behavior above stands. - Security model is a single static bearer token, not a full auth system. By default it's checked over plain HTTP on your LAN — adequate for a trusted home network, nothing more. The optional relay (see Remote access) carries the same single-bearer-token trust model onto the internet, just with the token checked at Cloudflare's edge over TLS instead of on your home network — it doesn't add per-user accounts or scoped permissions.
/hooksand/mcpare additionally restricted to localhost regardless of the token, and the relay has no route to either of them. - The usage endpoint (
/api/oauth/usage) is unofficial and undocumented. Parsing is deliberately liberal, and any failure degrades the watcher tounknownrather than guessing — but the endpoint may change or disappear at any time. - Hook output formats drift across Claude Code versions. The
Stop-block andPermissionRequestdecision shapes are pinned to current Claude Code and isolated in one function each, but a CC upgrade may require touching them.
This is an unofficial community tool, not affiliated with or endorsed by Anthropic. It automates your own Claude Code sessions on your own machine using your own credentials; the auto-continue feature consumes your plan's usage as if you had typed "continue" yourself.
MIT © Right Tech Soft LLC