@hyzyn/dsh-docker 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -2,63 +2,64 @@
2
2
 
3
3
  [中文](README.md) | English
4
4
 
5
- > The DSH sidebar "Containers" panel: containers on the local machine and on SSH hosts inspected on one screen — read logs, watch resources, enter containers, start / stop / remove, **Read-only by default**.
5
+ > The DSH sidebar "Containers" panel: containers on the local machine and on SSH hosts inspected on one screen — read logs, watch resources, enter containers, start / stop / remove, **read-only by default**.
6
6
 
7
7
  ## Features
8
8
 
9
- - **A resident session right-sidebar tab**: the panel lives as a session right-sidebar tab (`sidebar.right.pane.tab`) **side by side** with the conversation — after handing an error to the agent the logs stay on the right, without getting in the way of watching it work; collapsing it leaves the viewport. Panel-level state (target / view / filter text / selected container and its tab) is kept **across sessions**, and collapsing drops the live streams to hand the SSH channels back. Hosts without the right-sidebar services fall back to the original dock / modal, behaving exactly as before.
10
- - **An explicit one-click "connect local"**: two **non-permanent** entries — with no targets at all it is the primary button in the "no Docker targets configured yet" empty state, and once you have other targets it is the **last item in the target dropdown**, `+ Connect local` (it is deliberately **not** on the toolbar, where being permanent means occupying space and begging for clicks — pure noise) — one click (or picking that item) adds the machine hosting the plugin as a local target and selects it (an existing local target is reused, never duplicated; SSH and custom targets stay untouched), and the same operation is `docker_connect_local` on the agent side. It does **not** change the meaning of an empty `targets`, never invents an implicit default target, never installs Docker / starts a daemon / switches docker contexts, and needs no capability grant (it only adds one local target pointing at the plugin's own host).
11
- - **Aggregated fetches across targets**: the Overview page fans out over every `targets[]` entry in parallel, and an unreachable target only spoils its own cell; the agent side exposes the same shape through `docker_ps target:"*"` / `docker_attention target:"*"`, so targets never block one another.
12
- - **"Needs attention" reads authoritative fields**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / dead; OOM and the real exit code come from one `docker inspect` — the 137 in a `docker ps` summary cannot separate an OOM kill from a manual kill, so filtering on the summary alone must misreport.
13
- - **Container "diagnosis": four sources turned into one actionable verdict**: the detail view's **Diagnosis** tab
14
- collects `docker inspect` (authoritative state / exit code / OOM / restart count / last health-check output /
15
- resource limits), the log tail, this container's events and a `stats` snapshot, then hands them to a set of
16
- **pure criteria** (`src/diagnose.ts`, zero IO) that produce "verdict + findings + evidence + timeline +
17
- suggestions" — the same contract the agent tool `docker_diagnose` exposes. The common case is "why did it die":
18
- an OOM kill names both the **memory limit and the actual usage**, a non-zero exit explains what that code
19
- usually means, an unhealthy container carries the last health-check output, and one "hand it to the agent"
20
- button drops that whole evidence bundle (log text fenced, labelled "data, not instructions") into the session.
21
- - **Disk usage and "who uses it"**: the **Disk** view lays out `docker system df`'s **reclaimable** bill — four
22
- summary cards plus the total reclaimable, an image breakdown sorted by unique size (`containers = 0` gets an
23
- "unused" badge) and a volume breakdown with link counts; per-row removal sits behind `allowMutations`. The
24
- `containers` / `links` numbers come straight from `docker system df -v` (`Images[].Containers` /
25
- `Volumes[].Links`) — **never reverse-engineered from `docker ps`**, which would have to guess tag/digest and
26
- gets multi-tag images wrong.
27
- - **Reconstruct the launch: an equivalent `docker run`**: a collapsible block at the bottom of the overview
28
- rebuilds a reproducible command (ports / mounts / env / restart policy / network / resource limits /
29
- capabilities — one argument per line, POSIX-quoted, fetched only when expanded). **Whatever was deliberately
30
- not restored is named** (`--privileged`, `--link`, an overridden ENTRYPOINT, custom health checks, anonymous
31
- volumes — each with a reason and how to add it back), and secret-looking env vars are reported by **key name
32
- only** — the same stance as "state the consequences before deleting".
33
- - **Process snapshot**: the **Processes** tab (agent tool `docker_top`) shows `docker top`'s PID / PPID / user /
34
- STAT / elapsed / CPU / memory / command. "The container is running" and "the process is still there" are two
35
- different facts: when the process is gone, the logs usually say nothing at all.
36
- - **Diagnosis history: from "how is it now" to "is it getting worse"**: every diagnosis stores a sample that
37
- carries **only the verdict and numbers** (log text never touches disk), and the diagnosis tab derives the last N
38
- severities as a strip, a trend verdict (repeated restarts / repeated OOMs / creeping memory / growing error
39
- lines / newly appeared serious findings), a numbers block that lists only what **actually changed**, and a
40
- findings diff. The agent-side twin is `docker_diagnose`'s `history` parameter.
41
- - **Four long-lived SSE streams on one substrate**: log FOLLOW, `docker stats`, `docker events` and `docker pull` all run through the same `openSseStream` (heartbeat / active-stream registry / teardown on disconnect / a `ready` frame as soon as the pipeline is up) and differ only in how they end — logs and pulls finish on their own, stats and events are aborted by the browser. "Connected" and "receiving data" are two different things: the client only counts `ready`, never the arrival of the response headers (see "SSE event protocol"). Multi-select merged logs recover true cross-container ordering from the `--timestamps` prefix; "pause" freezes rendering only (the stream keeps receiving and flushes in one batch on resume).
42
- - **Read-only by default, capability switches in three tiers**: start / stop / remove, exec and image mutations are independent switches; while one is off the agent tools are **not registered** and the HTTP routes return 403 (the capability does not exist, rather than failing when called). Container names and IDs pass a whitelist, every command is built as argv with single-quote escaping, and passwords / passphrases are referenced as `env:NAME` (resolved through the official credential layer, falling back to the environment) and never sent back to the browser.
43
- - **Right-click a log into the agent**: select the failing lines in the log view, then right-click for "send to the current session / fill the input box so I can edit first" — the selection travels with its target, container, time window and 20 lines of context on each side (the menu states plainly that the content enters the model context and may carry credentials). Delivery reports itself twice: a **viewport-level toast** (attached to `body`, above the panel and the terminal modal), and — when the terminal panel is open — an **automatic fold of the terminal** (`minimize()` on the `ttyPanel` v2 contract; sessions keep running and the sidebar "Terminal" entry's badge restores it), so the conversation is simply there. On older tty without that call it degrades to the toast's "the conversation is behind the panel" hint. To edit first, use the draft action — **the session input box is the only editing surface** (multi-line, with the full context in view, and exactly what the agent receives), instead of a second, weaker card editor. Locating the target session is version-tolerant: **≤0.1.5** reads `current` off the `sessions.list` snapshot, while **since 0.1.6** that field moved out of the session domain together with view selection, so it reads `retainedBy.mainView > 0` instead (the same test `dsh-client-ui-session` and this repo's codegraph use) — trusting only the legacy field greys out both menu items as "no session open". The same read drives "carry the containers tab across a session switch".
44
- - **Data-level reuse of dsh-tty, no code coupling**: no tty code is imported and tty needs no source change, so the two install and upgrade independently; with tty present three optional extension points are consumed — connection-bar actions (`ttyConnbar`), the terminal host (`ttyTerminal`: a new tab under the tab/dock carriers, an in-place drawer under the modal) and the terminal-side dock (`ttyPanel.mountPane`, used only by the fallback path) — and each degrades silently without tty or below the required version.
9
+ - **A resident session right-sidebar tab**: the panel lives as a right-sidebar tab (`sidebar.right.pane.tab`)
10
+ **side by side** with the conversation — after handing an error to the agent the logs stay on the right;
11
+ collapsing it leaves the viewport and drops the live streams, handing the SSH channels back. Panel-level state
12
+ (target / view / filter text / selected container and its tab) is kept **across sessions**; hosts without the
13
+ right-sidebar services fall back to the dock / modal.
14
+ - **Right-click a log into the agent**: select the failing lines in the log view, then right-click for "send to
15
+ the current session / fill the input box so I can edit first" — the selection travels with its target,
16
+ container, time window and 20 lines of context on each side (the menu states plainly that the content enters
17
+ the model context and may carry credentials). Delivery reports itself twice: a **viewport-level toast**, and —
18
+ when the terminal panel is open — an **automatic fold of the terminal** (`ttyPanel` v2 contract). To edit first
19
+ use the draft action: **the session input box is the only editing surface**.
20
+ - **One-click "connect local" / aggregated fetches across targets**: two **non-permanent** entries (the empty
21
+ state's primary button / the target dropdown's last item) add this machine as a local target in one click (the
22
+ agent-side twin is `docker_connect_local`); the Overview fans out over every `targets[]` entry in parallel, and
23
+ an unreachable target only spoils its own cell (`docker_ps target:"*"` shares the same shape).
24
+ - **"Needs attention" reads authoritative fields**: unhealthy / repeatedly restarting / OOM-killed / non-zero
25
+ exit / dead; OOM and the real exit code come from one `docker inspect` — the 137 in a `docker ps` summary
26
+ cannot separate an OOM kill from a manual kill (criteria in [Overview](#overview-cross-target-and-the-needs-attention-criteria)).
27
+ - **A closed troubleshooting loop**: the Diagnosis tab turns four reads into "verdict + findings + evidence +
28
+ timeline + suggestions", and diagnosis history then answers "is it getting worse"; the Processes tab gives a
29
+ `docker top` snapshot; the Disk view lays out the reclaimable bill and "who uses it"; the overview bottom
30
+ rebuilds a reproducible `docker run`. See [Container diagnosis](#container-diagnosis-why-is-it-failing) and
31
+ [Diagnosis history](#diagnosis-history-is-it-slowly-getting-worse).
32
+ - **Four long-lived SSE streams on one substrate**: log FOLLOW, `docker stats`, `docker events` and `docker pull`
33
+ all run through the same `openSseStream` (heartbeat / active-stream registry / teardown on disconnect / a
34
+ `ready` frame as soon as the pipeline is up) and differ only in how they end; merged logs pack several
35
+ containers into **one** `/logs/stream?ids=…`. "Connected" and "receiving data" are two different things: the
36
+ client only counts `ready`. See [SSE event protocol](#sse-event-protocol-logsstream-statsstream-eventsstream-imagespullstream).
37
+ - **Read-only by default, two independent switches**: `allowMutations` (container start / stop / remove plus
38
+ image remove / prune / pull) and `allowExec` (one-off `docker exec`); while one is off the agent tools are
39
+ **not registered** and the HTTP routes return 403
40
+ (the capability does not exist, rather than failing when called). A grant takes one of two out-of-band channels
41
+ (launch environment variables / confirming in the card). Container names and IDs pass a whitelist, every command
42
+ is built as argv with single-quote escaping, and passwords / passphrases are referenced as `env:NAME` and never
43
+ sent back to the browser.
44
+ - **Data-level reuse of dsh-tty, no code coupling**: no tty code is imported and the two install and upgrade
45
+ independently; with tty present three optional extension points are consumed (`ttyConnbar` / `ttyTerminal` /
46
+ `ttyPanel`), each degrading silently without tty or below the required version.
45
47
 
46
48
  ## Relationship with dsh-tty
47
49
 
48
- This plugin stands on its own: it imports no tty code, and tty needs no source changes; the two can be installed and upgraded independently. With tty installed they cooperate at the optional extension points below, and without it they degrade quietly.
50
+ This plugin stands on its own: it imports no tty code and tty needs no source changes. With tty installed they
51
+ cooperate at the optional extension points below; without it they degrade quietly.
49
52
 
50
53
  | Dimension | Description |
51
54
  | --- | --- |
52
- | Plugin form | A standalone package `@hyzyn/dsh-docker` that imports no tty code, and tty needs no source changes; the two can be installed and upgraded independently |
53
- | Connection book | SSH targets can **reference a tty connection-book entry name** (read-only access to `sshHosts` through `ctx.settings.get('tty')`); when tty is not installed this degrades to "inline host/username" or a local target |
55
+ | Plugin form | A standalone package `@hyzyn/dsh-docker`; both can be installed and upgraded independently |
56
+ | Connection book | SSH targets can **reference a tty connection-book entry name** (read-only access to `sshHosts` through `ctx.settings.get('tty')`); without tty this degrades to "inline host/username" or a local target |
54
57
  | Host fingerprints | This plugin keeps its own `hostKeys` (TOFU) and **prefers tty's already-recorded fingerprints as the seed** — the same host does not have to be confirmed in two places |
55
58
  | Execution channel | Its own pooled SSH exec (`src/ssh-exec.ts`), fully independent of tty's PTY sessions; neither takes the other's slots |
56
- | Context entry point | With tty ≥ 0.13.0 it can optionally consume tty's client service `ttyConnbar` and insert a "Containers" button in the SSH connection bar (next to SFTP) (**shown as soon as it is registered**), with the target resolved from the current session at click time; **except in exec tabs this plugin opened itself** — those tabs are where the user just came from, so offering a way back to the very same panel is a loop (recognised via `spawnSpec.command`; a `docker exec -it` the user typed by hand does not count); if tty is missing or too old this is skipped silently |
57
- | Panel hosting | **The default is a session right-sidebar tab** (`sidebar.right.pane.tab`): the panel and the conversation share the screen, so logs stay visible while the agent works; collapsing it leaves the viewport without leaving the session. The **frame sidebar** entry only opens or focuses it, and **a page type deduplicates inside one column**, so clicking twice never opens a second tab. The **terminal connection bar** entry is the opposite — it sits on the viewport-covering tty modal, where a tab would be hidden, so that path docks to the right of the terminal via `ttyPanel.mountPane` (**the entry decides the carrier**). Without the right-sidebar services (older DSH), or with `localStorage['dsh-docker:carrier'] = 'modal'`, the sidebar entry also falls back to the dock (when tty is open) or to a full-screen modal with its own backdrop. All three carriers are **one component**, differing only in shell and geometry |
58
- | Terminal hosting | Interactive terminals are hosted by tty (it owns the PTY). **Right-sidebar tab**: when the panel is fullscreen (`sidebar.fullscreen`) the terminal is embedded in place via `ttyTerminal.mount` (enough width, logs and shell on one screen); otherwise a tab is opened in the terminal panel via `ttyTerminal.open` (re-clicking the same container **focuses the existing tab** instead of stacking duplicates — tty contract v3 `reuse`); (too narrow to squeeze both). **Dock** carrier (the panel already lives inside tty) always opens a tab; **modal** embeds in place. Those command tabs (non-empty `spawnSpec.command`) show **no connection-bar extension area** on the tty side — SFTP / tunnels / third-party panes all act on the connection itself, which misleads on a `docker exec` tab (SFTP browses the host, not what the user believes is inside the container); this plugin adds a version-independent fallback that withholds the "Containers" entry in exec tabs it opened itself (matched by the `spawnSpec.command` prefix). Without tty, or below the required version, copying the command is the fallback. This plugin implements no PTY / xterm / reconnect stack |
59
- | Division of labour | **Interactive troubleshooting** (`docker exec -it`, a shell inside the container, TUIs) is hosted by tty (embedded drawer or tab); **read-only inspection and agent automation** use this plugin's own exec channel |
60
-
61
- Reuse at the data level without coupling at the code level: the connection book and the fingerprint seed are "reading the same settings", and the connection-bar button is "consuming a generic extension point" — neither is "depending on tty's modules", so upgrading or uninstalling tty does not break this plugin along with it.
59
+ | Context entry point | With tty ≥ 0.13.0 it can optionally consume `ttyConnbar` and insert a "Containers" button in the SSH connection bar (next to SFTP) (**shown as soon as it is registered**), with the target resolved from the current session at click time; **except in exec tabs this plugin opened itself** (recognised via `spawnSpec.command`); if tty is missing or too old this is skipped silently |
60
+ | Panel hosting | **The default is a session right-sidebar tab** (`sidebar.right.pane.tab`); the frame sidebar entry only opens or focuses it, and **a page type deduplicates inside one column**. The **terminal connection bar** entry is the opposite — it sits on the viewport-covering tty modal, where a tab would be hidden, so that path docks to the right of the terminal via `ttyPanel.mountPane` (**the entry decides the carrier**). Without the right-sidebar services (older hosts), or with `localStorage['dsh-docker:carrier'] = 'modal'`, it falls back to the dock / full-screen modal. All three carriers are **one component**, differing only in shell and geometry |
61
+ | Terminal hosting | Interactive terminals are hosted by tty (it owns the PTY). **Right-sidebar tab**: when the panel is fullscreen (`sidebar.fullscreen`) the terminal is embedded in place via `ttyTerminal.mount`, otherwise a tab is opened via `ttyTerminal.open` (re-clicking the same container **focuses the existing tab** — tty contract v3 `reuse`); **dock** always opens a tab; **modal** embeds in place. Command tabs (non-empty `spawnSpec.command`) show **no connection-bar extension area** on the tty side (SFTP / tunnels act on the connection itself, which misleads on a `docker exec` tab); this plugin also withholds the "Containers" entry in exec tabs it opened itself. Without tty, or below the required version, copying the command is the fallback. This plugin implements no PTY / xterm / reconnect stack |
62
+ | Division of labour | **Interactive troubleshooting** (`docker exec -it`, a shell inside the container, TUIs) is hosted by tty; **read-only inspection and agent automation** use this plugin's own exec channel |
62
63
 
63
64
  ## Installation
64
65
 
@@ -67,68 +68,54 @@ dsh plugin --profile web add @hyzyn/dsh-docker # npm install (once
67
68
  dsh plugin --profile web add link:$(pwd)/packages/docker # repo development and debugging
68
69
  ```
69
70
 
70
- The aggregate package `@hyzyn/dsh-all` (or the repo-root bundle) already includes this plugin, so there is no need to
71
- add it separately when installing everything at once. After installing, restart `dsh web` and the "Containers"
72
- entry appears in the sidebar; the "Docker Container Panel" card maintains targets and switches, and **saving applies hot** (`settings/updated`
71
+ The aggregate package `@hyzyn/dsh-all` (or the repo-root bundle) already includes this plugin, so there is no need
72
+ to add it separately. After installing, restart `dsh web` and the "Containers" entry appears in the sidebar; the
73
+ "Docker Container Panel" card maintains targets and switches, and **saving applies hot** (`settings/updated`
73
74
  triggers re-resolution, with no restart needed).
74
75
 
75
- > If you have already installed `@hyzyn/dsh-all` or the root bundle in the web profile, do **not** add this package
76
+ > If `@hyzyn/dsh-all` or the root bundle is already installed in the web profile, do **not** add this package
76
77
  > again, or the plugin line is mounted twice and startup reports `duplicate loader entry id`.
77
78
 
78
79
  ## Usage
79
80
 
80
81
  Two entry points, one panel. **The default carrier is a session right-sidebar tab** — the entries only open or
81
- focus it, and the panel sits side by side with the conversation: after handing an error to the agent the logs stay
82
- on the right, without getting in the way of watching it work.
82
+ focus it, and the panel sits side by side with the conversation.
83
83
 
84
84
  ![Right-sidebar tab carrier: conversation and container panel on one screen, panel full height; collapsing leaves the viewport](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-tab.png)
85
85
 
86
86
  - **Sidebar "Containers"** (the main entry point): works for any target, including local docker and switching
87
87
  between multiple targets. If it is already open it focuses (a page type deduplicates inside one column), so
88
88
  clicking twice never opens a second "Docker containers" tab.
89
- - **Where to look after delivering**: under the right-sidebar tab the conversation is right beside
90
- you; under the docked / modal carriers a successful send **folds the terminal automatically** (it keeps
91
- running — click the sidebar "Terminal" entry's badge to restore), so you land in the conversation instead
92
- of guessing whether a viewport-covering modal reacted at all.
93
- - **SSH connection bar "Containers" button** (a contextual shortcut, tty ≥ 0.13.0): in an SSH tab of the tty
94
- terminal panel a "Containers" button appears next to the connection bar's SFTP button — **shown as soon as it is
95
- registered**, and clicking it opens the panel directly on **the host of the current session**, with no target to
96
- pick. The target is resolved at click time: when the session comes from the connection book it matches by entry
97
- name, otherwise it matches a resolved target by `host:port`; **no matching target does not hide the
98
- button** — the panel carries a hint naming the session host (including the connection-book name) and how to
99
- configure it in the settings card. This path goes to the **dock right of the terminal** rather than the tab —
100
- the button lives on the tty modal, which covers the viewport, so a tab would be hidden behind it and feel like
101
- "clicking did nothing". The target still travels into the panel and remounts it on that host. The rule is
102
- therefore **"the entry decides the carrier"**: from the frame's sidebar → the right-sidebar tab (side by side
103
- with the conversation); from inside the terminal modal → the dock right of the terminal (side by side with the
104
- terminal).
89
+ - **SSH connection bar "Containers" button** (a contextual shortcut, tty ≥ 0.13.0): it appears next to SFTP in an
90
+ SSH tab of the tty panel, **shown as soon as it is registered**, and opens the panel directly on **the host of
91
+ the current session** (the target is resolved at click time by entry name or `host:port`; **no matching target
92
+ does not hide the button** — the panel says how to configure it). This path goes to the **dock right of the
93
+ terminal** — the rule is **"the entry decides the carrier"**: from the frame's sidebar → the tab; from inside
94
+ the terminal modal → the dock.
105
95
 
106
96
  ### Carriers
107
97
 
108
98
  | Carrier | When | Behaviour |
109
99
  | --- | --- | --- |
110
- | **Session right-sidebar tab** (default) | the host provides `sidebarRight` / `sidebarRightTabs` | side by side with the conversation; collapsing hides it without losing state (panel-level state lives in a module store, see below); the right sidebar's fullscreen mode gives it the whole viewport |
111
- | **Dock right of the terminal** | arriving from the **terminal connection bar's** "Containers" button (that button sits on the tty modal, which would hide a tab), or no right-sidebar service / `localStorage['dsh-docker:carrier'] = 'modal'` with tty ≥ 0.16 and its panel open | docked to the right of the terminal panel (resize / collapse / ✕ provided by tty, the terminal stays usable); one dock at a time — docking this plugin takes down the previous occupant (for example tty's own SFTP); since tty 0.18.4 the pane is **owned by the tab it was opened from**: switching tabs hides it, switching back restores it and closing that tab tears it down (while hidden the React tree and polling keep running) |
100
+ | **Session right-sidebar tab** (default) | the host provides `sidebarRight` / `sidebarRightTabs` | side by side with the conversation; collapsing hides it without losing state; the right sidebar's fullscreen mode gives it the whole viewport |
101
+ | **Dock right of the terminal** | arriving from the **terminal connection bar's** "Containers" button, or no right-sidebar service / `localStorage['dsh-docker:carrier'] = 'modal'` with tty ≥ 0.16 and its panel open | docked to the right of the terminal panel (resize / collapse / ✕ provided by tty, the terminal stays usable); one dock at a time — docking this plugin takes down the previous occupant (for example tty's own SFTP); since tty 0.18.4 the pane is **owned by the tab it was opened from**: switching tabs hides it, switching back restores it and closing that tab tears it down (while hidden the React tree and polling keep running) |
112
102
  | **Full-screen modal** (fallback) | neither of the above | its own backdrop, closes on outside click; the panel sits above tty's modal in z-order |
113
103
 
114
104
  Rolling back to the old shape is one console line: `localStorage.setItem('dsh-docker:carrier', 'modal')`
115
105
  (`removeItem` restores the default). That switch is a temporary grey-release knob, so it deliberately stays out of
116
- settings — not worth changing the host config schema, the settings card and the docs for it.
106
+ settings.
117
107
 
118
108
  **State retention**: the panel inspects hosts, not workspaces, so view / filter text / selected container
119
109
  (including its overview-logs-stats tab) / target are kept **across sessions**; the log filter and LINES switch
120
110
  inside a container detail belong to that container and are not kept. **Collapsing the tab drops every live
121
- stream** (handing the SSH channels back) and expanding reconnects — single-container and merged log streams
122
- already open with `tail`, so history refills itself.
123
-
124
- **Stickiness across sessions**: DSH's right-sidebar tab records are **session-scoped** (`sidebar.right.pane.tab`
125
- and `rightbar.session` both declare `scope: 'session'`), so a tab opened in session A does not exist in session B.
126
- The panel inspects hosts, though, and losing it on a session switch is pure loss — so the plugin additionally
127
- keeps a "the user wants this open" intent: **switching sessions reopens the tab in the new session**, and only
128
- clicking the tab's ✕ stops that. The panel follows the person, not the session. That ✕ is executed by the
129
- **host** (`sidebarRight` closes the tab itself), so the plugin learns about it through
130
- `registerCloseHandler` — relying on the panel's own `onClose` alone would miss the close, and every session
131
- switch would bring the tab back after the user had just dismissed it.
111
+ stream** (handing the SSH channels back) and expanding reconnects — the streams open with `tail`, so history
112
+ refills itself.
113
+
114
+ **Stickiness across sessions**: right-sidebar tab records are **session-scoped**, so a tab opened in session A
115
+ does not exist in session B — yet the panel inspects hosts, so losing it on a session switch is pure loss. The
116
+ plugin therefore keeps a "the user wants this open" intent: **switching sessions reopens the tab in the new
117
+ session**, and only clicking the ✕ stops that (that ✕ is executed by the **host**, so it must be registered
118
+ through `registerCloseHandler` — a panel-level `onClose` alone would miss it).
132
119
 
133
120
  **Dock fallback carrier** (right of the terminal):
134
121
 
@@ -137,139 +124,62 @@ switch would bring the tab back after the user had just dismissed it.
137
124
  A panel opened this way **never covers the terminal**: with tty ≥ 0.16 it docks to the right of the terminal
138
125
  panel (draggable width, collapsible into a narrow strip, ✕ to tuck away) while you keep typing in the terminal.
139
126
  In dock mode the card's "Terminal" button **opens a new tab in the same terminal panel** running
140
- `docker exec -it` (the panel is already inside a terminal, so nesting one more layer makes no sense); it also
141
- **stops rendering the panel's own header** — the title and ✕ are handled by the sidebar title bar, and the
142
- refresh control and read-only badge move to the
143
- **end of the toolbar, right-aligned** (while refreshing the icon spins itself, with no extra spinner): the left
144
- end stays for the target / view / search / filter controls, so refresh is not mistaken for the first filter and
145
- sits where it does in the non-dock header; a 520px narrow column does not leave a blank line behind.
127
+ `docker exec -it`; it also **stops rendering the panel's own header** — the title and ✕ are handled by the
128
+ sidebar title bar, and the refresh control and read-only badge move to the **end of the toolbar, right-aligned**:
129
+ the left end stays for the target / view / search / filter controls.
146
130
 
147
131
  Inside the panel:
148
132
 
149
133
  ![Container list: target picker / search and state filtering / card action bars (the view and mutate groups)](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-panel.png)
150
134
 
151
135
  - **Switching targets has a transition and guards**: as soon as the picker changes, the cards below are still the
152
- previous target's (a remote round trip can take up to 20s). Three principles for the transition layer —
153
- **no layout shift, a sense of direction, little grey**:
154
- - a 2px **indeterminate shimmer progress bar** (absolutely positioned) along the top of the panel body gives a
155
- global "fetching" signal;
156
- - a **blue capsule floats up centred** at the top of the body (the same placement and colour language as the
157
- dsh-rss loading capsule): background `color-mix(accent 12%, surface)`, border `accent 42%`, text and spinner in
158
- `--dk-accent`; the visible copy is just two segments, `⟳ Switching to Target2 · Currently showing: Target1`
159
- (not written as a sentence, no quotes around target names), while the full "why won't it respond" story lives in
160
- `title`, available on hover;
161
- - old data stays at **82% + slight desaturation** (not dimmed into illegibility) and **the pointer is locked**: it
162
- reads as "a different batch of content" rather than "it broke", and it also prevents acting on the stale list —
163
- that would aim at the new target while sending commands with container IDs from the old list and really could
164
- stop a same-named container on the other side;
165
- - new data lands with an **8px slide-up + fade-in** over 200ms, so that "a different batch" is visible; the motion
166
- respects `prefers-reduced-motion`.
167
-
168
- Everything above is **absolutely positioned**: during a switch the first card's position and the scroll height
169
- provably do not change at all (a banner approach would push the whole block of content down). **A failed switch
170
- clears the old list** and settles into the new target's error state (it does not keep showing another target's
171
- data), and the empty-state copy distinguishes "failed to read" from "filtered too narrowly".
136
+ previous target's (a remote round trip can take up to 20s). During the switch the old cards are desaturated and
137
+ **the pointer is locked** (it reads as "a different batch of content" rather than "it broke", and it prevents
138
+ acting on the stale list — that would aim at the new target with container IDs from the old list and really
139
+ could stop a same-named container on the other side), while new data fades in; the whole block is
140
+ **absolutely positioned**, so the first card's position and the scroll height do not change. **A failed switch
141
+ clears the old list** and settles into the new target's error state, and the empty-state copy distinguishes
142
+ "failed to read" from "filtered too narrowly".
172
143
  - **Target selection**: the panel picks a target first (from the `targets` config, local / SSH); with only one
173
- target configured it is selected by default, and agent tools may also omit the `target` parameter.
174
- **The last selected target is remembered** (stored in the browser's `localStorage` under
175
- `dsh-docker:last-target`, not written to config or settings): the next time the panel opens it is selected
176
- automatically. The priority is **what the connection bar specifies > last remembered (and still present) >
177
- first in the list** — once the remembered target is deleted or renamed it falls back to the first one instead of
178
- resting on an "unknown target"; when entering from the terminal connection bar's "Containers" button, the current
179
- session host takes priority over the remembered value. Switching targets is **a whole-context switch**:
180
- requests still in flight for the previous target are all invalidated (a list write gate), so there is no
181
- cross-talk like "the picker is already Target2 while the cards are still Target1's containers", and an old
182
- target's timeout banner does not linger on the new target's page.
183
- - **Multi-target Overview (read-only)**: the `Overview` pill next to the target picker (shown only with ≥2 targets
184
- configured) — pick no target and see every host on one screen: a row of **counter cards** on top
185
- (target name + a local / SSH marker + running / stopped / unhealthy), and below it a **table of containers needing
186
- attention pinned to the top** (container / target / status / image; unhealthy sorts before restarting, ties follow
187
- the targets' order in config, and rows do not jump between polls). Fetching is **parallel + progressive**:
188
- each target issues its own `POST /containers` (`all:true`, since a bare `docker ps` does not return exited
189
- containers and "stopped" would always be 0), and an unreachable target affects only its own cell — the card
190
- outlined in red plus an "N targets unreachable" banner at the top, while the other targets' results show as usual
191
- (deliberately **not** aggregated with `Promise.all`: an unreachable SSH host waits out the 20s readyTimeout, so
192
- aggregating would keep the whole page silent for 20s). Clicking a counter card returns to that target's normal
193
- container list, and clicking an attention row opens that container's details (going back lands on **that
194
- target's** container list); **the Overview performs no cross-target operations**, and the attention table's rows
195
- have no action buttons. Entering the Overview clears the failed banner left over from "the current target": the
196
- Overview attributes everything per target (red card + unreachable banner) and no longer stacks a single-target
197
- "operation failed" — the same SSH timeout told twice looks like two dead machines. Polling reuses the toolbar's
198
- "auto refresh" switch (off by default): each round of this page is one `docker ps` per target across all N
199
- targets, more expensive than any single-target page. With no exceptions it shows
200
- "all good"; while some targets have not finished answering, the attention area shows "reading…" and
201
- **that counter card also enters its loading state** (spinner + dimmed, no 0/0/0 — "0 containers" would be read as
202
- "this machine has no containers" when it simply has not answered yet) ("don't know yet" is not "all good").
203
- - **Container list**: name / status / health / image / port mappings / compose project and service /
204
- short ID; it supports **search** by name or image, **filtering** by state (running / stopped / all),
205
- and **auto refresh** (polling at `pollIntervalSec`; switching to the images page pauses it and hides that
206
- switch — images change slowly, so there is no point running `docker images` every 5s; switching back to the
207
- containers page restores the previous setting). In the toolbar "include stopped" and "auto refresh" are two
208
- **grouped** switches, and the search box is the only flexible item among them: on a wide panel the whole toolbar
209
- collapses into one row, and only in a narrow column (dock, 520px) does it wrap by group — no orphan row holding a
210
- single checkbox.
144
+ target configured it is selected by default, and agent tools may omit `target`. **The last selected target is
145
+ remembered** (`localStorage` key `dsh-docker:last-target`, not written to config): the priority is **what the
146
+ connection bar specifies > last remembered (and still present) > first in the list**. Switching targets is **a
147
+ whole-context switch**: requests still in flight for the previous target are all invalidated (a list write
148
+ gate), so there is no cross-talk like "the picker is already Target2 while the cards are still Target1's".
149
+ - **Container list**: name / status / health / image / port mappings / compose project and service / short ID;
150
+ it supports **search** by name or image, **filtering** by state, and **auto refresh** (polling at
151
+ `pollIntervalSec`; switching to the images page pauses it and hides that switch). "Include stopped" and "auto
152
+ refresh" are two **grouped** switches, and the search box is the only flexible item among them: one row on a
153
+ wide panel, wrapping by group only in a narrow column (dock, 520px).
211
154
  - **"Activity" strip (the docker events stream)**: a collapsible narrow bar at the head of the container list
212
155
  (expanded by default) that follows one SSE stream (`GET /api/dsh-docker/events/stream`, where the server runs
213
156
  `docker events --filter type=container`) showing the last 8 events (time + container name + action, with `die`
214
- carrying its exit code such as `die(137)`). It is the entry point for **event-driven refresh**: 500ms
215
- **after an event arrives** it debounces a list refetch (not one request per frame), layering on top of the
216
- existing `AUTO REFRESH` rather than excluding it — polling is the safety net, events cover "just happened". Events
217
- stay in memory only (a ring buffer of 50), switching pages closes the stream, and switching targets clears the
218
- buffer. The allowlist keeps only the nine lifecycle actions (start / die / stop / kill / oom /
219
- health_status / destroy / rename / update): noise such as `exec_*` and `archive-path` (`docker cp`)
220
- is dropped server-side — on one batch machine 47 events over 24 hours were all exec with 0 allowlist hits, so on
221
- machines that are only ever exec'd the Activity strip is empty, and that is deliberate.
222
- A closed stream needs a **manual reconnect**: entering `closed` is terminal (EventSource only self-heals
223
- connection blips; the `docker events` process exiting or the server sending an `end` frame both close it), so the
224
- Activity strip offers a **Reconnect** button (shown only while `closed`; not in `unsupported` environments, where
225
- reconnecting would just fail again), and the toolbar's ⟳ reconnects as well. The **AUTO REFRESH poll deliberately
226
- does not reconnect**: folding it into the poll would reopen a terminated stream forever, both hiding that it broke
227
- and pointlessly creating SSE connections. A successful reconnect compensates with one full list refetch (the list
228
- may have fallen behind while the stream was down), including the case where the daemon never came up and the
229
- stream never opened at all — otherwise the strip turns green while the list still shows its error state, looking
230
- fixed when it is not.
231
- **A refused reconnect must say why** (`D164`): when the server actively refuses this stream it sends an
232
- `event: error` frame carrying a `message` (typically the long-stream quota being full — see "Known limitations";
233
- that sentence says what to do about it). The client now **shows it on the Activity strip** ("Reconnect refused: …")
234
- instead of discarding it. It used to just set `closed`, which made "refused → back to where it
235
- was" indistinguishable from "the click did nothing", leaving the user to click over and over. The reason is
236
- cleared on a successful reconnect (or when the stream is rebuilt).
237
- The hint on the right of that header row is **small text inside a single line**, so it is deliberately short
238
- (2026-10-08 user feedback: "verbose and stiff"): the state label on the left already says the stream is closed,
239
- so this only says "will not recover on its own — click Reconnect". The cause, the cap and the `MaxSessions`
240
- details live in this page, not in the UI.
241
- **"Receiving events" is only claimed on the host's `ready` frame** (2026-10-11 user report): SSE's `onopen`
242
- only means "the response headers arrived" — the host flushes those headers **before** it starts `docker events`,
243
- and connecting can stall for tens of seconds when the target is unreachable or the SSH connection is being
244
- re-established (D150's "quota full → rebuild the connection" adds another round). During that window the strip
245
- used to be green and say "Receiving live (docker events)" while not a single event had arrived. The green dot now
246
- waits for `ready` (= the pipeline really came up); until then the state stays at "Connecting to the event stream…"
247
- or "Connection lost, reconnecting…" — both of which are true. The log / stats / pull streams follow the same rule
248
- (see "SSE event protocol").
157
+ carrying its exit code). It is the entry point for **event-driven refresh**: 500ms **after an event arrives** it
158
+ debounces a list refetch, layering on top of the existing `AUTO REFRESH` rather than excluding it — polling is
159
+ the safety net, events cover "just happened". Events stay in memory only (a ring buffer of 50), switching pages
160
+ closes the stream, and switching targets clears the buffer. The allowlist keeps only the nine lifecycle actions
161
+ (start / die / stop / kill / oom / health_status / destroy / rename / update): noise such as `exec_*` and
162
+ `archive-path` is dropped server-side. A closed stream needs a **manual reconnect**: entering `closed` is
163
+ terminal, so the Activity strip offers a **Reconnect** button (not in `unsupported` environments) and the
164
+ toolbar's ⟳ reconnects too; **the AUTO REFRESH poll deliberately does not reconnect**, and a successful
165
+ reconnect compensates with one full list refetch. **A refused reconnect must say why** (`D164`): an actively
166
+ refused stream gets an `event: error` frame carrying a `message`, which the client **shows on the Activity
167
+ strip** — merely setting `closed` makes "refused" indistinguishable from "the click did nothing". **"Receiving
168
+ events" is only claimed on the host's `ready` frame** (response headers do not count).
249
169
  - **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
250
- mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
251
- longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
252
- start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" +
253
- `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; selecting the 3rd gives a soft hint, and above
254
- **3** the button is greyed out with a hint about the cap (**where that cap comes from is in "Known limitations" —
255
- the merge cap**; the selection bar states it too). Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
256
- project view (**one** `/logs/stream?ids=…` carrying every selected container, frames tagged with `id`, mixed by the
257
- `[service]` / container-name prefix, with filtering and auto-scroll), and going back exits selection mode and clears it. The merged view's **content controls are
258
- fully aligned with the single-container log view** (text filter + level threshold + `⬇ .log` / `⬇ .md` exporting
259
- what is displayed + line count), plus two merged-only controls: **by time / by arrival** ordering and a **pause**
260
- that freezes the view while the streams keep receiving (restoring flushes them in one go).
261
- Clicking `Select for merging` again or pressing **Esc** likewise exits and clears. Selection is **temporary**:
262
- not persisted, not named into groups, not written to settings; it is dropped when switching targets / switching
263
- the "Containers · Images · Compose" segment / closing the panel, and containers that disappeared after a list
264
- refresh are pruned by id.
265
- - **One-click selection by condition** (in selection mode): the action bar's second row offers a row of condition
266
- chips (`all visible / unhealthy / needs attention / stopped`, plus `same image / same project` once something is
267
- selected), with counts **truncated to the remaining slots** — a chip reading 3 really does select 3; anything over
268
- the cap is stated honestly in the title and the result hint. Conditions only apply within the
269
- **current filtered result** (search / filter the state first, then select in one click).
270
- - **Container cards**: "label + value" rows matching the reference layout (image / ID / ports / created /
271
- compose, with monospaced truncatable values) plus a row of icon action buttons, **split by a vertical rule into
272
- the "view / mutate" groups**:
170
+ mode — clicking a card body becomes **select / deselect** (the action bar collapses temporarily so that
171
+ multi-selecting does not mis-click start / stop / remove), and an action bar appears: "N containers selected" +
172
+ `merged logs` + `Cancel`. `merged logs` needs at least 2 containers and at most **3** (beyond that the button
173
+ is greyed out with its rationale). The merged view reuses the Compose project view's merged logs; its content
174
+ controls are fully aligned with the single-container log view, plus **by time / by arrival** ordering and a
175
+ **pause**. Selection is **temporary**: not persisted, not written to settings; it is dropped when switching
176
+ targets / segments / closing the panel, and vanished containers are pruned by id.
177
+ - **One-click selection by condition** (in selection mode): the action bar's second row offers condition chips
178
+ (`all visible / unhealthy / needs attention / stopped`, plus `same image / same project` once something is
179
+ selected), with counts **truncated to the remaining slots** — a chip reading 3 really does select 3. Conditions
180
+ only apply within the **current filtered result**.
181
+ - **Container cards**: "label + value" rows (image / ID / ports / created / compose) plus a row of icon buttons,
182
+ **split by a vertical rule into the "view / mutate" groups**:
273
183
 
274
184
  | Group | Buttons | Description |
275
185
  | --- | --- | --- |
@@ -277,141 +187,104 @@ Inside the panel:
277
187
  | Mutate | start / stop, restart, remove | Ordered by increasing destructiveness; available only with `allowMutations` on, otherwise the whole group is greyed out |
278
188
 
279
189
  Remove gets two extra protections: destructive colouring plus a gap between it and "restart", and a second
280
- confirmation after the click. **The whole group is locked while a command is in flight**: `stop` / `rm` waits for
281
- the container to actually exit (up to a dozen seconds), during which the confirmation dialog stays open showing
282
- "running…" (both buttons disabled), the card's other mutate buttons are dimmed and greyed, the icon that is running
283
- becomes a spinner, and everything is restored only once the refreshed list lands — preventing rapid clicking from
284
- stacking mutually interrupting commands such as stop + restart + remove. Image removal / pruning goes through the
285
- same confirmation dialog and also has a running state. Clicking a card body opens the overview.
190
+ confirmation after the click. **The whole group is locked while a command is in flight**: `stop` / `rm` waits
191
+ for the container to actually exit (up to a dozen seconds), during which the confirmation dialog stays open
192
+ showing "running…", the card's other mutate buttons are dimmed, the icon that is running becomes a spinner, and
193
+ everything is restored only once the refreshed list lands. Image removal / pruning goes through the same
194
+ confirmation dialog. Clicking a card body opens the overview.
286
195
  - **Container details (a full-column view)**: the top is "back + container name + status badge + target host",
287
- with five tabs below — overview (`docker inspect` authoritative data + one-shot exec + an equivalent `docker run`),
288
- logs, stats, diagnosis and processes. The log / stats icons on a card land directly on the corresponding tab;
289
- diagnosis and processes **fetch only while that tab is open** (leaving the tab stops the requests, and there is
290
- no polling).
196
+ with five tabs below — overview (`docker inspect` authoritative data + one-shot exec + an equivalent
197
+ `docker run`), logs, stats, diagnosis and processes. The log / stats icons on a card land directly on the
198
+ corresponding tab; diagnosis and processes **fetch only while that tab is open**.
291
199
  - **Log view (a compact two-row layout)**: the first row = back + container name + status + target host +
292
- `LINES` (tail line count) / `TIMESTAMPS` / **`FOLLOW` (live follow, see below)** /
293
- `AUTO REFRESH` (a switch plus 2/3/5/10s intervals, polling on the log page only) + refresh / close;
294
- the second row = the tabs + an **always-present** "filter logs" input (an ✕ floats inside to clear when it has
295
- content, and Esc clears too) + a **level threshold** (`all / INFO+ / WARN+ / ERROR+` — `INFO+` is the "quiet but keep what matters" step: the noise is almost always DEBUG and below, and `WARN+` would drop INFO along with it) + **export**
200
+ `LINES` / `TIMESTAMPS` / **`FOLLOW`** / `AUTO REFRESH` (a switch plus 2/3/5/10s intervals, log page only) +
201
+ refresh / close; the second row = the tabs + an **always-present** "filter logs" input (an ✕ floats inside to
202
+ clear, Esc clears too) + a **level threshold** (`all / INFO+ / WARN+ / ERROR+`) + **export**
296
203
  (`⬇ .log` / `⬇ .md`, exporting **what is currently displayed**) + the line count in a fixed slot on the right.
297
- **The split between the rows is deliberate**: the first row is *transport and display* (snapshot / stream /
298
- polling), the second is *content* — and the second row is **exactly the same as the merged log view**: one level
299
- kernel, one export builder, one count wording (`N lines`, or `N / M lines` while filtered). The level threshold
300
- treats a line without a level prefix as a **continuation of the previous entry and follows its level** — otherwise
301
- `ERROR+` would cut a stack trace in half. The input's width and position never change, so typing or clearing never nudges
302
- this row. The log body is coloured by level (both common prefixes, `[INFO]` and `|INFO`,
303
- are recognised), timestamps are dimmed, and filter hits are highlighted; beyond 2000 lines only the tail is
304
- coloured, with a hint. In the details view the list toolbar and panel header are no longer layered on top, so
305
- each screen has exactly one refresh entry point.
306
- - **FOLLOW live log stream**: with the log page's `FOLLOW` switch on, the UI switches from "polling a snapshot" to
307
- **SSE push** (`GET /api/dsh-docker/logs/stream`, where the server runs
308
- `docker logs --follow`) — new log lines are appended as they arrive and polling stops; `FOLLOW` and
309
- `AUTO REFRESH` are mutually exclusive (opening the stream stops polling and greys out the switch), and closing it
310
- returns to snapshots with an immediate refresh. Streaming logs keep the last **5000 lines / 4MB** (a ring buffer that drops the oldest on either cap; newline-free oversized output is force-split so memory stays bounded). Chunks render at most every **150ms** (no per-chunk re-render on chatty containers) and rows carry stable ids. The body is **windowed (D152)**: only the visible rows plus 24 rows of overscan on each side are mounted (about 230 nodes for 5000 rows, previously ~10000), the rest is represented by top/bottom padding — under a sustained 20k lines/s flood the max event-loop lag drops from 70–97ms to 6ms and >50ms long frames from 25–26 to **0**. Row heights are **measured** (not estimated) and the window anchors to the tail while pinned, so auto-scroll / scroll-up pause / "back to bottom" keep exact geometry; scrolling up through history is compensated by a top-of-viewport anchor recorded **across commits**, so it does not jump even while the ring buffer keeps evicting old rows (or the aggregate view inserts a row by timestamp). **Switching streams / containers / refreshing a snapshot invalidates that whole generation of measured heights and anchors (D155)**: snapshot row ids are positional (`'s'+index`), so the same id is different content after a refresh — keeping them would compute the padding from the wrong generation's heights (measured: `scrollHeight` inflated by 35644px), so the scrollbar, jumps and "which row is at the top" all describe the wrong generation. The view does not truncate: whatever `LINES` selects is scrollable and exported — it is simply not all mounted at once (bounded by the buffer and the host output cap). Auto-scroll to bottom,
311
- drops the oldest and hints once); filtering / level colouring share exactly the same rendering as snapshots. It
312
- auto-scrolls to the bottom, pauses when the user scrolls up and floats a
313
- "back to bottom" button; a status line in the top right shows the connection state, and a stream that ends
314
- naturally because the container exited switches back to snapshot refresh automatically.
315
- **Reconnection is managed by the plugin itself** (not by EventSource auto-reconnect): the first connection
316
- carries `tail` to backfill history, while every reconnect uses `tail=0` — new lines only, **never replaying
317
- history** (auto-reconnect reuses the URL with its `tail`, so the server pushes the last `tail` lines again as
318
- if they were new, and the log grows a duplicated block). When the host-side backpressure queue (8MB)
319
- **Host-side backpressure (D153)**: each stream buffers at most 8MB; the text tail streams (logs, pulls)
320
- drop the oldest frames and keep the newest when they overflow, sending a `skip{frames,bytes}` notice so the UI
321
- can say "a stretch is missing, following continues" — the stream is **not** torn down (the old policy sent
322
- `end{reason:output-limit}` and closed, which left the panel stuck in "connection lost" while the container kept
323
- flooding). The skip accounting is **exact**: when more frames are dropped while a queued `skip` notice has not
324
- been written yet, the server updates that notice **in place** (D156 — no gap goes unreported); the client
325
- **accumulates** consecutive skips and shows "N batches skipped on this connection", resetting on a successful
326
- reconnect (D157). Structured streams (stats, events) still close and reconnect, because a missing sample/event is
327
- semantically wrong.
328
- Connecting / switching pages / closing the panel all close the `EventSource`.
204
+ The first row is *transport and display*, the second is *content* — and the second row is **exactly the same as
205
+ the merged log view** (one level kernel, one export builder, one count wording). The level threshold treats a
206
+ line without a level prefix as a **continuation of the previous entry and follows its level** — otherwise
207
+ `ERROR+` would cut a stack trace in half. The log body is coloured by level, timestamps are dimmed, and filter
208
+ hits are highlighted.
209
+ - **FOLLOW live log stream**: with `FOLLOW` on, the UI switches from "polling a snapshot" to **SSE push**
210
+ (`GET /api/dsh-docker/logs/stream`, where the server runs `docker logs --follow`); `FOLLOW` and
211
+ `AUTO REFRESH` are mutually exclusive (opening the stream stops polling and greys out the switch), and closing
212
+ it returns to snapshots with an immediate refresh. Streaming logs keep the last **5000 lines / 4MB** (a ring
213
+ buffer dropping the oldest on either cap; newline-free output is force-split so memory stays bounded), and
214
+ chunks render at most every **150ms**. The body is **windowed** (`D152`): only the visible rows plus 24 rows of
215
+ overscan each side are mounted, the rest padded; measured row heights plus a tail anchor keep auto-scroll /
216
+ scroll-up pause / "back to bottom" exact, and **switching streams / containers / refreshing a snapshot
217
+ invalidates that whole generation** (`D155`: snapshot row ids are positional). The view does not truncate:
218
+ whatever `LINES` selects is scrollable and exported. **Reconnection is managed by the plugin itself**: the first
219
+ connection carries `tail`, every reconnect uses `tail=0` — new lines only, **never replaying history**.
220
+ Backpressure and overflow (`D153` / `D156` / `D157`) are in "SSE event protocol".
329
221
  - **Overview**: `docker inspect`'s authoritative data — state and health, exit code, restart count and policy,
330
222
  port mappings, mounts (including read-only flags), networks and IPs, entrypoint and command, and the latest
331
223
  health-check output; below it you can run a one-shot `docker exec` (requires `allowExec`).
332
224
  - **Logs**: a tail snapshot from `docker logs --tail` (`logTailDefault` lines by default), with timestamps and
333
- `--since` switchable; output beyond `maxOutputKb` is truncated and marked. To keep watching new logs, turn on
334
- `FOLLOW` above (the same argv plus `--follow`, with no overall timeout and no output cap, ending on the
225
+ `--since` switchable. **Two different caps**: `LINES` is a **line** cap while the settings card's "output cap
226
+ (KB)" is a **byte** cap — the smaller wins, so the byte cap can truncate with a banner. To keep watching new
227
+ logs turn on `FOLLOW` (the same argv plus `--follow`, no overall timeout and no byte cap, ending on the
335
228
  connection's lifecycle).
336
229
 
337
230
  ![Log page: level colouring + an always-present filter box + line counts](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-logs.png)
338
- - **Stats**: a `docker stats --no-stream` snapshot (CPU% / memory usage and share / network IO /
339
- block IO / PIDs), refreshed by the panel at `pollIntervalSec`. The stats page also has **`FOLLOW` live
340
- following**: turning it on switches to `GET /api/dsh-docker/stats/stream` (the server runs **without**
341
- `--no-stream` for `docker stats`, one line per second), and the browser side keeps a **60-point ring
342
- buffer** to draw CPU / memory **mini trend charts** (sparklines). Unlike the log stream, this one **does not end
343
- naturally**; its close semantics are the frontend actively aborting the `EventSource`, and when `docker stats`
344
- exits by itself the server sends `end` (reason=`stats-exit`), whereupon the UI hints and switches back to snapshot
345
- polling.
346
- - **Compose project view**: the toolbar's third segment. It groups the `composeProject` / `composeService`
347
- labels into "project → service → container", one row per project showing the running / unhealthy / service counts
348
- and each container's state; clicking into a project shows the service table, or opens **project-level merged
349
- logs** — **one** `/logs/stream?ids=…` carrying every container in the project (frames tagged with `id`), mixed
350
- client-side by the `[service]` prefix, with an auto-scroll switch and a filter box. Merging follows arrival order
351
- and does not guarantee strict cross-container ordering. A failed per-container sub-stream is marked "partially
352
- failed" while the other containers keep streaming (the connection stays open).
353
- - **Images**: a `docker images` list (reference / size / created / short ID);
354
- `<none>:<none>` dangling images carry a `dangling` marker. The search box and the "N / M images"
355
- counter are **fixed in the toolbar** (they do not scroll away with the list), and the header column names stick
356
- within the table body. Each row has two actions, "details / remove": **details** opens the full-column view
357
- (overview: size / size including parents / created / platform / layers / entrypoint and command / exposed ports /
358
- digest / labels; build history: the per-layer commands and sizes from `docker history`). **Remove** (requires
359
- `allowMutations`) runs `docker image rm` after a second confirmation (**without** `-f`, so an image that is
360
- referenced fails with a hint to "remove the related containers first").
361
- - **Networks / volumes (the fifth and sixth segments)**: the toolbar segments extend to "Containers / Images /
362
- Compose / Networks / Volumes" (when a narrow column cannot fit them, the segment container scrolls horizontally
363
- by itself, with no second-level menu). Both pages use the same "table + full-column details" layout:
364
- - **Networks**: name / driver / scope / internal badge / ID, with a row click opening details — overview
365
- (ID / driver / scope / created / subnet / gateway / internal·attachable·ingress·ipv6 / options / labels) and a
366
- "connected containers" tab (container / IPv4 / IPv6 / MAC). **Container counts deliberately stay out of the
367
- list rows**: only `docker network inspect` returns the connected list, and inspecting every row would be N
368
- docker calls, so it is fetched once on entering details instead.
369
- - **Volumes**: name / driver / scope / mountpoint (over-long paths are width-limited and truncated, with the full
370
- value in title); details are name / driver / scope / mountpoint / created / options / labels (volumes have no
371
- reverse index, so there is only the overview page).
231
+
232
+ - **Stats**: a `docker stats --no-stream` snapshot (CPU% / memory usage and share / network IO / block IO /
233
+ PIDs), refreshed by the panel at `pollIntervalSec`. The stats page also has **`FOLLOW` live following**: it
234
+ switches to `GET /api/dsh-docker/stats/stream` (the server runs `docker stats` **without** `--no-stream`, one
235
+ line per second), and the browser side keeps a **60-point** ring buffer for CPU / memory mini trend charts. This
236
+ stream **does not end naturally**; its close semantics are the frontend aborting, and when `docker stats` exits
237
+ by itself the server sends `end` (reason=`stats-exit`).
238
+ - **Compose project view**: the toolbar's third segment. It groups `composeProject` / `composeService` into
239
+ "project → service → container", one row per project with running / unhealthy / service counts; clicking into a
240
+ project shows the service table, or opens **project-level merged logs** — **one** `/logs/stream?ids=…` carrying
241
+ every container in the project (frames tagged with `id`), mixed client-side by the `[service]` prefix. A failed
242
+ per-container sub-stream is marked "partially failed" while the other containers keep streaming.
243
+ - **Images**: a `docker images` list (reference / size / created / short ID), with `<none>:<none>` dangling images
244
+ marked; the search box and counter are **fixed in the toolbar**, and the header sticks within the table body.
245
+ Each row has "details / remove": **details** opens the full-column view (overview + `docker history` build
246
+ history); **remove** (requires `allowMutations`) runs `docker image rm` after a second confirmation (without
247
+ `-f`, so a referenced image fails with a hint to remove the related containers first).
248
+ - **Networks / volumes**: the toolbar segments extend to "Containers / Images / Compose / Networks / Volumes /
249
+ Disk" (a narrow column scrolls the segment container horizontally, with no second-level menu). Both pages use
250
+ the same "table + full-column details" layout:
251
+ - **Networks**: name / driver / scope / internal badge / ID, with a row click opening details — overview and a
252
+ "connected containers" tab. **Container counts deliberately stay out of the list rows**: only
253
+ `docker network inspect` returns the connected list, and inspecting every row would be N docker calls.
254
+ - **Volumes**: name / driver / scope / mountpoint (over-long paths are width-limited, with the full value in
255
+ title); details are the overview page only (volumes have no reverse index).
372
256
  - The details header has a **remove** button, and both toolbars have a **prune icon**, all gated by
373
- `allowMutations` (greyed out with an explanatory title when off) and all requiring a second confirmation; a
374
- failed removal (a network still has containers attached / a volume is still in use / 403) shows an inline banner
375
- on the details page rather than failing silently.
376
- - Auto refresh matches the images page: these two pages **do not poll** (inventories change slowly, and a `docker`
377
- CLI call every 5s is burnt for nothing); they refresh only when switching pages or targets.
257
+ `allowMutations` (greyed out with an explanatory title) and all requiring a second confirmation; a failed
258
+ removal shows an inline banner rather than failing silently. These two pages **do not poll** (inventories
259
+ change slowly).
378
260
  - **Pulling images (an SSE progress stream)**: the pull icon in the images toolbar (requires `allowMutations`)
379
- opens the pull view; after entering a reference it goes through `GET /api/dsh-docker/images/pull/stream`
380
- (where the server runs `docker pull`) — per-layer progress (Pulling fs layer / Downloading /
381
- Extracting / Pull complete) appears live; progress lines are updated in place keyed by "layer key", and TTY `\r`
382
- refreshes do not make the buffer grow ever longer. The toolbar's "prune dangling" runs
383
- `docker image prune -f`, which **only removes untagged images** (deliberately without `--all`, to avoid
384
- deleting ordinary unused images by mistake).
261
+ opens the pull view; after entering a reference it goes through `GET /api/dsh-docker/images/pull/stream` —
262
+ per-layer progress appears live, updated in place keyed by layer, and TTY `\r` refreshes do not make the buffer
263
+ grow. The toolbar's "prune dangling" runs `docker image prune -f`, which **only removes untagged images**
264
+ (deliberately without `--all`).
385
265
 
386
266
  ![Images page: the search box is fixed in the toolbar and the header sticks](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-images.png)
267
+
387
268
  - **One-shot exec**: with `allowExec` on you can type a command, equivalent to
388
269
  `docker exec <container> sh -c "<command>"`, returning the exit code and stdout/stderr (no TTY).
389
- - **Interactive terminal (the first icon on a card)**: it runs `docker exec -it '<container>' sh`. It degrades in
270
+ - **Interactive terminal (the first icon on a card)**: it runs `docker exec -it '<container>' sh` and degrades in
390
271
  three steps depending on tty's capabilities —
391
272
  ![exec terminal drawer coexisting with the log page: collapsing only hides it, the session keeps running](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-exec.png)
392
273
 
393
- 1. **Embedded in place (tty ≥ 0.15, recommended)**: a **terminal drawer** opens at the bottom of the panel, and
394
- tty's `ttyTerminal.mount` mounts the terminal into it. The panel does not collapse, so you can watch the
395
- container's logs and step into the container to type commands without losing context. The drawer takes height
396
- away from the body, so it can give way without ending the session:
397
- **collapsing** (the arrow at the right of its title bar, or double-clicking its top edge) squashes the drawer
398
- into one title bar while the session keeps running; **dragging the top edge** resizes it (capped at 75% of the
399
- panel height). Only two actions really end a session — the ✕ on the drawer and closing the panel; with an
400
- active session, closing the panel asks for confirmation first, so that clicking the blank backdrop does not
401
- kill a container shell that is mid-troubleshooting (the global terminal panel also treats "clicking the blank
402
- area = minimize", the same stance).
274
+ 1. **Embedded in place (tty ≥ 0.15, recommended)**: a **terminal drawer** opens at the bottom of the panel via
275
+ `ttyTerminal.mount`, and the panel does not collapse; **collapsing** (the arrow, or double-clicking the top
276
+ edge) squashes the drawer into one title bar while the session keeps running, and **dragging the top edge**
277
+ resizes it (capped at 75%). Only the drawer's ✕ and closing the panel really end a session — with an active
278
+ session, closing the panel asks first, so that clicking the blank backdrop does not kill a container shell
279
+ mid-troubleshooting.
403
280
  2. **Open a tab through tty (tty ≥ 0.14)**: tty opens a new tab running the same command, and this panel then
404
- collapses (this panel has a higher z-index, so not collapsing would only make the user feel "nothing
405
- happened").
406
- 3. **Copy the command**: when tty is not installed / too old / an inline target uses key·password auth (the
407
- browser has no credentials), it degrades to copying the command with a hint to paste it into the terminal
408
- panel.
409
-
410
- Local targets open a local session, while SSH targets go over SSH by connection-book entry name (or by the inline
411
- fields authenticated by the agent), and the tab / drawer title is `<container> · exec`. Capability detection goes
412
- through the service contract version (`mount` exists only with `ttyTerminal.version >= 2`), not by guessing
413
- whether a function exists.
281
+ collapses (this panel has a higher z-index, so not collapsing would only feel like "nothing happened").
282
+ 3. **Copy the command**: when tty is missing / too old / an inline target uses key·password auth (the browser
283
+ has no credentials), it degrades to copying the command with a hint to paste it into the terminal panel.
414
284
 
285
+ Local targets open a local session, SSH targets go over SSH by connection-book entry name (or inline fields),
286
+ and the title is `<container> · exec`. Capability detection goes through the service contract version (`mount`
287
+ exists only with `ttyTerminal.version >= 2`), not by guessing whether a function exists.
415
288
 
416
289
  ### Targets (local / SSH)
417
290
 
@@ -420,88 +293,64 @@ semantically wrong.
420
293
  | `local` | the docker CLI on the machine hosting the plugin (`spawn` runs it directly, not through a shell) |
421
294
  | `ssh` | connects to a remote host over `ssh2` and runs the docker CLI remotely (argv single-quote escaped) |
422
295
 
423
- There are two ways to fill in `kind=ssh`:
424
-
425
- 1. **Reference a tty connection-book entry**: put the entry name in `book` (maintained in the connection book
426
- under the Terminal Panel card), and the host / port / username / auth method follow from it. The
427
- settings card's dropdown only lists connection-book entries tty has saved; if tty is not installed or the entry
428
- does not exist, that target fails to resolve and both the panel and the agent tools report a clear error.
429
- 2. **Inline fields**: `host` + `username` are required, the rest as needed (`port` / `auth` /
430
- `keyPath` / `password` / `passphrase` / `agentForward`).
296
+ `kind=ssh` has two shapes: ① **reference a tty connection-book entry** — put the entry name in `book` (maintained
297
+ in the connection book under the Terminal Panel card) and host / port / username / auth follow from it; the
298
+ settings card's dropdown only lists entries tty has saved, and a missing tty or entry makes that target fail to
299
+ resolve with a clear error in both the panel and the agent tools. ② **inline fields** — `host` + `username` are
300
+ required, the rest as needed (`port` / `auth` / `keyPath` / `password` / `passphrase` / `agentForward`).
431
301
 
432
- Targets are resolved **fresh for every operation**: if you change a connection-book entry in the tty card (a
433
- different port, a new password), the next operation uses the new value immediately with no restart. The remote side
434
- must satisfy: the docker CLI is installed, and the current account can use docker
435
- **without sudo** (usually because it is in the `docker` group); otherwise `probe` passes through errors such as
436
- `permission denied while trying to connect to the Docker daemon socket` verbatim.
302
+ Targets are resolved **fresh for every operation**: change a connection-book entry in the tty card and the next
303
+ operation uses it immediately, with no restart. The remote side needs the docker CLI installed and the current
304
+ account able to use docker **without sudo** (usually via the `docker` group); otherwise `probe` passes errors such
305
+ as `permission denied while trying to connect to the Docker daemon socket` through verbatim.
437
306
 
438
307
  #### Connect local (one click)
439
308
 
440
- If you would rather not fill in a target by hand, the UI offers two entries, neither of them
441
- permanent:
309
+ If you would rather not fill in a target by hand, the UI offers two entries, neither of them permanent:
442
310
 
443
311
  | When | Where |
444
312
  | --- | --- |
445
313
  | You have no targets at all | A **Connect local** primary button in the panel's empty state (the whole panel is blank then, so one click is the shortest path) |
446
314
  | You already have other targets | The **last item in the target dropdown**, `+ Connect local` — right where you look for a machine, and only visible once you open it |
447
315
 
448
- The two are mutually exclusive (the same entry never appears twice): with no targets you use the big
449
- button in the empty state; with targets, the dropdown carries the item. **Once a local target exists
450
- neither appears** (it is already in the selector, so listing it again would just be a duplicate).
451
-
452
- On the agent side the same operation is `docker_connect_local`, and over HTTP it is
453
- `POST /connect-local` — every entry point shares one implementation on the host.
454
-
455
- Why not a permanent toolbar button: a permanent "Connect local" both occupies the spot right of the
456
- target selector and has to attract clicks with an accent colour, while being pure noise **once you
457
- have a usable target** (the first version was permanent, and the user called it out as redundant on
458
- sight). But keeping only the empty-state entry would miss the common case of "I have a few SSH hosts
459
- and want to add this machine too" — so the second entry lives in the dropdown, appearing only when
460
- you are genuinely looking for a machine.
316
+ The two are mutually exclusive, and **once a local target exists neither appears**. A permanent toolbar button is
317
+ not worth it: **with a usable target already present** it both occupies space and begs for clicks; but keeping
318
+ only the empty-state entry would miss the common "I have a few SSH hosts and want to add this machine too" case.
319
+ On the agent side the same operation is `docker_connect_local`, over HTTP it is `POST /connect-local` — every
320
+ entry point shares one implementation on the host.
461
321
 
462
322
  | Situation | Behavior |
463
323
  | --- | --- |
464
324
  | A `kind=local` target already exists (whatever its name) | **Reuse it** (`created:false`) and just switch to it; nothing is created, rewritten or renamed |
465
- | No local target | Append one under the stable name `local`; if `local` is taken by another target, try `local-2`, `local-3`, … (deterministic, conflict-free, never overwriting) |
325
+ | No local target | Append one under the stable name `local`; if `local` is taken, try `local-2`, `local-3`, … |
466
326
  | SSH / custom targets configured | **Left exactly as they are** (order and credentials included); only the local entry is appended at the end |
467
327
  | Clicked twice / concurrently | Idempotent: exactly one local target in the end |
468
328
  | Settings store unavailable / save fails | A clear error (503 / 500 with the reason) and **no in-memory config change** — never "it showed up in the UI but is gone after a refresh" |
469
329
 
470
- After the write succeeds it also runs one **read-only probe** (`docker version`) and puts the result
471
- into `message`: the server version when the daemon is reachable, or the actual cause when it is not —
472
- **docker CLI missing** (install Docker, or point "docker CLI" at the right path) / **probe timed
473
- out** (the daemon is wedged, or `docker context` points at an unreachable remote; check with
474
- `docker context ls`) / **daemon not running** (Linux: `systemctl start docker`; macOS / Windows:
475
- start Docker Desktop) / **no permission on the socket** (add the account to the `docker` group) —
476
- instead of just "no containers". A failed probe is **not** a failed operation (the target really was
477
- added; `reachable:false` says so separately), and it works as soon as the daemon comes up.
478
-
479
- The binary this probe uses is **exactly the "docker CLI" value from the settings card** (`dockerBin`,
480
- `docker` by default). When that CLI is missing it does one **read-only** lookup for another container
481
- CLI on `PATH` (`docker` → `podman` → `nerdctl`) and names it in the message ("this machine has
482
- podman: put podman in 'docker CLI' and it works") — it **only tells you, and never rewrites your
483
- config**: silently changing `dockerBin` would contradict this plugin's refusal to decide on your
484
- behalf, and podman's output-format compatibility with this plugin has not been verified item by item
485
- (see "known limitations"). Candidate matching is by **tool name** (basename), not by the raw string
486
- in the config, so a `dockerBin` of `/usr/local/bin/docker` does not produce the nonsense "change
487
- docker to docker".
488
-
489
- Timeouts are a **separate case**: `probe()` does not just report an exit code (`code === null` makes
490
- "exit code null" carry zero information), it returns `timedOut:true` plus an actionable sentence. It
491
- also **actually returns on time** — SIGKILL only reaches the direct child, so when `dockerBin` points
492
- at a wrapper script the grandchild keeps holding the pipes and stretches the 15-second cap into
493
- minutes (measured: a 3-second cap took 60 seconds), so on expiry it kills, allows a 500 ms settle
494
- window, and then releases the pipes itself if `close` still has not arrived.
495
-
496
- Deliberately **not** done (the conservative boundary): installing Docker, starting a daemon,
497
- switching docker contexts, or touching any environment variable; an empty `targets` (= the user has
498
- none) keeps its meaning, with no implicit default target; and the `clearTargets` protection against
499
- accidental clearing is untouched. The path is also **independent of the capability switches** — it
500
- only writes the plugin's own configuration and involves no target-side write that would need a grant.
330
+ After the write succeeds it also runs one **read-only probe** (`docker version`) and puts the result into
331
+ `message`: the server version when the daemon is reachable, or the actual cause when it is not — **docker CLI
332
+ missing** (install Docker, or point "docker CLI" at the right path) / **probe timed out** (the daemon is wedged,
333
+ or `docker context` points at an unreachable remote) / **daemon not running** (Linux `systemctl start docker`,
334
+ macOS / Windows start Docker Desktop) / **no permission on the socket** (add the account to the `docker` group).
335
+ A failed probe is **not** a failed operation (the target really was added; `reachable:false` says so separately).
336
+
337
+ The binary this probe uses is **exactly the "docker CLI" value from the settings card** (`dockerBin`, `docker` by
338
+ default). When that CLI is missing it does one **read-only** lookup for another container CLI on `PATH`
339
+ (`docker` → `podman` → `nerdctl`) and names it in the message — it **only tells you, and never rewrites your
340
+ config** (podman's output-format compatibility has not been verified item by item, see "Known limitations").
341
+ Candidate matching is by **tool name** (basename), not by the raw config string. Timeouts are a **separate case**:
342
+ `probe()` returns `timedOut:true` plus an actionable sentence, and it **actually returns on time** — it kills,
343
+ allows a 500 ms settle window, and then releases the pipes itself if `close` still has not arrived (SIGKILL only
344
+ reaches the direct child, so a wrapper script's grandchild would otherwise stretch the cap into minutes).
345
+
346
+ Deliberately **not** done: installing Docker, starting a daemon, switching docker contexts, or touching any
347
+ environment variable; an empty `targets` keeps its meaning, with no implicit default target; and the
348
+ `clearTargets` protection is untouched. The path is also **independent of the capability switches** — it only
349
+ writes the plugin's own configuration.
501
350
 
502
351
  ### Merged logs (multi-select / Compose project)
503
352
 
504
- Selecting several containers or opening a Compose project can both merge the logs of several containers into one
353
+ Selecting several containers or opening a Compose project both merge the logs of several containers into one
505
354
  stream: **one** `/logs/stream?ids=…` carries them all (frames tagged with `id`; the host still starts one
506
355
  `docker logs -f` per container), mixed client-side in arrival order. The toolbar offers:
507
356
 
@@ -510,66 +359,65 @@ stream: **one** `/logs/stream?ids=…` carries them all (frames tagged with `id`
510
359
  | **Live / Paused** | a real pause (see below) |
511
360
  | **Timestamps** | shows a timestamp on every line. Timestamps are **always received with the stream** (`timestamps=1`); this only affects display |
512
361
  | **By arrival / By time** | `by arrival`: follows with zero delay; `by time`: merges into one true timeline using each line's container timestamp |
513
- | **Level filter** | `all levels / WARN+ / ERROR+`. **Lines with no level prefix (continuation lines such as stack traces) inherit the level of the previous log entry**, so ERROR+ keeps its stack trace with it and INFO continuations are filtered out together with their first line; orphan continuation lines at the start of the window (whose record header is outside the window) cannot be judged and are kept |
514
- | **⬇ .log / ⬇ .md** | exports what is currently displayed: `.log` is plain line text (`[service] ISO time body`), while `.md` carries a header with the source containers / line count / export time and can be attached to a ticket directly |
515
-
516
- **How merging by time works**: the SSE connections for the various containers are established at different moments,
517
- so A's initial backlog may arrive in one batch while B's arrives later, and sorting each batch on its own cannot fix
518
- cross-batch inversions. The implementation **backfills the last 400 lines** — whenever a new batch of lines arrives
519
- it re-sorts "the last 400 lines + the new lines" by timestamp (using the RFC3339 prefix from
520
- `docker logs --timestamps` as the sort key, which is parsed and then stripped from the body). That way it neither
521
- stalls for a while to get the first screen right (no one-second blank page on open) nor fails to correct historical
522
- misordering after the fact; the price is that in "by time" mode the last few lines already on screen may shift
523
- slightly (use "by arrival" while following live output).
362
+ | **Level filter** | `all levels / WARN+ / ERROR+`. **Lines with no level prefix (continuation lines such as stack traces) inherit the level of the previous log entry**, so ERROR+ keeps its stack trace with it; orphan continuations whose record header is outside the window cannot be judged and are kept |
363
+ | **⬇ .log / ⬇ .md** | exports what is currently displayed: `.log` is plain line text (`[service] ISO time body`), `.md` carries a header with the source containers / line count / export time |
364
+
365
+ **How merging by time works**: the SSE connections are established at different moments, so A's initial backlog
366
+ may arrive in one batch while B's arrives later, and sorting each batch on its own cannot fix cross-batch
367
+ inversions. The implementation **backfills the last 400 lines** — whenever a new batch arrives it re-sorts "the
368
+ last 400 lines + the new lines" by timestamp (the RFC3339 prefix, parsed and then stripped from the body). The
369
+ price is that in "by time" mode the last few lines already on screen may shift slightly (use "by arrival" while
370
+ following live output).
524
371
 
525
372
  ### "Pause" in merged logs (a real pause)
526
373
 
527
374
  The merged logs of a multi-select / Compose project have a `Live / Paused` switch. **Pausing freezes the content**,
528
- not just auto-scroll:
529
-
530
- - while paused, newly arriving logs go into a client-side buffer and **the DOM is no longer appended to** — the
531
- screen reader is not pushed away, and the view does not jump when the display cap (2000 lines) trims the front;
532
- - the button itself shows how many lines have accumulated (`Paused +348`);
533
- - on resume the buffer is merged in one go (still under the 5000-line ring cap) and the view returns to the bottom.
534
-
535
- Only stopping auto-scroll is not enough: with the label saying "paused" while the content keeps growing, users think
536
- the switch is broken; and with a high log volume the view also jumps by itself as the front is trimmed.
375
+ not just auto-scroll: while paused, newly arriving logs go into a client-side buffer and **the DOM is no longer
376
+ appended to** (the screen reader is not pushed away, and the view does not jump when the display cap trims the
377
+ front); the button itself shows how many lines have accumulated (`Paused +348`); on resume the buffer is merged
378
+ in one go (still under the 5000-line ring cap) and the view returns to the bottom. Only stopping auto-scroll is
379
+ not enough: with the label saying "paused" while the content keeps growing, users think the switch is broken.
537
380
 
538
381
  ### Overview (cross-target) and the "needs attention" criteria
539
382
 
540
- The "Overview" page lays out all targets on one screen: one counter card per target (running / stopped / unhealthy /
541
- **needs attention**), and below it a cross-target "containers needing attention" table (container / target / status /
542
- **reason** / image; click a row for details, click a card to switch to that target's list). Three design constraints:
543
-
544
- - **Progressive landing + failure isolation**: each target requests and lands independently; one unreachable SSH host
545
- does not silence the whole page — the unreachable target gets its own banner and the other targets' results remain
546
- available.
547
- - **The "needs attention" criteria are the host's**: the container list and `/attention` are requested in parallel;
548
- the latter does an extra `docker inspect`, so it can identify **OOM (OOMKilled)** and the **real exit code** — a
549
- `ps` summary's `Exited (137)` cannot tell an OOM kill from a manual kill. When `/attention` is unavailable (an
550
- older host / that target failed) it falls back to the summary criteria and labels the count
551
- "needs attention (rough)".
383
+ The `Overview` pill next to the target picker (shown only with ≥2 targets) lays out all targets on one screen: one
384
+ counter card per target (running / stopped / unhealthy / **needs attention**), and below it a cross-target
385
+ "containers needing attention" table (container / target / status / **reason** / image; click a row for details,
386
+ click a card to switch to that target's list). Three design constraints:
387
+
388
+ - **Progressive landing + failure isolation**: each target requests and lands independently; one unreachable SSH
389
+ host does not silence the whole page (deliberately **not** aggregated with `Promise.all`: an unreachable SSH
390
+ host waits out the 20s readyTimeout, so aggregating would keep the whole page silent for 20s).
391
+ - **The "needs attention" criteria are the host's**: the container list and `/attention` are requested in
392
+ parallel; the latter does an extra `docker inspect`, so it can identify **OOM (OOMKilled)** and the **real exit
393
+ code** — a `ps` summary's `Exited (137)` cannot tell an OOM kill from a manual kill. When `/attention` is
394
+ unavailable it falls back to the summary criteria and labels the count "needs attention (rough)".
552
395
  - **Ordering**: OOM > zombie > unhealthy > repeatedly restarting > non-zero exit; equal weights are ordered by
553
- "most recent end time" descending, so the freshest crash is at the top (hovering a row shows the end / start times,
554
- restart count and exit code).
396
+ "most recent end time" descending (hovering a row shows the end / start times, restart count and exit code).
397
+
398
+ Polling reuses the toolbar's "auto refresh" switch (off by default): each round of this page is one `docker ps`
399
+ per target across all N targets, more expensive than any single-target page. With no exceptions it shows "all
400
+ good"; while some targets have not finished answering, the attention area shows "reading…" and **that counter card
401
+ also enters its loading state** (no 0/0/0 — "0 containers" would be read as "this machine has no containers").
402
+ Entering the Overview clears the single-target failed banner: the Overview attributes everything per target and no
403
+ longer stacks a single-target "operation failed" — the same SSH timeout told twice looks like two dead machines.
555
404
 
556
405
  ### Container diagnosis ("why is it failing")
557
406
 
558
- The container detail view adds a **Diagnosis** tab; the agent-side twin is `docker_diagnose`. It makes four
559
- reads in one pass — `docker inspect` (authoritative state / exit code / OOM / restart count / last health-check
560
- output / resource limits), `docker logs --tail N`, `docker events --since` (**filtered server-side** with
407
+ The container detail view adds a **Diagnosis** tab; the agent-side twin is `docker_diagnose`. It makes four reads
408
+ in one pass — `docker inspect` (authoritative state / exit code / OOM / restart count / last health-check output /
409
+ resource limits), `docker logs --tail N`, `docker events --since` (**filtered server-side** with
561
410
  `--filter container=<id>`) and `docker stats --no-stream` — and hands them to a set of **pure criteria**
562
411
  (`src/diagnose.ts`: zero IO, no clock) that produce:
563
412
 
564
413
  - a **one-line verdict** taking the highest severity found (`critical` / `warning` / `info` / `ok`);
565
- - **findings**: code + one line + evidence. Fifteen today — OOM-killed, non-zero exit (with what 126 / 127 /
566
- 137 / 143 usually mean), crash loop, unhealthy (with the last health-check output), health check still
567
- starting, clean exit, not running (created / paused / dead), memory pressure (≥90% with a limit set), CPU
568
- pressure (≥90%), error lines in the log, no health check, plus three **collection-failure** signals;
569
- - **evidence**: state, a ps-style status line (`Up 4 hours (healthy)`), exit code, OOM, restart count,
570
- start/finish times, memory limit, CPU quota, log and error line counts, event count and current usage;
571
- - a **timeline** of this container's `start` / `die` / `oom` / `health_status` events, ascending;
572
- - **suggestions**: 0–5 concrete actions.
414
+ - **findings**: code + one line + evidence. The criteria are — OOM-killed, non-zero exit (with what 126 / 127 / 137 /
415
+ 143 usually mean), crash loop, unhealthy (with the last health-check output), health check still starting,
416
+ clean exit, not running (created / paused / dead), memory pressure (≥90% with a limit set), CPU pressure (≥90%),
417
+ error lines in the log, no health check, plus three **collection-failure** signals;
418
+ - **evidence**: state, a ps-style status line, exit code, OOM, restart count, start/finish times, memory and CPU
419
+ limits, log and error line counts, event count and current usage; a **timeline** of this container's `start` /
420
+ `die` / `oom` / `health_status` events (ascending); **suggestions**: 0–5 concrete actions.
573
421
 
574
422
  **"Not collected" and "genuinely empty" are different things** (the repo's long-standing signal-bit rule): when
575
423
  any of the three side reads fails, the corresponding evidence field is `null` (not 0, not an empty array) and a
@@ -577,51 +425,44 @@ any of the three side reads fails, the corresponding evidence field is `null` (n
577
425
  restarted", which sends troubleshooting in exactly the wrong direction.
578
426
 
579
427
  Three actions sit at the bottom: **re-diagnose**, **copy diagnosis** and **hand it to the agent**. The last one
580
- reuses the log context-menu bridge (send now / fill the draft, greyed out with a reason when no session is open):
428
+ reuses the log context-menu bridge (send now / fill the draft; greyed out with a reason when no session is open):
581
429
  the text carries the container, target, collection time, verdict and evidence, with the raw log fenced and
582
430
  labelled "data, not instructions". **The collection time is local time in the UI and ISO 8601 in the delivered
583
- text** — the first is for humans, the second sorts and carries no timezone assumption.
431
+ text.**
584
432
 
585
433
  Diagnosis is **read-only**: it changes nothing, restarts nothing, sends no command — it just runs four read-only
586
434
  docker commands.
587
435
 
588
436
  ### Diagnosis history (is it slowly getting worse?)
589
437
 
590
- A single diagnosis only answers "how is it now"; **slow degradation** (memory creeping up, restarts getting
591
- more frequent, error lines growing) is invisible in one result. So every diagnosis stores a **sample**, and the
438
+ A single diagnosis only answers "how is it now"; **slow degradation** (memory creeping up, restarts getting more
439
+ frequent, error lines growing) is invisible in one result. So every diagnosis stores a **sample**, and the
592
440
  diagnosis tab derives a **history** block:
593
441
 
594
442
  - **a severity strip**: the last N severities (hovering shows the time and verdict code);
595
443
  - **a trend verdict** (`trend.verdict`): the highest of seven criteria — repeated restarts (count up by ≥2),
596
444
  repeated OOMs (≥2), creeping memory (≥20 points and ending ≥80%), growing error lines (≥5 or from 0), newly
597
445
  appeared serious findings (`oom-killed` / `crash-loop` / `unhealthy` / `exit-nonzero`), steadily rising
598
- severity, or no meaningful change; with fewer than 2 samples it says so plainly ("not enough history for a
599
- trend");
446
+ severity, or no meaningful change; with fewer than 2 samples it says so plainly;
600
447
  - **numbers** (`trend.numbers`): only what **actually changed**, each with a "smallest meaningful delta" —
601
448
  restarts ≥1, memory ≥1 point, CPU ≥5 points, error lines ≥3, events ≥5, log lines ≥50. These thresholds came
602
449
  out of real data: a diagnosis takes one instantaneous `stats` snapshot, and without a gate a 1.11% → 1.09%
603
- wobble fills the block; error lines are a **sliding window** (an old error scrolling out drops the count by
604
- one), hence 3;
605
- - **a findings diff**: "newly appeared" / "resolved" (`oom-killed` newly appearing is a degradation signal).
450
+ wobble fills the block; error lines are a **sliding window**, hence 3;
451
+ - **a findings diff**: "newly appeared" / "resolved".
606
452
 
607
- **What is stored, and what is not** (a deliberate trade-off): a sample holds **only the verdict and numbers** —
608
- severity, verdict code, the finding codes, state / exit code / OOM / restart count / memory and CPU usage /
609
- error lines / event count / log lines / time. **Never persisted**: the raw log (`logTail`) and each finding's
610
- detail (it embeds log lines that may carry credentials). The file is therefore safe to `cat`, but it also
611
- **cannot** replay a past incident — re-run the diagnosis when you need the raw text.
453
+ **What is stored, and what is not**: a sample holds **only the verdict and numbers** (severity, verdict code, the
454
+ finding codes, state / exit code / OOM / restart count / memory and CPU usage / error lines / event count / log
455
+ lines / time). **Never persisted**: the raw log (`logTail`) and each finding's detail (it embeds log lines that may
456
+ carry credentials) — so the file is safe to `cat`, but it also **cannot** replay a past incident.
612
457
 
613
458
  Location: `<DSH home>/dsh-docker/diagnosis-history.jsonl` (directory `0700`, file `0600`), one record per line.
614
459
  Caps: **50 per container**, **2000 per file**; past that the oldest are dropped and the file is rewritten
615
- **atomically** (temp file + rename). **30-second dedupe**: the same container within 30s, with an identical
616
- severity + verdict + finding set + restart count, is **not appended** (clicking "re-diagnose" repeatedly must not
617
- fake a wobbly trend), and the UI says so ("identical to the previous run; not recorded again within 30 seconds").
618
- `DSH_DOCKER_HISTORY_DIR` overrides the directory (**tests / troubleshooting only**); an empty string means unset.
619
-
620
- Three known edges: ① **renaming a container starts a new history** (the index key is "target + container name") —
621
- better than merging two segments and having the trend report changes that never happened; ② **appends are not
622
- locked** (two concurrent diagnoses may lose one update) — history is supporting information and a file lock is
623
- not worth it; ③ **a write failure only degrades**: the diagnosis still returns, `recorded = false`, and one line
624
- lands on the host's stdout.
460
+ **atomically**. **30-second dedupe**: the same container within 30s with an identical severity + verdict + finding
461
+ set + restart count is **not appended**. `DSH_DOCKER_HISTORY_DIR` overrides the directory (**tests / troubleshooting
462
+ only**); an empty string means unset. Three edges: ① **renaming a container starts a new history** (the index key
463
+ is "target + container name"); ② **appends are not locked** (two concurrent diagnoses may lose one update);
464
+ ③ **a write failure only degrades**: the diagnosis still returns, `recorded = false`, and one line lands on the
465
+ host's stdout.
625
466
 
626
467
  ### Disk usage and reclaimable space
627
468
 
@@ -634,20 +475,15 @@ agent-side twin is `docker_disk_usage`. Two commands feed it: `docker system df`
634
475
  - **total reclaimable**: the byte sum of the four (if any row cannot be parsed the whole line shows `—` — better
635
476
  to say nothing than to print a wrong number);
636
477
  - **image breakdown** (by unique size, descending): repository:tag / size / unique / **container count** —
637
- `containers === 0` gets an "unused" badge, with a removal icon behind `allowMutations` and the existing
638
- confirmation dialog;
639
- - **volume breakdown** (by size, descending): name / driver / size / **link count** — `links === 0` likewise
640
- gets an "unused" badge.
478
+ `containers === 0` gets an "unused" badge, with a removal icon behind `allowMutations`;
479
+ - **volume breakdown** (by size, descending): name / driver / size / **link count** — `links === 0` likewise gets
480
+ an "unused" badge.
641
481
 
642
- Three deliberate trade-offs:
643
-
644
- - **sort first, truncate second**: on a real host 258 of 259 volumes were 0B; truncating first would spend all
645
- 100 slots on empty volumes and leave the ones that actually cost space out of the table;
646
- - **the detail cap is 100 rows per class**, announced in the header ("detail truncated, showing the first 100")
647
- — the tool side caps at 20 and the two `truncated` bits are OR-ed (truncation must leave a trace);
648
- - **an older docker only degrades, never errors**: no `-v --format '{{json .}}'`, a truncated output or an
649
- unexpected shape all give `degraded: true` plus a plain-language reason, the summary cards stay usable and the
650
- detail tables are **not rendered** (an empty table would read as "there is nothing").
482
+ Three deliberate trade-offs: **sort first, truncate second** (on a real host 258 of 259 volumes were 0B, so
483
+ truncating first would spend all 100 slots on empty volumes); **the detail cap is 100 rows per class**, announced
484
+ in the header (the tool side caps at 20 and the two `truncated` bits are OR-ed); **an older docker only degrades,
485
+ never errors** (`degraded: true` plus a plain-language reason, the summary cards stay usable and the detail tables
486
+ are **not rendered** — an empty table would read as "there is nothing").
651
487
 
652
488
  The disk view **does not poll** (`system df -v` costs more than `docker images`) and has **no prune button**:
653
489
  per-row removal behind a confirmation dialog is safer than "wipe a whole class", and that entry already exists
@@ -660,56 +496,55 @@ A collapsible **equivalent docker run** block at the bottom of the overview; the
660
496
  deliberately does **not** widen `ContainerDetail`, because that would drag env vars into `docker_inspect`'s model
661
497
  context. Covered: name, restart policy, port mappings (unbound ones become `--expose`), binds / named volumes /
662
498
  tmpfs, a non-default network, env vars, user / workdir / hostname (omitted when they equal the default), memory
663
- and CPU limits, cap-add / cap-drop / device, plus the image and command.
499
+ and CPU limits, cap-add / cap-drop / device, plus the image and command. It **fetches only when expanded**, one
500
+ argument per line, POSIX single-quote escaping, ready to copy.
501
+
502
+ Three deliberate trade-offs: **env values are kept as-is** (rewriting them would break equivalence), with keys
503
+ matching `/(PASS|PWD|SECRET|TOKEN|KEY|CRED|AUTH|PRIVATE)/i` reported in `secretKeys` — the UI shows a "may contain
504
+ credentials" warning **naming keys only**, and the command itself is not redacted (a redacted command cannot run);
505
+ **whatever was deliberately not restored is named** (`omitted`: `--privileged`, `--link`, a non-default log driver,
506
+ `--pid/--ipc/--uts`, `--sysctl`, `--add-host`, `--security-opt`, exotic mount types, anonymous volumes, an
507
+ overridden ENTRYPOINT, custom health checks — each with why and how to add it back, **nothing dropped silently**);
508
+ **a Compose-managed container is only an approximation** (network aliases / labels / depends_on are not in the
509
+ command, and the UI says so).
664
510
 
665
- It **fetches only when expanded**, one argument per line, POSIX single-quote escaping, ready to copy.
511
+ ## Configuration (saved and applied hot)
666
512
 
667
- Three deliberate trade-offs:
513
+ While a capability is not granted, **clicking that switch in the settings card starts a grant in place**: the panel
514
+ shows a command to run "in a host terminal", and within ten seconds of running it the switch unlocks (no restart);
515
+ once granted it is an ordinary switch, and **turning it off** always works (the host never blocks downgrades).
516
+ "Configured but not granted" shows a "not in effect: no host grant" badge — that state is the one most easily
517
+ mistaken for a broken plugin, so it needs a name. **One row per capability**: each switch is followed by its own
518
+ grant state and **grant time** (`Granted · 2026-09-26 22:31:08`), with the revoke button right-aligned.
668
519
 
669
- - **env values are kept as-is** (rewriting them would break equivalence); keys matching
670
- `/(PASS|PWD|SECRET|TOKEN|KEY|CRED|AUTH|PRIVATE)/i` are reported in `secretKeys`, the UI shows a "may contain
671
- credentials" warning **naming keys only**, and the command itself is not redacted — a redacted command cannot
672
- run, and running it is the whole point;
673
- - **whatever was deliberately not restored is named** (`omitted`): `--privileged`, `--link`, a non-default log
674
- driver, `--pid/--ipc/--uts`, `--sysctl`, `--add-host`, `--security-opt`, exotic mount types, anonymous
675
- volumes, an overridden ENTRYPOINT, custom health checks — each with "why it is missing" and "how to add it
676
- back". **Nothing is dropped silently**;
677
- - **a Compose-managed container is only an approximation**: network aliases / labels / depends_on are not in the
678
- command, and the UI says so.
520
+ **Both capabilities can be confirmed with a single paste**: clicking the second switch does not replace the first
521
+ panel; the panels stack vertically and a **Copy all (N)** button appears at the top.
679
522
 
680
- ## Configuration (saved and applied hot)
523
+ Why the time is shown: an out-of-band grant is **persistent** — after a host restart it takes effect with no
524
+ confirmation at all, so the host prints one
525
+ `[dsh-docker] elevation: load capability=… via=file grantedAt=…` line (no nonce, no path) at startup and the card
526
+ shows the same time. A launch-environment grant has **no** time; that row says "granted by the launch
527
+ environment; to revoke, remove it there and restart the host".
681
528
 
682
529
  ![Settings card: target CRUD, capability switches and parameters, saved and applied hot](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-setting.png)
683
530
 
684
- Where the settings surface **lives** depends on the DSH version, but it is always the **same form**
685
- (same settings namespace, same write path) — the data and behaviour are identical:
686
-
687
- | Host | Where the settings are |
688
- |---|---|
689
- | `0.2.0-rc.1` and later | **Directly below the description on the plugin detail page** (`plugins.bundle.config`, key = **this package's own** bundle name) — no extra ">" step into a sub-page |
690
- | `0.1.6` line | Plugins sidebar → the bundle's **row** → the ">" row detail (`plugins.row.config`) |
691
- | All of the above | Settings → **plugin configuration**, the same entry (`settings.kit.item`, provided by `@hyzyn/dsh-kit-settings`) |
692
-
693
- On hosts that have the bundle slot, **this package's** row entry is **no longer registered** — two entries
694
- for one form only makes people think there are two sets of settings; hosts without it (the bundle slot
695
- missing) fall back to the row detail automatically, with no loss of function. The **aggregate bundle
696
- `@hyzyn/dsh-all` is the exception**: its `plugins.bundle.config` key is shared by every plugin (a second
697
- registrant throws), so it has no inline slot and keeps using its row entry.
531
+ Where the settings surface **lives** changes with the DSH version, but it is **always the same form** (same settings
532
+ namespace, same write path — identical data and behaviour) — the per-generation landing spots and the four
533
+ registration contracts live in [conventions.md § client settings surface](../../docs/conventions.md#客户端设置面内联优先).
698
534
 
699
535
  Configuration lives in the settings namespace `docker`, i.e. the `docker:` section of `~/.dsh/settings.yaml`
700
- (`$DSH_HOME/settings.yaml`; DSH's settings file is provided by the host's `dsh-settings-file`). The composition
701
- config in the plugin line acts as the schema's `base`, which the settings layer overrides; the HTTP
702
- `POST /api/dsh-docker/config` is the card's write channel and accepts only the keys in the table below (unknown keys
703
- return 400).
536
+ (`$DSH_HOME/settings.yaml`). The composition config in the plugin line acts as the schema's `base`, which the
537
+ settings layer overrides; the HTTP `POST /api/dsh-docker/config` is the card's write channel and accepts only the
538
+ keys below (unknown keys return 400).
704
539
 
705
540
  | Item | Default | Description |
706
541
  | --- | --- | --- |
707
542
  | `enabled` | true | disables the whole plugin (**takes effect on save**: tools are unregistered immediately, the announcement is withdrawn, and all data routes except `/config` return 403; `/config` stays readable and writable — the settings card is the way back in. Unlike tty, which needs a restart) |
708
543
  | `announceToAgent` | true | whether to inject a capability announcement into the agent (systemPrompt section `plugin:dsh-docker`) |
709
- | `dockerBin` | `docker` | the docker CLI executable name or path (`podman` works here); only letters, digits and `_ . / \ : -` plus interior spaces are allowed, and it may not start with `-` (**Windows drive letters and `\` must be allowed**, otherwise no absolute path can be entered at all). **"Connect local" probes with exactly this value**; when it is wrong (that CLI does not exist) the message names another container CLI found on `PATH`, but it does **not** rewrite this value for you |
544
+ | `dockerBin` | `docker` | the docker CLI executable name or path (`podman` works here); only letters, digits and `_ . / \ : -` plus interior spaces are allowed, and it may not start with `-` (**Windows drive letters and `\` must be allowed**). **"Connect local" probes with exactly this value**; when it is wrong the message names another container CLI found on `PATH`, but it does **not** rewrite this value for you |
710
545
  | `allowMutations` | false | allows **mutating operations**: container start / stop / restart / remove, image removal / dangling pruning / pulling (the panel buttons and the `docker_action`, `docker_image_remove`, `docker_image_prune`, `docker_image_pull` tools; while off, `/action`, `/images/remove`, `/images/prune`, `/images/pull/stream` return 403 and the corresponding tools are not registered) |
546
+ | — (capability grant) | not granted | `allowMutations` / `allowExec` have **two out-of-band channels**: ① **launch environment variables** (`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`, values `1` / `true` / `yes` / `on`) — resolved from the host's **launch snapshot**, inherited `process` layer only, so writing project `.env` or `~/.dsh/env.yml` does **not** count as a grant; ② **grant in place** (no restart): click the switch in the settings card and run the command it shows in a terminal on the host — effective within seconds. HTTP can always **turn them off** (an emergency brake cannot depend on a restart), but writing `true` without a grant is rejected with 400 and both paths explained. A `true` in the config file is **not** a grant (it lives in the same store as the value HTTP wrote, so the source cannot be told apart). **Upgrade impact**: switches opened through the UI before the upgrade become off — set the environment variables and restart, or confirm in the card. **Why**: the loopback fence and the same-origin proof cannot stop a cross-site page or an in-page script, and the docker socket equals root on the target host; details in [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
711
547
  | `allowExec` | false | allows a **one-shot** `docker exec` (the one-off command box on the container detail drawer's **Overview** tab, and the `docker_exec` tool; while off, `/exec` returns 403). **Does not** cover the card's Terminal button (`docker exec -it`), which tty hosts (see "Known limitations") |
712
- | — (capability grant) | not granted | `allowMutations` / `allowExec` have **two out-of-band channels**: ① **launch environment variables** (`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`, values `1` / `true` / `yes` / `on`) — resolved from the host's **launch snapshot**, inherited `process` layer only, so writing project `.env` or `~/.dsh/env.yml` does **not** count as a grant; ② **grant in place** (no restart): click the switch in the settings card and run the command it shows in a terminal on the host — effective within seconds, on its own row (one row per capability — the grants are per capability, so side-by-side revoke buttons would not say which one belongs to which), next to a `Granted · YYYY-MM-DD HH:mm:ss` line and a `Revoke host grant` button (a launch-environment grant has no timestamp and cannot be revoked from the UI). **Both capabilities can be confirmed with a single paste**: clicking the second switch while the first panel is open no longer replaces it (the two grants are independent anyway) — the panels stack vertically and a **Copy all (N)** button appears at the top of the group, copying the still-pending commands in the order they were started, joined by a **newline**, so one paste in a host terminal runs them all (`bash` / `zsh` / PowerShell / cmd all run a multi-line paste line by line, which is exactly why `&&` — not understood by PowerShell 5.1 — and `;` — not understood by `cmd` — are deliberately **not** used as separators). Once a command expires, Copy all copies only the still-pending ones; when everything is granted (or every panel is collapsed) the button disappears by itself. The two challenges have independent TTLs and probe cycles, so the wording only promises "every switch unlocks by itself once they run" and **never the same instant** — the countdown stays per panel, and each panel's copy / regenerate / collapse keeps acting on its own capability only. In-place grants are **persistent**: they live in `<DSH home>/dsh-kit/capability-grants.json` (0600; the confirmation dir is `<DSH home>/dsh-kit/grant-confirm/`, 0700 — kept inside the kit's own subdirectory, because mechanism names dropped into the shared home root can collide with the harness or another plugin), are read at the next boot, and then **take effect with no further confirmation** — so every boot logs one `elevation: load capability=… via=file grantedAt=…` line per loaded grant. Over HTTP they can always be **turned off** (the emergency brake must not depend on a restart), but setting `true` without a grant is rejected with 400 and both routes spelled out. A `true` in the config is **not** a grant (it lives in the same store HTTP writes to, so the source is indistinguishable). **Upgrade note**: switches opened through the UI before this change become off — set the variable and restart, or grant in place. **Why**: neither the loopback fence nor the same-origin proof stops cross-site pages or in-page scripts (they can just set `Sec-Fetch-Site: same-origin`), and the docker socket is root on the target host; details (including whom this does *not* stop) in [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
713
548
  | `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
714
549
  | `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
715
550
  | `logTailDefault` | 200 | default log tail line count (1–5000) |
@@ -735,17 +570,14 @@ Out-of-range numbers are clamped to the boundary, and a value of the wrong type
735
570
  | `passphrase` | `''` | private key passphrase; **prefer `env:NAME`**, a credential reference |
736
571
  | `agentForward` | false | whether to forward the local ssh-agent (takes effect when `SSH_AUTH_SOCK` exists) |
737
572
 
738
- An `env:NAME` inside `password` / `passphrase` is a **credential reference** — exactly the official shape, where
739
- configuration holds only the reference and a provider owns the value. It is resolved **only when connecting**, in this order:
740
-
741
- 1. the **official credential layer** (`ctx.credentials`, from `@deepseek-ai/dsh-credentials`), which layers
742
- `file` (`$DSH_HOME/.credentials.yaml`) / `env` / `project-env` / `user-env` and re-resolves per operation — so a
743
- changed credential reaches the next operation **without a host restart**;
744
- 2. when that service is unavailable (older host, bundle not installed) or holds no such reference, `process.env[NAME]`.
745
-
746
- With neither, the error names **both** sources and carries the credential service's own error too — otherwise a broken
747
- credential service would masquerade as "you did not configure it", which is the hardest kind to diagnose. These two values are **never sent back to the browser**:
748
- the config snapshot only provides the two booleans `passwordSet` / `passphraseSet`.
573
+ An `env:NAME` inside `password` / `passphrase` is a **credential reference** (configuration holds only the
574
+ reference, a provider owns the value), resolved **only when connecting**: ① the **official credential layer**
575
+ (`ctx.credentials`) layers `file` (`$DSH_HOME/.credentials.yaml`) / `env` / `project-env` / `user-env` and
576
+ re-resolves per operation, so a changed credential reaches the next operation **without a host restart**; ② when
577
+ that service is unavailable or holds no such reference, `process.env[NAME]`. With neither, the error names
578
+ **both** sources and carries the credential service's own error too — otherwise a broken credential service would
579
+ masquerade as "you did not configure it". These two values are **never sent back to the browser**: the config
580
+ snapshot only provides `passwordSet` / `passphraseSet`.
749
581
 
750
582
  ### `hostKeys[]` (SSH host fingerprints, TOFU)
751
583
 
@@ -756,74 +588,67 @@ the config snapshot only provides the two booleans `passwordSet` / `passphraseSe
756
588
  | `fingerprints` | **fingerprint set** (required, at least one): the verbatim hex fingerprints received by the `hostHash: 'sha256'` callback. One host:port may hold several host keys (rsa / ed25519 …); matching any of them counts as a match |
757
589
  | `fingerprint` | legacy single-fingerprint field (string): migration input only, merged into `fingerprints` on load; do not use it in new configs |
758
590
 
759
- The first connection is recorded automatically and written to disk; every later connection must match, and
760
- **a changed fingerprint rejects the connection outright**, with guidance in the error to "delete this host's record
761
- and reconnect". The record list can be deleted / reset in the settings card (deletion is submitted as the explicit
591
+ The first connection is recorded automatically and written to disk; every later connection must match, and **a
592
+ changed fingerprint rejects the connection outright**, with guidance to "delete this host's record and
593
+ reconnect". The record list can be deleted / reset in the settings card (deletion is submitted as the explicit
762
594
  `hostKeysRemove: [{host, port}]`, so a union merge cannot silently undo it).
763
595
 
764
596
  ## Agent tools
765
597
 
766
598
  | Tool | Registration | Parameters | Purpose / typical use |
767
599
  | --- | --- | --- | --- |
768
- | `docker_targets` | always registered | `probe?: boolean` | lists targets (name / kind / label); `probe:true` probes the docker version and daemon reachability for each one (SSH targets open connections, so it is slower). Other tools take their `target` from here |
769
- | `docker_connect_local` | always registered | — (no parameters) | **Connect local**: makes sure a `kind=local` target exists and selects it (an existing one is reused, never duplicated; SSH / custom targets stay untouched), then probes the daemon read-only: returns `{name, kind, created, saved, reachable, serverVersion?}`. With no targets configured the other tools are not registered at all (see the zero-target note below) — this is the one step that fixes that. **Requires no capability grant** (it only writes the plugin's own config plus a read-only probe) and never installs Docker / starts a daemon / switches context / touches environment variables. When the daemon is unreachable the result text names the cause (CLI missing / probe timed out / daemon not running / no socket permission; when the CLI is missing it also names another container CLI found on PATH, but never rewrites dockerBin for you) instead of an empty list |
770
- | `docker_ps` | registered with ≥1 target | `target?` (**pass `*` = all targets**), `all?: boolean`, `limit?` (1–500, default 100), `offset?` (default 0) | lists containers (name / state / health / image / ports / compose project and service / short ID); by default only running ones, `all:true` includes stopped. With `target:'*'` it returns results grouped by target, and **one unreachable target does not affect the others** (that group carries `error`). Ports are merged across the IPv4/IPv6 dual-stack expansion (one `-p` no longer shows twice, D130); an empty `ports` does **not** mean "nothing exposed" — host-network containers publish on the host itself, and that case now carries a `net` field (D131). The first step of troubleshooting |
771
- | `docker_attention` | registered with ≥1 target | `target?` (supports `*`), `limit?: number` | a **needs-attention summary**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / zombie; every item carries `reasons`, `exitCode`, `oomKilled`, `restartCount`. OOM and the real exit code come from one `docker inspect` (a ps summary cannot distinguish a manual kill from 137). The troubleshooting entry point: call it first when you are unsure which machine or container to look at |
772
- | `docker_diagnose` | registered with ≥1 target | `target?`, `id` (required), `logTail?` (1–500, default 80), `since?` (docker syntax, default `1h`), `history?` (0–50, default 10; 0 = no history) | **"why is this container failing"**: one pass over inspect + log tail + this container's events (server-side `--filter container`) + a `stats` snapshot, yielding `verdict` (one line) / `findings` (the criteria table, each with code, severity, title, detail) / `evidence` (exit code, OOM, restart count, memory and CPU limits, log and event counts, current usage) / `timeline` / `suggestions`. **Read-only**; "not collected" becomes `null` plus a `*-unavailable` finding — never 0. For more logs or keyword searches use `docker_logs_grep`. The result also carries `history` (the last N samples holding **only the verdict and numbers**, plus the `trend` verdict) — `trend.verdict.summary` is the sentence answering "is it slowly getting worse" |
600
+ | `docker_targets` | always registered | `probe?: boolean` | lists targets (name / kind / label), and **an SSH target also reports the tty connection-book entry it references as `book`** (targets with inline host/username have no such field; use it as the `book` for the terminal panel / `sftp_*` — when the names differ, this is the only way to know); `probe:true` probes the docker version and daemon reachability for each one (SSH targets open connections, so it is slower). Other tools take their `target` from here |
601
+ | `docker_connect_local` | always registered | — (no parameters) | **Connect local**: makes sure a `kind=local` target exists and selects it (an existing one is reused; SSH / custom targets stay untouched), then probes the daemon read-only: returns `{name, kind, created, saved, reachable, serverVersion?}`. With no targets the other tools are not registered at all (see the zero-target note below) — this is the one step that fixes that. **Requires no capability grant** and never installs Docker / starts a daemon / switches context / touches environment variables. When the daemon is unreachable the text names the cause (CLI missing / probe timed out / daemon not running / no socket permission) instead of an empty list |
602
+ | `docker_ps` | registered with ≥1 target | `target?` (**pass `*` = all targets**), `all?: boolean`, `name?` (container-name substring), `limit?` (1–500, default 100), `offset?` (default 0) | lists containers (name / state / health / image / ports / compose project and service / short ID); by default only running ones, `all:true` includes stopped. `name` is a **server-side filter** (`docker ps --filter name=`, substring match) applied before pagination, so page totals do not drift. With `target:'*'` it returns results grouped by target, and **one unreachable target does not affect the others** (that group carries `error`). Ports are merged across the IPv4/IPv6 dual-stack expansion (D130); an empty `ports` does **not** mean "nothing exposed" — host-network containers publish on the host itself, and that case carries a `net` field (D131). The first step of troubleshooting |
603
+ | `docker_attention` | registered with ≥1 target | `target?` (supports `*`), `limit?: number` | a **needs-attention summary**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / zombie; every item carries `reasons`, `exitCode`, `oomKilled`, `restartCount`. OOM and the real exit code come from one `docker inspect`. Call it first when you are unsure which machine or container to look at |
604
+ | `docker_diagnose` | registered with ≥1 target | `target?`, `id` (required), `logTail?` (1–500, default 80), `since?` (docker syntax, default `1h`), `history?` (0–50, default 10; 0 = no history) | **"why is this container failing"**: one pass over inspect + log tail + this container's events (server-side `--filter container`) + a `stats` snapshot, yielding `verdict` / `findings` (each with code, severity, title, detail) / `evidence` / `timeline` / `suggestions`. **Read-only**; "not collected" becomes `null` plus a `*-unavailable` finding — never 0. For more logs or keyword searches use `docker_logs_grep`. The result also carries `history` (the last N samples holding **only the verdict and numbers**, plus the `trend` verdict) — `trend.verdict.summary` answers "is it slowly getting worse" |
773
605
  | `docker_inspect` | registered with ≥1 target | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
774
- | `docker_logs` | registered with ≥1 target | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?`, `until?` | the tail from `docker logs --tail`; `since` / `until` use docker syntax (e.g. `10m`, `2026-09-09T10:00:00`) to bound a time window; over the cap it is marked `truncated`; when truncated (`truncated`) the result explains how to narrow it — a smaller `tail`, a `since`/`until` window, or `docker_logs_grep` |
775
- | `docker_logs_grep` | registered with ≥1 target | `target?`, `id`, `pattern` (required, ≤512 chars), `regexp?`, `ignoreCase?`, `since?`, `until?`, `context?` (0–20, default 2), `limit?` (1–500, default 200), `tail?` (omitted = scan all history), `timestamps?` (on by default) | **server-side log search**: line-by-line filtering in the host (never touches the remote shell, never pipes ` | grep`), returns hit lines plus ±context lines, and honestly reports `matched` (all hits even when not returned) / `scanned` / `limited` / `truncated` — "only scanned this far" is distinguishable from "scanned everything"; the scan window is **aligned to line boundaries** (the partial line left by a byte-level cut is never returned as a line, so every entry in `lines` is a complete line); returned lines have their own cap: at most 4000 lines (`MAX_RESULT_LINES`); over it the result is cut to the cap and carries `truncatedForSize` plus `notes` (lower `limit` / reduce `context` / bound the window with `since`·`until`), while `matched` still reports the full count. Division of labour with `docker_logs`: error in the recent tail → `docker_logs` (cheaper); buried deep / a specific time window / keywords, error codes, request IDs → this tool. Literal substring by default (grep -F semantics, zero ReDoS); JS regex only with `regexp: true` |
606
+ | `docker_logs` | registered with ≥1 target | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?`, `until?` | the tail from `docker logs --tail`; `since` / `until` use docker syntax to bound a time window; over the cap it is marked `truncated`, and the result explains how to narrow it (smaller `tail`, a `since`/`until` window, or `docker_logs_grep`) |
607
+ | `docker_logs_grep` | registered with ≥1 target | `target?`, `id`, `pattern` (required, ≤512 chars), `regexp?`, `ignoreCase?`, `since?`, `until?`, `context?` (0–20, default 2), `limit?` (1–500, default 200), `tail?` (omitted = scan all history), `timestamps?` (on by default) | **server-side log search**: line-by-line filtering in the host (never touches the remote shell), returning hit lines plus context and honestly reporting `matched` / `scanned` / `limited` / `truncated` — "only scanned this far" is distinguishable from "scanned everything"; the scan window is **aligned to line boundaries**; returned lines have their own cap (at most 4000, `MAX_RESULT_LINES`), over which the result carries `truncatedForSize` and `notes` while `matched` still reports the full count. Division of labour with `docker_logs`: error in the recent tail → `docker_logs` (cheaper); buried deep / a specific time window / keywords, error codes, request IDs → this tool. Literal substring by default (grep -F semantics, zero ReDoS); JS regex only with `regexp: true` |
776
608
  | `docker_stats` | registered with ≥1 target | `target?`, `ids?` (comma-separated container names/IDs) | a `docker stats --no-stream` snapshot: CPU% / memory usage and share / network IO / block IO / PIDs; omitting `ids` means all running containers. Live following is a panel capability (SSE), and the tool keeps single-value snapshot semantics |
777
- | `docker_images` | registered with ≥1 target | `target?`、`limit?` (1–500, default 100), `offset?` (default 0) | image list (repository:tag / size / created / short ID) |
609
+ | `docker_images` | registered with ≥1 target | `target?`, `limit?` (1–500, default 100), `offset?` (default 0) | image list (repository:tag / size / created / short ID) |
778
610
  | `docker_top` | registered with ≥1 target | `target?`, `id` (required) | a process snapshot inside the container (`docker top <id> -eo pid,ppid,user,stat,etime,pcpu,pmem,cmd`): PID / PPID / user / STAT / elapsed / CPU / memory / command, every field as docker prints it. "The container is running" and "the process is still there" are two different facts |
779
- | `docker_events` | registered with ≥1 target | `target?`, `since?` (docker `--since` syntax, default `10m`) | a container-event snapshot (`docker events --since <d> --until <now>`, likewise through the server-side allowlist): the nine kinds start / die / stop / kill / oom / health_status / destroy / rename / update, with noise such as `exec_*` already dropped server-side. For continuous observation have the user watch the "Activity" strip in the panel's container list |
780
- | `docker_networks` | registered with ≥1 target | `target?`、`limit?` (1–500, default 100), `offset?` (default 0) | network list (name / driver / scope / internal flag / short ID). The connected-container list stays out of the list rows — the details page does the inspect, whereas inspecting row by row would be N docker calls |
781
- | `docker_volumes` | registered with ≥1 target | `target?`、`limit?` (1–500, default 100), `offset?` (default 0) | volume list (name / driver / scope / mountpoint) |
782
- | `docker_disk_usage` | registered with ≥1 target | `target?` | disk usage and **"who uses it"**: the four `docker system df` summary classes (items / active / size / reclaimable) plus `-v` detail (an image's `containers`, a volume's `links`, 20 rows each). Check whether `containers` is 0 before deleting; summary and detail are two commands, and a detail failure only degrades (`degraded`) |
783
- | `docker_run_command` | registered with ≥1 target | `target?`, `id` (required) | **derives, never executes**: rebuilds an equivalent `docker run` command text from `docker inspect` (ports / mounts / env / restart policy / network / resource limits / capabilities) plus `warnings` / `secretKeys` (key names only, values unredacted) / `omitted` (what was deliberately not restored, each with a reason). A Compose container is only an approximation |
784
- | `docker_image_inspect` | registered with ≥1 target | `target?`, `ref` (required) | `docker image inspect` + `docker history`: size / size including parents / created / platform / layer count and layer list / entrypoint and command / exposed ports / digest / build history (each step's command and size) |
611
+ | `docker_events` | registered with ≥1 target | `target?`, `since?` (docker `--since` syntax, default `10m`) | a container-event snapshot (likewise through the server-side allowlist): the nine kinds start / die / stop / kill / oom / health_status / destroy / rename / update. For continuous observation have the user watch the "Activity" strip |
612
+ | `docker_networks` | registered with ≥1 target | `target?`, `limit?` (1–500, default 100), `offset?` (default 0) | network list (name / driver / scope / internal flag / short ID). The connected-container list stays out of the list rows — inspecting row by row would be N docker calls |
613
+ | `docker_volumes` | registered with ≥1 target | `target?`, `limit?` (1–500, default 100), `offset?` (default 0) | volume list (name / driver / scope / mountpoint) |
614
+ | `docker_disk_usage` | registered with ≥1 target | `target?` | disk usage and **"who uses it"**: the four `docker system df` summary classes plus `-v` detail (an image's `containers`, a volume's `links`, 20 rows each). Check whether `containers` is 0 before deleting; a detail failure only degrades (`degraded`) |
615
+ | `docker_run_command` | registered with ≥1 target | `target?`, `id` (required) | **derives, never executes**: rebuilds an equivalent `docker run` command from `docker inspect` plus `warnings` / `secretKeys` (key names only, values unredacted) / `omitted` (what was deliberately not restored, each with a reason) |
616
+ | `docker_image_inspect` | registered with ≥1 target | `target?`, `ref` (required) | `docker image inspect` + `docker history`: size / size including parents / created / platform / layer count and list / entrypoint and command / exposed ports / digest / build history |
785
617
  | `docker_action` | `allowMutations` + ≥1 target | `target?`, `action` (`start` \| `stop` \| `restart` \| `remove`), `id` | container lifecycle operations. `remove` is destructive: it deletes the container config and writable layer (data volumes are not included), and the target container must be confirmed with the user before running |
786
- | `docker_image_remove` | `allowMutations` + ≥1 target | `target?`, `ref` (required) | removes an image (`docker image rm`, without `-f`). **Destructive**: it fails when the image is referenced by a container or a child image; confirm the target image and restate the consequences before running |
787
- | `docker_image_prune` | `allowMutations` + ≥1 target | `target?` | prunes dangling (untagged) images (`docker image prune -f`). Deliberately without `--all`, so only untagged images go |
788
- | `docker_image_pull` | `allowMutations` + ≥1 target | `target?`, `ref` (required), `timeoutSec?` (10–1800, default 600) | `docker pull` in snapshot form (**may take several minutes**); for interactive per-layer progress have the user watch the SSE progress stream from the pull icon on the panel's images page |
789
- | `docker_exec` | `allowExec` + ≥1 target | `target?`, `id`, `command` (required, run through `sh -c` inside the container), `timeoutSec?` (1–120, default `execTimeoutSec`) | a one-shot `docker exec` returning the exit code / stdout / stderr; there is no TTY, so for interactive troubleshooting have the user run `docker exec -it <container> sh` in the tty panel |
790
-
791
- - When `target` is omitted it falls back to **the only** configured target; with several targets configured it is
792
- required, and the error message lists the available target names.
793
- - **With no targets configured only `docker_targets` / `docker_connect_local` are registered**: the other tools
794
- have nothing to address, so registering them would only make the model guess once. As soon as a target is
795
- configured they are registered **immediately**, with no restart. Those are exactly the two rows marked
796
- “always registered” in the table.
618
+ | `docker_image_remove` | `allowMutations` + ≥1 target | `target?`, `ref` (required) | removes an image (`docker image rm`, without `-f`). **Destructive**: it fails when the image is referenced; confirm the target image and restate the consequences before running |
619
+ | `docker_image_prune` | `allowMutations` + ≥1 target | `target?` | prunes dangling (untagged) images (`docker image prune -f`). Deliberately without `--all` |
620
+ | `docker_image_pull` | `allowMutations` + ≥1 target | `target?`, `ref` (required), `timeoutSec?` (10–1800, default 600) | `docker pull` in snapshot form (**may take several minutes**); for per-layer progress have the user watch the SSE progress stream in the images page |
621
+ | `docker_exec` | `allowExec` + ≥1 target | `target?`, `id`, `command` / `argv` (**exactly one**: `command` runs through `sh -c` inside the container; `argv` is an exact argument array that neither the host nor the container expands), `timeoutSec?` (1–120, default `execTimeoutSec`) | a one-shot `docker exec` returning the exit code / stdout / stderr. Use `command` for simple commands; use `argv` when spaces, quotes or empty arguments must survive exactly (e.g. `["printf","%s","a b"]`), and write `["sh","-c","…"]` explicitly when you need pipes or variables. There is no TTY, so for interactive troubleshooting have the user run `docker exec -it <container> sh` in the tty panel |
622
+
623
+ - When `target` is omitted it falls back to **the only** configured target; with several targets it is required,
624
+ and the error lists the available names.
625
+ - **With no targets only `docker_targets` / `docker_connect_local` are registered**: the other tools have nothing
626
+ to address, so registering them would only make the model guess once. As soon as a target is configured they
627
+ are registered **immediately**, with no restart.
797
628
  - **The four list tools support pagination**: `docker_ps` / `docker_images` / `docker_networks` / `docker_volumes`
798
- all accept `limit` (1–500, default 100) and `offset` (default 0), and their results carry `total` / `truncated`;
799
- when truncated the rendered text gives the `offset` for the next page. Pagination only fixes “the result is too
800
- long to read through” — it does not change the 512KB ceiling of a single docker command (that still errors).
629
+ accept `limit` (1–500, default 100) and `offset` (default 0), and their results carry `total` / `truncated`.
630
+ Pagination only fixes "the result is too long to read through" — it does not change the 512KB ceiling of a
631
+ single docker command.
801
632
  - Changing either switch **re-registers the tools immediately**: after turning off `allowMutations` / `allowExec`
802
- the corresponding tools (including the three image mutation tools) disappear from the agent side, with no restart.
803
- - **Registered calls are also subject to the session permission tier gate (tier gate)**: by class — every
804
- tool marked “registered with ≥1 target” in the table above is `read` (allowed on every tier); `docker_action` /
805
- `docker_image_remove` / `docker_image_prune` / `docker_image_pull` are `write`, `docker_exec` is `exec`,
806
- and these two classes trigger an **approval prompt on every call** under a confined tier (Read Only /
807
- Workspace Write — the call runs only if the user allows this one; deterministically denied when unattended,
808
- i.e. approval policy `never`). Full access never prompts, and under Auto review the plugin does not add its
809
- own prompt (the host’s model pre-review already covers every call). When the host has not composed the
810
- `sandboxPolicy` / `approval` services the gate is off (behaviour identical to before), and the startup log
811
- says so: `tier-gate: services absent (…), per-call gate disabled`. This is a **separate layer** from the two
812
- static switches above: the switches govern registration (host-level, persistent grant), the tier gate governs
813
- each registered call (per call, follows the tier immediately). Mechanism:
814
- [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
815
- - Recommended troubleshooting order: `docker_targets` → `docker_ps` → **`docker_diagnose`** (one call gives
816
- the verdict, the criteria and the evidence — most "why did it die / why is it stuck" questions have a
817
- direction right here) → `docker_logs` (when the tail does not explain the failure, `docker_logs_grep` with
818
- `pattern` / `since` / `until` to search deeper) → `docker_inspect` (raw fields) → `docker_stats` /
819
- `docker_top` (alive but wrong: check resources first, then whether the process is still there); for image
820
- problems `docker_images` → `docker_image_inspect`; before reclaiming disk, `docker_disk_usage` (see
821
- `containers` / `links`, i.e. who uses what) and only then decide what to delete.
822
- - With `announceToAgent` on, the plugin injects a compact announcement into the systemPrompt: the plugin/panel
823
- location, the "read-only by default" and "docker socket ≈ root on the target host" boundaries, the
824
- "`allowExec` only governs the one-shot exec channel" split (the Terminal button is not gated by it), the `*`
825
- scope of `target`, and the cross-tool troubleshooting order. It does **not** restate each tool's usage — that
826
- is what the tool descriptions are for.
633
+ the corresponding tools (including the three image mutation tools) disappear, with no restart.
634
+ - **Registered calls are also subject to the session permission tier gate**: `read` tools (every row marked
635
+ "registered with ≥1 target") are allowed on every tier; `docker_action` / `docker_image_remove` /
636
+ `docker_image_prune` / `docker_image_pull` are `write` and `docker_exec` is `exec`, and these two classes trigger
637
+ an **approval prompt on every call** under a confined tier (deterministically denied when unattended), never
638
+ under full access, and no extra prompt under Auto review. When the host has not composed the
639
+ `sandboxPolicy` / `approval` services the gate is off, and the startup log says
640
+ `tier-gate: services absent (…), per-call gate disabled`. This is a **separate layer** from the two static
641
+ switches: they govern registration (host-level, persistent grant), the tier gate governs each registered call.
642
+ Mechanism: [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
643
+ - Recommended troubleshooting order: `docker_targets` → `docker_ps` → **`docker_diagnose`** (verdict, criteria and
644
+ evidence in one call — most "why did it die" questions have a direction right here) → `docker_logs` (then
645
+ `docker_logs_grep` with `pattern` / `since` / `until` when the tail does not explain it) → `docker_inspect`
646
+ (raw fields) → `docker_stats` / `docker_top` (alive but wrong: check resources first, then the process); for
647
+ image problems `docker_images` → `docker_image_inspect`; before reclaiming disk, `docker_disk_usage`.
648
+ - With `announceToAgent` on, the plugin injects a compact announcement: the plugin/panel location, the
649
+ "read-only by default" and "docker socket ≈ root" boundaries, the "`allowExec` only governs the one-shot exec
650
+ channel" split (the Terminal button is not gated by it), the `*` scope of `target`, and the cross-tool
651
+ troubleshooting order. It does **not** restate each tool's usage.
827
652
 
828
653
  ## HTTP routes (under the `/api/dsh-docker` prefix, all behind the loopback fence)
829
654
 
@@ -834,43 +659,42 @@ loopback, `localhost`, or a hostname / `/etc/hosts` alias that **resolves to thi
834
659
  `referrer-policy: no-referrer`.
835
660
 
836
661
  **On top of loopback there is a second gate, the "same-origin proof"**: the four SSE streams (`/logs/stream`,
837
- `/stats/stream`, `/events/stream`, `/images/pull/stream`) and nine write sub-routes (`/action`,
838
- `/images/remove|prune`, `/networks/remove|prune`, `/volumes/remove|prune`, `/exec`, `/connect-local`) require either
839
- `Origin: <same origin>` or `Sec-Fetch-Site: same-origin`, otherwise 403 `缺少同源证明` (missing same-origin
840
- proof). This blocks cross-site side effects such as a malicious page using
841
- `<img src=.../images/pull/stream>` to trigger a real pull; curl, old Safari and some WebViews do not send those
842
- headers and will hit it (normal browser use is unaffected). Read-only routes do not require the proof.
662
+ `/stats/stream`, `/events/stream`, `/images/pull/stream`) and every write sub-route (`/action`,
663
+ `/images/remove|prune`, `/networks/remove|prune`, `/volumes/remove|prune`, `/exec`, `/elevate` (including
664
+ `/elevate/status` and `/elevate/revoke`), `/connect-local`) require
665
+ either `Origin: <same origin>` or `Sec-Fetch-Site: same-origin`, otherwise 403 `缺少同源证明`. This blocks
666
+ cross-site side effects such as a malicious page using `<img src=.../images/pull/stream>` to trigger a real pull;
667
+ curl, old Safari and some WebViews do not send those headers and will hit it (normal browser use is unaffected).
668
+ Read-only routes do not require the proof.
843
669
 
844
670
  **Exception: the Desktop app passes the gate with the Host cookie (D139).** DeepSeek Harness Desktop forwards
845
- page requests to `dsh-app://app/api/…` to the real Host after **deleting `Origin` and `Sec-Fetch-Site`** and
846
- rewriting only `Cookie` (`forwardWebRequest` in `app.asar/lib/main.js`), so "both proofs absent" is the *normal*
847
- shape there. Such a forward is recognised by its **Host session cookie** (the `set-cookie` the shell exchanges
848
- for the launch URL; without it the whole forward returns 503): **no `Origin`, no `Sec-Fetch-Site`, but a
849
- `Cookie` → allowed**; both absent *and* no cookie (bare curl / old Safari) → still 403. A browser page cannot
671
+ page requests to `dsh-app://app/api/…` after **deleting `Origin` and `Sec-Fetch-Site`** and rewriting only
672
+ `Cookie` (`forwardWebRequest`), so "both proofs absent" is the *normal* shape there. Such a forward is recognised
673
+ by its **Host session cookie** (without it the whole forward returns 503): **no `Origin`, no `Sec-Fetch-Site`, but
674
+ a `Cookie` → allowed**; both absent *and* no cookie (bare curl / old Safari) → still 403. A browser page cannot
850
675
  forge the `Cookie` header, and a cross-site request always carries `sec-fetch-site: cross-site` (rejected by the
851
- loopback fence first), so the rejection branch was not relaxed. Before the fix all twelve of these routes
852
- returned 403 on Desktop — the symptom was the logs / stats / events / pull streams retrying forever with
853
- "connection lost, reconnecting…", while read-only routes kept working.
676
+ loopback fence first), so the rejection branch was not relaxed; symptoms and diagnosis are in
677
+ [docs/troubleshooting.md](../../docs/troubleshooting.md).
854
678
 
855
679
  | Route | Method | Request body | Response |
856
680
  | --- | --- | --- | --- |
857
- | `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered` / `streamProtocol` — the last one is the SSE frame protocol version the client uses to decide what counts as "ready"; see "SSE event protocol") |
681
+ | `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered` / `streamProtocol` — the last one is the SSE frame protocol version the client uses to decide what counts as "ready") |
858
682
  | `/config` | POST | any subset of the config keys above | `{ok:true, config}`; an unknown key is 400 and invalid JSON is 400 |
859
683
  | `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
860
684
  | `/connect-local` | POST | `{}` (the body is ignored) | **Connect local**: reuses or creates a `kind=local` target and returns `{ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config}`; 503 when the host has no settings service and 500 when the save fails (both carry a reason and change no in-memory config). Requires the same-origin proof (it is a write path) but **not** `allowMutations` / `allowExec` |
861
685
  | `/probe` | POST | `{target?}` | `{ok:true, probe:{ok, bin, serverVersion, error, target}}` |
862
- | `/containers` | POST | `{target?, all?}` | `{ok:true, containers: ContainerSummary[]}`; with `target:'*'` it returns `{ok:true, groups:[{target,label,ok,error?,data?}]}` (concurrent cross-target aggregation) |
863
- | `/attention` | POST | `{target?, limit?}` | for a single target `{ok:true, items: AttentionItem[], total, truncated, degraded}` (`limit` applies **after** filtering + severity sorting; default 100, max 500); with `target:'*'` `{ok:true, groups:[{target,label,ok,error?,data:{items,total,truncated,degraded}}]}` |
686
+ | `/containers` | POST | `{target?, all?}` | `{ok:true, containers: ContainerSummary[]}`; with `target:'*'` it returns `{ok:true, groups:[{target,label,ok,error?,data?}]}` |
687
+ | `/attention` | POST | `{target?, limit?}` | for a single target `{ok:true, items, total, truncated, degraded}` (`limit` applies **after** filtering + severity sorting; default 100, max 500); with `target:'*'` grouped by target |
864
688
  | `/inspect` | POST | `{target?, id}` | `{ok:true, details: ContainerDetail[]}` |
865
689
  | `/stats` | POST | `{target?, ids?: string[]}` | `{ok:true, stats: ContainerStats[]}` |
866
690
  | `/logs` | POST | `{target?, id, tail?, timestamps?, since?}` | `{ok:true, logs:{id, text, truncated}}` |
867
- | `/top` | POST | `{target?, id}` (required) | `{ok:true, top:{id, name, rows:[{pid, ppid, user, stat, elapsed, cpu, mem, command}], truncated}}` — every field exactly as `docker top` prints it; a stopped or missing container goes through the existing error channel (never a 200 with an empty list) |
868
- | `/diagnose` | POST | `{target?, id}` (required), `logTail?` (1–500, default 80), `since?` (default `1h`), `history?` (0–50, default 10) | `{ok:true, diagnosis:{verdict, findings, evidence, timeline, logTail, suggestions, history:{samples, trend, recorded}}}`. `history` is the last N samples **after** this run's recording plus the trend (`recorded` says whether this call actually appended — `false` when the 30-second dedupe hit). Four reads; a failed side read only degrades (the evidence field is `null` plus a `*-unavailable` finding); an invalid `since` is rejected with 400 in the allowlist block |
869
- | `/logs/stream` | GET | query: `target?`, `id` (single container, or use `ids` instead) or `ids` (comma-separated multiple containers, **one connection carries all of them**, at most 32), `tail?` (**0**–5000; `0` = follow only, no history backfill, used on client reconnect), `timestamps?` (`1`/`true`), `since?` | `200 text/event-stream` long connection, event protocol below; bad parameters / unknown target / non-loopback return ordinary JSON errors |
870
- | `/stats/stream` | GET | query: `target?`, `ids?` (comma-separated; omitted = all running) | `200 text/event-stream`: one `stats` frame per second (ContainerStats, same shape as the /stats snapshot); it does not end naturally and is finished off by the client disconnecting |
871
- | `/events/stream` | GET | query: `target?` | `200 text/event-stream`: one container event per `event` frame (already through the server-side allowlist, with missing-value fields omitted); it does not end naturally and is finished off by the client disconnecting |
691
+ | `/top` | POST | `{target?, id}` (required) | `{ok:true, top:{id, name, rows:[{pid, ppid, user, stat, elapsed, cpu, mem, command}], truncated}}` — every field exactly as `docker top` prints it; a stopped or missing container goes through the existing error channel |
692
+ | `/diagnose` | POST | `{target?, id}` (required), `logTail?` (1–500, default 80), `since?` (default `1h`), `history?` (0–50, default 10) | `{ok:true, diagnosis:{verdict, findings, evidence, timeline, logTail, suggestions, history:{samples, trend, recorded}}}`. `history` is the last N samples **after** this run's recording plus the trend (`recorded` says whether this call actually appended). Four reads; a failed side read only degrades (the field is `null` plus a `*-unavailable` finding); an invalid `since` is rejected with 400 |
693
+ | `/logs/stream` | GET | query: `target?`, `id` (single container, or use `ids` instead) or `ids` (comma-separated multiple containers, **one connection carries all**, at most 32), `tail?` (**0**–5000; `0` = follow only, used on reconnect), `timestamps?` (`1`/`true`), `since?` | `200 text/event-stream`, event protocol below; bad parameters / unknown target / non-loopback return ordinary JSON errors |
694
+ | `/stats/stream` | GET | query: `target?`, `ids?` (comma-separated; omitted = all running) | `200 text/event-stream`: one `stats` frame per second (same shape as the /stats snapshot); it does not end naturally |
695
+ | `/events/stream` | GET | query: `target?` | `200 text/event-stream`: one container event per `event` frame (already through the server-side allowlist); it does not end naturally |
872
696
  | `/images` | POST | `{target?}` | `{ok:true, images: ImageSummary[]}` |
873
- | `/images/inspect` | POST | `{target?, ref}` (required) | `{ok:true, image:{ref, detail: ImageDetail, history: ImageHistoryEntry[], historyError}}`; history prefers `--format '{{json .}}'` and falls back to a plain-text table on older versions |
697
+ | `/images/inspect` | POST | `{target?, ref}` (required) | `{ok:true, image:{ref, detail: ImageDetail, history: ImageHistoryEntry[], historyError}}`; history prefers `--format '{{json .}}'` and falls back to a plain-text table |
874
698
  | `/images/remove` | POST | `{target?, ref}` (required) | requires `allowMutations` (otherwise 403); `docker image rm` (without `-f`); `{ok:true, result:{ref, message}}` |
875
699
  | `/images/prune` | POST | `{target?}` | requires `allowMutations` (otherwise 403); `docker image prune -f` (dangling only); `{ok:true, result:{message}}` |
876
700
  | `/images/pull/stream` | GET | query: `target?`, `ref` (required) | requires `allowMutations` (otherwise 403 and no stream is opened); `200 text/event-stream`: `line` per-layer progress + `end{reason:'pull-exit',code,ref}` |
@@ -883,46 +707,39 @@ returned 403 on Desktop — the symptom was the logs / stats / events / pull str
883
707
  | `/volumes/remove` | POST | `{target?, name}` (required) | requires `allowMutations` (otherwise 403); `docker volume rm` (**the data goes with the volume**); `{ok:true, result:{name, message}}` |
884
708
  | `/volumes/prune` | POST | `{target?}` | requires `allowMutations` (otherwise 403); `docker volume prune -f` (**deletes data**, see known limitations); `{ok:true, result:{message}}` |
885
709
  | `/system-df` | POST | `{target?}` | `{ok:true, disk:{summary, images, volumes, buildCache, truncated, degraded, degradedReason}}`: `summary` is the four classes and each detail list caps at 100 rows (**sorted before truncation**); an older docker degrades instead of erroring |
886
- | `/run-command` | POST | `{target?, id}` (required) | `{ok:true, recreate:{id, name, command, warnings, secretKeys, omitted}}` — **derives only, never executes**; `omitted` is the single outlet for "deliberately not restored" |
710
+ | `/run-command` | POST | `{target?, id}` (required) | `{ok:true, recreate:{id, name, command, warnings, secretKeys, omitted}}` — **derives only, never executes** |
887
711
  | `/action` | POST | `{target?, action, id}` | requires `allowMutations` (otherwise 403); `{ok:true, result:{id, action, message}}` |
888
712
  | `/exec` | POST | `{target?, id, command, timeoutSec?}` | requires `allowExec` (otherwise 403); `{ok:true, result:{id, command, code, stdout, stderr, truncated, durationMs}}` |
889
713
 
890
- Any other sub-path is 404 (`unknown route: ...`); a GET on anything other than `/config`, `/targets` and the three
891
- `*/stream` routes above returns 405; an execution failure (a docker error, a target that fails to resolve, and so on)
892
- returns 500 or 400 plus an `{error}` text.
714
+ Any other sub-path is 404 (`unknown route: ...`); a GET on anything other than `/config`, `/targets` and the
715
+ four `*/stream` routes above returns 405; an execution failure returns 500 or 400 plus an `{error}` text.
893
716
 
894
717
  ### SSE event protocol (`/logs/stream`, `/stats/stream`, `/events/stream`, `/images/pull/stream`)
895
718
 
896
719
  The four long streams share one piece of infrastructure (`openSseStream`): uniform header writing +
897
- `flushHeaders()`, a 15s `: ping` heartbeat frame, active-stream registration (a uniform `end` + abort when the plugin
898
- is disabled / the config is hot-updated / it is uninstalled), a silent abort when the client disconnects, and a
899
- **`ready` frame sent as soon as the pipeline is actually up** (see the table below and
900
- `StreamHandlers.onStart`). They differ only in the executor and the end reason:
901
-
902
- Each frame is one `event:` line + one JSON `data:` line (single-line JSON encapsulation: newlines / quotes are
903
- escaped and multi-byte characters are never split by an SSE line boundary), followed by an empty line:
720
+ `flushHeaders()`, a 15s `: ping` heartbeat, active-stream registration (a uniform `end` + abort when the plugin
721
+ is disabled / hot-updated / uninstalled), a silent abort on client disconnect, and a **`ready` frame sent as soon
722
+ as the pipeline is actually up**. Each frame is one `event:` line + one JSON `data:` line (single-line JSON:
723
+ newlines / quotes escaped, multi-byte characters never split by an SSE line boundary), followed by an empty line:
904
724
 
905
725
  | Event | data | Description |
906
726
  | --- | --- | --- |
907
- | `ready` | `{}` | sent once **the executor is really running** (local child `spawn` / remote channel opened = `StreamHandlers.onStart`): only then does the client consider the stream "receiving data". **Arrival of the response headers (`onopen`) does not count** — those are written by `flushHeaders()` immediately, while connecting (SSH `acquire` / opening the remote channel) can take tens of seconds, and an unreachable target produces nothing but an `error`. If it never starts there is **no** `ready` at all |
727
+ | `ready` | `{}` | sent once **the executor is really running** (local child `spawn` / remote channel opened = `StreamHandlers.onStart`): only then does the client consider the stream "receiving data". **Arrival of the response headers (`onopen`) does not count** — those are written immediately by `flushHeaders()`, while connecting can take tens of seconds. If it never starts there is **no** `ready` at all |
908
728
  | `line` | `{"d":"..."}` / `{"e":"..."}`; with multiple sources `{"id":"<container id>","d"\|"e":"..."}` | stdout / stderr chunks (not guaranteed to split on line boundaries, so the client reassembles lines). Used by the log stream and the pull stream. **In `ids=` mode every frame carries `id`**, and the client routes the chunk back to that container |
909
- | `source-end` | `{"id":"<container id>","code":N\|null}` | **multiple sources only**: that container's `docker logs -f` exited naturally (the container stopped). The connection **stays open** — the other containers keep streaming; `end` only comes once every source has finished |
910
- | `source-error` | `{"id":"<container id>","message":"..."}` | **multiple sources only**: that one sub-stream failed (spawn error / remote channel refused). The connection also stays open and the other containers continue; the client marks it "partially failed" |
911
- | `stats` | `ContainerStats` | stats stream only: one frame per container per second, with fields exactly matching the `/stats` snapshot. The server extracts **flat `{...}` objects** (`docker stats` goes through the TTY renderer even when stdout is a pipe, so frames are mixed with `ESC[H/ESC[K/ESC[J`, and parsing by line would drop whole lines) and collapses repeated renders of the same sample, so the client no longer has to parse docker's PascalCase strings |
912
- | `event` | `{action, name, image, composeProject?, time?, exitCode?}` | event stream only: one container event per frame (event lines outside the allowlist are dropped server-side; fields whose value is null are omitted) |
913
- | `end` | `{"reason":"container-exit"\|"stats-exit"\|"events-exit"\|"pull-exit","code":N, ...}` | the executor exited naturally. The log stream attaches a container exit code (with multiple sources it is the **last non-null code in `ids` order**; each container's real code is in its own `source-end`), the stats stream has reason=`stats-exit`, the event stream reason=`events-exit`, and the pull stream attaches `ref` |
729
+ | `source-end` | `{"id":"<container id>","code":N\|null}` | **multiple sources only**: that container's `docker logs -f` exited naturally. The connection **stays open** — `end` only comes once every source has finished |
730
+ | `source-error` | `{"id":"<container id>","message":"..."}` | **multiple sources only**: that one sub-stream failed. The connection also stays open; the client marks it "partially failed" |
731
+ | `stats` | `ContainerStats` | stats stream only: one frame per container per second, matching the `/stats` snapshot. The server extracts **flat `{...}` objects** (`docker stats` goes through the TTY renderer even when stdout is a pipe, so frames are mixed with `ESC[H/ESC[K/ESC[J`, and parsing by line would drop whole lines) and collapses repeated renders |
732
+ | `event` | `{action, name, image, composeProject?, time?, exitCode?}` | event stream only: one container event per frame (event lines outside the allowlist are dropped server-side) |
733
+ | `end` | `{"reason":"container-exit"\|"stats-exit"\|"events-exit"\|"pull-exit","code":N, ...}` | the executor exited naturally. The log stream attaches a container exit code (with multiple sources, the **last non-null code in `ids` order**; each container's real code is in its own `source-end`), the stats stream has reason=`stats-exit`, the event stream `events-exit`, and the pull stream attaches `ref` |
914
734
  | `error` | `{"message":"..."}` | a parameter / execution failure, after which the stream closes; a break at the connection layer does not send this event |
915
735
 
916
- **Multiple containers over one connection (`ids=`, D168)**: merged logs (multi-select / Compose project logs) pack N
917
- containers into **one** SSE — the host starts one `docker logs -f` per container and tags frames with `id`. That way the
918
- merged view holds exactly **one** browser per-origin connection no matter how many containers are selected (the old
919
- implementation opened one `EventSource` per container, which together with the Activity strip's event stream could
920
- fill the 4-live-stream budget measured in D165). The single-source `id=` shape is **byte-for-byte unchanged**: no `id`
921
- in frames, no `source-end`/`source-error` — old clients keep parsing it exactly as before. `/config`'s
736
+ **Multiple containers over one connection (`ids=`, D168)**: merged logs pack N containers into **one** SSE — the
737
+ host starts one `docker logs -f` per container and tags frames with `id`. That way the merged view holds exactly
738
+ **one** browser per-origin connection no matter how many containers are selected (the old implementation opened
739
+ one `EventSource` per container, which together with the Activity strip's event stream could fill the 4-live-stream
740
+ budget measured in D165). The single-source `id=` shape is **byte-for-byte unchanged**. `/config`'s
922
741
  `streamProtocol` is therefore bumped to **3**, and the client uses it to decide between the single connection and
923
- falling back to "one stream per container".
924
-
925
- The four streams differ only in "executor + end reason":
742
+ falling back to "one stream per container". The four streams differ only in "executor + end reason":
926
743
 
927
744
  | Stream | Executor | Natural end condition | Close semantics |
928
745
  | --- | --- | --- | --- |
@@ -931,39 +748,29 @@ The four streams differ only in "executor + end reason":
931
748
  | `/events/stream` | `docker events` (`--filter type=container`) | the daemon-side stream ends (`events-exit`) | switching pages / switching targets / closing the panel |
932
749
  | `/images/pull/stream` | `docker pull` | the pull completes / fails (`pull-exit`) | the frontend leaves the pull view |
933
750
 
934
- - Response headers: `content-type: text/event-stream; charset=utf-8`, `cache-control:
935
- no-cache`, `connection: keep-alive`, with `flushHeaders()` immediately after writing them (the host's gzip
936
- explicitly skips `text/event-stream`, so nothing is buffered).
937
- - Heartbeat: one `: ping` comment frame every 15s (ignored by clients per the SSE spec).
938
- - **Chunk coalescing (D151)**: the log and pull streams merge upstream chunks over a 50ms window before pushing
939
- them (strict ordering within a channel, stdout before stderr, an immediate flush once 256KB has accumulated), so a
940
- `line` frame's `d` / `e` may carry **several lines**. The client already splits on arbitrary chunks, so the
941
- semantics are unchanged — what disappears is "one SSE frame per stdout chunk" (with a chatty container that is
942
- thousands of frames per second, and every frame's fixed cost lands on the browser's main thread). The `end` frame
943
- always comes after the last batch of `line` frames in the window.
751
+ - Response headers: `content-type: text/event-stream; charset=utf-8`, `cache-control: no-cache`,
752
+ `connection: keep-alive`, with `flushHeaders()` immediately after writing them (the host's gzip explicitly
753
+ skips `text/event-stream`).
754
+ - Heartbeat: one `: ping` comment frame every 15s.
944
755
  - **Backpressure and overflow (D153)**: when the client cannot keep up, frames go into an in-memory queue
945
- (≤ 8MB per stream). The log and pull streams (text tails) drop the oldest whole frames and keep the newest on
946
- overflow, pushing a `skip{frames,bytes}` notice (`skip` itself is never dropped) — the stream stays alive. When
947
- more frames are dropped while a queued `skip` has not been written yet, its counters are updated **in place**
948
- (D156 — reporting only the first gap would undercount). The
949
- stats and events streams send `end{reason:output-limit}` and reconnect instead. Also, `write()` returning false
950
- only means "past the high-water mark": a frame is **never written twice** (that duplication was a real bug).
951
- - Teardown: the client disconnects → the executor is aborted immediately (locally `SIGTERM`, then `SIGKILL` if it has
952
- not exited in 2s; over SSH that exec channel is closed and the pooled connection is kept for reuse), writing no
953
- frame at all; the plugin is disabled / the config is hot-updated / it is uninstalled → the server wraps up on its
954
- own initiative (abort + `end`).
955
- - A long SSH stream occupies a connection in the pool (a busy count) and idle reclamation (120s) does not kill it by
956
- mistake; reclamation resumes once the stream ends.
957
- - **What counts as "ready" (client side)**: the green dot / "following live" only trusts the host's `ready` frame —
958
- arriving response headers do not count. Each stream also has a harder piece of evidence as a fallback (receiving it
959
- means the pipeline is producing): the first real event for the events stream, the first line for the log stream,
960
- the first sample for the stats stream, the first progress frame for the pull stream.
961
- **Development skew** (the host process still runs the old code while the browser reads a new `client.js` from disk)
962
- is handled by version negotiation: `streamProtocol < 2` in `/config` (or a host old enough to lack the field)
963
- means that host never sends `ready`, so the client falls back to the old criterion (headers arrived = connected)
964
- and reopens that stream exactly once so the fallback takes effect — measured on 2026-10-11: an old host plus a new
965
- client used to be pinned on "Connecting to the event stream…" forever (cards could read containers while the strip
966
- kept spinning).
756
+ (≤ 8MB per stream). The log and pull streams drop the oldest whole frames and keep the newest on overflow,
757
+ pushing a `skip{frames,bytes}` notice (`skip` itself is never dropped) — the stream stays alive; when more
758
+ frames are dropped while a queued `skip` has not been written yet, its counters are updated **in place**
759
+ (D156). The stats and events streams send `end{reason:output-limit}` and reconnect instead. A `write()`
760
+ returning false only means "past the high-water mark": a frame is **never written twice**.
761
+ - **Chunk coalescing (D151)**: the log and pull streams merge upstream chunks over a 50ms window before pushing
762
+ them (strict ordering within a channel, stdout before stderr, an immediate flush once 256KB has accumulated), so
763
+ a `line` frame's `d` / `e` may carry **several lines**. The `end` frame always comes after the last batch.
764
+ - Teardown: the client disconnects → the executor is aborted immediately (locally `SIGTERM`, then `SIGKILL` if it
765
+ has not exited in 2s; over SSH that exec channel is closed and the pooled connection is kept for reuse), writing
766
+ no frame at all; the plugin is disabled / hot-updated / uninstalled → the server wraps up (abort + `end`).
767
+ - A long SSH stream occupies a connection in the pool (a busy count) and idle reclamation (120s) does not kill it
768
+ by mistake.
769
+ - **What counts as "ready" (client side)**: the green dot / "following live" only trusts the host's `ready`
770
+ frame. Each stream also has a harder piece of evidence as a fallback: the first real event for the events
771
+ stream, the first line for the log stream, the first sample for the stats stream, the first progress frame for
772
+ the pull stream. **Development skew** is handled by version negotiation: `streamProtocol < 2` means that host
773
+ never sends `ready`, so the client falls back to the old criterion and reopens that stream exactly once.
967
774
 
968
775
  ## Security model
969
776
 
@@ -971,57 +778,47 @@ The four streams differ only in "executor + end reason":
971
778
  containers in privileged mode and read the secrets inside containers — which is why this plugin is designed
972
779
  read-only first:
973
780
 
974
- 1. **Read-only by default**. With `allowMutations` off, `/action`, `/images/remove`,
975
- `/images/prune`, `/images/pull/stream` all return 403, the panel's start / stop / remove / image
976
- removal / pruning / pulling are unavailable, and the `docker_action`, `docker_image_remove`,
977
- `docker_image_prune`, `docker_image_pull` tools are **not registered at all**; with `allowExec`
978
- off, `/exec` returns 403 and the `docker_exec` tool is likewise not registered. The two switches are
979
- independent and must be turned on explicitly by the user in the settings card. Read-type routes (`/logs/stream`,
980
- `/stats/stream`, `/events/stream`, `/images/inspect`) are unaffected by either switch.
981
- **`allowExec` only governs this plugin's *one-shot* exec channel** (the `/exec` route, the `docker_exec`
982
- tool, the one-off command box on the container detail drawer's Overview tab) — the card's **Terminal button runs an interactive
983
- `docker exec -it` shell hosted by tty, which passes through neither switch** and is available even in
984
- read-only mode (see the boundary note under "Known limitations": it is not a missing gate, it is a
985
- deliberately kept entry path).
781
+ 1. **Read-only by default**. With `allowMutations` off, `/action`, `/images/remove`, `/images/prune`,
782
+ `/images/pull/stream` all return 403, the panel's start / stop / remove / image removal / pruning / pulling are
783
+ unavailable, and the `docker_action`, `docker_image_remove`, `docker_image_prune`, `docker_image_pull` tools
784
+ are **not registered at all**; with `allowExec` off, `/exec` returns 403 and the `docker_exec` tool is likewise
785
+ not registered. The two switches are independent and must be turned on explicitly in the settings card.
786
+ Read-type routes (`/logs/stream`, `/stats/stream`, `/events/stream`, `/images/inspect`) are unaffected by
787
+ either switch. **`allowExec` only governs this plugin's *one-shot* exec channel** (the `/exec` route, the
788
+ `docker_exec` tool, the one-off command box on the container detail drawer's Overview tab) — the card's
789
+ **Terminal button runs an interactive `docker exec -it` shell hosted by tty; it passes through neither switch**
790
+ and is available even in read-only mode (it is not a missing gate but a deliberately kept entry path; see
791
+ "Known limitations").
986
792
  2. **Destructive operations restate their consequences**. `remove` maps to `docker rm` (**without `-f`**), and the
987
793
  agent announcement requires confirming the target container with the user before running; a running container
988
- errors with a hint that "the container is still running: stop it before removing", never a silent force-delete.
794
+ errors with a hint that it must be stopped first, never a silent force-delete.
989
795
  3. **Credentials do not land in plaintext (recommended)**. `password` / `passphrase` support `env:NAME` credential
990
- references; the value lives in the **official credential store** (`$DSH_HOME/.credentials.yaml`, owned by the
991
- credential layer's provider), so no plaintext reaches `settings.yaml` — and no env-plugin middleman is required.
992
- `agent` auth (`SSH_AUTH_SOCK`) is not written to disk at all. The config snapshot only answers "is it set".
796
+ references; the value lives in the **official credential store** (`$DSH_HOME/.credentials.yaml`), so no
797
+ plaintext reaches `settings.yaml`. `agent` auth (`SSH_AUTH_SOCK`) is not written to disk at all.
993
798
  4. **Host fingerprint TOFU pinning**. The first connection records the sha256 fingerprint; every later one must
994
- match, and a change rejects the connection (MITM protection); a host tty has already confirmed is trusted directly
995
- as a seed and copied into this plugin's records. TOFU's inherent limits are that "if the first connection already
996
- met an MITM, what got recorded is a fake fingerprint", and that only one record is kept per host:port (multiple
997
- key types on the same host may report a change incorrectly; deleting the record and reconnecting re-calibrates
998
- it).
799
+ match, and a change rejects the connection. TOFU's inherent limits are that "if the first connection already
800
+ met an MITM, what got recorded is a fake fingerprint", and that only one record is kept per host:port.
999
801
  5. **Commands are always built as argv, never by string concatenation**. Container names / IDs first pass the
1000
- `assertRef` allowlist (`[A-Za-z0-9][A-Za-z0-9_.-]*`, ≤128 characters, rejecting spaces, `;`,
1001
- `$()`, backticks and so on), and image and container references likewise; remotely each argument is single-quote
1002
- escaped by `shJoin` before being handed to the remote shell, while locally `spawn(bin, args)` goes through no
1003
- shell.
802
+ `assertRef` allowlist (`[A-Za-z0-9][A-Za-z0-9_.-]*`, ≤128 characters, rejecting spaces, `;`, `$()`, backticks
803
+ and so on); remotely each argument is single-quote escaped by `shJoin`, while locally `spawn(bin, args)` goes
804
+ through no shell.
1004
805
  6. **Output is capped**. `maxOutputKb` limits the stdout/stderr bytes of a single command; anything beyond is
1005
806
  truncated and marked, so large logs cannot blow up memory or the agent's context.
1006
- 7. **HTTP is exposed to this machine only**. All routes go through the loopback fence, so a remote browser cannot
1007
- call them.
807
+ 7. **HTTP is exposed to this machine only**. All routes go through the loopback fence.
1008
808
  8. **The live log stream is a read-only capability**. `GET /logs/stream` matches `/logs`: it is not gated by
1009
- `allowMutations` / `allowExec` (it does not change container state), but it likewise admits loopback only, passes
1010
- `id` through the `assertRef` allowlist and clamps parameters the same way. Unlike a snapshot, a long stream has no
1011
- `maxOutputKb` cap (following would lose its point if truncated), and memory protection is shouldered by the
1012
- client's 5000-line ring buffer and 2000-line colouring cap.
1013
- 9. **Mutating operations get a per-use audit trail**. The five audit lines from kit elevation answer "what was
1014
- authorized"; this answers "what it was used for": every mutation / exec that **passes the capability gate** lands
1015
- one structured `[dsh-docker] capability-use: …` audit line (outlet: the host's stdout), covering both the panel
1016
- routes and the agent tools. The line format, what is *not* logged (403 / tier-gate asks), and the cost that exec
1017
- commands appear alongside the lines are described in the usage-audit entry under "Known limitations".
809
+ `allowMutations` / `allowExec` (it does not change container state), but it admits loopback only, passes `id`
810
+ through the `assertRef` allowlist and clamps parameters the same way. A long stream has no `maxOutputKb` cap;
811
+ memory protection is shouldered by the client's 5000-line ring buffer.
812
+ 9. **Mutating operations get a per-use audit trail**. Kit elevation's lines answer "what was authorized"; this
813
+ answers "what it was used for": every mutation / exec that **passes the capability gate** lands one structured
814
+ `[dsh-docker] capability-use: …` line (outlet: the host's stdout), covering both the panel routes and the agent
815
+ tools. The line format, what is *not* logged (403 / tier-gate asks) and the cost are under "Known limitations".
1018
816
 
1019
817
  ## Known limitations
1020
818
 
1021
- - **No interactive TTY**: `exec` is a one-shot command (`docker exec <id> sh -c <cmd>`,
1022
- without `-i` / `-t`), so it cannot run vim / top / an interactive shell, nor feed stdin for a
1023
- dialogue. For interactive troubleshooting run `docker exec -it <container> sh` in the tty panel (this works for
1024
- both local and SSH targets).
819
+ - **No interactive TTY**: `exec` is a one-shot command (`docker exec <id> sh -c <cmd>`, without `-i` / `-t`), so it
820
+ cannot run vim / top / an interactive shell, nor feed stdin. For interactive troubleshooting run
821
+ `docker exec -it <container> sh` in the tty panel.
1025
822
  - **`allowExec` is not the gate that keeps you out of the container**: the card's Terminal button
1026
823
  (`docker exec -it '<container>' sh`) is hosted through tty's `ttyTerminal` and **does not check
1027
824
  `allowExec`** — it stays available in read-only mode, exactly as the panel's own hint says when exec is
@@ -1034,239 +831,135 @@ read-only first:
1034
831
  / XSS) anyway — via `ttyTerminal.open` or a WS `spawn` frame they can run arbitrary commands, which is
1035
832
  strictly stronger than this plugin's one-shot exec. See
1036
833
  [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) for that layer's threat-model boundary.
1037
- - **The event stream has a window while disconnected**: `docker events` is a stream of "from now on", so events that
1038
- happen while the browser is disconnected and reconnecting have already been pushed by the server and are not
1039
- resent. The client compensates with "do a full list refresh right after a successful reconnect"
1040
- (aligning state, not replaying the events); the few entries missing from the Activity strip can only be inferred
1041
- from the final state after that refresh. For an exact, complete event history use the `docker_events` tool (a
1042
- snapshot with `--since`).
1043
- - **The four boundaries of streaming**: the log page's `FOLLOW` (SSE + `docker logs -f`), the stats page's
1044
- `FOLLOW` (SSE + `docker stats` + a 60-point sparkline), the container list's event stream
1045
- (SSE + `docker events`, driving the Activity strip and the debounced list refresh), and the images page's pull
1046
- progress stream (SSE + `docker pull`); but the agent tools `docker_logs` / `docker_stats` /
1047
- `docker_image_pull` all keep **snapshot semantics** (a single-value return model does not suit an unbounded
1048
- stream). Streaming logs keep only the last 5000 lines on the browser side (dropping the oldest, with a hint), and
1049
- stats keep only 60 samples.
1050
- A long SSH stream holds that connection in the pool (busy) while other commands on the same host still reuse the
1051
- same connection without affecting each other. **The stats stream does not end naturally**, so closing it must be
1052
- the frontend actively aborting the `EventSource`.
1053
- - **The browser's per-origin budget is 4 live streams, and the Docker panel now holds at most 1 (D165 measurement + D168)**:
1054
- the host speaks **HTTP/1.1** (no h2 multiplexing), a browser allows only so many TCP connections **per origin**, and
1055
- **every `EventSource` owns one connection and never releases it voluntarily**. Once that budget is
1056
- exhausted, **every** ordinary request from the same page (list refreshes, `/api/*`, session loading…) queues behind it —
1057
- users see the **whole DSH page freeze**, not just the Docker panel (measured: 0/4 live streams → in-page requests fine;
1058
- **5 streams → 10/10 timed out**; full data and the incident are in [DEFECTS.md](./DEFECTS.md) under D165).
1059
- The Docker panel's resident streams are now **1 in every view**: container list = the Activity strip's event stream;
1060
- details = log FOLLOW or stats FOLLOW (only one tab is live at a time); merged / Compose project logs = **one** `ids=`
1061
- multi-container stream; pull = one progress stream. **Entering a detail / merged view closes the event stream**
1062
- (the list is replaced, the Activity strip is not visible), and returning reconnects it and does a full refresh (D168).
1063
- **This is a hard constraint, not a performance tip**: going over does not mean "a bit slower", it means
1064
- every request on that page fails together. To reproduce: open
1065
- `new EventSource('/api/dsh-docker/events/stream?target=<target>')` in devtools until the 5th one, then fire an ordinary request
1066
- and watch it hang; closing them restores it. (WebSockets do **not** draw on this budget — the tty terminal's WS coexisted fine with 4 SSE streams.)
1067
- - **Multiple tabs have to be counted across *all* plugins, not just Docker**: those TCP slots are shared by the whole
1068
- page — the official `dsh-client-hmr`, other plugins' SSE streams, everything. If the host itself keeps 2 resident SSE
1069
- streams per tab, **3 tabs = 6 connections**, which is already the cliff; this layer is not something this plugin can
1070
- fix (the real fix is WebSocket / h2 on the host side, or one multiplexed connection shared by all plugins). Docker
1071
- has already reduced its own share to 1 (see above); count the rest from what is actually installed.
1072
- - **The merge cap is a readability + host-channel bound, no longer a browser-connection bound (D168)**: merged logs use
1073
- one connection for every selected container, so `PICK_MAX = 3` is no longer derived from "4-slot budget − 1 event
1074
- stream"; it stays 3 because more than three merged logs are hard to read and because each selected container still
1075
- runs its own `docker logs -f` on the host (one channel on SSH targets, see the 8-channel budget below). The selection
1076
- bar says the same thing.
834
+ - **The event stream has a window while disconnected**: `docker events` is a stream of "from now on", so events
835
+ that happen while the browser is reconnecting have already been pushed and are not resent. The client
836
+ compensates with "a full list refresh right after a successful reconnect" (aligning state, not replaying the
837
+ events); for an exact, complete event history use the `docker_events` tool (a snapshot with `--since`).
838
+ - **The browser's per-origin budget is a hard constraint, and the Docker panel holds at most 1 resident stream
839
+ (D165 measurement + D168)**: the host speaks **HTTP/1.1** (no h2 multiplexing), and **every `EventSource` owns
840
+ one connection and never releases it voluntarily**. Once that budget is exhausted, **every** ordinary request
841
+ from the same page (list refreshes, `/api/*`, session loading…) queues behind it — users see the **whole DSH
842
+ page freeze**, not just the Docker panel. The panel's resident streams are **1 in every view**: list = the
843
+ event stream; details = log / stats FOLLOW (one tab live at a time); merged / Compose logs = **one** `ids=`
844
+ stream; pull = one progress stream (entering details / merged views closes the event stream, returning
845
+ reconnects it plus one full refresh). Going over does not mean "a bit slower", it means every request on that
846
+ page fails together; full data is in [DEFECTS.md](./DEFECTS.md) under D165. (WebSockets do **not** draw on
847
+ this budget; the retention the streaming log / stats keep is in "Usage".)
848
+ - **Multiple tabs and the merge cap**: those connections are shared by the whole page (the official
849
+ `dsh-client-hmr` and other plugins' SSE included); the real fix is WebSocket / h2 on the host side, or one
850
+ multiplexed connection shared by all plugins. The merge cap of 3 is now a "readability + SSH 8-channel" bound
851
+ rather than a browser-budget one (D168): more than three merged logs are hard to read, and each selected
852
+ container still runs its own `docker logs -f` on the host, i.e. one channel on SSH targets. `PICK_MAX = 3` is
853
+ that value.
1077
854
  - **The SSH channel budget is shared per target (`MaxSessions`)**: a target keeps only **one** TCP connection, and
1078
- every long stream and short command shares that connection's channels — while OpenSSH's `MaxSessions` defaults to
1079
- just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel; merged logs still run
1080
- **one channel per selected container** (up to 3), and the next
1081
- "refresh list" (a short command) can then be rejected by the
1082
- far end with `(SSH) Channel open failure: open failed`. The plugin therefore caps each SSH target at **8 concurrent
1083
- long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect), and the merge selection at 3.
1084
- Going over the cap, and the far end refusing a channel, both produce pointed messages rather than ssh2's raw text. If your sshd
1085
- tunes `MaxSessions` (`sshd -T | grep maxsessions`), the current cap is a compile-time constant — file an issue if it
1086
- needs to follow.
1087
- Two more remedies attach to this (`D150`): short commands **queue per target** (at most 2 concurrent per connection,
1088
- `DSH_DOCKER_SHORT_CHANNELS` overrides, for testing / troubleshooting only), and a full budget **self-heals** by
1089
- dropping and `end()`ing the connection and retrying once on a fresh one. And (`D164`): aborting a long stream now
1090
- **settles it on the spot** instead of waiting for the far end — previously, if the far end never confirmed (the
1091
- `KILL` above being refused, or a silent channel), that stream's `busy` count never came down; after 8 such leaks
1092
- **every** long stream on that target was refused with no self-healing path at all (observed: 248 pinned at 8/8 with
1093
- the Activity strip's Reconnect appearing to do nothing). `run()` had already hit this same trap twice (`D112` /
1094
- `D161`); all three teardown paths now settle themselves.
1095
- - **Docker CLI version differences**: parsing goes through `--format '{{json .}}'`, and fields come and go between
1096
- versions; the parser always degrades instead of throwing (for example, a missing `State` has the state derived from
1097
- `Status`, and health is extracted from `(healthy)` / `(unhealthy)`); with fields missing the corresponding columns
1098
- may be empty, so use the details (`docker inspect`) when you need authoritative data.
1099
- - **`rm` without `-f`**: container `remove` maps to `docker rm` and image `remove` to
1100
- `docker image rm`, neither with `-f` — a running container, or an image referenced by a container or a child
1101
- image, fails with a hint; to force deletion, run it by hand in the tty panel.
1102
- - **SSH targets need sudo-free docker**: if the account is not in the docker group, docker reports a permission
1103
- error which the panel and the tools pass through verbatim, without attempting automatic sudo escalation.
1104
- - **Docker not installed on the remote**: `probe` fails (`command not found` / exit code 127) and the panel shows
1105
- the error; when PATH differs, fill `dockerBin` with an absolute path.
855
+ every long stream and short command shares that connection's channels — while OpenSSH's `MaxSessions` defaults
856
+ to just 10. Long streams hold a channel until the user closes the panel, and merged logs still run **one channel
857
+ per selected container**; the next "refresh list" can then be rejected with
858
+ `(SSH) Channel open failure: open failed`. The plugin caps each SSH target at **8 concurrent long streams**
859
+ (the `MAX_STREAMS_PER_TARGET` constant, i.e. 10 − 2, leaving two for short commands) and the merge selection at 3; folding the right-sidebar tab drops
860
+ the streams and hands the channels back. Two remedies attach to this (`D150`): ① short commands **queue per
861
+ target** (at most 2 concurrent per connection, `DSH_DOCKER_SHORT_CHANNELS` overrides, testing only); ② a full
862
+ budget **self-heals** — on a `no more sessions` style refusal the plugin drops and `end()`s the connection and
863
+ retries once on a fresh one, leaving the `通道额度已满:已重建连接重试` line in the host log (that connection
864
+ **does not recover by itself**). (`D164`) ③ **aborting a long stream settles it on the spot** — on an
865
+ `AbortSignal` the plugin sends `KILL` and closes the channel, then **ends the stream itself** instead of waiting
866
+ for the far end (previously a far end that never confirmed left that stream's `busy` count **permanently raised**,
867
+ and after 8 such leaks every long stream on that target was refused with no self-healing path). **The real fix
868
+ is on the sshd side**: raise that host's `MaxSessions` — for example
869
+ `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` with a `MaxSessions 50` line, then `sshd -t && systemctl
870
+ reload ssh` (reload only affects new connections). The current cap is a compile-time constant — file an issue if
871
+ it needs to follow.
872
+ - **Docker CLI version differences**: parsing goes through `--format '{{json .}}'`, and the parser always degrades
873
+ instead of throwing (for example, a missing `State` has the state derived from `Status`); with fields missing
874
+ the corresponding columns may be empty, so use `docker inspect` when you need authoritative data.
875
+ - **`rm` without `-f`**: container `remove` maps to `docker rm` and image `remove` to `docker image rm`, neither
876
+ with `-f` — a running container or a referenced image fails with a hint; to force deletion, do it by hand in
877
+ the tty panel.
878
+ - **SSH targets need sudo-free docker**: if the account is not in the docker group the permission error is passed
879
+ through verbatim, with no automatic sudo escalation. **Docker not installed on the remote** makes `probe` fail
880
+ (exit code 127); when PATH differs, fill `dockerBin` with an absolute path.
1106
881
  - **`dockerBin` is validated before it is persisted**: an illegal value returns 400 with a reason and is never
1107
- written to `settings.yaml` (the earlier implementation validated after `scope.update`, so the illegal value was
1108
- stored anyway and the user only received a bodyless 400; the next start silently reverted the entire docker
1109
- section to defaults).
1110
- - **A `dockerBin` pointing at a `.cmd` / `.bat` cannot start the local channel**: local execution goes through the
1111
- bare `spawn` in `runLocal` / `runLocalStream`, and Node refuses to execute `.cmd` directly on Windows
1112
- (`EINVAL`). Docker ships `docker.exe`, so this is not hit in practice; with a hand-written `.cmd` wrapper, use
1113
- an `.exe` instead, or wait for the local channel to move to kit's `spawnPortable` as well.
882
+ written to `settings.yaml`. **A `dockerBin` pointing at `.cmd` / `.bat` cannot start the local channel**: local
883
+ execution goes through a bare `spawn`, and Node refuses to execute `.cmd` directly on Windows (`EINVAL`) — with
884
+ a hand-written wrapper, use an `.exe` instead, or wait for the local channel to move to kit's `spawnPortable`.
1114
885
  - **The Compose project view only knows `com.docker.compose.project`**: a service deployed with
1115
886
  `docker stack deploy` carries `com.docker.stack.namespace` / `com.docker.swarm.service.name` instead, so its
1116
- containers show up as ungrouped (measured on a real three-node swarm, Ubuntu 24.04 + Docker 29.3.1, where every
1117
- stack container reported `composeProject: null`). Supporting it needs a separate stack-grouping notion rather
1118
- than being folded into the compose one.
1119
- - **`volume prune` only reclaims anonymous unused volumes on Docker 29**: a named volume stays even while it
1120
- appears in `docker volume ls -f dangling=true`, and `docker volume prune -f` reports `Total reclaimed space: 0B`
1121
- (measured; an anonymous volume, by contrast, is deleted and named in the output). That matches this plugin's
1122
- deliberate refusal to pass `--all`; use `/volumes/remove` (the panel's volume delete) for named volumes.
887
+ containers show up as ungrouped. Supporting it needs a separate stack-grouping notion.
888
+ - **`volume prune` reclaims different things per version, and can delete data**: on Docker 29 `docker volume
889
+ prune -f` **only reclaims anonymous unused volumes** (named ones stay); docker ≥ 23 has `-a/--all`, while
890
+ docker < 23 has no such switch and a plain prune deletes unused **named** volumes too. This plugin never passes
891
+ `--all`, the confirmation copy spells out the version difference, and named volumes go through
892
+ `/volumes/remove`.
1123
893
  - **Podman compatibility through `dockerBin`**: filling in `podman` runs, but the fields and output formats of
1124
894
  `stats` and `--format '{{json .}}'` differ from docker's, so only the parser's degradation paths are relied on;
1125
895
  this has not been verified item by item.
1126
- - **Diagnosis is a set of criteria, not an expert**: the fifteen criteria cover the objective evidence a container
1127
- reports about itself (exit code / OOM / restart count / health check / resource pressure / error lines in the
1128
- log) and **know nothing about the business**. The verdict says what the evidence points at; whether that error
1129
- can be ignored is a human (or model) decision on the other side of "hand it to the agent". The cost is stated
1130
- too: one diagnosis runs **four** read-only docker commands (inspect + logs + events + stats), which on an SSH
1131
- target means up to four channels (the short-command gate caps concurrency at 2, so two batches) — noticeable
1132
- on a slow host.
1133
- - **Event collection gets its own budget (8MB, keeping the tail)**: in one hour of a single container's events the
1134
- `exec_create` / `exec_start` / `exec_die` triple dominates (dropped by the parser's allowlist; a real host
1135
- measured 256 events / 588KB in 1h, almost all of them that triple), so the generic 512KB budget would leave the
896
+ - **No image builds / Compose orchestration changes**: images support pulling / removal / dangling pruning, but
897
+ there is no `docker build`, `docker save` / `load` or `docker push`; Compose is a **read-only** project view.
898
+ **Network / volume mutations exist only as panel buttons, with no agent tools** — deliberately without widening
899
+ the mutation surface on the agent side.
900
+ - **Diagnosis is a set of criteria, not an expert**: these criteria cover the objective evidence a container
901
+ reports about itself and **know nothing about the business**. Whether an error can be ignored is a human (or
902
+ model) decision on the other side of "hand it to the agent". The cost: one diagnosis runs **four** read-only
903
+ commands, which on an SSH target means up to four channels (the short-command gate caps concurrency at 2, so
904
+ two batches).
905
+ - **Event collection gets its own budget (8MB, keeping the tail)**: the `exec_create` / `exec_start` /
906
+ `exec_die` triple dominates one hour of a single container's events, so the generic 512KB budget would leave the
1136
907
  timeline **permanently empty**. Diagnosis therefore widens event collection to 8MB and keeps the tail — on a
1137
908
  container with very many events the timeline covers only the **most recent** span.
1138
909
  - **The equivalent `docker run` is an approximation, not a backup**: a Compose-managed container loses network
1139
910
  aliases / labels / depends_on; `--privileged` / `--link` / an overridden ENTRYPOINT / custom health checks /
1140
- anonymous volumes are **deliberately not restored** and listed one by one in `omitted`. **It cannot replace
1141
- `docker commit` / volume backups** — it is "a command you can read, edit and reproduce".
911
+ anonymous volumes are **deliberately not restored** and listed in `omitted`. **It cannot replace
912
+ `docker commit` / volume backups**.
1142
913
  - **The disk view has two independent truncations**: 100 rows per class on the host side (`truncated`) and 20 rows
1143
914
  on the tool side; on a host with thousands of volumes the `system df -v` output itself can hit the 8MB budget
1144
- (then `degraded: true` and the summary stays usable). `system df`'s human-readable sizes are docker's own (SI
1145
- units) and the "total reclaimable" parses them into bytes and sums — **if a row cannot be parsed the whole line
1146
- shows `—`** rather than an approximation.
1147
- - **No image builds / Compose orchestration changes**: images support pulling / removal / dangling pruning, but there
1148
- is no `docker build`, `docker save` / `load` or `docker push`; Compose is a **read-only**
1149
- project view (grouping by project + project-level merged logs) and provides no `compose up` / `down` / `restart`.
1150
- - **`volume prune` deletes data and its behaviour varies by version**: `docker volume prune` on docker ≥ 23
1151
- has `-a/--all`, and **without it only anonymous volumes go** (which is how this plugin calls it); but docker < 23
1152
- has no such switch, and a plain prune deletes **named** volumes that are not in use as well. The confirmation copy
1153
- for volume pruning therefore spells out the version difference; before running it, please confirm that there is no
1154
- data volume you want to keep.
1155
- - **Network / volume mutations exist only as panel buttons, with no agent tools**: image remove / prune have
1156
- corresponding tools, but for this round networks / volumes only gained HTTP endpoints (`/networks/remove` and the
1157
- like) and panel buttons — deliberately without widening the mutation surface on the agent side. Letting the agent
1158
- delete them too would require adding separate tools (with their own confirmation conventions).
1159
- - **The Overview only summarises "the container picture" and is entirely read-only**: the `Overview` next to the
1160
- target picker only does counter cards and the attention table pinned to the top, with **no cross-target operations
1161
- whatsoever** (start / stop / remove are still done one by one in a single target's list), and it does not merge
1162
- logs / stats / event streams either (those remain the business of single-target, single-container pages). In
1163
- addition, "attention" covers only `unhealthy` and
1164
- `restarting`: `ContainerSummary` has no `exitCode`, so a "crashed exit" cannot be told from a "manually
1165
- stopped" one, and counting every `exited` as an exception would let a container that was stopped once flood the
1166
- screen forever — look at dead containers under "stopped" in the counter card instead.
1167
- - **The connection-bar button needs a matching target to have data**: the button always shows on an SSH tab, but if
1168
- the session host has no corresponding `kind=ssh` target (neither the connection-book name nor `host:port` matches),
1169
- clicking it only shows a hint that it is "not configured as a Docker target" instead of a container list; the
1170
- connection bar on local tabs is hidden entirely.
1171
- Target additions and removals are picked up within at most 30 seconds (saving the settings card refreshes
1172
- immediately).
1173
- - **`enabled: false` takes effect on save**: after saving, tools are unregistered immediately, the announcement is
1174
- withdrawn, and data routes other than `/config` return 403 (long streams in progress — logs / stats / pulling —
1175
- are wrapped up immediately too). The route objects themselves are not unloaded; they are blocked by the 403, and
1176
- `/config` stays readable and writable — the settings card is the way back in, with no restart of `dsh web` needed.
915
+ (then `degraded: true` and the summary stays usable). The "total reclaimable" parses human-readable sizes into
916
+ bytes and sums — **if a row cannot be parsed the whole line shows `—`**.
917
+ - **The Overview only summarises "the container picture" and is entirely read-only**: counter cards and the
918
+ attention table only, with **no cross-target operations** and no log / stats / event merging. In addition,
919
+ "attention" covers only `unhealthy` and `restarting`: `ContainerSummary` has no `exitCode`, so a "crashed exit"
920
+ cannot be told from a "manually stopped" one — look at dead containers under "stopped" in the counter card.
921
+ - **The connection-bar button needs a matching target to have data**: the button always shows on an SSH tab, but
922
+ with no corresponding `kind=ssh` target (neither the book name nor `host:port` matches) clicking it only shows a
923
+ "not configured as a Docker target" hint; the connection bar on local tabs is hidden. Target additions and
924
+ removals are picked up within 30 seconds (saving the settings card refreshes immediately).
925
+ - **`enabled: false` takes effect on save**: tools are unregistered immediately, the announcement is withdrawn, and
926
+ data routes other than `/config` return 403 (long streams in progress are wrapped up too). The route objects are
927
+ not unloaded; `/config` stays readable and writable, and the settings card is the way back in with no restart.
1177
928
  - **The usage audit goes to the host's stdout, and exec commands appear in it**: every mutation / exec that
1178
- **passes the capability gate** (the panel's mutation / exec routes and the agent tools `docker_action` /
1179
- `docker_image_remove` / `docker_image_prune` / `docker_image_pull` / `docker_exec`; 403 rejections and parameter
1180
- validation 400s are not logged — "a use = something that passed the gate") lands one
1181
- `[dsh-docker] capability-use: capability=allowMutations|allowExec source=http|tool action=container.remove|…
1182
- target=… ref=… ok=true|false durationMs=…` line in the same key=value style as kit elevation's authorization
1183
- lines, with `capability` matching those lines directly; the pull progress stream logs a start / end pair (if the
1184
- host dies mid-stream, at least the start line remains). **The outlet is `console.log`** (docker D162: on a real
1185
- host, `ctx.logger` lines were measured to reach neither the host's stdout nor any file, and an audit line whose
1186
- whole point is "findable when something goes wrong" is worthless in an invisible channel; tier-gate's log outlet
1187
- makes the same call) — the terminal running `dsh web` shows them, grep works. Kit elevation's authorization lines share the same stdout outlet
1188
- (kit D14 — both line families on one channel, searchable side by side). **The cost**: the exec command is recorded in the
1189
- trailing `detail` field (together with the exit code, e.g. `detail=code=0 cmd=ls -la /app`), truncated to 200
1190
- characters overall with control characters escaped as `\n` literals — a line meant for humans to read has to
1191
- accept appearing in the terminal-visible log stream, the same trade-off in the positive direction as elevation's
1192
- "nonce never enters the log". A docker error also counts as a use (`ok=false` plus `detail=` with the truncated
1193
- error text); tier-gate asks / denies are not logged (nothing was executed, and the host's approval log already
1194
- covers them). There is no query UI and no separate audit file — the way to search it is the host's stdout.
1195
-
929
+ **passes the capability gate** (the panel routes and the agent tools `docker_action` / `docker_image_remove` /
930
+ `docker_image_prune` / `docker_image_pull` / `docker_exec`; 403s and validation 400s are not logged — "a use =
931
+ something that passed the gate") lands one `[dsh-docker] capability-use: capability=allowMutations|allowExec
932
+ source=http|tool action=… target=… ref=… ok=true|false durationMs=…` line in the same key=value style as kit
933
+ elevation's authorization lines; the pull stream logs a start / end pair. **The outlet is `console.log`**
934
+ (`D162`: `ctx.logger` lines were measured to reach neither stdout nor any file on 0.2.x hosts) — the terminal
935
+ running `dsh web` shows them and grep works. **The cost**: the exec command goes into the trailing `detail`
936
+ field (with the exit code), truncated to 200 characters with control characters escaped — a line meant for
937
+ humans has to accept appearing in the terminal-visible log stream. A docker error also counts as a use
938
+ (`ok=false`); tier-gate asks / denies are not logged. There is no query UI and no separate audit file.
1196
939
  - **The boundaries of cross-target aggregation**: concurrency cap 4; a **90s per-target budget (fallback)**, while
1197
- each command's own timeout (30s) is the one actually honoured — when a budget fires, the underlying child process /
1198
- SSH channel is **really cancelled** (not merely reported as a failed cell), otherwise the timed-out command keeps
1199
- holding a remote session slot (D161). A single target failing or timing out affects only its own cell (the group
1200
- carries `error`). With many targets the Overview's request volume grows linearly with the target count (2 requests
1201
- per target), and auto refresh multiplies that volume — with many targets it is best to turn auto refresh off.
940
+ each command's own timeout (30s) is the one actually honoured — when a budget fires, the underlying child
941
+ process / SSH channel is **really cancelled**, otherwise the timed-out command keeps holding a remote session
942
+ slot (D161). A single target failing or timing out affects only its own cell. With many targets the Overview's
943
+ request volume grows linearly and auto refresh multiplies it.
1202
944
 
1203
945
  ## How it works
1204
946
 
1205
947
  ```
1206
948
  Browser half (client.js)
1207
- ├─ sidebar "Containers" entry → panel: target picker / container list (search + state filter) /
1208
- │ container cards (action bar in two groups: view = terminal/logs/stats | mutate = start-stop/restart/remove) /
1209
- │ container list "Activity" strip (event-driven refresh) + "Select for merging" multi-select → temporary merged logs /
1210
- │ multi-target Overview (all targets fetched in parallel + progressive landing; counter cards / attention table pinned to the top; read-only) /
1211
- │ Compose project view (project grouping / service table / project-level merged logs) /
1212
- │ image list (inline details · remove) + image details (layers / build history) + pull progress /
1213
- │ network list + network details (subnet / connected containers) + volume list + volume details / one-shot exec
1214
- │ └─ terminal drawer: with tty ≥ 0.15, embeds tty's terminal in place via ttyTerminal.mount
1215
- │ (the panel does not collapse; collapsing/dragging only changes size and does not interrupt the session; only ✕ or closing the panel disposes,
1216
- │ and with an active session closing the panel confirms first → tty ends the session and tears down the DOM)
1217
- │ ├─ fetch → /api/dsh-docker/* (loopback fence)
1218
- │ ├─ FOLLOW → EventSource /logs/stream (SSE: 5000-line ring buffer / auto stick to bottom /
1219
- │ │ back to bottom / auto reconnect on disconnect / back to snapshot when the container exits)
1220
- │ ├─ stats FOLLOW → EventSource /stats/stream (SSE: a 60-point ring buffer draws
1221
- │ │ CPU / memory sparklines; **the frontend aborts it**, and the stream only ends when docker stats exits by itself)
1222
- │ ├─ events → EventSource /events/stream (SSE: the Activity strip's 50-entry ring buffer +
1223
- │ │ a 500ms debounce triggering a list refetch; one full refresh after a successful reconnect)
1224
- │ ├─ pull → EventSource /images/pull/stream (SSE: per-layer progress upserted by layer key)
1225
- │ └─ merged logs → **one** EventSource /logs/stream?ids=… (all containers share it; frames carry id,
1226
- │ mixed client-side by [service]; old hosts fall back to one per container)
1227
- ├─ optionally consumes tty's ttyConnbar service → SSH connection bar "Containers" button
1228
- │ (matches configured targets by book name / host:port; only appears on a hit)
1229
- ├─ optionally consumes tty's ttyPanel service (0.16.0) → docks to the right while the terminal panel is open
1230
- └─ optionally consumes tty's ttyTerminal service → the card's "Terminal" button opens a docker exec tab directly
1231
- (falls back to copying the command when tty is unavailable / credentials are not in the browser)
949
+ ├─ sidebar "Containers" entry → panel (target picker / container list / card actions / five-tab details /
950
+ │ cross-target Overview / Compose · images · networks · volumes · disk pages) + terminal drawer
951
+ │ (ttyTerminal.mount)
952
+ ├─ four SSE streams (/logs/stream incl. ids=, /stats/stream, /events/stream, /images/pull/stream)
953
+ │ + ordinary fetch → /api/dsh-docker/* (loopback fence; write routes also need the same-origin proof)
954
+ └─ optionally consumes tty's ttyConnbar / ttyPanel / ttyTerminal (each degrades without tty)
1232
955
 
1233
956
  Host half (src/index.ts)
1234
- ├─ settings namespace docker (~/.dsh/settings.yaml)
1235
- │ DOCKER_SETTINGS_SCHEMA → normalizeConfig (clamping / allowlisted keys)
957
+ ├─ settings namespace docker → normalizeConfig (clamping / allowlisted keys)
1236
958
  ├─ read-only reuse of tty settings' sshHosts (connection book) and hostKeys (fingerprint seed)
1237
- ├─ resolveTarget: local → runLocal; ssh → look up the connection book via book or use inline fields
1238
- ├─ one DockerApi per target (src/docker.ts)
1239
- │ ├─ argv construction + assertRef allowlist + output cap (maxOutputKb)
1240
- │ └─ tolerant parsing: {{json .}} line by line / as an array, case-insensitive field names, degradation on missing fields
1241
- ├─ RemoteExec (src/ssh-exec.ts)
1242
- │ ├─ lazy connection pool: one connection reused per user@host:port, reclaimed after 120s idle
1243
- │ │ (swept every 30s, connect timeout 20s, keepalive 10s; long streams with busy>0 skip reclamation)
1244
- │ ├─ non-PTY exec channel: one channel per command, closed as soon as stdout/stderr are drained;
1245
- │ │ long streams (run()/stream()) have no overall timeout or output cap and are stopped via AbortSignal
1246
- │ ├─ shJoin single-quote escaping (parsed by the remote shell); env:VAR secret lookup
1247
- │ └─ hostVerifier TOFU pinning (record on first use, reject on change)
1248
- ├─ runLocal / runLocalStream: spawn(dockerBin, args) (no shell, local targets)
1249
- │ stop ladder: SIGTERM → SIGKILL if it has not exited in 2s
1250
- ├─ generic SSE long connection openSseStream (src/index.ts, one piece of infrastructure for all four streams)
1251
- │ ├─ loopback fence + assertRef / assertImageRef + tail clamping (same as the snapshot routes)
1252
- │ ├─ header write + flushHeaders / 15s ping heartbeat / active-stream registry / silent abort when the frontend disconnects
1253
- │ │ once the pipeline is up, a ready{} frame first (StreamHandlers.onStart: the only thing the client counts as "receiving data")
1254
- │ ├─ /logs/stream: docker logs -f → line{"d"|"e"} + end{container-exit,code};
1255
- │ │ ids= mode: one connection, N executors → line{id,d|e} + source-end/source-error per container
1256
- │ ├─ /stats/stream (read-only): docker stats normalised line by line → stats{ContainerStats};
1257
- │ │ it does not end naturally, so a frontend disconnect aborts it and docker stats exiting on its own sends end{stats-exit}
1258
- │ ├─ /events/stream (read-only): docker events --filter type=container →
1259
- │ │ event{action,name,image,...} (allowlist filtered); the daemon side ending sends end{events-exit}
1260
- │ ├─ /images/pull/stream (allowMutations): docker pull → line{d|e} +
1261
- │ │ end{pull-exit,code,ref}; 403 and no stream when mutations are off
1262
- │ └─ plugin disabled / config hot-updated / uninstalled → all four streams get a uniform end + abort
1263
- ├─ image routes: /images/inspect (read-only) · /images/remove · /images/prune (allowMutations)
1264
- ├─ network / volume routes: /networks · /volumes and their inspect (read-only), remove / prune (allowMutations)
1265
- └─ agent tools: docker_targets / docker_ps / docker_inspect /
1266
- docker_logs (snapshot semantics unchanged) / docker_logs_grep (server-side search) / docker_stats / docker_events / docker_images /
1267
- docker_image_inspect / docker_networks / docker_volumes (registered with ≥1 target)
1268
- + docker_action / docker_image_remove / docker_image_prune / docker_image_pull
1269
- (allowMutations + ≥1 target) / docker_exec (allowExec + ≥1 target)
959
+ ├─ resolveTarget → one DockerApi per target (src/docker.ts) + RemoteExec (src/ssh-exec.ts):
960
+ │ argv construction + assertRef + output cap; lazy pool / non-PTY exec channel / shJoin / TOFU
961
+ ├─ openSseStream: one substrate for all four streams (ready / heartbeat / registry / backpressure / teardown)
962
+ └─ routes + agent tools (read tools always registered, write / exec tools by switch) — see the two tables above
1270
963
  ```
1271
964
 
1272
965
  ## Development and acceptance
@@ -1275,16 +968,20 @@ Host half (src/index.ts)
1275
968
  pnpm --filter @hyzyn/dsh-docker build # tsc → lib/ (host half) + esbuild → client.js (browser half)
1276
969
  pnpm --filter @hyzyn/dsh-docker typecheck
1277
970
  pnpm --filter @hyzyn/dsh-docker smoke # three offline regression suites, none needing a docker daemon
1278
- pnpm test # repo-level vitest (this package's suites under test/ are included; counts are whatever the run reports)
971
+ pnpm test # repo-level vitest (this package's suites under test/ are included)
1279
972
  ```
1280
973
 
1281
- The verification surface has three layers: **three offline smoke suites** (no docker daemon needed),
1282
- **this package's vitest suites**, and the **manual acceptance checklist against a real daemon**.
1283
- Per-item coverage, the browser-side performance budget and the manual steps live in
1284
- [docs/verification.md](./docs/verification.md) (Chinese) — README only keeps "how to run"; the lists
1285
- are not duplicated here (the same fact in two places is debt, see
1286
- [conventions § documentation layers](../../docs/conventions.md#文档分层)).
974
+ > **Which half changed, and how it takes effect**: `client-src/*` (browser half) → esbuild emits `client.js`, the
975
+ > host polls for client HMR, so it goes live **hot** on a page refresh; `src/*.ts` (host half) → tsc emits `lib/`,
976
+ > which **needs a new process** (a running `dsh web` loaded `lib/` into memory at startup), so rebuild and restart.
977
+ > Forgetting the restart shows up as **a new client and an old host**: a newly added host-side message simply never
978
+ > appears.
979
+
980
+ The verification surface has three layers: **three offline smoke suites** (no docker daemon needed), **this
981
+ package's vitest suites**, and the **manual acceptance checklist against a real daemon**. Per-item coverage, the
982
+ browser-side performance budget and the manual steps live in
983
+ [docs/verification.md](./docs/verification.md) — README only keeps "how to run".
1287
984
 
1288
985
  ## Version / license
1289
986
 
1290
- `@hyzyn/dsh-docker` 0.3.1 · [Apache License 2.0](../../LICENSE)
987
+ `@hyzyn/dsh-docker` (see `package.json` for the version) · [Apache License 2.0](../../LICENSE)