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 | 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
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
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
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 [--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 [--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 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 [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 <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 # 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:
PreToolUsestamps identity. Claude Code runs every subagent over the parent's oneplumb 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 everymcp__plumb__*call it adds one top-level argument,plumb_agent, holding the conversation id (main thread) or<conversation>/<agent_id>(subagent), and onsession_startit also setssession_idto 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 declaresplumb_agentin every tool's schema so the stamp gets through. The hook asks the running daemon what it accepts (over the control socket, oneidentity-keysdial that also reports the version, cached for ten minutes underPLUMB_WAKE_DIR): its version, and whether itsidentity-keysanswer listsplumb_agent. A daemon that does not list it gets the older keydev.plumbkit/logical-agentinstead, which Claude desktop's connector strips, and bareplumb hookssays so. One older than 0.19.1 would reject either as an unknown parameter, so against it the hook stamps nothing and bareplumb hookssays that too.PLUMB_IDENTITY_HOOK=offdisables 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: itsupdatedInputrequirespermissionDecision: "allow", a permission side-effect plumb will not take silently. After upgrading, re-runplumb hooks install claude-code— an older install has only the first two hooks, and the table shows the third asmissinguntil 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 everyPLUMB_WAKE_INTERVALseconds (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_secondsPLUMB_WAKE_WINDOW300 base window; what a session with no peer costs [collab] wake_peer_window_secondsPLUMB_WAKE_PEER_WINDOW3600 ceiling while a live peer shares the workspace; 0disables the extensionNobody 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
timeoutsits 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-runningplumb hooks installleaves the client cancelling the watcher early.plumb hooksreports the mismatch asstale. A project's.plumb/config.tomlmay 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 exportedPLUMB_WAKE_WINDOWorPLUMB_WAKE_PEER_WINDOWis 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_activeguard 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/hooksin 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.jsonis user-wide, so the Claude Code hook checks for a.plumbmarker 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 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.
--forcereplaces a conflicting skill with the shipped copy instead of proposing it. The editedSKILL.mdis first saved beside it asSKILL.md.<timestamp>.bak, then replaced, and the skill's.plumb-newfile is removed. The row reportsreplacedwith the backup's path, and the summary counts it underreplaced. Skills that are not in conflict are handled as usual.--checkreports every action sync would take, and which.bakcopies it would clean up, without writing anything. With--forceit 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 [--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 daemonfor 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 nextplumb serveclears), 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 whenjavais 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 aplumb setup <client>fix hint — or runplumb setup --allto 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;--jsonstill lists them flat in the array, under their full "(extra profiles)"/"(tool surface)"/"(skills)" names. - Configuration — global and project
config.tomlparse cleanly. Itscapability trustrow 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 aplumb trustfix 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 <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 [--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 (--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 [--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).
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.
- Line format:
plumb history show <seq | call_id>: displays the unified diff(s) and associatedstats.tool_callsmetadata (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 thanT. Requires--yesin non-interactive mode. With--vacuum, reclaims file space with SQLiteVACUUM(refused while the daemon is running to prevent database lock contention).
| 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). |
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 exceededmax_content_bytes, or the diff exceededmax_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 [--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
--sincescopes the whole command to a window —plumb stats --failures --since 7dis the useful triage call.
Statistics are global to the daemon (
stats.db) but filtered to the requested workspace.plumb statusis identical toplumb stats— it does not launch the TUI dashboard (run bareplumbfor that).
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 <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 <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 <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 [--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. |
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.
{
"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.
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 [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 [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.