DeckHand

mcp
Security Audit
Fail
Health Warn
  • License — License: Apache-2.0
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Low visibility — Only 7 GitHub stars
Code Fail
  • process.env — Environment variable access in cli/deckhand.mjs
  • network request — Outbound network request in cli/deckhand.mjs
  • network request — Outbound network request in hooks/codex-hook.mjs
  • process.env — Environment variable access in mcp/deckhand-mcp.mjs
  • network request — Outbound network request in mcp/deckhand-mcp.mjs
  • process.env — Environment variable access in server/access.ts
  • exec() — Shell command execution in server/agentAccess.ts
  • process.env — Environment variable access in server/agents/codex.ts
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

Self-hosted fleet controller for Claude Code and Codex sessions across all your machines — one dashboard, phone-friendly, no cloud.

README.md

Deckhand

license node platform

Deckhand dashboard

TL;DR — Run Claude Code and Codex sessions on any of your computers, see them all in one dashboard
(or your phone), and get a chime the moment any session is waiting on you. Self-hosted, no cloud, no accounts.

Quickstart (one machine, ~5 min)

Prerequisites: Node.js 20+ and Claude Code. Codex CLI is optional (npm i -g @openai/codex).

git clone https://github.com/CristianMori/DeckHand.git
cd DeckHand
npm install
npm run build
npm start

Open http://127.0.0.1:5959. npm start serves server/public, which a clone does not include until npm run build. For a live reload UI instead, npm run dev serves it at http://127.0.0.1:5960 (the hub stays on 5959).

Add a second machine: install Tailscale on both, then repeat the clone, install, build, and start. Hubs discover each other on the tailnet; every dashboard shows the whole fleet. The always-on installer, projects root, and firewall rules are in Install (each machine).

A self-hosted fleet controller for coding-agent sessions — Claude Code and
OpenAI Codex, side by side. Run agent sessions on any of your computers, watch
and drive all of them from one dashboard (or your phone), and let projects and
conversations follow you between machines. No cloud service, no accounts, no configuration files to
maintain — machines discover each other and the fleet assembles itself.

Deckhand grew out of a simple dashboard whose only job was to make sure you
never miss the moment a session stops and waits for you. That job still comes
first: cards are sorted waiting-first, and every dashboard everywhere chimes
when any session on any machine needs a human.


What it does

  • Mixed agents. Claude Code and Codex sessions live in the same session
    list and tab bar. The launch dialog offers whichever CLIs are installed on
    the machine you pick, with that agent's own model and permission choices.
    Status, resume, cross-machine migration, the durable transcript store and
    PRINT export work for both. Each agent is an adapter under server/agents/,
    so a third CLI is one file away.
  • Plain terminals. TERMINAL is a third engine in the launch dialog: an
    interactive shell in a card, on any machine, with no agent. Windows offers
    PowerShell 7 (when installed), Windows PowerShell and cmd; macOS and Linux
    your login shell plus bash/zsh/sh. It can start in the home folder (~)
    as well as in a project. It is always available, so even a machine with
    no Claude or Codex is useful. Its status comes from output alone: WORKING
    while a command prints, IDLE once the prompt is back, and an alert only
    when a command that ran 10 s or more finishes. A terminal has no
    conversation: after a hub restart its card waits as EXITED with
    RESTART, a fresh shell in the same folder; it is never handed off or
    moved, and it claims no folder (agents never need its permission).
  • "Give me that file" (dh_send_file). Ask any session for a file and
    it calls dh_send_file: the file stays on its machine and the dashboard
    you are looking at (in front, focused) starts downloading it by itself —
    no button, no popup. A dashboard in the background downloads it when you
    bring it forward within two minutes. Links are
    unguessable ids served by the owning hub (/api/shared/<id>, behind the
    usual access check), the newest 10 per session, until that hub restarts.
  • Run a command anywhere (dh_run). POST /api/run, and the dh_run
    MCP tool, run one command on any fleet machine and hand back its output
    and exit code. It runs in a visible terminal card on that machine, named
    after the command, so you can open it and see what any session ran there
    (the newest 10 such cards are kept). A command still running after the
    wait (max 4 min) keeps running and the caller gets the card to wait on.
    A command in a folder that a live agent session works in is refused
    until the caller has agreed it with that session and passes force;
    anywhere else (home, /opt, a folder nobody works in) just runs.
  • Install an engine from the dashboard. When the machine you pick has
    no Claude Code (or Codex), the launch dialog offers INSTALL: it opens a
    terminal there running the official installer, and you sign in in the
    same card. The hub also looks in the installer's default folders, so a
    freshly installed Claude shows up without a hub restart.
  • Hand a session to another engine. HAND OFF on any card moves the work
    to a different engine, machine and/or folder, including a brand-new folder.
    Not a transcript transplant (each agent's log is its own private replay
    format): the departing agent is asked to write a brief for its successor,
    the hub appends the conversation as plain dialogue, drops both into the
    target folder as .deckhand/handoff.md, and starts the target engine with
    "read it, then continue". The new card shows where it came from.
  • Freeze a project to a machine. FREEZE in the terminal header pins the
    session's folder to the machine it lives on: it is never registered for
    sync, never pushed to the home machine, and its conversations can only be
    resumed there (the resume dialog says so). Replication that already started
    is stopped; files stay where they are. The list lives in the hub's data dir,
    not in the folder, so the mark itself never travels.
  • One dashboard, whole fleet. Every machine runs the same hub; every hub
    shows every machine's sessions. Open http://<any-machine>:5959 from any
    device on your tailnet — they are all equivalent.
  • Full terminals in the browser. Each session runs in a real PTY. The
    server keeps a headless terminal mirror per session, so opening a terminal
    (from any machine) restores a pixel-perfect snapshot — no replay garbling.
    Terminals open as tabs: click to switch, – to soft-close (session keeps
    running), × to end.
  • Sessions survive everything. Close the browser, reboot the machine,
    kill the process — the conversation is durable. EXITED cards resume with
    full history. RESTART on a card kills a live session and brings its
    conversation back in place; RESTART ALL does it for every idle agent
    session on the machine serving the dashboard (terminals stay out), and any
    that could not restart turn red with the reason.
  • Work follows you. Resume any conversation on any machine. If the
    project folder isn't there, it replicates first (through the always-on
    home machine); the transcript ships along and is re-homed for the local
    path; the resumed session is told its folder moved so it doesn't trip on
    old absolute paths.
  • On-use folder sync. Nothing replicates until a session touches a
    folder. From then on it syncs continuously (file-watch, seconds) to the
    home machine, which keeps the canonical copy plus 30 days of per-file
    version history.
  • Durable conversation store. Every hub pushes transcripts to the home
    machine. Conversations outlive their origin machine, local retention
    cleanup, and folder moves — the resume dialog backfills from the store when
    no machine can offer a copy, and an EXITED card whose transcript Claude
    Code's retention cleanup has removed (default: 30 days idle) fetches it back
    from the store when you press RESUME. Hub-spawned Claude sessions also get
    cleanupPeriodDays set to 100 years through the injected settings, so the
    local copy stays. Transcripts are append-only, so only the new
    bytes travel (gzipped, verified by size + sha256, whole file as fallback);
    a live session's transcript goes at most every 10 minutes and right when
    the session goes idle or exits, and one sweep sends at most 32 MB
    (HUB_TSTORE_SWEEP_MB).
  • Fleet auto-update. Publish a build to the home machine; every hub sees
    it and updates itself from the ADMIN panel (or its 6-hour self-check).
    Dev checkouts (build 0) never auto-update.
  • Fleet file browser. FILES walks any connected machine's project folders,
    downloads a file or a whole folder as a zip to your browser, or copies a file
    straight to another machine:
    the target hub pulls the bytes from the source hub, so nothing passes through
    the dashboard.
  • Console-first workflow if you want it. The deckhand CLI spawns
    hub-owned sessions bridged into your real terminal, tmux-style: Ctrl+Q
    detaches, the session lives on, reattach from anywhere — including a
    session running on another machine.
  • Concurrency guard. Opening a folder that has a live session on another
    machine asks for confirmation first — two agents editing one folder can
    overwrite each other.
  • Phone-friendly. On narrow screens the session list is the screen and
    terminals open as a full-screen overlay with a back button and thumb-sized
    quick-answer keys.

What it is not

  • Not multi-user. There is no login system; the trust boundary is your
    private network (see Security).
  • Not a CI runner or an agent framework. It hosts and moves interactive
    sessions; the sessions themselves are stock Claude Code or stock Codex.

Architecture in one page

Every machine runs the same hub (Node + Express + node-pty). Hubs are
peers; there is no controller. Three subsystems ride on that:

                 ┌──────────────── tailnet (WireGuard) ───────────────┐
                 │                                                    │
   ┌─────────┐  discovery: probe tailnet devices on :5959-5969  ┌─────────┐
   │  hub A  │◄────────────────────────────────────────────────►│  hub B  │
   │ (laptop)│  federation: control WS + terminal proxy + REST  │(desktop)│
   └────┬────┘                                                  └────┬────┘
        │                                                            │
        │        ┌──────────────────────────────────────┐            │
        └───────►│         home machine (hub C)         │◄───────────┘
                 │  e.g. a $5 VPS, or any always-on box │
                 │  • Syncthing canonical folder copies │
                 │  • durable transcript store          │
                 │  • release shelf for auto-update     │
                 └──────────────────────────────────────┘
  • Discovery — each hub asks the local Tailscale daemon for the device
    list every 60 s and probes each online device for /api/hub-info.
    Finding a sibling → one outbound control WebSocket. Zero configuration;
    new machines appear on every dashboard within a minute.
  • Federation — session lists are merged (tagged by machine), actions
    (spawn / kill / resume) are forwarded to the owning hub, and terminal
    WebSockets are proxied transparently. Peers that drop stay visible as
    UNREACHABLE cards with cached state; sessions themselves never depend on
    the links.
  • Home machine — by convention a machine named vps-node (override with
    env vars, below). It is an ordinary fleet member that happens to never
    sleep, and it hosts the three durable stores. Any always-on machine works.

Status detection

Four signals fused with precedence (higher wins, 3-second shield):

  1. PTY exit → EXITED
  2. Agent hooks. Claude Code: injected per-session via --settings, no
    changes to your global settings — UserPromptSubmit → WORKING,
    AskUserQuestion → WAITING_QUESTION, Stop → IDLE, permission notification
    → WAITING_PERMISSION. Codex: the hub keeps its own entries in
    ~/.codex/hooks.json (yours are preserved) and launches with
    --dangerously-bypass-hook-trust so they run without the per-hook review
    prompt — UserPromptSubmit → WORKING, PermissionRequest →
    WAITING_PERMISSION, Stop/Interrupt → IDLE. Codex mints its own
    conversation id; the SessionStart hook reports it and the hub binds it to
    the card it just launched.
  3. ~/.claude/sessions/*.json watcher — Claude Code's own status feed.
  4. Output heuristics — fallback until the first hook arrives (a resumed
    session fires none until its first prompt). Output alone is not work: a
    resize or an opened tab repaints the whole screen. Once a burst settles the
    hub reads the screen the TUI drew — its activity line ("✻ Thinking… (12s ·
    tokens)") means WORKING, an empty composer means IDLE — and each agent
    contributes its own prompt patterns.

Codex conversations are found through the threads table of
~/.codex/state_*.sqlite (read with Node's built-in sqlite) and live as
rollout files under ~/.codex/sessions/. Moving one to another machine is a
file copy — Codex rebuilds its index on resume. The first launch in a folder
would ask Codex's "do you trust this directory?" question; the hub records the
yes answer in ~/.codex/config.toml beforehand, the same way pressing Yes does.

Alerts fire only after a state persists 1.5 s. They reach every connected
dashboard (chime + tab badge). There are deliberately no desktop toasts.

Provenance

Every hub keeps data/provenance.jsonl (rotated at 20 MB, five files kept):
one line per action — every change (prompts, keys, kill, create, run,
rename, memories, handoffs, …) and every read made by an agent, a gateway
client or another hub on someone's behalf. Each line names the caller
(session:<hubId>/<conversation>@<machine> from the MCP server,
gateway:<client>, dashboard, hub:<machine> for a hub's own work), the
hubs it passed through, the session it acted on, what was asked (first 300
characters) and the answer. A request keeps one id across every hop, so the
AUDIT window's cross-requests log, merged from all machines, follows an action
from a gateway client or an agent to the hub that carried it out. Dashboard
polling and hubs' background syncing are not logged.

Fleet etiquette

Several agents work side by side in a fleet, so each one is told where it is
and how to behave towards the others.

  • Briefing (level 1). Every session the hub spawns is told, as extra
    system instructions, that it is a Deckhand fleet session, its hub id,
    conversation id, engine, machine and folder, and the rules: your folder is
    yours, another live session's folder is theirs, talk to other sessions only
    through the deckhand-agent MCP tools, agree a change before making it in
    someone else's code. Claude Code receives it via --append-system-prompt,
    Codex via developer_instructions. The same identity is in the process
    environment as DECKHAND_SESSION, DECKHAND_CONVERSATION,
    DECKHAND_MACHINE, DECKHAND_ENGINE, DECKHAND_FOLDER,
    DECKHAND_HUB_URL. The rules text is served at GET /api/etiquette.
  • Reach (the deckhand MCP server). Every spawned session is also handed
    the hub's own MCP server, mcp/deckhand-mcp.mjs (stdio, no dependencies,
    runs under the hub's node). Claude Code gets it through --mcp-config
    (data/hub-mcp.json, next to the project's own .mcp.json), Codex
    through -c mcp_servers.deckhand.*. Its tools mirror the RavenAgents
    deckhand-agent server with the same names and camelCase parameters:
    dh_whoami, dh_list_sessions, dh_get_session, dh_read_screen,
    dh_read_exchanges, dh_last_reply, dh_wait_for_session,
    dh_send_prompt, dh_send_keys, dh_set_autoyes, dh_create_session,
    dh_kill_session, dh_resume_session, dh_remove_session,
    dh_fleet_resume, dh_handoff, dh_list_fleet, dh_list_agents,
    dh_list_projects, dh_list_fleet_folders, dh_hub_info, dh_etiquette,
    plus dh_run (a command on any machine, output and exit code back),
    dh_rename_session and the fleet memory tools below.
    It talks to the hub at DECKHAND_HUB_URL and never calls admin routes.
  • Fleet memory. Small facts every session on every machine shares — "the
    user has the Apple Developer subscription", "WSL2 is on the laptop for
    ROS2" — so a new session does not have to ask. Sessions write with
    dh_remember (scope global, machine or project folder), search with
    dh_recall, delete with dh_forget; the briefing tells them to store facts
    about the world around the code, never task progress, and always to store
    what the user says to remember (marked told by the user, listed first,
    never pruned). At spawn and resume the briefing carries the memories that
    apply to that machine and folder (about 6 KB at most). The store machine
    (HUB_STORE_MACHINE, the VPS) holds the truth; every hub keeps a SQLite
    replica (data/memories.sqlite, FTS5 via node:sqlite) pulled every
    minute, so recall and briefings work while the VPS is away, and a write
    made then waits in an outbox. Facts are capped at 500 characters; beyond
    400 live ones the oldest unprompted facts are pruned. A folder rename
    carries its project memories along.
  • Warning (level 2). The PreToolUse hook the hub already injects answers
    an edit (Edit/Write/MultiEdit/NotebookEdit, Codex
    apply_patch) that lands in a project folder owned by another live
    session
    with a warning, delivered to the agent as additional context and
    to the user as a system message. Nothing is blocked: the agent is told
    whose folder it is and which hub id to talk to, and decides.
  • Commands (dh_run). The same ownership rule, enforced: a command
    whose working folder belongs to another live agent session is refused
    (409, naming the owner) until it is repeated with force. Only Claude and
    Codex sessions own folders; terminal cards never do.

Requirements

What Why Notes
Windows 10/11, macOS, or Linux hub runs on all three macOS: Apple silicon or Intel, LaunchAgent
Tailscale (free) discovery, transport, trust boundary required for any multi-machine features
Node.js 20+ runs the hub winget install OpenJS.NodeJS.LTS / brew install node / distro package
Claude Code the sessions themselves logged in on each machine that runs sessions; a machine without it still runs terminal cards, and the launch dialog can install it
Codex CLI optional second agent npm i -g @openai/codex, logged in; machines without it simply don't offer it
Syncthing (free) folder replication optional — skip it and everything but folder sync works

A single machine with none of the above except Node + Claude Code still
works as the original standalone dashboard.


Install (each machine)

  1. Join the tailnet — install Tailscale, sign in. Give servers a
    friendly hostname (tailscale up --hostname mybox).

  2. Get the code — extract a release tgz, or git clone. Then:

    npm install
    
  3. Set the projects root (where your project folders live, and where
    fleet folders materialize). Windows: create hub-env.cmd next to
    start-hub.cmd:

    set HUB_PROJECTS_ROOT=D:\Projects
    

    macOS/Linux: pass HUB_PROJECTS_ROOT to the installer (defaults
    ~/Projects on macOS, /srv/sync on Linux), or create hub-env.sh next
    to start-hub.sh with export HUB_PROJECTS_ROOT=….

  4. Install the always-on runner:

    Windows — one installer, two modes (it asks if you don't pick):

    powershell -ExecutionPolicy Bypass -File scripts\install.ps1 -Mode startup
    powershell -ExecutionPolicy Bypass -File scripts\install.ps1 -Mode service -Account .\<you>
    powershell -ExecutionPolicy Bypass -File scripts\install.ps1 -Uninstall
    
    • startup — scheduled task at logon. No password, no admin, works with
      Microsoft-account/PIN sign-ins, auto-restarts on crash. Runs in your
      interactive session. Recommended default.
    • service — NSSM Windows service. Starts at boot before login; requires
      an elevated shell and a local-account password (Microsoft-account
      sign-ins usually reject service logon — either switch the account to
      local sign-in or use startup mode).

    macOS (per-user LaunchAgent: starts at login, kept alive, also how
    updates apply; runs in your login session so Claude Code and Codex see
    your sign-ins — keep the Mac from sleeping):

    bash scripts/install-macos.sh
    bash scripts/install-macos.sh --uninstall
    

    To survive a reboot unattended, turn on automatic login (System Settings
    → Users & Groups), which requires FileVault off. The hub usually starts
    before the Tailscale app at login and runs solo under its OS hostname for
    a minute or two (the log says tailscale status failed); it joins the
    fleet under its tailnet name as soon as the app answers.

    Linux (systemd; also how updates apply):

    sudo bash scripts/install.sh
    sudo bash scripts/install.sh --uninstall
    
  5. Windows firewall: when first run interactively, click Allow on
    the prompt. Services get no prompt — if other machines can't reach this
    hub, add the rules once (elevated; the UDP one is for LAN discovery):

    New-NetFirewallRule -DisplayName Deckhand -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5959-5969
    New-NetFirewallRule -DisplayName "Deckhand beacon" -Direction Inbound -Action Allow -Protocol UDP -LocalPort 5959-5969
    
  6. Open http://127.0.0.1:5959. Within a minute every other dashboard in
    the fleet shows this machine, and vice versa.

Enabling folder sync (once per fleet)

Install Syncthing on each machine that should sync (winget install Syncthing.Syncthing, run with --no-console --no-browser; apt install syncthing + systemctl enable --now syncthing@<user> on Linux).

On the home machine, bind its Syncthing API to the tailnet and write the
hub's sync config data/sync.json:

{
  "local": { "url": "http://127.0.0.1:8384",        "apiKey": "<its syncthing api key>", "deviceId": "<its device id>" },
  "vps":   { "url": "http://<home-tailnet-ip>:8384", "apiKey": "<same>",                 "deviceId": "<same>", "folderRoot": "/srv/sync" }
}

(on the home machine itself local and vps are the same endpoint).
Every other machine needs nothing: a hub without data/sync.json asks a
fleet peer for the home endpoint, reads its own local Syncthing credentials,
and writes its own config within a minute or two of starting.

API keys live in Syncthing's config.xml (%LOCALAPPDATA%\Syncthing on
Windows, ~/.local/state/syncthing on Linux). Device pairing, folder
registration, ignore patterns (node_modules etc.) and versioning are all
managed by the hubs — never configure folders in the Syncthing GUI by hand.


Using it

Dashboard

  • Cards (left) — every session in the fleet, machine-tagged, sorted by
    urgency: WAITING first, then WORKING, IDLE, EXITED, with unreachable
    machines' cards at the bottom. Inside the working, idle and exited bands
    the card that entered the state most recently is on top, so a session that
    just finished sits right under the working ones and one that just exited
    sits right under the idle ones. Cards show elapsed time in state and the
    last assistant message.
  • Terminal tabs (right) — click a card to open its terminal as a tab.
    – (or middle-click) closes the tab and leaves the session running; ×
    ends the session (it stays resumable). On another machine's session,
    keystrokes are proxied through the mesh — there is no difference in use.
  • Quick answers — ENTER / 1 / 2 / 3 / ↓ / ESC buttons and a one-line
    prompt box under the terminal, for triaging permission prompts without
    clicking into the terminal.
  • Drag & drop — drop files onto the terminal panel; they upload to that
    session's machine and the saved path is typed into the prompt (browsers
    can't reveal real file paths — the hub bridges that).
  • + NEW SESSION — pick machine, engine (Claude, Codex or Terminal),
    folder (listed from that machine; ~ for a terminal), permission mode,
    model (the shell, for a terminal), optional first prompt (typed into a
    terminal as a command). INSTALL buttons appear for engines that machine
    lacks.
  • RESUME ⟳ — the fleet resume dialog: pick the machine that will run
    the session, optionally filter by source machine, pick a folder
    (badged LOCAL / ON VPS / on other machines, with live-session warnings),
    pick one of its conversations (merged from every machine plus the durable
    store, newest first). If the folder isn't on the target, you watch it
    materialize: source pushing → pulling → transferring conversation →
    starting
    .
  • RENAME (on a stopped card) — a session is named after its folder, so
    renaming it renames the folder on every machine that holds a copy, and
    everything keyed by it follows: Claude's conversations and auto-memory
    (~/.claude/projects/<folder>), Codex's thread index and rollouts, the
    fleet store, the frozen flag, the Syncthing folder and the cards. Refused
    while any session in that folder is running anywhere, or when the new name
    is taken on any machine. A machine that is offline catches up by itself:
    every hub keeps a log of fleet renames (data/renames.json, shared between
    hubs), and a hub that reconnects applies the ones it missed, unless a
    session is running in that folder there (it retries every minute), the
    new name is taken there, or the folder is frozen.
    A terminal card just gets the new name. Agents: dh_rename_session.
  • ADMIN — per-machine version, build, uptime; check for updates; UPDATE
    and RESTART buttons that work across the fleet from any dashboard.

The deckhand CLI

Put cli/ on your PATH (hubclaude remains as a legacy alias).

deckhand                  spawn a hub-owned session in the current directory and attach
deckhand attach <name>    attach to a running session — any machine in the fleet
deckhand ls               list fleet sessions

Ctrl+Q detaches; the session keeps running and is also visible in every
dashboard (both can be attached at once — keystrokes merge live). With no
hub on 127.0.0.1:5959-5969 the CLI broadcasts DECKHAND? on the LAN and
takes the first hub that answers; set HUB_URL to skip the search and
HUB_TOKEN (that machine's data/api-token) when the hub is a LAN one.

REST API (automation)

Everything the dashboard does is plain HTTP on the hub, and a few extra
routes exist only for scripts: type a prompt, wait for the turn to end, read
the reply, read the screen. The full reference, written to be handed to
another agent as its briefing, is docs/automation-api.md. Talk to any hub in the fleet — per-session calls
are forwarded to the machine that owns the session, and machine on a spawn
picks where it runs. Same trust rule as the dashboard: callers must come from
localhost or the tailnet.

One-shot question, answer in the response:

curl -X POST http://127.0.0.1:5959/api/sessions \
  -H 'Content-Type: application/json' \
  -d '{"cwd":"C:\\DataDrive\\myproject","agentType":"claude",
       "initialPrompt":"Summarize the README in one sentence.","wait":120000}'
→ { "hubId":"3724583a", "state":"IDLE", "reply":"…", "timedOut":false, … }

Then keep the conversation going:

POST /api/sessions/:id/prompt     {"text":"…","wait":120000}   → same shape, with "reply"
POST /api/sessions/:id/keys       {"keys":["down","enter"]}     → answer a menu or prompt
GET  /api/sessions/:id/wait?until=settled&timeout=120000        → long-poll
GET  /api/sessions/:id/screen[?scrollback=200][&format=ansi]    → the terminal as text
GET  /api/sessions/:id/exchanges?n=5                            → [{q, r}, …] oldest first
GET  /api/sessions/:id                                          → card data
POST /api/sessions/:id/kill | /resume | /restart | /remove | /autoyes {"on":true}

Rules of the road:

  • wait is true or a millisecond budget, capped at 240 s per call.
    A response with "timedOut": "start" means the prompt never started a
    turn (read screen — usually a trust or permission prompt is up);
    "done" means the turn is still running: call wait again. Long turns
    are just a loop over wait.
  • prompt refuses a WORKING session with 409 unless "queue": true;
    the TUI would stack it behind the running turn.
  • reply is the agent's text for that prompt, pulled from its transcript.
    Codex and Claude both provide it; an engine with no readable transcript
    returns no reply and 404 on exchanges.
  • keys understands enter esc tab backspace up down left right ctrl-c ctrl-d ctrl-u shift-tab; anything else is sent as typed. {"data":"…"}
    sends raw bytes.
  • until for wait is settled (default — anything but STARTING/WORKING),
    changed (leaves the current state; pass &from=WORKING to name it), or a
    state: IDLE WORKING WAITING_PERMISSION WAITING_QUESTION EXITED.
  • Permission prompts: either turn on autoyes for the session or watch for
    WAITING_PERMISSION and answer with keys. Plan-mode exit is never
    auto-approved.
  • Folder trust is handled for you: before every spawn the hub records the
    folder as trusted for that engine (Claude's ~/.claude.json, Codex's
    config.toml), so a brand-new project starts its first turn without a
    "do you trust this folder?" prompt. If some other prompt does appear, the
    session sits in STARTING — screen shows it, keys answers it.

Outside agents (through Mimir)

Agents that cannot join the tailnet (an assistant bot on a cloud computer,
say) reach the fleet through Mimir, RAVEN's broker: its own service and
its own public gateway (mimir-gateway on vps-node, published by Tailscale
Funnel at https://<vps>.<tailnet>.ts.net; enrollment, signed and sealed
requests, webhook push and inbox live there, in Mimir's repo). Deckhand keeps
no gateway; the seam is three calls:

  • Sessions hand Mimir tasks: dh_mimir_task(text) ("research the best
    hotel in London") → hub POST /api/mimir/tasks → Mimir's
    /api/v1/tasks, carrying the session's conversation id. Mimir delegates to
    an enrolled agent as T7 and answers the session with a
    [Mimir, task T7] … prompt (outside-agent data, not instructions).
    Tasks are conversations: dh_mimir_task_status shows the state (running,
    needs_input, needs_approval, blocked, … completed) and, when the
    task waits on the session, its latest history; dh_mimir_task_reply answers
    on the same T# (input, approval, message) and dh_mimir_task_cancel cancels.
    Mimir carries questions and answers; it never decides an approval.

  • Agents act in Deckhand through Mimir, within their grants: Mimir calls
    the hub API for sessions, prompts and memories as
    X-Deckhand-Caller: gateway:<agent> with the delegate key
    (data/delegate.key on desktop-master) in X-Deckhand-Delegate-Key.
    The hub decides what that agent may reach before the request goes near a
    session (server/agentAccess.ts), deny by default:

    grant the agent may
    none nothing — its session list is empty, every session answers 404
    discover see the session in lists and read its card
    observe + exchanges, screen, wait
    interact + prompt; read and write project memories of that folder

    Grants cover a project folder, every folder (*), or one conversation. A
    folder grant matches the session's project — the top folder under its
    hub's projects root, stamped on the session by the owning hub — so it covers
    everything inside that project and never a same-named folder elsewhere;
    project names are the fleet-wide identity (sync, resume, handoff and rename
    key on them). Grants survive restarts, resumes, migrations and renames.
    Sessions outside any projects root are reached only by * or a session
    grant. Anything else — spawn,
    kill, keys, files, admin, Mimir tasks, global memories — is refused to
    agents. A hub that forwards an agent's call keeps its caller, and the target
    hub checks again. Enrollment in Mimir, reachability and routing never imply
    access.

  • Grants are made only in ADMIN → TRUST (/api/trust, refused to
    sessions, agents and Mimir). Each is stored as a fleet memory (origin
    trust-panel, tags trusted-agent client:<id> access:<level> folders:<A,B|*> / sessions:<id>), so every session reads it in its
    briefing — but the hub decides from the tags, in code. dh_remember and
    POST /api/memories refuse anything that reads as a grant, and grants
    cannot be forgotten except from the panel. Older plain-memory trust entries
    are listed there, unenforced, with a one-click convert. Prompts still arrive
    stamped [Deckhand gateway, client <id>] [via Mimir, …] and sessions apply
    the trust rule as a second layer. Terminals are never reachable.

  • ADMIN and AUDIT show Mimir's view of the agents (GET /api/mimir/agents
    → Mimir's /api/v1/agents): enrolled agents, live or not, open tasks, last
    messages, and pending access requests, which are accepted on Mimir's
    /admin/agents. Clicking an agent in AUDIT filters the cross-request log to
    what it did in Deckhand.

The hub reads Mimir's task key from Mimir's data/secrets/deckhand-hub-tasks.key
on desktop-master (HUB_MIMIR_URL, HUB_MIMIR_TASKS_KEY, HUB_MIMIR_MACHINE);
other hubs forward Mimir calls there.

Even Realities glasses (G2 + R1)

The Even app's built-in Terminal client is made for Even's own
even-terminal server. Every hub speaks that server's protocol on port
3456, so the glasses drive the whole fleet instead of one laptop:

  • the session list shows every agent session on every machine, ones waiting on you first;
  • a prompt (typed or spoken in the Even app) goes to the chosen session; an exited
    session is resumed first, and a prompt with no session starts one in
    <projects root>/glasses (or HUB_EVEN_CWD);
  • tool calls show as one-line summaries, the reply arrives when the turn ends;
  • permission prompts and questions show their real options, answered with the ring;
    the bridge presses the matching key in the session;
  • interrupt sends Esc.

Pair once in the Even app's Terminal client (scan or type the server): URL
http://<hub's Tailscale IP>:3456 and the hub's API token (data/api-token).
Over Tailscale the token isn't checked; on the Wi-Fi it is. Terminal cards are
not listed. Remote sessions show tool lines from the owning hub's status
("using Bash") rather than full summaries.

While you are in one session, the others still reach you: when any session
needs permission, asks a question or finishes a turn, a notification line
appears in the session you are watching.

Publishing updates (the machine you develop on)

powershell -File scripts\publish.ps1

Builds the frontend, stamps version.json (semver + git commit count as a
monotonic build number), tars the tree, uploads to the home machine's
release shelf. Every hub notices within 6 hours, or immediately via
ADMIN → CHECK FOR UPDATES. Every check prestages a newer build: it is
downloaded to data/update-staged.tgz and verified (complete download, gzip
checksum) before it counts, so applying installs from that file and works
while the release machine is away, e.g. restarting for its own update; a bad
download never replaces a good staged copy. ADMIN shows "build N staged" per
machine. Applying: Windows hands off to a detached runner
(swap files, npm install, relaunch — or let the service manager relaunch);
macOS/Linux extract in place and exit for launchd/systemd to restart. A dev checkout
(no version.json → build 0) reports DEV in ADMIN and never auto-updates.

ADMIN → UPDATE ALL pins the fleet to the newest published build: it
leaves a mark, fleet-target.json, on the release machine (publishing does
not touch it), has every connected hub stage the release, then tells them to
update, the release machine last among them, and updates the hub you
clicked on last of all. A machine that was off reads the mark ~90 s after its
hub starts and every 10 minutes after that, and updates itself when it is
behind (live sessions are saved first and come back after the restart). The
ADMIN dialog shows the current fleet target. Hubs only install the newest
published build, so a machine catching up later lands on the target or newer.


Configuration reference

All optional; set in the environment (hub-env.cmd on Windows, hub-env.sh
or the LaunchAgent on macOS, the systemd unit on Linux).

Variable Default Meaning
HUB_PORT 5959 listen port; if taken, walks up to +10 (fleet probes 5959–5969)
HUB_PROJECTS_ROOT ~\Projects / ~/Projects / /srv/sync where projects live and fleet folders materialize; the hub refuses to materialize folders if it doesn't exist
HUB_DATA_DIR <repo>\data runtime state (below)
HUB_STORE_MACHINE vps-node fleet machine holding the durable transcript store
HUB_UPDATE_MACHINE vps-node fleet machine holding the release shelf
DECKHAND_SERVICE unset set by service installers; tells the hub a supervisor handles relaunches
HUB_SOLO unset skip fleet discovery entirely — for a throwaway test hub on an odd port that must not join the fleet
HUB_LAN 1 0 = refuse LAN callers even with the token, and don't run the beacon
HUB_EVEN_PORT 3456 glasses bridge port (Even app Terminal client); 0 = off
HUB_EVEN_CWD <projects root>/glasses where a session started from the glasses without a folder runs
HUB_BEACON 1 0 = don't answer DECKHAND? / announce on UDP
HUB_BEACON_PORT 5959 UDP port the beacon listens on (walks up to +10 if taken)
HUB_TOKEN unset deckhand CLI: bearer token for a LAN hub (data/api-token on that machine)
HUB_URL probe localhost deckhand CLI: explicit hub address

data/ contents: hub-hooks.json (generated hook settings injected into
sessions), hub-sessions.json (resume records for EXITED cards; sessions that were running when the hub went down are relaunched at the next boot, a couple of seconds apart — HUB_RESTORE=0 turns that off),
sync.json (Syncthing endpoints — contains API keys, never commit),
api-token (the LAN bearer token — same rule),
drops/ (drag-and-drop uploads), hub.log (launcher-captured output),
transcripts/ (the durable store, home machine), releases/ (release
shelf, home machine), update.tgz + update-runner.cmd (transient, during
updates).


Security model

Read this section before port-forwarding anything.

  • The dashboard executes commands on your machines by design — treat
    access to it as shell access to the entire fleet.
  • The hub binds 0.0.0.0 and sorts callers into three classes:
    • loopback, the machine's own addresses, tailnet sources
      (100.64.0.0/10, fd7a:115c:a1e0::/48) — trusted, no token.
      The tailnet is the trust boundary: any device you admit to your
      tailnet can drive your fleet. Don't admit guests.
    • private LAN (10/8, 172.16/12, 192.168/16) — allowed with the
      API token
      in data/api-token (generated on first start). Present it
      as Authorization: Bearer …, or open http://<lan-ip>:5959/?token=…
      once in a browser — that sets an HttpOnly cookie and the dashboard works
      from a phone on the Wi-Fi. Wrong or missing token → 401. HUB_LAN=0
      turns LAN access off (tailnet-only, as before).
    • everything else — 403 / dropped, token or not.
  • LAN discovery: the hub answers DECKHAND? on UDP 5959 with its
    hub-info JSON (machine, build, HTTP port, URLs) and announces itself every
    30 s. Send to the LAN's directed broadcast (192.168.1.255), not only
    255.255.255.255 — Windows routes the limited broadcast out one
    interface, often the Tailscale one. HUB_BEACON=0 disables it. The
    beacon reveals nothing the token doesn't already protect.
  • There is no TLS (WireGuard encrypts tailnet transit; the LAN is
    plaintext, so the token is only as private as your Wi-Fi). Never expose
    the port publicly; if you want access from browsers outside both
    networks, put an authenticating proxy (e.g. Cloudflare Tunnel + Access)
    in front — the hub itself should stay unreachable from the internet.
  • Syncthing API keys in data/sync.json grant control of file sync; the
    file is gitignored — keep it that way.

Known gotchas (all discovered the hard way)

Symptom Cause / fix
Tailnet dead while a commercial VPN is on VPN clients (NordVPN etc.) claim the 100.64/10 range or block non-VPN traffic. Disconnect, or scope the VPN to selected apps (split tunneling). Their "Meshnet"-type features conflict permanently — keep them off.
Machine reachable but hub isn't Windows firewall blocking inbound for the service context — add the port rule (Install §5).
Service "Running" but hub dead, or crash-looping Look at data/hub.log (Windows) / journalctl -u deckhand (Linux). If the service logon account is wrong, NSSM runs but the app dies instantly.
Two hubs on one machine, port walked to 5960 Two supervisors installed (startup task and service). Keep one; the installer warns and cleans up its own kind.
Service Log On rejects your Microsoft-account password MSA/PIN sign-ins usually can't do service logon. Use startup mode, or switch Windows to local-account sign-in.
Resume dialog shows a folder but no conversations Conversations attached to a live session are hidden (resuming them would fork). EXITED sessions' conversations show normally.
Migrated session talks about old paths It's oriented automatically on migration; for old sessions just tell it the folder moved.
Old transcripts vanish Claude Code prunes them (default ≈30 days). Deckhand's store keeps its copies forever; also consider "cleanupPeriodDays": 3650 in ~/.claude/settings.json.

Development

npm run dev      # tsx watch server + vite dev server on :5960 (proxies to :5959)
npm run build    # vite build → server/public (what npm start serves)
npx tsc --noEmit && npx tsc -p web --noEmit   # typecheck both sides

Layout:

  • server/ — index.ts (HTTP/WS API, wiring), sessionManager.ts (PTYs +
    headless terminal mirrors), statusEngine.ts (signal fusion),
    discovery.ts (tailnet probing), federation.ts (peer connections,
    merged sessions, forwarding), syncthing.ts (on-use folder sync driver),
    fleetFolders.ts (folder + conversation index), fleetResume.ts
    (cross-machine resume jobs), transcriptStore.ts (durable store +
    pusher), updater.ts + version.ts (auto-update), hooksReceiver.ts,
    claudeSessionsWatcher.ts, transcripts.ts, conversations.ts,
    persistence.ts, config.ts.
  • web/ — vite vanilla-TS frontend (xterm.js): main.ts, sessionList.ts,
    terminalView.ts, resumeDialog.ts, newSessionDialog.ts,
    adminDialog.ts, quickAnswers.ts, styles.css.
  • cli/deckhand.mjs — terminal attach client.
  • hooks/hub-hooks.template.json — hook settings injected into sessions.
  • scripts/ — install.ps1, install.sh, publish.ps1, vps-bootstrap.sh.

License

Apache License 2.0 — Copyright 2026 Cristian Mori. See LICENSE.

Related projects

Part of a family of open-source libraries by @CristianMori.

Reviews (0)

No results found