Skip to content

Latest commit

 

History

History
1245 lines (1036 loc) · 76.7 KB

cli-reference.md

File metadata and controls

1245 lines (1036 loc) · 76.7 KB

CLI Reference

Every plumb command, argument, and flag. Run plumb <command> --help for the same information at the terminal.

Plumb has no global flags. Daemon logging is controlled through configuration (log_level, log_format, log_file), the PLUMB_LOG_* environment variables, and — at runtime — the log-level command.

Many commands resolve the workspace by walking up from the given path (or the current directory) until they find a .plumb/ marker, a language root marker (go.mod, pyproject.toml, …), or a .git/ directory — the same way the daemon does. A git repo with no language marker resolves to its git root with language none.

Command index

Command Summary
plumb Launch the interactive TUI dashboard
plumb serve Start the MCP server over stdio (the command MCP clients run)
plumb daemon Run the shared background daemon (usually automatic)
plumb stop Stop the background daemon
plumb restart Restart the daemon (stop + fresh spawn)
plumb service Manage the account-wide service: its launch profile (install / show / set-*)
plumb init Create a .plumb/ workspace marker
plumb setup Register plumb as an MCP server for a client
plumb skills Show or sync plumb's embedded skills per client
plumb hooks Status of the opt-in lifecycle hooks, per client (install / uninstall write them)
plumb doctor Run health checks
plumb config Inspect resolved configuration
plumb sessions List active sessions
plumb mail Report whether a session has unread agent-to-agent messages
plumb history Review the diffs of every file write plumb made
plumb stats Show tool-call statistics (alias: status)
plumb diagnostics Print LSP diagnostics (alias: diag, diags)
plumb log-level Change the running daemon's log level
plumb enable-lsp Enable a language server in the running daemon without a restart
plumb debug Daemon introspection: memory, heap/stack dumps, LSP state, daemon census
plumb version Print version information

plumb (dashboard)

plumb

Run with no subcommand, plumb launches the interactive TUI dashboard — a read-only live monitor of the daemon built with Bubble Tea v2. Sections (opened with /): Dashboard, Sessions, Memory, Logs, Settings. The Settings section includes a live theme picker; the active theme is read from [ui].theme in the global config.

In the Sessions section, press r to rename the selected session and a to refresh; both are also listed in the right panel's footer and the in-app help overlay (ctrl+h). Press q or ctrl+c to quit. See the TUI conventions in CONTRIBUTING.md for navigation details.

Copying (c). In the log detail and the call-detail popup, c copies to the system clipboard by piping into a platform helper: pbcopy on macOS, and on Linux wl-copy under Wayland (wl-clipboard) or xclip/xsel under X11 — including XWayland, when DISPLAY is set but wl-copy is not installed. With no helper available, and on a bare SSH session or TTY, it falls back to an OSC 52 escape sequence, which needs no binary but depends on the terminal honouring it. The status line says which happened and never reports a copy it cannot verify. plumb doctor reports the resolved helper under Dev Tools.


plumb serve

plumb serve

Start the MCP server over stdio. This is the command MCP clients invoke. serve is a resilient, frame-aware proxy: it dials the daemon's Unix socket — spawning plumb daemon if none is running — and proxies MCP frames between the client and the socket. On a daemon crash or hang it respawns the daemon and replays the captured initialize handshake, so the client never notices. It registers no tools and owns no language-server processes itself.

If the running daemon's build version differs from this binary's, serve prints a warning to stderr suggesting plumb restart to refresh.

A daemon an older binary left running is found first. An upgrade moves where the daemon lives, but not the daemon already started: it is still serving at the runtime directory its environment resolved. Before deciding to start anything, serve probes each of those legacy locations by dialling its MCP socket, and resolves the socket, control socket, pid file and version file of that location as a set — reading the version from one directory and dialling another is what makes a half-migrated process. A daemon that published this binary's build version is reused, and the log says migration pending; run plumb restart. One from a different build, or one that published no version at all (which cannot be confirmed compatible), makes serve fail and say what to do:

plumb: a plumb daemon from a different build is already running at <socket>
(its version: <its build>; this binary is <yours>): this plumb will not start a
second daemon beside it, and it is not migrated on its own — run `plumb restart`
to replace it with this build, or `plumb service install` to record which binary
the account service runs

Nothing is started beside it and nothing about it is migrated silently. A socket file nobody accepts on is not a daemon — that is what a crashed daemon leaves behind, and it does not block a launch. The probe takes no lock and creates no file: a probe that held the lifetime lock, even for an instant, would make a concurrent serve wait out its whole start deadline and fail.

Flag Default Effect
--no-reconnect false Disable the reconnecting proxy; fall back to a plain byte copy (legacy behaviour).
--allow-dir <path> — Grant an extra read-write root to this connection (repeatable). Additive to the pinned workspace and config extra_roots; never replaces them. On a serve with no workspace pinned yet (no --workspace/PLUMB_WORKSPACE, no session_start yet) the grant is inert until the first pin, then additive to it — a grant never supplies a workspace. Also read from PLUMB_ALLOWED_DIRS (OS-list-separated). Each path is $VAR-expanded and made absolute, then canonicalised (symlink-aware) by the daemon. Requires the resilient proxy (the default); ignored under --no-reconnect.
--attach-only false Dial the running daemon and never start one: if nothing is listening, serve fails instead of spawning. The resilient proxy's redial takes the same path, so a reconnect cannot start a daemon either. Use it in scripts and test harnesses that must not become the reason a daemon exists.
--workspace <path> — Pre-pin this connection's workspace to <path> — for MCP clients that spawn serve without controlling its directory and report no roots. Also read from PLUMB_WORKSPACE; the flag wins over the env var. Without either, serve starts unattached and session_start pins the workspace. The value is $VAR-expanded and made absolute, then validated (symlink-aware) by the daemon. Requires the resilient proxy (the default); ignored under --no-reconnect.

The --allow-dir grant is transported to the daemon inside the captured initialize frame's params._meta (dev.plumbkit/allow-dirs), so it rides the handshake replay automatically — a reconnected daemon re-applies it with no separate message. The grant is per-connection: it never leaks into another client's session, and it survives a workspace re-pin.

--attach-only exists for callers that must not start anything. Ordinarily a serve that finds no daemon starts one — the installed binary the service profile records (plumb service show) — but a scripted client, a harness or a diagnostic wants to know whether a daemon is already there. With the flag, serve dials, waits only while a daemon is demonstrably starting or running, and exits non-zero otherwise.

serve also transports an explicit workspace pre-pin the same way (dev.plumbkit/workspace) for clients that report no MCP roots (e.g. Claude Desktop): --workspace/PLUMB_WORKSPACE, when given. There is deliberately no serve-cwd fallback — the proxy's working directory is wherever the MCP client's launcher happened to run it, so it is not treated as a declaration of intent. A serve started with neither starts unattached: no workspace, no pin, and relative-path calls fail closed until the caller pins with session_start({"workspace": …}). The pre-pin is validated against project markers, never overrides an explicit choice, and is never persisted as the sticky pin.


plumb daemon

plumb daemon

Run the shared background daemon. Usually started automatically by serve — you rarely run this by hand. The daemon owns the language-server subprocesses (one per (root, language) — a single workspace root may host several, e.g. gopls + an HTML server), the per-connection MCP sessions, the stats database, and the topology pool.

It takes two exclusive flocks, in this order, for its lifetime: the account authority (<account data dir>/daemon.authority.lock, outside the removable runtime directory) and then the runtime lifetime lock (plumb.daemon.lock). A second plumb daemon invocation sees either held and exits immediately, enforcing the one-daemon-per-account invariant. Managed launches — plumb serve and plumb restart — take both before starting a child and hand them over on fds 3 and 4, so the locks are held from the moment the daemon exists; a launcher that finds either held waits and re-dials instead of starting a second candidate. A direct plumb daemon attempts the locks itself and exits if another daemon holds them.

No flags.


plumb stop

plumb stop [--force]

Stop the background daemon. The daemon is located in three stages: PID file → lsof on the socket → pgrep -f "plumb daemon". The pgrep fallback covers binary upgrades that changed the socket/PID path.

Flag Default Effect
--force false Stop without asking for confirmation.

Use plumb stop (or plumb restart) after rebuilding the binary so the next serve starts a daemon running your new code.


plumb restart

plumb restart [--force]

Stop the running daemon and bring a fresh one straight back up — the resilient proxy reconnects active clients. Use it after rebuilding so new code activates without manually stopping and waiting for the next serve. It is also how a daemon left behind by an older binary is migrated: serve reuses a compatible one and refuses an incompatible one, and restart is the command that replaces either with this build.

It stops by authority or pid identity, never by a process scan: the pid the holder of the account authority published, the pid file of the runtime directory in use and of every legacy one, and the process owning any of their sockets. A process that merely looks like a plumb daemon is left alone — a stray daemon surviving a restart is visible and recoverable, and killing somebody else's is neither.

The successor starts only after the old daemon's exit and the release of every lock are both confirmed, within the 30 s launcher deadline; if either cannot be confirmed, restart fails closed and starts nothing. There is no two-daemon window, restart included. The new daemon inherits the account authority, the runtime lifetime lock and the lifetime lock of every known legacy runtime location, so an old client's reconnect cannot win one and start a legacy successor in the gap.

Flag Default Effect
--force false Skip the confirmation prompt.

plumb service

plumb service install
plumb service show
plumb service set-executable <path>
plumb service set-path <path>
plumb service set-xdg [--config-home <dir>] [--data-home <dir>] [--state-home <dir>] [--cache-home <dir>] [--runtime-dir <dir>]

Manage the account-wide service's launch profile (<account data dir>/service.toml) — the record of which binary is the daemon, the PATH its language servers inherit, and the global directories it serves from, and the environment a launched daemon is given. The profile is what a managed launch obeys — plumb serve and plumb restart, the two commands that start a daemon — so that no client's HOME, XDG_* or PATH can decide what runs or where it keeps its files. plumb service install does not start anything: it only writes the profile.

Subcommand Effect
install Write the profile, or update the executable in an existing one. Starts nothing — run from the binary that should be the service; this is what a package manager's post-install step runs. Idempotent, and it never resets your settings: an existing profile keeps its directories, PATH and adopted XDG overrides, and only the recorded executable changes.
show Print the profile path and its contents.
set-executable <path> Point the service at another installed binary (an absolute path to a runnable file).
set-path <path> Replace the PATH the daemon and its children inherit.
set-xdg Adopt an XDG base directory explicitly, or drop one by passing an empty value. The service's directories for the roles that base governs follow it.

install records the account's standard directories by default (macOS: ~/Library/Application Support/plumb, ~/Library/Logs/plumb and ~/Library/Caches/plumb; Linux: ~/.config/plumb, ~/.local/share/plumb, ~/.local/state/plumb and ~/.cache/plumb). If the environment it runs in sets an XDG base that already holds a plumb directory of its own, that base is adopted and printed, so a real XDG setup keeps its configuration instead of being silently abandoned. A base that holds no plumb state — a session manager's throwaway temp directory — is never adopted.

A launch with no profile refuses: plumb serve fails with the plumb service install instruction rather than guessing an executable, a PATH or a directory. In this version the profile is consulted only when the account-authority mode is enabled; until it is the default, every build behaves exactly as before and the profile is ignored.


plumb init

plumb init [directory] [--discover]

Create a .plumb/ workspace marker in the current directory (or directory if given) and seed .plumb/context.md from a template. If .plumb/ already exists, the command reports its location and does nothing else.

Argument Description
directory Optional. Directory to initialise. Defaults to the current directory. (Max 1.)
Flag Default Effect
--discover false Auto-detect project structure (languages, build systems, entry points, test layout) and seed context.md from the discovery instead of the blank template.

.plumb/ also holds the memories/ store and — when [topology] is enabled — topology.db. Commit it to share project context with your team, or add it to .gitignore to keep it local.


plumb setup

plumb setup <client> [flags]
plumb setup --all
plumb setup

Register the current plumb binary as a stdio MCP server in a client's config. Setup helpers preserve any existing MCP servers (and any extra keys on an existing plumb entry, such as Codex's per-tool approval tables) and back up the config before modifying it. Registration is config-only — it never installs skill files; those come from plumb skills sync, and a named registration prints a hint when it notices the client's skills missing or stale. Subcommand descriptions are one short line each — "Register plumb in " — with the detail living in the tables below.

Bare plumb setup (no client, no flag) opens an interactive client picker on a terminal: one row per client, pre-classified from the filesystem, with the registered ones arriving checked. space toggles the row under the cursor — unchecking a registered client flips it to an explicit uninstall, shown in the warn colour, and pressing space again backs out, so the destructive direction is never a default; a marks every unregistered client for registration without ever creating an uninstall; enter applies the selection — registrations go through the --all engine (a re-register also repoints at the current binary), removals through the --uninstall writers — and reports the outcomes in one grouped status table; q quits without changing anything. Without a terminal, bare plumb setup prints help.

Subcommand Config target
plumb setup claude-desktop Claude Desktop's platform-specific JSON config (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json); also heuristically repoints any sibling Claude*/claude_desktop_config.json profile that already exists (e.g. Claude-Personal)
plumb setup claude-code ~/.claude.json (user scope) + ~/.claude/skills/ (skills via plumb skills sync)
plumb setup claude-code --project .mcp.json in the current directory (project scope) + ~/.claude/skills/ (skills via plumb skills sync)
plumb setup codex $CODEX_HOME/config.toml (or ~/.codex/config.toml) + $CODEX_HOME/skills/ (skills via plumb skills sync); writes omit_tools_from = ["direct"] so Codex defers Plumb schemas from its initial model surface while retaining the full MCP catalogue
plumb setup codex --lean Same, plus an enabled_tools allowlist pinning plumb's lean tool set
plumb setup gemini ~/.gemini/settings.json
plumb setup gemini --lean Same, plus an includeTools allowlist pinning plumb's lean tool set
plumb setup cursor ~/.cursor/mcp.json (shared by the editor and the cursor-agent CLI)
plumb setup augment ~/.augment/settings.json (the auggie CLI)
plumb setup qwen ~/.qwen/settings.json
plumb setup junie ~/.junie/mcp/mcp.json (mcpServers key) + ~/.junie/skills/ (skills via plumb skills sync)
plumb setup kimi-code $KIMI_CODE_HOME/mcp.json (or ~/.kimi-code/mcp.json; mcpServers key — the legacy Kimi Desktop reads the same file; Kimi Work does NOT, register it with kimi-work) + $KIMI_CODE_HOME/skills/ (skills via plumb skills sync)
plumb setup kimi-code --lean Same, plus an enabledTools allowlist pinning plumb's lean tool set
plumb setup kimi-work The Kimi Work desktop app's bundled kernel config: ~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home/mcp.json on macOS (mcpServers key; KIMI_WORK_HOME overrides the kernel home, and is the only route on other platforms — the app's data layout there is unverified). The app does not read ~/.kimi-code/mcp.json, so the kimi-code registration does not cover it. No --lean (enabledTools is unverified against the app); skills need no separate channel — the app's kernel reads the CLI's ~/.kimi-code/skills, which plumb skills sync kimi-code already populates
plumb setup antigravity ~/.gemini/antigravity-cli/mcp/plumb.json (a standalone {command, args} entry — Antigravity's per-server config format; the legacy flat mcp_config.json layer under ~/.gemini is no longer written or managed)
plumb setup antigravity-desktop ~/.gemini/antigravity/mcp/plumb.json (same standalone {command, args} entry)
plumb setup opencode ~/.config/opencode/opencode.json (mcp key; type:"local", command array)
plumb setup crush ~/.config/crush/crush.json (mcp key; type:"stdio")
plumb setup goose ~/.config/goose/config.yaml (extensions key; YAML)
plumb setup hermes ~/.hermes/config.yaml (mcp_servers key; YAML)
plumb setup zcode ~/.zcode/cli/config.json (nested mcp.servers key; strict server schema — command must be a plain string, unknown keys are dropped) + ~/.zcode/skills/ (skills via plumb skills sync)
plumb setup dsh DeepSeek Harness's home-level user patch layer ($DSH_HOME/cordis.patch.yml, or ~/.dsh/cordis.patch.yml) — a YAML patch row for the in-box @deepseek-ai/dsh-mcp-client plugin. The home layer applies to every dsh profile (web, headless, custom), so one registration covers all of them; the bridged tools appear as mcp__plumb__*.
All clients funnel through one format-agnostic merge (mergeServerEntry)
backed by JSON, TOML, or YAML serialisers; config locations are resolved via
OS/user-home helpers — no hardcoded paths. (Aider is intentionally absent — it
has no native MCP client, only third-party servers that wrap it.) ZCode
keeps the same merge contract but cannot use the shared helper — its server
map sits one level deeper, under mcp.servers (setup_zcode.go).
Flag Applies to Effect
--project claude-code Write to .mcp.json in the current directory (project-scoped) instead of the user-level config.
--lean kimi-code, codex, gemini Also write a client-side tool allowlist on the plumb entry, pinning the ~21 tools of plumb's lean set (tools.LeanToolNames(), its only source) so the client loads those schemas instead of all 60. Each client has its own key, and none supports globbing — the value is always exact tool names: enabledTools for Kimi Code, enabled_tools for Codex (a TOML array on [mcp_servers.plumb]), includeTools for Gemini CLI (whose sibling excludeTools wins wherever both are present; plumb never touches the deprecated global tools.allowed/tools.exclude settings). The saving has to be taken client-side because none of these clients carries a verified deferred-discovery capability: plumb's own [tools] profile = "lean" would remove capability rather than schemas, where a filtered-out tool in the client's own config is the user's explicit choice. The list is a snapshot: re-run plumb setup <client> --lean after upgrading plumb to refresh it. Re-running with --lean replaces a hand-edited list with plumb's; the bulk plumb setup --all sweep carries no --lean state and therefore preserves whatever key is on disk, so a routine binary repoint never widens a surface you narrowed. A later bare named re-register differs by client: plumb setup codex/plumb setup gemini clear the key (the flag state on the command line is authoritative) and say so in their output, while plumb setup kimi-code preserves it — Kimi shipped first with no clearing path, so delete the key from mcp.json by hand to go back to its full surface. Because a bare re-register clears, plumb doctor's repoint fix keeps --lean on the command it suggests whenever that client's config carries an allowlist today — following doctor's advice about a moved binary never widens a surface you narrowed.
plumb doctor grades all three the same way, one parameterised check per client. It mentions the flag when a client registers plumb without an allowlist (informational — a full surface is a valid default), stays silent when the allowlist equals today's lean set, and otherwise grades the key's content rather than its shape: a list naming no tool plumb registers earns a warning with a fix (it leaves the client with no plumb tools at all, however well-formed the file), an aged snapshot of the lean set earns an informational drift hint naming what is missing or no longer registered, and a value that cannot be an allowlist at all — [], null, or a non-list — earns a warning worded for that specific shape, since only [] definitely means "no tools" (null most likely reads as no allowlist at all, and a wrong-typed value is one plumb cannot predict the client's handling of).
--all plumb setup The single bulk flag: repoint every already-registered client at the current plumb binary (the repair after the binary moves or is rebuilt elsewhere — pairs with plumb doctor's registered-binary check), and register plumb in installed clients that don't have it yet — any client whose config file already exists but has no plumb entry. Clients with no config file at all are left untouched (plumb can't tell an absent config from an uninstalled client — use the client's named subcommand to create one), with five exceptions: Junie is detected via its home dir (~/.junie), Kimi Code via its data dir ($KIMI_CODE_HOME, or ~/.kimi-code), Kimi Work via its bundled kernel home ($KIMI_WORK_HOME, or the app's data dir on macOS), ZCode via its home dir (~/.zcode), and DeepSeek Harness via its home dir ($DSH_HOME, or ~/.dsh), because their MCP configs (mcp.json, the home cordis.patch.yml) only exist once an entry is configured, so --all creates them fresh. Triggers the bulk run on its own, so plumb setup --all is the one-shot first-time setup for every client already present on the machine — and the repair sweep afterwards. A client plumb cannot read or write is reported error in the table like any other status, against its own config path; the reason prints in a counted block below the table (after the summary line), because a parser message naming the file, the line and the syntax it choked on is far wider than any config path and stretched the Config column past the terminal width when it was inlined. Home directories inside those messages are contracted to ~ to match the table, and the summary line stops short of vouching for every installed client whenever any error is reported.
--repair, --install-missing plumb setup Deprecated hidden aliases of --all: both still parse and print a deprecation warning, then run the full --all sweep. --repair used to be the repoint-only sweep and --install-missing the only register-missing path; --all now does both, so neither old spelling has a behaviour of its own.
--uninstall every <client> subcommand Reverse the registration: back the config up, then remove plumb's entry — only plumb's; sibling MCP servers survive, and repeating the call on a client plumb is not registered in is a no-op. It also removes that client's lifecycle hooks (plumb hooks uninstall's writer, so only plumb's own handlers go and the user's survive), and for a skill-capable client the skill directories plumb's sync installed — but only those still carrying plumb's provenance marker or exactly matching the embedded content, so a skill the user rewrote survives and is reported as left in place; each removed skill directory is backed up to a sibling <name>.<timestamp>.bak directory first. plumb setup claude-code --project --uninstall touches only the project's .mcp.json, never the user-level config or the user-scoped skills, which live in the user scope.

plumb hooks

plumb hooks                      # read-only status, per client, per hook
plumb hooks install [client]     # install or refresh
plumb hooks install [client] --context          # also add the experimental context-hint handlers
plumb hooks uninstall [client]   # remove plumb's handlers, and only those
plumb hooks uninstall [client] --only context   # remove just the context-hint handlers
plumb hooks uptake [--since 168h] [--json]       # are context hints configured, trusted, invoked, consumed

Experimental context hints. --context adds two advisory hint handlers, UserPromptSubmit and SubagentStart, for Claude Code and for Codex (which asks you to trust them in /hooks before they run); with hints turned on ([context] hints = true or PLUMB_CONTEXT_HINTS=on) the client's SessionStart handler also appends a hint after the linkage sentence. Once added, a later plain install keeps them current. A hint names code (selectors and locations, never source) when a prompt names files or symbols explicitly, after a resume or compaction, and when a subagent starts; it is capped at 1 KiB a turn and 8 KiB per agent, and never counts as reading a file. Hints are off by default; --only context removes the two hint-only handlers. plumb hooks uptake reports configured (handlers on disk), trusted (Codex's own approval record), invoked (the daemon's bounded hint ledger, by outcome and reason) and consumed (a later plumb call by the same agent named what the hint named: a proxy, and hints with no attributable later call are reported as unattributed).

Lifecycle hooks are the one place plumb can reach an agent between tool calls: at the start of a session, and at the end of a turn. Bare plumb hooks is read-only — a per-client, per-hook table showing installed, missing, or stale (present, but written by a different binary path or an older entry shape), with unregistered for a client whose config does not register plumb. The two writers are install and uninstall; with no client argument each sweeps every client, and install skips an unregistered one with the plumb setup <client> fix rather than writing hooks for a plumb the client cannot reach.

Client Config Hooks installed
claude-code ~/.claude/settings.json (hooks live here, not in ~/.claude.json, which is where the MCP registration goes) SessionStart (timeout 5s), Stop (async + asyncRewake, timeout 3630s by default — the wake ceiling plus slack) and PreToolUse (matcher mcp__plumb__.*, timeout 5s)
codex $CODEX_HOME/hooks.json, or ~/.codex/hooks.json SessionStart and Stop, both command handlers with a 5s timeout

Both clients get the SessionStart/Stop pair. SessionStart states the client's own conversation ID as a fact, so the agent's first session_start can pass it as session_id — the linkage that lets plumb mail, leave_note and the wake path address that exact session rather than guessing from a shared directory. Stop reports unread peer mail as a turn ends. Claude Code's SessionStart does one more thing when its source is clear (the conversation was begun by /clear, which keeps the plumb serve connection and starts a new conversation id): it sends conversation-cleared <new session id> to the daemon's control socket, so the connection can hand its identity, name and mail to the new conversation instead of treating it as a second one sharing the connection. The notification is best effort and fails open: it has a one second budget, an absent or older daemon is ignored, and the hook's stdout is still only the linkage sentence. The daemon keeps it as a one-shot marker for 24 hours (the hook fires when /clear is typed, not when the next prompt is sent), consumed by that conversation's first call, and only when the linked conversation is the only one on the connection. Without it (the hook not installed, a daemon restart in between, or other conversations on the connection) the new conversation simply gets an identity of its own. Codex's SessionStart sends the same notification for source: "clear" (Codex 0.161.0 also reports startup, resume, compact and fork, none of which announce), with the same budget and an unchanged JSON stdout. Codex calls carry no identity stamp, so its next session_start relinks the connection by itself; the marker records that the old conversation ended. Claude Code gets a third hook:

  • PreToolUse stamps identity. Claude Code runs every subagent over the parent's one plumb serve, and its transport carries no per-agent identity, so the daemon could not tell a subagent's call from its parent's — and, once two agents had declared themselves, refused every write from both as unattributable. The hook closes that: on every mcp__plumb__* call it adds one top-level argument, plumb_agent, holding the conversation id (main thread) or <conversation>/<agent_id> (subagent), and on session_start it also sets session_id to the same value, so no agent has to remember one. The daemon lifts the key out before validation; no tool ever sees it. For a client that forwards only declared arguments (Claude desktop's connector) the daemon declares plumb_agent in every tool's schema so the stamp gets through. The hook asks the running daemon what it accepts (over the control socket, one identity-keys dial that also reports the version, cached for ten minutes under PLUMB_WAKE_DIR): its version, and whether its identity-keys answer lists plumb_agent. A daemon that does not list it gets the older key dev.plumbkit/logical-agent instead, which Claude desktop's connector strips, and bare plumb hooks says so. One older than 0.19.1 would reject either as an unknown parameter, so against it the hook stamps nothing and bare plumb hooks says that too. PLUMB_IDENTITY_HOOK=off disables it. It never blocks a call: normally the worst case is an unstamped call, which is what the client sent anyway. The cached answer belongs to one daemon instance (its PID file and control socket), so a daemon restarted or replaced is asked again; when the daemon cannot be asked at that moment (a machine at full CPU can starve its control socket) the hook stamps from the cached answer for the same instance rather than send the call unstamped, and never from another instance's. A plumb call the hook does leave unstamped leaves one line on stderr (plumb identity hook: left the call unstamped: tool_name=… tool_use_id=… reason=…), which Claude Code shows only in its debug output. A plugin-scoped registration (mcp__plugin_<p>_plumb__*) is not matched. Codex is not covered: its updatedInput requires permissionDecision: "allow", a permission side-effect plumb will not take silently. After upgrading, re-run plumb hooks install claude-code — an older install has only the first two hooks, and the table shows the third as missing until it does, with a note beneath saying what that costs: nothing stamps a per-agent identity, so agents sharing one connection share one session and their writes are refused.

What plumb hooks install claude-code writes, in full:

{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "type": "command", "command": "\"/opt/homebrew/bin/plumb\" hooks run-claude", "timeout": 5 }] }],
    "Stop":         [{ "hooks": [{ "type": "command", "command": "\"/opt/homebrew/bin/plumb\" hooks run-claude", "timeout": 3630, "async": true, "asyncRewake": true }] }],
    "PreToolUse":   [{ "matcher": "mcp__plumb__.*", "hooks": [{ "type": "command", "command": "\"/opt/homebrew/bin/plumb\" hooks run-claude", "timeout": 5 }] }]
  }
}

What Stop can do differs by client, and the difference is not cosmetic:

  • Claude Code wakes. The handler installs as a background watcher (async + asyncRewake), which is the pair that lets a hook reach a session with no turn in flight. It probes the daemon every PLUMB_WAKE_INTERVAL seconds (default 7, never longer than the base window) and exits 2 with one line on stderr the moment mail is waiting — that pair is the wake payload.

    How long it watches depends on whether anyone could write to it. The deadline starts one base window out and slides forward by another base window on every poll that sees another live session on the same workspace, capped at the peer ceiling:

    Setting Env override Default Meaning
    [collab] wake_window_seconds PLUMB_WAKE_WINDOW 300 base window; what a session with no peer costs
    [collab] wake_peer_window_seconds PLUMB_WAKE_PEER_WINDOW 3600 ceiling while a live peer shares the workspace; 0 disables the extension

    Nobody outside the workspace can write to that mailbox uninvited, so a solo session keeps the short window — and the one resident watcher process it costs — exactly as before. A session with a peer stays reachable for up to an hour, which is the case the mechanism exists for: mail landing six minutes into an idle stretch used to wake nothing until that session's next turn ended, and for an idle agent that may be never. A watcher whose peer goes away exits within one base window of the last sighting, and a session whose probe never resolves never extends at all.

    The hour is measured from the turn end that ARMED the watcher, not from the last turn: while one watcher holds a session's lock, later turn ends arm nothing (that is the one-watcher-per-session guarantee). A session that goes quiet late in a watcher's hour therefore has the remainder of that hour, not a fresh one — the same shape the fixed window always had, at a longer scale.

    The installed handler's timeout sits above the ceiling on purpose: a shorter one kills the watcher mid-watch, and nothing in any output would say so. Because that timeout is written at install time from the windows in effect then, re-tune and re-install together — raising either window without re-running plumb hooks install leaves the client cancelling the watcher early. plumb hooks reports the mismatch as stale. A project's .plumb/config.toml may narrow either window and may not raise either one: each is bounded by its own global value, so a repository cannot lengthen the base window every session pays whether or not a peer exists. An exported PLUMB_WAKE_WINDOW or PLUMB_WAKE_PEER_WINDOW is read last and wins over both, which also means it overrides a project's narrowing. PLUMB_WAKE_DIR (default ~/.claude/plumb-wake) is where the watcher keeps its per-session stamp, lock and re-arm records. Stamps and re-arm records older than a week are swept on later turn ends; lock directories are not swept, and are reclaimed by the session that owns the key — nothing a sweep could measure is an upper bound on a live watcher, and deleting one would give that session two.

  • Codex checks. Codex has no background-wake mechanism, so its handler makes one metadata probe of the running daemon as the turn ends. It issues one Stop notice per unchanged eligible mailbox state; a new arrival can notify again. The existing stop_hook_active guard always allows completion. An empty or failed explicit check therefore cannot create an unchanged-notification loop. This narrows the end-of-turn race; it is not push delivery. Codex also requires an interactive trust review for non-managed command hooks — run /hooks in Codex after installing.

Properties both hooks hold, and that the tests pin:

  • Fail open, always. No linkage, no matching session, an ambiguous workspace, an unreadable mailbox, a daemon that is down — every one of them lets the turn end silently. A hook that failed closed would strand turns on an unrelated fault.
  • Daemon authority. Both hooks use the established live recipient's delivery policy, workspace, identity and verified predecessor identities. They never open a separate mailbox database or fall back to offline inspection. Missing linkage, unsupported older daemons and uncertain probes allow completion. Probes use pooled handles only: an existing database not yet opened by the daemon is unavailable until ordinary message delivery opens it. A confirmed absent store is empty and does not hide messages in the other consented store. Stop queries are serialised per recipient, so a slow recipient cannot delay another's probe. The hook client enforces a 300 ms deadline; SQLite's existing busy handler and filesystem reads inside the daemon are not themselves hard elapsed-time bounds.
  • A count, never a body. The metadata probe does not claim or disclose message text. Pasting a peer's words into hook feedback would be a direct injection channel into the agent; the body stays unclaimed and arrives through check_messages, labelled as the unverified claim it is.
  • Silent when there is nothing to say. The Claude Code watcher emits nothing and costs nothing unless mail is actually waiting; a hook that woke an agent on a timer would burn paid turns for no reason.
  • Scoped to plumb workspaces. settings.json is user-wide, so the Claude Code hook checks for a .plumb marker at or above the session's working directory and stands down before touching anything when there is none.
  • One watcher per session. A lock keyed by the resolved session name, and recording the conversation that owns it, stops repeated turns from stacking watcher processes — and lets a session that reuses a name take the lock over from a previous tenant instead of being silently unwakeable.
  • Bounded observation. Codex's notification digests live in daemon memory, with a hard cap of 512 recipient/workspace states. Saturation allows completion; reconnecting to a restarted daemon can produce one fresh notice. No notification state changes delivery watermarks or read tracking.
  • Bounded continuation. A Claude Code woken turn re-arms only when it provably consumed mail (the pending count dropped), capped at PLUMB_WAKE_CHAIN_MAX (default 10) wakes per chain; any ambiguity stands the chain down.
  • Paid for only where it buys something. The long window is spent only while another live session shares the workspace, because that is the only case where a message can arrive at all. A peer count is all the watcher learns — no name, and nothing about what any peer is doing.

Writing is conservative in both directions. An install merges: hooks the user wrote on the same events keep their place, the file is backed up before it changes, re-running is a no-op once it matches, and plumb's own entry on an event is refreshed after the binary moves — with any duplicate of it on the same event removed, so an install always converges on exactly one handler per event rather than leaving two that both fire. An uninstall removes only plumb's handlers, and drops surrounding structure only where it emptied it and only where that structure was plumb's own — a bare {"hooks": [...]} group it took the last handler from, then an event key with no group left, then the file itself if the whole config was plumb's hooks and nothing else, or the {"matcher": "mcp__plumb__.*", "hooks": [...]} group the identity hook installs into. A group carrying anything more — a different matcher, a note of your own — is yours even when plumb's handler is the only thing inside it, so it stays with its keys intact and an emptied hooks array. A group you left empty (Claude Code's own /hooks editor leaves those) is structure plumb never wrote and is untouched. An uninstall with nothing of plumb's to remove writes nothing at all — not even a backup.

The hand-installed shell hooks plumb's own recipe documented (plumb-session-link.sh, plumb-mail-wake.sh) count as plumb's: an install replaces those entries in place, so the migration leaves one pair of hooks rather than two that both fire, and the .sh files themselves are left on disk. That match is narrow on purpose, because it also decides what an uninstall deletes: the command's own executable must be one of those two scripts, and it must sit on an event the recipe used. A wrapper called wrap-plumb-mail-wake.sh, a command that merely names the script in an argument, or anything at all on an event plumb does not install on, is the user's and is never removed.

Installation is always explicit. Hooks execute commands with the user's credentials, so nothing installs them as a side effect — not plumb setup, not project config, not a repository someone cloned. Removal is the one direction that also happens elsewhere: plumb setup <client> --uninstall removes that client's hooks along with its registration and skills, because hooks left pointing at a deregistered plumb are dead weight.


plumb skills

plumb skills
plumb skills sync [client] [--check] [--force]

Bare plumb skills is read-only: a status table over the clients with a verified skills directory (Claude Code ~/.claude/skills/, Codex $CODEX_HOME/skills/ or ~/.codex/skills/, Junie ~/.junie/skills/, Kimi Code $KIMI_CODE_HOME/skills/ or ~/.kimi-code/skills/, ZCode ~/.zcode/skills/), showing each embedded skill as installed, missing, or stale (content differs from the copy compiled into this binary). A skill-capable client whose config does not register plumb is shown as unregistered, with the reason sync skips it and the plumb setup fix on their own rows under the skills directory. Every other client has no skills directory and receives the same routing as the condensed session_start guidance block instead. The table groups its rows per client — blocks separated by full-width dotted rules — with each status coloured at the render layer: installed green, missing/stale/unregistered warn.

Skill capability is per-client data (setupTarget.skillsDirFn, internal/cli/setup_skills.go), verified against a live install rather than inferred. Every other client's skillsDirFn stays nil until someone verifies a real directory — writing files into a guessed path is worse than not writing them — so TestSkillCapableClients_ArePinned makes the skill-capable client set a deliberate edit, not something that drifts by accident.

plumb skills sync installs or refreshes the eight embedded skills into the skills directories of every skill-capable client that registers plumb, or only the named client with plumb skills sync <client> (an unknown name is a usage error listing the valid ones; naming an unregistered client is an error pointing at plumb setup <client>). A skill plumb shipped earlier and the user has not touched is replaced in place, with no backup, because its content hash is on record in .plumb/skills-manifest.json; an unchanged one is left alone; and a per-skill error is a warning, not a failure. A skill the user has edited (or whose origin the manifest cannot prove) is a conflict: it is left untouched, the shipped copy is written beside it as <name>.plumb-new for review, and the row says how to force it. The report ends with a ● Summary section — one ┊-guttered line per synced client, with any unregistered-client skip notes a blank line below. Sync is the only writer of skill files — plumb setup is config-only.

  • --force replaces a conflicting skill with the shipped copy instead of proposing it. The edited SKILL.md is first saved beside it as SKILL.md.<timestamp>.bak, then replaced, and the skill's .plumb-new file is removed. The row reports replaced with the backup's path, and the summary counts it under replaced. Skills that are not in conflict are handled as usual.
  • --check reports every action sync would take, and which .bak copies it would clean up, without writing anything. With --force it previews the replacements (would replace) and saves no backup.

Tables fit the terminal width, wrapping a long cell within its column; when output is piped or redirected they keep their natural column widths and nothing is wrapped to fit, so grep and awk see each row whole.

Re-run plumb skills sync after upgrading plumb to pick up new skill content; plumb doctor prints an informational line (never a warning) for any registered client whose skills are missing or stale.

Clients with no skill channel get the same routing as a condensed session_start block (writeGenericGuidance, internal/tools/session_start_guidance.go) — one authored source, two render targets: the skills are the canonical, expanded home for multi-tool workflows, and tool descriptions and session_start guidance point at them rather than restating them. The count is a stated ceiling, not drift: the reasoning lives on skillsFS in internal/cli/skills.go, where each skill past the stated limit has to argue against it in writing — plumb-chat is the eighth and does so there, so a ninth argues against both paragraphs.

SKILL.md and the skill's references/*.md notes are installed, the latter into <skills dir>/<name>/references/ — so a SKILL.md that points at one (as plumb-chat does for its note on waking an idle agent) points at a file the reader actually has. Reference notes carry no provenance marker, and they count towards the skill's freshness: a current SKILL.md whose reference note is missing or has drifted reports stale, not installed, and a re-sync backs the drifted copy up before restoring it. Supporting material anywhere else in a skill directory is not shipped at all — a skill that needs a file must name it under references/.


plumb doctor

plumb doctor [--workspace <dir>] [--json]

Run health checks grouped by topic and report what needs attention. Exits non-zero if any check fails. Sections:

  • Daemon — socket reachable; running version matches this binary; exactly one plumb daemon for this user and it holds the launch authority (the row fails on a violation and on a census that could not read the process table, because "cannot tell" is not "fine"); and each socket path still holds the socket the daemon published for it. That last check adds a row per affected path: a path a live daemon published that is now missing, or that holds a file with a different inode (a replacement), fails — the daemon is running and no client can reach it — while two weaker states are warnings, never failures: a socket file nothing serves (the ordinary leftover of a killed daemon, which the next plumb serve clears), and a socket whose file is still the one the daemon bound but whose metadata moved (a chmod, a backup tool; the inode is unchanged, so it is not a replacement, and the daemon keeps serving).
  • Language Servers — each configured LSP binary is on PATH (enabled servers that are missing fail; disabled ones are informational), plus a Java 21+ runtime check when java is configured.
  • LSP Live — the language-server sessions actually running in the daemon, one row per live server under its <server> (live) name. The section sits directly after Language Servers and is omitted entirely — no header, no rows — when no sessions run. The JSON output shape is unchanged.
  • MCP Clients — for each supported client, alphabetically by name, whether plumb is registered and that the binary the config launches still exists and matches the running executable. A registered binary that no longer exists is a failure; a binary that exists but differs from the current one (e.g. after moving or rebuilding plumb elsewhere) is a non-fatal warning (!). Both carry a plumb setup <client> fix hint — or run plumb setup --all to repoint every client at once. Per-client diagnostics (extra profiles, tool surface, skills) render as ╰─ branches indented under their client rather than rows of their own; --json still lists them flat in the array, under their full "(extra profiles)"/"(tool surface)"/"(skills)" names.
  • Configuration — global and project config.toml parse cleanly. Its capability trust row reports the project's capability-granting keys with a head count line followed by one key per line — trusted when the project's grant is recorded, a warning with a plumb trust fix when the keys are being ignored and the global config's values are in force instead.
  • Data — the global stats database is readable.
  • Indexing — when [topology] is enabled for the workspace, the topology index is present and healthy (passes when topology is disabled — the opt-in default). A missing or corrupt index fails; an index that exists but is still building (empty, all files skipped, or no symbols extracted yet) is reported as a non-fatal warning (!) so a freshly enabled workspace does not false-negative. Inspected strictly read-only (mode=ro) without starting an indexer or creating sidecar files; warnings and failures carry a fix hint.
Flag Default Effect
--workspace <dir> current dir Include project-scoped checks (project config, stats rows) for this workspace.
--json false Emit results as a JSON array instead of the ANSI table.

plumb doctor is the first thing to run when something isn't working — see Troubleshooting.


plumb config

plumb config <subcommand>

Inspect plumb's resolved configuration. See the Configuration reference for what each field means.

Subcommand Description
plumb config print Print the resolved configuration as TOML.
plumb config reload Tell the running daemon to re-read global config now (same as the fsnotify watch).
plumb config show [--workspace <dir>] Show the resolved configuration with source provenance — which layer (default, global, project, env) set each value. Includes a Directories section listing plumb's config, data, state, log, and runtime directories, and an Agent-written keys footer (provenance=agent).
plumb config unset <key> [--workspace <dir>] Remove a project-config key (the one-step revert for an agent-written value): drops it from .plumb/config.toml and the provenance sidecar, then reloads.
Flag Applies to Default Effect
--workspace <dir> show current dir Resolve project-layer config from this workspace.
--adapters show off Print only the language-server adapter table (language, server, validation tier, activation state). Aliases: --adapter, --lsp, --lsps, --integration, --integrations.

plumb sessions

plumb sessions [--all]

List active plumb sessions — one per live MCP connection — with the generated session name, ID, resolved workspace, and client identity.

Flag Default Effect
--all false Include sessions whose workspace has not resolved yet (Folder empty).

plumb mail

plumb mail (--session <name> | --external-id <id> | --workspace <dir>) [--json]

Report how many agent-to-agent messages are waiting for a plumb session, without reading or consuming them.

This is an offline inspection command for humans and external scripts. It opens a separate read-only SQLite view and resolves sessions from the session registry; it does not establish the running daemon's current delivery policy or mailbox health. Its count can disagree with the daemon during storage faults. Plumb's installed lifecycle hooks instead ask the daemon directly and never fall back to this view.

Delivery still happens through check_messages or session_start. Previews and hook counts never consume a message. An explicit check_messages failure reports an unavailable or incomplete mailbox, rather than asserting "No messages"; any rows already delivered remain visible in the partial result.

It never claims. The handle is mode=ro, so the delivery watermark cannot be set: the messages stay undelivered and reach the agent through check_messages as usual. A probe that consumed its answer would mark a message delivered to an agent that never saw it — exactly-once turned into exactly-never.

It reports no message content: the resolved session name, its workspace root, a count, and the age of each waiting message — nothing about any message beyond that it exists and when it arrived. Never bodies, senders, or conversation ids. Note what that boundary is not: the workspace root is an absolute path in the output, and --session/--external-id answer for any live session on the machine, including one in another project — the same surface plumb sessions already prints. This is not a permission boundary; what it holds back is message content. A session name is not an identity (names come from a small pool, an ended session frees its name, and rename_session lets a session pick one), and a message body is another agent's free text — printed here it would flow straight into whatever consumes this output, which is an injection channel into the agent the mailbox otherwise labels these as unverified claims for.

Flag Default Effect
--session <name> — A live session's name, as shown by plumb sessions.
--external-id <id> — The value a session passed to session_start's session_id — a client's own conversation id. The reliable selector for a hook, which knows its conversation but not the session name.
--workspace <dir> — A directory inside a session's workspace — plumb resolves a workspace by walking up to a root marker, so a session pinned to /repo answers for /repo/internal/cli. The nearest enclosing root wins when workspaces nest; several sessions sharing that root is an error, not a guess.
--json false Emit {"session","workspace","count","ages_seconds"} instead of a sentence. ages_seconds is oldest first.

Name exactly one selector. Exit status is 0 whether or not mail is waiting, and non-zero only on error, so "has mail" is never confused with "the check failed" — read the count. A workspace that has never used the mailbox has no collab.db, which is reported as no mail rather than an error.

Scope: the workspace mailbox, plus notes from other checkouts of the same repository — a worktree or submodule of this project pinned elsewhere — which need no opt-in. Messages from other projects live behind the recipient's [collab] cross_project opt-in and are not reported: the probe never resolves the workspace's config, so it counts only what needs no consent. Notes addressed to "next" — leave_note's default — ARE counted, for every session except the author: the probe claims nothing, so there is no race to lose, and a session can never claim its own note, so waking it for one would fire on every turn end. (The in-session listing in workspace_sessions still omits them, because it advertises messages the reader then has to win the claim race for.)

The plumb-chat skill's references/idle-agent-wake-hook.md gives the full Claude Code Stop-hook recipe built on this command.


plumb history

plumb history [--workspace PATH|--all] [--session ID] [--agent ID] [--tool NAME]
              [--file PATH] [--since T] [--until T] [--limit N=50] [--json]
plumb history show <seq | call_id> [--json]
plumb history prune --before T [--workspace PATH] [--yes] [--vacuum]

Review the diffs of every file change plumb made on an agent's behalf. Changes are recorded in ~/.local/share/plumb/history.db across all mutation tools (write_file, edit_file, find_replace, delete_file, rename_file, copy_file, undo_edit, rename_symbol, move_symbol, transaction_apply, and project configuration/memory writes).

Subcommands

  • plumb history (list): lists changes newest first.
    • Line format: <timestamp> <op>[ dir] <tool> <session>[/<agent>] <path> +A -R [<content>].
    • When a file changed outside plumb's write tools between two recorded writes, an unrecorded-change marker line is printed: ⋯ unrecorded change (outside plumb's write tools) If rows were dropped due to a queue overflow in that interval, it appends ; history dropped rows in this interval.
  • plumb history show <seq | call_id>: displays the unified diff(s) and associated stats.tool_calls metadata (duration, success/failure, session). If call metadata is not available (e.g. from an older version of plumb), the diffs are still printed.
  • plumb history prune --before T: deletes write history entries older than T. Requires --yes in non-interactive mode. With --vacuum, reclaims file space with SQLite VACUUM (refused while the daemon is running to prevent database lock contention).

Flags

Command Flag Default Effect
history --workspace <path> Current workspace Filter changes to the specified workspace path.
history --all false List changes across all workspaces.
history --session <id> — Filter to a specific session ID.
history --agent <name> — Filter to a specific logical agent.
history --tool <name> — Filter to a specific tool (e.g. edit_file, transaction_apply).
history --file <path> — Filter to a specific file path (canonicalised so different path spellings match).
history --since <T> — Only show changes newer than T (RFC 3339, YYYY-MM-DD, or relative age like 2h, 7d).
history --until <T> — Only show changes older than T.
history --limit <N> 50 Maximum number of entries to return.
history, show --json false Emit JSON array. In show, each entry includes the decompressed diff.
prune --before <T> — Delete changes older than T (required).
prune --workspace <path> — Limit pruning to changes from a specific workspace.
prune --yes false Confirm deletion without interactive prompt.
prune --vacuum false Reclaim database file space with VACUUM (requires stopping the daemon first via plumb stop).

Content markers

A change recorded without a diff shows its content marker in brackets: at the end of its list line, and in place of the diff in show. The JSON output carries the same value in content (diff when a diff is stored).

  • [none]: nothing to diff: a directory, a rename that moved the content unchanged, or a write that left the bytes as they were;
  • [withheld:sensitive]: the path, its resolved path or its copy/rename source matched [history] sensitive_globs;
  • [withheld:binary]: a side contained a NUL byte in its first 8000 bytes;
  • [withheld:too_large]: a side exceeded max_content_bytes, or the diff exceeded max_diff_bytes;
  • [withheld:overflow]: the write queue overflowed or the row could not be written in full, so only its metadata was kept.

Redaction does not use a marker. A stored diff shows each secret it masked as a labelled placeholder ([REDACTED:aws-key] and so on), and the JSON output counts them in redactions.


plumb stats

plumb stats [--workspace <dir>] [--limit <n>] [--since <age>] [--failures]
plumb stats --health [--workspace <dir>]

Aliases: plumb status.

Show tool-call statistics for a workspace: a per-tool summary (calls, average and P95 latency, input/output bytes, errors, token-efficiency estimate) and a list of the most recent calls.

Flag Default Effect
--workspace <dir> current dir Workspace to inspect.
--limit <n> 20 Number of recent calls to show. Rejected together with --health.
--since <age> all history Only count calls newer than this age: 90m, 24h, 7d, 2w. Rejected together with --health.
--failures false Replace the default view with a failure breakdown grouped by kind, tool and client build. Rejected together with --health.
--health false Replace the default view with the three standing health metrics (PLAN-368), computed for today (UTC) and upserted into stats.db's health_daily table.

--health renders and persists three metrics, idempotently per UTC day (safe to run more than once — a re-run overwrites that day's rows rather than duplicating them, so it doubles as a nightly cron job): lane-defection rate (share of sessions that read a file where a later write_file or transaction_apply call was refused because the file had changed on disk since — both the guarded expected_mtime/expected_sha path and the unguarded auto-detect path count, since neither tool has a separate "never read at all" refusal to confuse it with; known undercounts, not an exhaustive list: a session that never tries the write again is invisible, and edit_file/the symbol-edit tools' refusals aren't counted at all, because calls can't tell their "changed since read" apart from their own "never read" refusal), semantic-surface error rate (a 7-day rolling per-tool error rate over the LSP query/edit surface, flagged only when the tool is advertised — pinned into a Claude Code connection's context, PLAN-355 — AND both its own sample and the read_file baseline sample are large enough to trust), and net economics per client (the estimated read savings and guard-refusal count, trended per day per client; the third PLAN-367 line, the advertised tool catalogue, is a live per-connection figure this offline view has no access to, the same reason the default view above omits it too). --health always covers today and has no recent-calls list or failure breakdown to filter or limit, so it is rejected in combination with --limit, --since, or --failures rather than silently ignoring them.

--failures is the triage view. It groups failed calls by their machine-readable kind (dirty_file, lsp_timeout, …), the tool, and the client build, and reports how many of each were retryable. It is a separate view rather than extra columns because the grain differs: the default table is one row per tool, a failure bucket is one row per (kind × tool × client). --limit caps how many buckets are shown.

Failures plumb makes no structured claim about — and every call recorded before the classification columns existed — appear under an explicit unclassified label, always last, with their retryable count shown as — because the honest answer is "unknown" rather than zero. They are fetched outside the --limit, so raising or lowering it never makes them disappear, and the note beneath the table counts every unclassified failure the filter matched, not just the rows on screen. Nothing is inferred from the stored error text, so that bucket is honest about what is unknown rather than folded into internal. The unclassified buckets themselves are still bounded, independently of --limit — capped at 10 rows — since raising --limit only ever widens the classified side of the split; the count in the note is unaffected either way.

A bounded view says so: when --limit cuts buckets, a footer reports how many buckets and failed calls the table is leaving out.

The database is never pruned, so on an installation with history the pre-classification failures outnumber the classified ones for a long time. Classified buckets therefore sort ahead of the unclassified one regardless of size, and --since scopes the whole command to a window — plumb stats --failures --since 7d is the useful triage call.

Statistics are global to the daemon (stats.db) but filtered to the requested workspace. plumb status is identical to plumb stats — it does not launch the TUI dashboard (run bare plumb for that).


plumb diagnostics

plumb diagnostics [file]

Aliases: plumb diag, plumb diags.

Print LSP diagnostics for the workspace — a debugging aid. Pass an optional file to scope output to a single file. Requires a running daemon with an attached language server.

Argument Description
file Optional. Restrict diagnostics to this file.

plumb log-level

plumb log-level <level>

Change the running daemon's log level at runtime, over its control socket. The change lasts for the daemon's lifetime only — it does not persist.

Level Effect
debug Verbose logging.
info Standard logging (default).
warn Warnings and errors only.
error Errors only.
reset Restore the level captured at daemon startup (including any PLUMB_LOG_LEVEL override active then).

To make a level permanent, set log_level in ~/.config/plumb/config.toml. Fails clearly if the daemon is not running.


plumb enable-lsp

plumb enable-lsp <language>

Enable a configured language ([lsp.<language>]) in the running daemon, over its control socket, without a restart. Enabling a language normally requires restarting the daemon; this flips it on live: the daemon adds it to its effective language set, and its server attaches lazily on the next file of that language a session opens (no process is spawned eagerly, and existing sessions and their servers are untouched).

The change is daemon-lifetime only, like plumb log-level. To make it permanent, set enabled = true under [lsp.<language>] in the config file — though installing the server is usually enough, since an installed, enabled language activates automatically at startup.

Errors are honest: an unknown language (no [lsp.<language>] block), or a server binary that is not installed (the message names the binary to install). Enabling a language that is already active is a reported no-op. Fails clearly if the daemon is not running.


plumb debug

plumb debug <subcommand>

Daemon introspection. Every subcommand but daemons reads the running daemon over the control socket and requires it to be up.

Subcommand Description
plumb debug mem Print a runtime.ReadMemStats snapshot (heap, GC count, goroutines).
plumb debug heap Force a GC and write a runtime/pprof heap profile to the cache dir.
plumb debug stacks Write a full goroutine stack dump (the SIGQUIT-equivalent) for diagnosing a hang.
plumb debug lsp List each language server's state, PID, RSS, and idle time.
plumb debug daemons List every plumb daemon of this user and flag any that is not the launch-authority holder, or whose socket path no longer holds the socket it bound.

plumb debug daemons is deliberately local: it dials no socket, because the state it exists to find is a daemon whose socket has been unlinked and which therefore answers nothing. It reports each daemon's pid, parent, start time, executable, whether it holds the authority and which plumb socket it owns, and then every socket path with something to say about it: a path a live daemon published that is now missing or holds a different file (its socket was deleted or replaced under it) is printed as a fault, and a socket file nothing serves as a note. It exits non-zero when the account does not have exactly one daemon holding the authority, when the census could not read the process table, or when a live daemon's published socket path is gone or replaced — since "cannot tell" is not "fine". It never signals a process, never removes a stale pid file, and never takes a lock.


plumb version

plumb version [--json]

Print the plumb build version, the Go runtime version, and — when the binary carries one — the source commit it was built from. The build version is stamped at compile time (see Versioning in AGENTS.md).

Flag Default Effect
--json false Print a machine-readable report instead of the human line. Suppresses the logo banner.

Human output

A binary with no revision stamp prints exactly what plumb has always printed:

plumb 0.16.3 (go1.26.4)

A stamped binary extends that same single line with the abbreviated (12-char) commit. The suffix distinguishes all three tree states — measured clean, measured dirty, and not measurable — so dirty is never readable here without its known counterpart either:

plumb 0.16.3 (go1.26.4, rev 4c6e4da9d8fa)         # measured, clean
plumb 0.16.3 (go1.26.4, rev 4c6e4da9d8fa-dirty)   # measured, dirty
plumb 0.16.3 (go1.26.4, rev 4c6e4da9d8fa-dirty?)  # could not be measured

It stays one line on purpose — make install echoes plumb version | tail -1, and assorted tooling scrapes the first line.

JSON output

{
  "version": "0.16.3",
  "revision": "4c6e4da9d8fafc5ca36d762460caf6abf46c5ca6",
  "revision_known": true,
  "dirty": false,
  "dirty_known": true,
  "go_version": "go1.26.4",
  "os": "darwin",
  "arch": "arm64",
  "build_channel": "dev"
}

revision_known and dirty_known exist so a consumer can tell "clean" from "we have no idea". An unstamped binary reports revision: "", revision_known: false, dirty: false, dirty_known: false — reading dirty without dirty_known would misreport an unknown build as clean. build_channel is release (a GoReleaser release build), dev (the Makefile, or a GoReleaser --snapshot dry run), or "" when unknown.

The same source commit is reported by the daemon_info MCP tool, on its source commit: row, so the commit a running daemon was built from can be read without shelling out to the binary.

How the revision is stamped

The revision, its dirty flag, and the build channel are injected at link time, alongside the version:

-X github.com/plumbkit/plumb/internal/cli.Revision=<full sha>
-X github.com/plumbkit/plumb/internal/cli.RevisionDirty=true|false
-X github.com/plumbkit/plumb/internal/cli.BuildChannel=dev|release

They are resolved in a fixed order: the ldflags stamps first, then Go's own embedded vcs.revision / vcs.modified build settings, then unknown. Dirtiness is always read from the same source as the revision — never mixed.

The presence of a revision stamp — not its plausibility — decides which source answers. A stamp that is present but is not a plausible commit SHA (seven or more hex digits and nothing else) yields unknown; it does not fall through to the embedded VCS settings. GoReleaser renders {{ .FullCommit }} as the literal string none when it cannot resolve git information, and the only situations that produce such a stamp are also situations where the embedded settings describe the outer module — so deferring to them would report plumb-ops' HEAD as this repository's, confidently and wrongly. A wrong commit is strictly worse than an honest "unknown".

Both stampers measure dirtiness rather than asserting it, and emit nothing when they cannot — which lands as dirty_known: false. The Makefile treats a failing git status as unknown rather than as clean (git rev-parse can succeed while git status fails), and .goreleaser.yml uses {{ .IsGitDirty }} rather than a hard-coded false, because goreleaser --snapshot skips the dirty-tree validation a real release run enforces.

The explicit stamps exist because debug.ReadBuildInfo() alone gets this wrong for plumb: the private plumb-ops superproject mounts this repository as a submodule through go.work, so a build launched from the ops root resolves the embedded VCS settings against the outer module — naming the wrong commit (or none) and marking a clean tree modified. The Makefile always runs with its working directory inside this repository, so git rev-parse HEAD there is correct, and .goreleaser.yml passes {{ .FullCommit }} for releases.

No build timestamp is stamped, deliberately: development builds stay reproducible.


plumb build / test / lint / e2e / verify

plumb build [target]
plumb test  [target]
plumb lint  [target]
plumb e2e   [target]
plumb verify

Run the configured [tasks.<lang>] command for the current workspace's primary language, streaming its output. verify runs the build slot then the test slot. [target] fills a {target} placeholder (a single shell-safe argument) in the stored command. A project-supplied command must be trusted first (plumb trust); the shipped defaults and global-config commands always run.


plumb trust

plumb trust [directory]
plumb trust --revoke [directory]

Trust this workspace's project-supplied task commands (those set in its .plumb/config.toml), so plumb build/test/… and the run_task tool will run them. Trust is recorded per workspace root in plumb's data directory (never in the project itself), so a cloned repository can never mark itself trusted.

Trust is bound to a hash of the trusted command set: if a task command is later added, removed, or rewritten (including via agent_config), the grant no longer matches and the command is refused until you re-run plumb trust — so an agent that changes a trusted command cannot have the new command run without a fresh prompt. A trust.json written by an older plumb (the legacy boolean format) is treated as untrusted and re-confirmed once.

--revoke removes the workspace's grant instead. A linked git worktree shares its repository's grant for identical content and has no grant of its own to remove, so --revoke there says the worktree is still trusted and names the checkout to run plumb trust --revoke at.

When it records trust, plumb trust prints each command it is about to trust and flags any that invoke an interpreter with inline code (bash -c, sh -c, python -c, node -e, perl -e, ruby -e) as arbitrary code execution by design — review those before trusting. Default- and global-config commands always run and never need trusting.

The disclosure prints in the shared CLI presentation style — ● section headers, ┊ row separators, muted values, warn-coloured warnings — with the flood-defence ordering unchanged: the key listing is capped and the warnings are grouped last, so a padded key set cannot scroll the dangerous lines out of view.

Confirmation is the ❯ Yes/No selector — the same selector plumb stop and plumb restart use — starting on No: press y (or move to Yes and press enter) to grant; n, q, esc, and ctrl+c all decline. --yes records the grant without asking and is the only way to grant without a terminal — non-terminal stdin is refused outright rather than auto-accepted, so a script or an agent pipeline cannot acquire the grant by side effect.