From d1e988523071c71a3a44e7d1a92b10d4727079dd Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Sat, 8 Aug 2026 07:16:41 -0400 Subject: [PATCH 1/6] docs(configuration,reference): start 0.66 refresh From 8073b2ff05bca5244d0459cac57241e3ed0e0a96 Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Sat, 8 Aug 2026 07:19:13 -0400 Subject: [PATCH 2/6] docs(configuration): headers/type:sse are carried, and host plugins work herdctl 5.32.0 (Paddock 0.63.0) carries an MCP server's `type` and `headers` verbatim on both the host-inherit and declared paths, and added the `plugins` passthrough that makes host plugin inheritance real. Three pages still told readers neither capability existed. - config-file.md: rewrite the two "cannot be carried" cautions; replace the "inert today" plugins paragraph and the "none of them reach Paddock" block with the instructions x mcpServers truth table from claude-plugins.ts:48-52; add the mcpServers: schema table; note headers in the argv-exposure caution and the env:VAR leaf list; fourteen -> fifteen paddock_manage tools. - environment.md: same for the PADDOCK_CLAUDE_MCP_SERVERS and PADDOCK_CLAUDE_INSTRUCTIONS rows, and the env:VAR leaf list. --- .../content/docs/configuration/config-file.md | 141 +++++++++++++----- .../content/docs/configuration/environment.md | 8 +- 2 files changed, 105 insertions(+), 44 deletions(-) diff --git a/website/src/content/docs/configuration/config-file.md b/website/src/content/docs/configuration/config-file.md index a94ae2b4..63398b0e 100644 --- a/website/src/content/docs/configuration/config-file.md +++ b/website/src/content/docs/configuration/config-file.md @@ -318,14 +318,27 @@ are on `host`. Earlier versions wrote that notice at `info`, which the quiet default filtered out — so if you started on 0.62 and saw nothing, that is why ([#706](https://github.com/edspencer/paddock/issues/706)). -`plugins/` is bridged under `host` for completeness rather than effect. The -runtime's plugin root really is the Claude home, and it does discover what is -there — but discovery is driven by `enabledPlugins`, which lives in -`settings.json`, and Paddock's agents run with only the *project* settings source -loaded (see below), so the home's `settings.json` is never read and the flag that -would switch a bridged plugin on never arrives. Inert today, for a reason that is -a property of two callers rather than of the files, so the lever governs them -regardless. +**`plugins/` is this key too, and since 0.63 it actually works.** The symlink +alone never did: the runtime's plugin root is the Claude home and it does +discover what is there, but discovery of an *installed* plugin is driven by +`enabledPlugins`, which lives in `settings.json`, and Paddock's agents run with +only the *project* settings source loaded (see below) — so the flag that would +switch a bridged plugin on never arrived. That is why plugins looked bridged and +did nothing for months. + +herdctl 5.32.0 added the channel this needed: a `plugins` array on the agent +config, passed to the SDK's own `plugins` option (and `--plugin-dir` on the CLI +runtime). A plugin passed that way is a **session** plugin, and a session +plugin's enablement is `enabledPlugins["@inline"] ?? manifest.defaultEnabled +!== false` — **enabled by default**, with no settings-source grant required. So +under `instructions: host` Paddock enumerates the host's installed plugins from +`/installed_plugins.json` and passes them, and your commands, +agents, skills and hooks from a plugin reach your keepers. The host's +`enabledPlugins` is still consulted, but only to **veto**: an id set to `false` +is skipped, so a `/plugin disable` on your machine is respected. + +A plugin's *MCP servers* are a second question, gated by +[`mcpServers`](#mcpservers) below — see the truth table there. ### `hooks` @@ -410,20 +423,25 @@ thing this whole block exists to prevent. The file is read **once, at startup**. Add a server and restart Paddock to pick it up. -:::caution[Two things that cannot be carried through yet] -Paddock passes servers to the engine, and the engine's MCP schema has fields for -`command`, `args`, `env` and `url` only. Two keys of Claude Code's own MCP config -have nowhere to go, so Paddock warns about each affected server by name at -startup rather than letting it fail mysteriously later: - -- **`headers`** — a remote server authenticated by a bearer header arrives - without it. Worse than it sounds: MCP OAuth tokens are stored under a key - derived from a hash of `{type, url, headers}`, so a stripped header also means - the stored token is not found. -- **`type: sse`** — every `url` server is connected to as HTTP. - -A stdio server (`command` + `args` + `env`), which is most of them, is carried -exactly. A server declaring neither a `command` nor a `url` is skipped entirely. +:::note[`headers` and `type: sse` are carried — as of 0.63] +Until `@herdctl/core` 5.32.0 the engine's MCP schema was `{command, args, env, +url}` only, so `headers` was silently stripped and every `url` server was +connected to as HTTP. Paddock 0.62 shipped a boot warning naming each server that +lost a field ([#699](https://github.com/edspencer/paddock/issues/699)). + +**That is fixed.** herdctl 5.32.0 widened the schema to mirror the SDK's own +`McpServerConfig`, and Paddock 0.63 carries `type` and `headers` through +**verbatim** — an explicit `type` now wins over the bare-`url` ⇒ `http` +inference, so an `sse` server is connected to as SSE. The warning no longer fires +for either. + +This mattered more than it looked: MCP OAuth tokens are keyed on a hash of +`{type, url, headers}`, so the old stripping meant the stored token was not +found. Carrying both fields is what makes `credentials: host` + +`mcpServers: host` work for an OAuth server at all. + +The one host server still **dropped** is one declaring neither a `command` nor a +`url`, which cannot be started at all. ::: **MCP logins do follow `credentials`.** An OAuth-authenticated server's tokens @@ -433,13 +451,30 @@ Keychain item — so `credentials: host` (the default) carries them, and `credentials: own` means re-authorising inside Paddock. There is no separate MCP token store. -**Plugins are not covered by this key.** A plugin can contribute MCP servers, and -none of them reach Paddock. The plugin files are bridged by `instructions: host`, -but what switches a plugin on is `enabledPlugins` in the Claude home's -`settings.json`, and Paddock's agents run with only the *project* settings source -loaded — so the flag is never read. The engine also has no way to pass a plugin -path per session. Tracked in #691; until then, a plugin's MCP server has to be -declared directly with `claude mcp add`. +**Plugins take both keys, one per half.** A plugin bundles commands, agents, +skills and hooks — which is *instructions* — and it can also contribute MCP +servers, which is this key. So the two levers split it, using the SDK's +`skipMcpDiscovery` flag: + +| `claude.instructions` | `claude.mcpServers` | result | +|---|---|---| +| `host` | `host` | plugins passed whole, MCP servers included | +| `host` | `own` | plugins passed with `skipMcpDiscovery: true` — skills, hooks, agents and commands only, no `.mcp.json` read | +| `own` | *any* | no plugins at all | + +`instructions: own` withdraws the plugin bridge and Paddock says so; passing the +plugins anyway because a *different* key said `host` would contradict a notice +Paddock itself emits. If you set `mcpServers: host` while `instructions` is +`own`, the boot log names `claude.instructions` as the key that turns plugins on. + +Paddock also widens each keeper's tool allow-list with +**`mcp__plugin____*`** for every plugin server it can name, +recovering `` from `/.mcp.json` and from an inline `mcpServers` +object in `/.claude-plugin/plugin.json`. A manifest that instead *points* +`mcpServers` at another file or an MCPB source cannot be enumerated: the plugin +is still attached, and a boot warning names it and the exact pattern to add by +hand — because the alternative failure is the silent one, where the server +connects and every call is denied with nothing in the logs. **Declaring a server that is only for Paddock** is not this key — it borrows servers you already have. That is the top-level [`mcpServers:`](#mcpservers--the-servers-this-instance-declares-itself) @@ -463,8 +498,26 @@ mcpServers: NOTION_TOKEN: env:NOTION_TOKEN # a REFERENCE — see below linear: url: https://mcp.example.com/mcp + type: sse + headers: + Authorization: env:LINEAR_BEARER # also a REFERENCE ``` +The full key set of one declaration — anything else is a typo and refuses the +server: + +| Key | Type | Notes | +|-----|------|-------| +| `command` | string | Executable for a **stdio** server. Mutually exclusive with `url`; one of the two is required. | +| `args` | `string[]` | Arguments for `command`. | +| `env` | map of string → string | Environment for the server process. | +| `url` | string | Endpoint for a **remote** server. Mutually exclusive with `command`. | +| `type` | string | `stdio` for a `command`; `http` or `sse` for a `url`. Optional — a bare `url` infers `http`. Must agree with the rest of the declaration. | +| `headers` | map of string → string | Headers for a remote server. **`url` servers only** — `headers` on a `command` server is an error. | + +Every string leaf above accepts `env:VAR_NAME` — see +[Keeping the token out of the file](#keeping-the-token-out-of-the-file). + Every project's keeper gets every server here, and Paddock adds each one's `mcp____*` pattern to that keeper's tool allow-list — without which the server would attach and then have every one of its calls silently refused. The @@ -495,7 +548,8 @@ one is an error, and a `playwright` of yours loses to the built-in browser serve ### Keeping the token out of the file This file is git-tracked, and the Config screen can write to it. So anywhere a -string is expected — `command`, an `args` entry, an `env` value, `url` — +string is expected — `command`, an `args` entry, an `env` value, `url`, a +`headers` value — **`env:VAR_NAME` means "read this from the environment"**, exactly as `managementApi.clients..auth.ref` does: @@ -514,8 +568,9 @@ server that connects unauthenticated is worse than one that does not connect. An inline value is allowed, because unlike a management token an MCP `env` entry is often not a secret (`NOTION_VERSION` is not one). But an inline value under a -credential-shaped key (`…TOKEN`, `…KEY`, `…SECRET`, …), or a `url` carrying a -query string or `user:pass@`, gets a warning telling you to move it. **Nothing +credential-shaped key (`…TOKEN`, `…KEY`, `…SECRET`, `…AUTH`, …) — in `env` or in +`headers`, one rule to learn — or a `url` carrying a query string or +`user:pass@`, gets a warning telling you to move it. **Nothing Paddock logs about this block ever contains a value from it**: server names, key names and referenced variable names only, with URLs stripped of their query string. For the same reason the block is absent from the Config screen and from @@ -529,7 +584,8 @@ readable only by its owner, exactly as your own Claude Code does it. Under **`driveMode: batch`** the engine instead passes the whole definition to `claude` as a `--mcp-config` **argument**, and a process argument is world-readable on Linux (`/proc//cmdline`, and `ps`). Any local user can read the token for -as long as the turn runs. +as long as the turn runs. That covers resolved **`headers`** as well as `env` — +and an `Authorization` bearer is the likelier long-lived credential of the two. So prefer `session` — the default — for any server holding a credential. Paddock warns at startup if you are on `batch` with one, and notes it even on `session`, @@ -543,17 +599,22 @@ with a warning, on the grounds that you configured it elsewhere for something else — a declaration here is **refused** if Paddock cannot carry it faithfully. You typed it at Paddock, so a mistake is one you can fix: -- **`headers:`** — the engine's MCP schema has no field for them, so a - bearer-authenticated server would arrive unauthenticated *and* miss its stored - OAuth token (keyed on a hash that includes the headers). -- **`type: sse`** — every `url` server is connected to as streamable HTTP, so an - sse server would be silently downgraded. - **an unrecognised key** — `arg:` for `args:` would otherwise start the server with the wrong arguments and no indication why. -- both a `command` and a `url`, or neither. +- both a `command` and a `url`, or **neither**. +- **`headers:` on a `command` server** — only a `url` server can carry headers. +- **a `type:` that disagrees with the declaration** — `stdio` for a `command`, + `http` or `sse` for a `url`. A `type` that contradicts the rest is a typo, and + starting the wrong transport is a confusing failure rather than a loud one. +- **a reserved name** — `paddock` or `paddock_manage`. Each is reported at startup naming the server, and **only that server** is dropped; the rest still attach and the instance still boots. + +`headers:` and `type: sse` *used* to be on that list, because herdctl's schema +had no field for either. Since herdctl 5.32.0 both are carried verbatim, so both +are **accepted** here — and `headers` is a first-class secret-bearing field with +the same `env:VAR` resolution and the same never-print rule as `env`. ::: ## Capability & safety gates worth setting here @@ -564,7 +625,7 @@ prime candidates for the config file because they rarely change between runs. Each is settable **either** in the YAML **or** via its env var (env wins), and several also take a per-project override that wins at dispatch time. -The first four rows decide which of the fourteen `mcp__paddock_manage__*` tools +The first four rows decide which of the fifteen `mcp__paddock_manage__*` tools Claude is handed; the [self-management MCP reference](/reference/self-mcp/) lists every tool, its arguments and the exact gating matrix. diff --git a/website/src/content/docs/configuration/environment.md b/website/src/content/docs/configuration/environment.md index 07542c19..034cf795 100644 --- a/website/src/content/docs/configuration/environment.md +++ b/website/src/content/docs/configuration/environment.md @@ -64,9 +64,9 @@ Consequences worth knowing: | `CLAUDE_CONFIG_DIR` | `/claude-home` | no | Where **Paddock's own** Claude home goes — the directory whose `projects//` folders hold Claude Code's session transcripts, and the value handed to Claude Code as its config dir. Paddock **always owns this directory** (#691): the data dir is a single relocatable root, and the user's `~/.claude` is a read-only source Paddock bridges config out of but never runs as. This variable is honoured (rather than ignored) because it is Claude Code's own, and herdctl deliberately refuses to clobber an operator-set value (herdctl#423) — if Paddock disagreed with it, the SDK would write transcripts to one tree while herdctl read from another, and chats would **list from one directory and open empty from another** (#588). A `claudeHome:` key in the [config file](/configuration/config-file/) sits beneath it. **Paddock refuses to start if this resolves to your own `~/.claude`** — that is the one value that re-welds every concern to a single lever and breaks agent memory (an agent cannot write to any path with a `.claude` component, #690). To share your real transcripts, use `claude.transcripts: host` instead; it shares the files without moving the home. Resolved **once** at startup into `PaddockConfig.claudeHome` (`resolveClaudeHome()` in `config.ts`) and threaded to *both* consumers: Paddock's transcript relocation and import detection (`ensureProjectChats` in `transcripts.ts`, `AdoptableIndex` in `adoptable.ts`), **and** the engine, as `FleetManagerOptions.claudeHomePath` (`herdctl.ts`). Note that Claude Code scopes its credential store to whether this is set at all, so a keychain login made against the default home is not visible under Paddock's — which is what `PADDOCK_CLAUDE_CREDENTIALS` (below, default `host`) exists to undo. Paddock warns at boot when it can find no credential source at all. | | `PADDOCK_CLAUDE_TRANSCRIPTS` | `own` | no | Whose session transcripts this instance uses (#691) — the env override for the `claude.transcripts` key. `own` keeps them in each project's `.chats/`, inside the data dir. `host` shares your real `~/.claude/projects//` folder live, in both directions: a Paddock chat and a `claude --resume` in the same directory are the same file. Under `host`, deleting a chat **releases** it rather than removing it — it is your history, not Paddock's copy (#689). See [the config file](/configuration/config-file/). | | `PADDOCK_CLAUDE_CREDENTIALS` | `host` | no | Whose Claude Code **login** this instance uses (#691) — the env override for the `claude.credentials` key, and the one key in that block whose default is `host` rather than `own`. `host` uses the login already on this machine: on macOS the Keychain entry a plain `claude /login` wrote, elsewhere your `~/.claude/.credentials.json` (symlinked into Paddock's home, never copied). `own` uses only what is inside Paddock's own Claude home — a token in the environment, or a `CLAUDE_CONFIG_DIR=/claude-home claude login`. The default is `host` because reading a login **writes nothing**, while isolating it by default produces an instance that boots cleanly and fails every turn with "Not logged in" (#683). Mechanically, `host` sets `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` in the environment the runtime gets: Claude Code scopes its secure storage to that variable *instead of* `CLAUDE_CONFIG_DIR` when it is defined, and the empty value selects the unsuffixed service name — so the login is shared without Paddock's Claude home moving anywhere. Set the variable yourself to a non-empty value and Paddock honours it over this key. | -| `PADDOCK_CLAUDE_INSTRUCTIONS` | `own` | no | Whose user-level **instructions** this instance loads (#691) — the env override for the `claude.instructions` key. Governs your `~/.claude` `CLAUDE.md`, `agents/`, `commands/` and `plugins/`: inert content the model reads or invokes by name, none of which runs a command on its own. `own` loads none of them; `host` symlinks all four in, which is what every version before 0.62 did unconditionally. **This default is a reversal with a real cost** — a curated `~/.claude/CLAUDE.md` stops reaching your agents, silently — and it is the default anyway so that "`own` everywhere means nothing outside the data dir is read or written" is a guarantee rather than a footnote. Paddock names this key at startup when it finds files it is not loading. Each project's own `CLAUDE.md` is unaffected either way. | +| `PADDOCK_CLAUDE_INSTRUCTIONS` | `own` | no | Whose user-level **instructions** this instance loads (#691) — the env override for the `claude.instructions` key. Governs your `~/.claude` `CLAUDE.md`, `agents/`, `commands/` and `plugins/`: inert content the model reads or invokes by name, none of which runs a command on its own. `own` loads none of them; `host` symlinks all four in, which is what every version before 0.62 did unconditionally. **It is also the gate for plugins.** The symlink alone never made one work — enablement lives in `enabledPlugins` in the user settings source, which Paddock's agents do not load — so since 0.63 Paddock enumerates the host's installed plugins and passes them to the engine as *session* plugins, which are enabled by default. `host` here is what turns that on; `PADDOCK_CLAUDE_MCP_SERVERS` then decides whether a plugin's MCP servers come with it. **This default is a reversal with a real cost** — a curated `~/.claude/CLAUDE.md` stops reaching your agents, silently — and it is the default anyway so that "`own` everywhere means nothing outside the data dir is read or written" is a guarantee rather than a footnote. Paddock names this key at startup when it finds files it is not loading. Each project's own `CLAUDE.md` is unaffected either way. | | `PADDOCK_CLAUDE_HOOKS` | `own` | no | Whether this instance runs the host machine's Claude Code **hooks** (#691) — the env override for the `claude.hooks` key, and the only lever in the block that governs code execution rather than data. Hooks are shell commands `~/.claude/settings.json` binds to tool use and session lifecycle; before 0.62 they were inherited unconditionally, so every hook you had configured ran inside every Paddock turn with no way to stop it. `own` drops them; `host` symlinks your `settings.json` in whole. Because that file is a mixed bag (`hooks` *and* `permissions`, `model`, `statusLine`, `enabledPlugins`), `own` cannot be a symlink decision: Paddock **writes its own `settings.json`** carrying your other keys with `hooks` removed, regenerated at each startup — so a restart is what applies an edit to yours. A `settings.json` you put in Paddock's own home is recognised by hash and never overwritten. An unparseable source plants nothing rather than falling back to the symlink. Scope: this means *no host hooks*, not *no host commands* — `apiKeyHelper`, `awsAuthRefresh`, `statusLine` and friends are still inherited. | -| `PADDOCK_CLAUDE_MCP_SERVERS` | `own` | no | Whose **MCP servers** this instance's project agents get (#691) — the env override for the `claude.mcpServers` key. `own` attaches only the servers Paddock provides itself (`send_file`, the optional self-management tools, the optional browser server); `host` also attaches the ones you have declared with `claude mcp add` — the top-level `mcpServers` of your `~/.claude.json` plus any scoped to a project's own working directory (`projects..mcpServers`). Note the path: MCP servers are declared in `~/.claude.json`, a **sibling of** `~/.claude` rather than a file inside it, which is why they were the one thing Paddock's config bridge structurally could not reach. Paddock **reads** that file and passes the servers to the runtime; it never symlinks or writes it, because Claude Code keeps mutable state there (per-project trust, approvals) that is yours. Read **once, at startup** — add a server and restart Paddock to pick it up — and the boot log names every server it attached. Two things cannot be carried through today and are warned about individually: an http/sse server's `headers` (so a bearer token is lost, and its stored OAuth token, which is keyed on a hash of those headers, is not found either), and the `sse` transport (connected to as HTTP). MCP OAuth tokens otherwise live in the same credential store as your Anthropic login, so `PADDOCK_CLAUDE_CREDENTIALS=host` carries them. Plugin-provided MCP servers are **not** covered — see the config file page. | +| `PADDOCK_CLAUDE_MCP_SERVERS` | `own` | no | Whose **MCP servers** this instance's project agents get (#691) — the env override for the `claude.mcpServers` key. `own` attaches only the servers Paddock provides itself (`send_file`, the optional self-management tools, the optional browser server); `host` also attaches the ones you have declared with `claude mcp add` — the top-level `mcpServers` of your `~/.claude.json` plus any scoped to a project's own working directory (`projects..mcpServers`). Note the path: MCP servers are declared in `~/.claude.json`, a **sibling of** `~/.claude` rather than a file inside it, which is why they were the one thing Paddock's config bridge structurally could not reach. Paddock **reads** that file and passes the servers to the runtime; it never symlinks or writes it, because Claude Code keeps mutable state there (per-project trust, approvals) that is yours. Read **once, at startup** — add a server and restart Paddock to pick it up — and the boot log names every server it attached. Since 0.63 (herdctl 5.32.0) a server's `type` and `headers` are carried through **verbatim**, so a bearer-authenticated server keeps its header and an `sse` server is connected to as SSE; the 0.62 warnings about both are gone. The only host server still dropped is one declaring neither a `command` nor a `url`. MCP OAuth tokens live in the same credential store as your Anthropic login — and are keyed on a hash of `{type, url, headers}`, which is why carrying those fields is what makes `PADDOCK_CLAUDE_CREDENTIALS=host` work for an OAuth server at all. Plugin-provided MCP servers **are** covered, but only alongside `PADDOCK_CLAUDE_INSTRUCTIONS=host` — see the config file page for the truth table. | > **Safe-by-default binding.** Paddock runs code and spends Claude tokens, so it @@ -145,8 +145,8 @@ secrets file, not a committed `.env`. The top-level [`mcpServers:`](/configuration/config-file/#mcpservers--the-servers-this-instance-declares-itself) block — where you declare an MCP server *to this instance* — is likewise **config-file-only**, and borrows the same indirection. Anywhere it expects a -string (`command`, an `args` entry, an `env` value, `url`), `env:VAR_NAME` reads -that value from the environment instead: +string (`command`, an `args` entry, an `env` value, `url`, a `headers` value), +`env:VAR_NAME` reads that value from the environment instead: ```yaml mcpServers: From 8fc3165dccd63dbfd890a0ab4a87a840fae74a42 Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Sat, 8 Aug 2026 07:20:16 -0400 Subject: [PATCH 3/6] docs(configuration): the Config screen's 0.66.2 DTO instance-settings.md described a single always-on restart banner and a write with no concurrency story. #757/#722/#723 replaced both. - three banner states (configFileError / just-saved / restartRequired), and restartRequired is computed now, not hardcoded false - value vs pendingValue vs pendingRestart, per field - configVersion -> expectedVersion -> 409 config_conflict, opt-in - the 400 on an env-shadowed write - a save stamps schemaVersion: 1 when absent, which is what config-file.md promises --- .../docs/configuration/instance-settings.md | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/website/src/content/docs/configuration/instance-settings.md b/website/src/content/docs/configuration/instance-settings.md index d047cc8e..91c3ce69 100644 --- a/website/src/content/docs/configuration/instance-settings.md +++ b/website/src/content/docs/configuration/instance-settings.md @@ -32,17 +32,43 @@ split fixed. ### 1. Every change needs a restart -This is the big one, and the screen says so in a banner that never goes away. +This is the big one, and the screen says so in a banner that never goes away — but +since v0.66.2 the banner has **three** states rather than one, because "what a +restart would load" is now something Paddock actually checks rather than assumes. Paddock resolves its instance config **once, at boot**, and freezes it. Saving writes your changes to `paddock.config.yaml` on disk — it does **not** hot-apply them. The -running process keeps the config it started with until you restart it. After a -successful save the banner switches to a green "Saved to disk — these changes take -effect only after the server restarts" confirmation, which is the honest version of a -success message. +running process keeps the config it started with until you restart it. + +| Banner | When | What it means | +|---|---|---| +| Red — *"Could not read paddock.config.yaml"* | the file exists but won't parse | The values shown are the **running** instance's. What a restart would load is unknown until the file parses. | +| Green — *"Saved to disk"* | just after a successful save | Your write landed. It takes effect at the next restart. | +| Amber — *"Restart pending"* | the file holds changes the process hasn't picked up | At least one field's on-disk value differs from what is in force. Those fields are individually chipped **restart**. | +| Amber — the plain notice | otherwise | Nothing is diverging; edits here will need a restart like any other. | + +That amber "Restart pending" state used to be unreachable: `restartRequired` was +hardcoded `false`, so an unapplied edit — yours, another tab's, or one made with an +editor — looked exactly like a clean instance. It is now **computed**, by comparing +each editable field's on-disk value against the one the process is running. So the workflow is: change what you want, save, restart Paddock, verify. +#### Two values per field + +That comparison is visible in the API too. Every field the screen renders carries +**both**: + +- **`value`** — what the running, boot-frozen process resolved. +- **`pendingValue`** — what `paddock.config.yaml` says *this instant*, i.e. what a + restart would load. Computed for **editable, non-env-shadowed** fields only; + everywhere else it just repeats `value`. +- **`pendingRestart`** — the two differ. + +The editor renders `pendingValue`, and a save round-trips through it. So the screen +is an editor for the *file*, showing you where the file and the process disagree — +not a window onto the running process. + ### 2. An environment variable wins, and the screen tells you Precedence is **env → file → built-in default**. A field that's currently pinned by a @@ -175,6 +201,30 @@ so the screen can't produce a file the loader would then quietly degrade. Only fields you actually changed are sent, so a save is a patch, not a rewrite. +A save also **stamps `schemaVersion: 1`** into a file that doesn't declare one yet +— which is every file written before v0.66.0 and every fresh one the first save +creates. Only when absent: a patch round-trips the document's other keys untouched, +so it is in no position to assert what version the *whole* file is, and a file that +already declares one has been through the boot guard already. That is the "written +on the next save" half of the promise in +[Config file → `schemaVersion`](/configuration/config-file/#schemaversion--the-downgrade-guard). + +### Two tabs, one file + +The GET returns a **`configVersion`** — a fingerprint of the file as read for that +response. The screen echoes it back as **`expectedVersion`** on the PUT, and the +server refuses the write with a **`409`** (`code: "config_conflict"`) if the file no +longer matches. **Nothing is written** in that case; reload the settings and reapply. +Before this, a second tab silently erased the first tab's save. + +`expectedVersion` is **opt-in**. A client that omits it — curl, a script — writes +unconditionally, exactly as before. The UI always sends one. + +One related response worth knowing: writing an **env-shadowed** field is a **`400`** +naming the variable that wins. It used to return `200` + `restartRequired: true` for +a write that could never take effect. (Sending `null` for a field clears its key, as +[above](#what-you-can-edit) — that is a `200`, not an error.) + ## See also - [Config file (YAML)](/configuration/config-file/) — the file this screen edits, and every key in it. From cb49f9aae46ac694aa07c92d0573e4f0adeb3ee8 Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Sat, 8 Aug 2026 07:21:37 -0400 Subject: [PATCH 4/6] docs(reference): websocket.md was three frames and several fields behind - add chat:queued_state, chat:queued_returned and chat:injected to the server->client table (all three exist and are handled by the client) - chat:send and chat:set_queue carry attachments; set_queue's identity is qid, not the legacy ts (#245/#736) -- note added on why - chat:tool_start/tool_call carry subagentType/description/hasSubagent (#429) - chat:queued_flushed carries attachments (#728) - the no-seq list was missing the three hub.broadcast frames - replace the misleading "no chat:queued" note with a section on the four queue frames and why _returned is not a flag on _flushed --- .../src/content/docs/reference/websocket.md | 61 ++++++++++++++++--- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/website/src/content/docs/reference/websocket.md b/website/src/content/docs/reference/websocket.md index e0631e17..ae970f37 100644 --- a/website/src/content/docs/reference/websocket.md +++ b/website/src/content/docs/reference/websocket.md @@ -25,7 +25,7 @@ Server→client **chat events** carry a common **`Routing`** block in `payload`: | `projectSlug` | `string` | Workspace key — a project slug, or `""` for the root workspace. | | `sessionId` | `string \| null` | Null until a brand-new chat's id first streams back. | | `jobId` | `string \| null` | The cancellable job id, when known. | -| `seq` | `number?` | Per-turn monotonic sequence for reconnect/gap-replay. Absent on frames not routed through the hub (`chat:error`, `chat:resync`, `chat:active`, `chat:queued_flushed`, `pong`). | +| `seq` | `number?` | Per-turn monotonic sequence for reconnect/gap-replay. Absent on frames not stamped by the hub's `emit` — `chat:error`, `chat:resync`, `chat:active`, `chat:queued_flushed`, `chat:queued_state`, `chat:queued_returned`, `chat:killed_task`, `pong`. Those last four go out via `hub.broadcast`, which reaches the origin socket **and** every subscriber without seq-stamping or buffering, precisely so an out-of-band signal still reaches a client that reconnected on a new socket. | Client→server payloads carry `projectSlug`. Invalid JSON / unknown kinds get a `chat:error` reply. @@ -34,26 +34,49 @@ Client→server payloads carry `projectSlug`. Invalid JSON / unknown kinds get a | Kind | When it fires | Payload (beyond `projectSlug`) | |------|---------------|------------------------------------------| | `chat:subscribe` | On (re)connect, to attach a socket to a session's live stream and replay any missed gap. | `sessionId: string`, `wantReplay?: boolean`, `lastSeq?: number` | -| `chat:send` | User (or a server-side queue drain) sends a message / starts or resumes a turn. | `sessionId?: string \| null` (null ⇒ new chat), `message: string`, `preloadContext?: boolean`, `model?: string` | +| `chat:send` | User (or a server-side queue drain) sends a message / starts or resumes a turn. | `sessionId?: string \| null` (null ⇒ new chat), `message: string`, `preloadContext?: boolean`, `model?: string`, `attachments?: AttachmentRef[]` | | `chat:command` | User runs a slash command (e.g. `/compact`) in the current chat. | `sessionId?: string \| null`, `command: string` (full text incl. leading slash) | | `chat:cancel` | User clicks Stop; cancels the running turn's job. | `jobId: string` | -| `chat:set_queue` | Persist/clear the single-slot composer queue server-side (survives browser close). | `sessionId?: string \| null`, `text?: string \| null` (null/empty ⇒ clear), `ts?: number \| null` | +| `chat:set_queue` | Write/clear the chat's single queue slot server-side (survives browser close, and is **shared** across clients — see `chat:queued_state`). | `sessionId?: string \| null`, `text?: string \| null` (null/empty ⇒ clear), `qid?: string \| null`, `attachments?: AttachmentRef[]`, `ts?: number \| null` *(legacy)* | | `chat:continue` | The **Continue** button on a killed-task notice — re-drives a hung chat with a recovery-attributed nudge (`sender: { kind: "recovery" }`). Refused server-side when the resolved `recovery.surfaceKilledTask` is off, so a client can't re-drive an instance whose operator turned Layer 2 off. | `sessionId: string` (**required** — recovery needs a chat), `projectSlug?: string` | | `ping` | Client keepalive every 25s. | *(none)* | +**`AttachmentRef`** (on `chat:send` and `chat:set_queue`): +`{ id: string, filename: string, kind?: string }`. Each references an attachment +already uploaded via +[`POST …/chats/:id/upload`](/reference/api/) — the frame carries the reference, +never the bytes. Project chats only. + +:::note[`qid`, and why `ts` is legacy] +`qid` is the **opaque** identity of a queue slot: minted once when the queue is +first created and kept across edits, appends and reloads, so the server can tell +this client updating its own queue apart from another client queueing alongside +it, and a stale re-assert of an already-drained message apart from a new one. + +`ts` was the original identity and is still accepted — an older client sends only +that, and the server folds it into an id. But it is compared for **equality only** +and is never stored as the enqueue time; the server stamps that itself. Treating +it as an *ordered* identity is what let one client with a fast clock poison the +dedup marker and silently destroy every later queued message on that chat, from +any client ([#736](https://github.com/edspencer/paddock/issues/736)). Send `qid`. +::: + ## Server → client | Kind | When it fires | Payload (beyond `Routing`) | |------|---------------|-----------------------------| | `chat:active` | A session's live-turn status changed (start/stop); broadcast to all clients, and sent as a snapshot to a newly-connected or subscribing socket. | `sessionId: string`, `jobId: string \| null`, `running: boolean` (this frame carries its own `projectSlug`/`sessionId`, no `seq`) | | `chat:response` | A streamed assistant text delta. Also surfaces a `/compact` boundary as a synthetic note. | `chunk: string` | -| `chat:tool_start` | A tool_use begins (before it runs) — renders a pending "running…" row. | `toolName: string`, `inputSummary?: string`, `toolUseId?: string`, `parentToolUseId: string \| null` | -| `chat:tool_call` | A tool completes (paired tool_use→tool_result); reconciles the pending row. | `toolName: string`, `inputSummary?: string`, `output: string`, `isError: boolean`, `durationMs?: number`, `toolUseId?: string` | +| `chat:tool_start` | A tool_use begins (before it runs) — renders a pending "running…" row. | `toolName: string`, `inputSummary?: string`, `toolUseId?: string`, `parentToolUseId: string \| null`, `subagentType?: string`, `description?: string`, `hasSubagent?: boolean` | +| `chat:tool_call` | A tool completes (paired tool_use→tool_result); reconciles the pending row. | `toolName: string`, `inputSummary?: string`, `output: string`, `isError: boolean`, `durationMs?: number`, `toolUseId?: string`, `subagentType?: string`, `description?: string`, `hasSubagent?: boolean` | | `chat:message_boundary` | An assistant message bubble ended. | *(Routing only)* | | `chat:complete` | The turn finished (success or failure); carries final usage/model. | `success: boolean`, `error?: string`, `model?: string`, `usage?: ChatCompleteUsage` | | `chat:error` | A turn threw before/without a resolved session (sent to the origin socket only); also the reply to invalid JSON / unknown frames. | `projectSlug: string`, `error: string` (no `sessionId`/`jobId`/`seq`) | | `chat:resync` | Reconnect fallback: the live turn's frame buffer aged out past the requested gap, so the client must re-hydrate from the transcript. | `projectSlug: string`, `sessionId: string` | -| `chat:queued_flushed` | The server auto-drained the persisted queued message after a turn (or when idle). | `projectSlug: string`, `sessionId: string`, `text?: string` (present ⇒ render as a user bubble; absent ⇒ just clear a stale copy) | +| `chat:injected` | A **resumed** turn received its prompt. Emitted once per turn, and only on a resume, so a client can render the injected message attributed to whoever (or whatever) sent it. | `sender: MessageSender`, `content: string`, `timestamp: string` (ISO) | +| `chat:queued_flushed` | The server auto-drained the queued message after a turn (or when idle). | `projectSlug: string`, `sessionId: string`, `text?: string` (present ⇒ render as a user bubble; absent ⇒ just clear a stale copy), `attachments?: AttachmentRef[]` (only alongside `text`) | +| `chat:queued_state` | The chat's queue slot was written. **Broadcast to every socket attached to the session**, so the queue is shared chat state that all clients render identically. | `projectSlug: string`, `sessionId: string`, `text: string \| null` (null ⇒ the slot is now empty), `attachments?: AttachmentRef[]`, `qid?: string` (adopt it, so your next edit updates this slot in place rather than appending beside it), `reason?: "returned"` | +| `chat:queued_returned` | A user pressed Stop, so the message queued behind that turn is handed **back** to them. Sent **only to the socket that issued `chat:cancel`**; the other clients get a `chat:queued_state` with `reason: "returned"` instead. | `projectSlug: string`, `sessionId: string`, `text: string`, `attachments?: AttachmentRef[]` | | `chat:killed_task` | A background task the chat was waiting on was killed. Broadcast **live**, the moment the recovery engine detects it — otherwise the notification sits in the SDK input queue until some later turn flushes it, and the "Claude is idle / Continue" affordance only appears after a manual refresh. Rendered as the amber killed-task notice. Gated on `recovery.surfaceKilledTask`, which is **on by default**. | `projectSlug: string`, `sessionId: string`, `summary: string` (the killed ``'s ``, or a generic fallback), `timestamp: string` (ISO, used client-side to dedup replays) | | `chat:notice` | A turn dead-ended without a normal reply — a usage/subscription limit, the max-turns cap, or an error (network, API 5xx-overloaded, auth, crash). Emitted **inline during the turn** and session-routed like the other turn frames, so the chat says *why* it stopped instead of looking dead. | `notice: TurnNotice` (carries the reset time for a usage limit, and `retryable` for the Retry/Continue affordance) | | `pong` | Reply to a client `ping`. | *(none)* | @@ -63,8 +86,26 @@ Client→server payloads carry `projectSlug`. Invalid JSON / unknown kinds get a cacheCreation), `contextLimit` (= the model's context limit). Stale-by-one-turn by design. +## The queue frames + +The queued message is **shared chat state**, not per-client state, and that takes +four frames plus the inbound `chat:set_queue`. Which one you get says what +happened to the slot: + +| Frame | Direction | Meaning | +|---|---|---| +| `chat:set_queue` | client → server | Write or clear the slot. Contributions **merge**; attachments union by id, so one client's write can never silently drop another's file. | +| `chat:queued_state` | server → **all** attached sockets | The slot changed; here is its full current contents. This is what makes the merge visible. | +| `chat:queued_flushed` | server → all | The slot was **drained and sent** as a turn. Render `text` as the user bubble — the drained turn streams only the reply. | +| `chat:queued_returned` | server → **the stopping socket only** | Stop was pressed; the message was **not** sent and goes back into that user's composer. | + +The `_returned` / `_flushed` split is deliberate and not a flag on one frame: +`_flushed` means "this text was sent, render it as the user's bubble", and a +returned message is precisely one that was *not* sent. Overloading it would put a +phantom user turn in the transcript for a message the agent never received. + > Notes: There is **no** `chat:tool_end` (completion is `chat:tool_call`), no -> `chat:queued` (drain is `chat:queued_flushed`), and no dedicated snapshot frame — -> `chat:active` doubles as the on-connect snapshot, and reconnect/replay flows -> through `chat:subscribe` → (replay | `chat:resync`). A `/compact` compaction is -> folded into a `chat:response` chunk + `chat:message_boundary`, not its own kind. +> bare `chat:queued`, and no dedicated snapshot frame — `chat:active` doubles as +> the on-connect snapshot, and reconnect/replay flows through `chat:subscribe` → +> (replay | `chat:resync`). A `/compact` compaction is folded into a +> `chat:response` chunk + `chat:message_boundary`, not its own kind. From 06bd70344ea722d83ca4955e7836c202ea4d02a3 Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Sat, 8 Aug 2026 07:24:32 -0400 Subject: [PATCH 5/6] docs(configuration,reference): remaining stale claims - self-mcp.md frontmatter + mcp.md scope prose: fourteen -> fifteen paddock_manage tools (ALL_OPERATIONS = 3 read + 8 write + 4 trigger) - api.md: the auth section named /api/health as the sole exemption; there are three groups (health, the compiled bundle, /mcp + its metadata). Also document the /api/root/* mount -- every workspace route is registered once and mounted twice, which is why the root workspace's key is "" - hooks.md: add the two trigger routes it was missing (GET .../runtime and POST .../:name/run); schedules.md already listed all six - openapi.md: note /open-api/yaml - environment.md: add the CLAUDE_SECURESTORAGE_CONFIG_DIR row --- .../content/docs/configuration/environment.md | 1 + .../src/content/docs/configuration/openapi.md | 5 ++-- website/src/content/docs/reference/api.md | 25 ++++++++++++++++++- website/src/content/docs/reference/hooks.md | 2 ++ website/src/content/docs/reference/mcp.md | 2 +- .../src/content/docs/reference/self-mcp.md | 2 +- 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/website/src/content/docs/configuration/environment.md b/website/src/content/docs/configuration/environment.md index 034cf795..01638720 100644 --- a/website/src/content/docs/configuration/environment.md +++ b/website/src/content/docs/configuration/environment.md @@ -323,6 +323,7 @@ non-numeric, blank) falls back to the default rather than failing startup. |----------|---------|----------|---------| | `CLAUDE_CODE_OAUTH_TOKEN` | — | conditional | Claude **Max plan** auth. Read from the server's environment and passed through to the `claude` process the runtime spawns; never written to config. Provide this **or** `ANTHROPIC_API_KEY`. | | `ANTHROPIC_API_KEY` | — | conditional | Claude **API-key** auth (API pricing). Alternative to `CLAUDE_CODE_OAUTH_TOKEN`. | +| `CLAUDE_SECURESTORAGE_CONFIG_DIR` | *(set by Paddock)* | no | Where Claude Code scopes its **secure credential store** — it uses this *instead of* `CLAUDE_CONFIG_DIR` whenever it is defined. This is the mechanism behind `claude.credentials`: under the default `host`, Paddock sets it to the empty string, which selects the unsuffixed keychain service name and so shares the login a plain `claude /login` wrote; under `own` it **unsets** the variable, so the store falls back to Paddock's own Claude home. **An operator-set non-empty value wins over the key in either mode**, and Paddock reports that it is honouring yours at startup. (An empty value is not treated as yours — that is exactly what `host` writes.) Set it yourself only if you keep credentials somewhere neither `host` nor `own` describes. It is also where MCP OAuth tokens live (under an `mcpOAuth` key in the same store), so it moves those too. | | `LOG_LEVEL` | `info` | no | Fastify/pino log level (`fatal`…`trace`). | | `HERDCTL_LOG_LEVEL` | `info` | no | `@herdctl/core`'s own logger (the `[fleet-manager]` / `[CLIRuntime]` lines), which pino's level cannot reach. Paddock routes these through a handler that cuts the reconstructed `claude` argv out of agent-failure messages — a `claude -p` command line carries the whole system prompt and is noise in a log (#684). Set this to `debug` to get the full command back. | | `PADDOCK_QUIET` | — | no | Set by the `paddock` CLI unless `--verbose`. Collapses a *recognised*, non-fatal background failure (no login, no credit, no `claude` on PATH) to one actionable line instead of a stack trace; an unrecognised failure always keeps its full detail. A level alone could not do this — these are logged at `error`, above every threshold. | diff --git a/website/src/content/docs/configuration/openapi.md b/website/src/content/docs/configuration/openapi.md index 73fdd359..4a37b9cb 100644 --- a/website/src/content/docs/configuration/openapi.md +++ b/website/src/content/docs/configuration/openapi.md @@ -48,9 +48,10 @@ Restart, and the surface appears: - **`/open-api`** — the Swagger UI itself, styled with your instance's [branding](/configuration/environment/#branding-per-instance) (logo, favicon, and the accent colour on the topbar trim). -- **`/open-api/json`** — the raw OpenAPI 3.0 document. +- **`/open-api/json`** — the raw OpenAPI 3.0 document. `@fastify/swagger-ui` + mounts **`/open-api/yaml`** beside it, serving the same document as YAML. - **`/open-api.json`** — an alias for the same document, because that's the path people - reach for first. Both follow `PADDOCK_OPENAPI_PATH` if you move the prefix. + reach for first. All of these follow `PADDOCK_OPENAPI_PATH` if you move the prefix. - **A "Swagger API" link in the sidebar**, below Config, opening in a new tab. It only renders when the surface is enabled, so the sidebar of a plain instance is unchanged. diff --git a/website/src/content/docs/reference/api.md b/website/src/content/docs/reference/api.md index 35398247..37c4b1e4 100644 --- a/website/src/content/docs/reference/api.md +++ b/website/src/content/docs/reference/api.md @@ -30,6 +30,22 @@ disabled. To exercise the API against your own instance, run Paddock with **`/open-api`** on that instance — same document, live, with the raw spec at `/open-api.json`. +### Addressing the root workspace + +One thing the generated spec shows but does not explain. Every workspace route is +registered **once** and mounted **twice**: + +- `/api/projects/:slug` — a named project. +- `/api/root` — the **root workspace**, whose key is the empty string. An + `onRequest` hook injects `slug: ""` so the handler cannot tell the difference. + +So `GET /api/root/chats` is the root workspace's chat list, and every +`/api/projects/:slug/…` route below has an `/api/root/…` twin. That is also why +the root workspace is `project: ""` in the +[self-management MCP](/reference/self-mcp/) and `projectSlug: ""` on the +[WebSocket](/reference/websocket/) — one empty-string key, three spellings of the +same idea. + ## Authentication Every request passes through the auth layer (`packages/server/src/auth.ts`) chosen @@ -40,7 +56,14 @@ by `PADDOCK_AUTH_MODE` (see [CONFIGURATION.md](/configuration/environment) and the API is fully open. - In **`trusted-header`** / **`jwt`** modes the proxy/IdP identity becomes `req.user`, and per-user **read-state** (unread/seen) is keyed by username. -- **`GET /api/health` is always exempt** (liveness probe). +- **Three groups are exempt** from the hook (see + [Authentication](/configuration/authentication/) for the reasoning on each): + `GET /api/health` (liveness probe); the compiled front-end bundle — the + `/assets/`, `/icons/` and `/fonts/` prefixes plus `/sw.js`, + `/manifest.webmanifest` and `/favicon.ico`; and `/mcp` + + `/.well-known/oauth-protected-resource` (both prefix-matched), which + authenticate themselves. Everything else — every `/api` route, the app shell, + and `/ws` — stays gated. - There is **no per-resource authorization** — chat visibility is deliberately not gated (#189). "Auth" means "the configured mode must admit the request", not "this principal owns this chat". diff --git a/website/src/content/docs/reference/hooks.md b/website/src/content/docs/reference/hooks.md index a3ce3582..767ac4b6 100644 --- a/website/src/content/docs/reference/hooks.md +++ b/website/src/content/docs/reference/hooks.md @@ -87,9 +87,11 @@ The Triggers tab drives these; they're here for completeness. | Method + path | Does | | --- | --- | | `GET /api/projects/:slug/triggers` | List the project's triggers, plus the picker catalog: `grantableTools`, the available `events`, and `triggerTypes`. | +| `GET /api/projects/:slug/triggers/runtime` | Live per-trigger last-run / next-run / status, joining each trigger's config with herdctl runtime state. Its own endpoint so the tab can poll it cheaply without re-fetching the picker catalog. A static segment, matched before `/:name`, so no trigger can shadow it. | | `GET /api/projects/:slug/triggers/:name` | Fetch one trigger (404 if undeclared). | | `PUT /api/projects/:slug/triggers/:name` | Create or replace one. Enable/disable is this same call with `enabled` flipped. | | `DELETE /api/projects/:slug/triggers/:name` | Delete one. | +| `POST /api/projects/:slug/triggers/:name/run` | "Run now" — fires the trigger through the same hub path a cron/event fire uses, producing a first-class badged run, **regardless of its `enabled` flag**. `202` with `{ ok, name, sessionId }`; `404` unknown, `409` for the post-turn curator trigger, `503` when firing isn't wired. | ## Hook-management MCP tools diff --git a/website/src/content/docs/reference/mcp.md b/website/src/content/docs/reference/mcp.md index 6a32e4ac..524831f4 100644 --- a/website/src/content/docs/reference/mcp.md +++ b/website/src/content/docs/reference/mcp.md @@ -406,7 +406,7 @@ Two granularities exist on purpose. Internally a scope is a list of **operation* names — the right granularity for an operator writing a config file, who wants to say exactly which verbs a CI token may call. Over OAuth, scopes are coarse (`paddock:read`, `paddock:write`) because they are shown to a *human* on a consent -screen: "grant write access" is a prompt someone reads; a list of fourteen verbs +screen: "grant write access" is a prompt someone reads; a list of fifteen verbs is not. The coarse names are a **projection** used only in the discovery document and in diff --git a/website/src/content/docs/reference/self-mcp.md b/website/src/content/docs/reference/self-mcp.md index bf7cb821..e635720c 100644 --- a/website/src/content/docs/reference/self-mcp.md +++ b/website/src/content/docs/reference/self-mcp.md @@ -1,6 +1,6 @@ --- title: "Self-management MCP (`paddock_manage`)" -description: "The in-process toolset Claude uses to drive Paddock itself: all 14 paddock_manage tools with their arguments and return shapes, and the four instance flags plus maxSpawnDepth that decide which of them exist." +description: "The in-process toolset Claude uses to drive Paddock itself: all 15 paddock_manage tools with their arguments and return shapes, and the four instance flags plus maxSpawnDepth that decide which of them exist." --- Claude can drive **Paddock itself** — enumerate projects, read another chat's From 83ad8b2f5c688c639a38dc5b4e885d1dbc95e067 Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Sat, 8 Aug 2026 07:28:15 -0400 Subject: [PATCH 6/6] docs(configuration,reference): cite the filed code-bug issues - websocket.md: a note that the page documents what the server SENDS, citing #772 (chat:injected absent from the ServerMessage union) and #773 (the web client's mirrored types are stale on the #728 queue attachments), so neither is later used to 'correct' the page back - instance-settings.md: the Self MCP projects field's help text says only 'create whole new projects' but the flag also gates promote_project, which clones a caller-supplied URL (#775) -- document the grant, not the string - retire the 'notebook project to repo-backed' phrasing in the two capability rows, matching self-mcp.md's managed/unmanaged vocabulary --- .../content/docs/configuration/config-file.md | 2 +- .../content/docs/configuration/environment.md | 2 +- .../docs/configuration/instance-settings.md | 9 +++++++++ website/src/content/docs/reference/websocket.md | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/website/src/content/docs/configuration/config-file.md b/website/src/content/docs/configuration/config-file.md index 63398b0e..f95fb613 100644 --- a/website/src/content/docs/configuration/config-file.md +++ b/website/src/content/docs/configuration/config-file.md @@ -633,7 +633,7 @@ every tool, its arguments and the exact gating matrix. |----------|---------|---------|---------------| | `selfMcpEnabled` | `PADDOCK_SELF_MCP` | `false` | Give Claude the read-only [self-management MCP](/reference/self-mcp/) (`mcp__paddock_manage__*`): `list_projects`, `list_chats`, `read_chat`. | | `selfMcpWriteEnabled` | `PADDOCK_SELF_MCP_WRITE` | `false` | Add the self-management **write** tools: `create_chat`, `fork_chat`, `send_message`, `archive_chat`, `unarchive_chat`, `fork_chat_batch`. **Only honoured when `selfMcpEnabled` is also on** — write implies read. (The trigger tools are gated separately, by `hooksMcpEnabled` below.) | -| `selfMcpProjectsEnabled` | `PADDOCK_SELF_MCP_PROJECTS` | `false` | Add the self-management **project** tools (`create_project`, `promote_project`) — provisioning a whole new project, or promoting an existing notebook project to repo-backed. **Only honoured when `selfMcpWriteEnabled` (and so `selfMcpEnabled`) is also on.** Their own flag because they create or restructure instance-level state and clone a caller-supplied URL; when off the tools are **absent**, not present-but-refusing. | +| `selfMcpProjectsEnabled` | `PADDOCK_SELF_MCP_PROJECTS` | `false` | Add the self-management **project** tools (`create_project`, `promote_project`) — provisioning a whole new project, or promoting an existing **managed** (notebook) project into an unmanaged one backed by a repo. **Only honoured when `selfMcpWriteEnabled` (and so `selfMcpEnabled`) is also on.** Their own flag because they create or restructure instance-level state and clone a caller-supplied URL; when off the tools are **absent**, not present-but-refusing. | | `maxSpawnDepth` | `PADDOCK_MAX_SPAWN_DEPTH` | `1` | How deep a spawned chat may itself spawn: a **server-initiated** turn at depth `d` gets the self-MCP only if `d ≤ maxSpawnDepth`. `0` restores "no spawned child gets it"; valid values are `0`–`8`. A human turn is the depth-0 root and is never depth-gated. A per-project override wins at dispatch. | | `scheduleMutationEnabled` | `PADDOCK_SCHEDULE_MUTATION` | `false` | Construct herdctl's fleet manager with `allowScheduleMutation`, permitting its runtime schedule add/remove APIs; off (the default) makes them throw. It is **not** what gates the self-MCP trigger tools (that's `hooksMcpEnabled`), and triggers declared in `project.yaml` are armed regardless. | | `hooksMcpEnabled` | `PADDOCK_HOOKS_MCP` | `false` | Advertise the unified trigger-management MCP tools — `list_triggers`, `set_trigger`, `remove_trigger`, `run_trigger`. (There are no `list_hooks`/`set_hook`/`remove_hook` tools; Epic T collapsed the separate hook and schedule verbs into this one family, and kept this flag as their gate.) Only honoured alongside the self-MCP write tools; a per-project `hooksMcpEnabled` override wins at dispatch. | diff --git a/website/src/content/docs/configuration/environment.md b/website/src/content/docs/configuration/environment.md index 01638720..00e5f652 100644 --- a/website/src/content/docs/configuration/environment.md +++ b/website/src/content/docs/configuration/environment.md @@ -213,7 +213,7 @@ HushPod's whisper config so both can share a backend. See [DEV.md](https://githu | `PADDOCK_NATIVE_PROMPT` | `true` | no | Agents use the native Claude Code system prompt + `CLAUDE.md` hierarchy. Set `0`/`false`/`no` for the terse Paddock "replace" prompt (e.g. an instance with no `CLAUDE.md`). | | `PADDOCK_SELF_MCP` | `false` | no | Give Claude the read-only self-management MCP (`mcp__paddock_manage__*`: enumerate projects/chats, read another chat's transcript). | | `PADDOCK_SELF_MCP_WRITE` | `false` | no | Additionally give Claude the self-management **write** tools (`create_chat`, `fork_chat`, `send_message`, `fork_chat_batch`). Only honored when `PADDOCK_SELF_MCP` is also on (write implies read). | -| `PADDOCK_SELF_MCP_PROJECTS` | `false` | no | Additionally give Claude the self-management **project** tools (`create_project`, `promote_project`) — provisioning a whole new project, or promoting an existing notebook project to repo-backed, cloning a repo either way. Gated separately from the other write tools because it creates instance-level state and clones a caller-supplied git URL. Only honored when `PADDOCK_SELF_MCP` and `PADDOCK_SELF_MCP_WRITE` are also on. | +| `PADDOCK_SELF_MCP_PROJECTS` | `false` | no | Additionally give Claude the self-management **project** tools (`create_project`, `promote_project`) — provisioning a whole new project, or promoting an existing **managed** (notebook) project into an unmanaged one backed by a repo — cloning a caller-supplied URL either way. Gated separately from the other write tools because it creates instance-level state and clones a caller-supplied git URL. Only honored when `PADDOCK_SELF_MCP` and `PADDOCK_SELF_MCP_WRITE` are also on. | | `PADDOCK_MAX_SPAWN_DEPTH` | `1` | no | How deep a spawn tree may grow before spawned children stop receiving the self-management MCP: a spawned turn at depth `d` gets it (including the write tools, so a child can `send_message` back to its parent) only while `d ≤` this value. `0` means no spawned child ever gets it. A per-project `maxSpawnDepth` overrides this at dispatch; an out-of-range value falls back to the default rather than failing startup. Only meaningful when the **write** self-MCP is on — spawning needs those tools. | | `PADDOCK_SCHEDULE_MUTATION` | `false` | no | Allow schedules to be created / edited / deleted **programmatically** at runtime (the Schedules REST routes and the trigger MCP tools). Off by default, so a plain instance's schedules can only change by editing `project.yaml`. Schedules declared statically in `project.yaml` are armed either way. Accepts `1`/`true`/`yes`. See [Scheduling & the schedule gates](/configuration/schedules/). | | `PADDOCK_HOOKS_MCP` | `false` | no | Instance default for the hook/trigger-management tools (`list_triggers` / `set_trigger` / `remove_trigger`) — Claude declaring and editing its own [event hooks](/concepts/hooks/) and schedules. Off by default; a per-project `hooksMcpEnabled` in `project.yaml` overrides it. Only honored when the self-management **write** MCP is also on; when off the tools are **absent** (not present-but-refusing). Accepts `1`/`true`/`yes`. | diff --git a/website/src/content/docs/configuration/instance-settings.md b/website/src/content/docs/configuration/instance-settings.md index 91c3ce69..2be63d9f 100644 --- a/website/src/content/docs/configuration/instance-settings.md +++ b/website/src/content/docs/configuration/instance-settings.md @@ -125,6 +125,15 @@ without a browser. Clearing an optional numeric field (leaving it blank) removes the key from the file, so it falls back to the built-in default rather than being pinned to zero. +:::caution[The **Self MCP projects** field under-describes its own grant] +The screen's help text for that field says it lets Claude "create whole new +projects". Since 0.65.0 it also gates **`promote_project`**, which clones a +caller-supplied git URL into an existing project. The real grant is both tools — +see [`selfMcpProjectsEnabled`](/configuration/config-file/#capability--safety-gates-worth-setting-here). +Tracked in [#775](https://github.com/edspencer/paddock/issues/775); this page +describes the grant, not the string. +::: + ## The environment prompt **Environment prompt** (Capabilities) is the one multi-line field on this screen. It is diff --git a/website/src/content/docs/reference/websocket.md b/website/src/content/docs/reference/websocket.md index ae970f37..5341b800 100644 --- a/website/src/content/docs/reference/websocket.md +++ b/website/src/content/docs/reference/websocket.md @@ -86,6 +86,22 @@ any client ([#736](https://github.com/edspencer/paddock/issues/736)). Send `qid` cacheCreation), `contextLimit` (= the model's context limit). Stale-by-one-turn by design. +:::note[This page documents what the server sends, not what the types say] +Two places in the code currently understate the protocol, so neither is a safe +source to "correct" this page against: + +- **`chat:injected` is not a member of the server's `ServerMessage` union.** It is + emitted through a loosely-typed seam, so the union is not the authoritative + frame list — [#772](https://github.com/edspencer/paddock/issues/772). +- **The web client's mirrored frame types are stale on the queue attachments**, + with inline casts hiding the drift, so the client mirror understates + `chat:queued_flushed`, `chat:queued_state` and `chat:queued_returned` — + [#773](https://github.com/edspencer/paddock/issues/773). + +The payloads above are taken from `ws-protocol.ts` and the emit sites, which is +what actually goes on the wire. +::: + ## The queue frames The queued message is **shared chat state**, not per-client state, and that takes