@hyzyn/dsh-docker 0.10.0 → 0.12.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
@@ -201,13 +201,23 @@ Inside the panel:
201
201
  may have fallen behind while the stream was down), including the case where the daemon never came up and the
202
202
  stream never opened at all — otherwise the strip turns green while the list still shows its error state, looking
203
203
  fixed when it is not.
204
+ **A refused reconnect must say why** (`D164`): when the server actively refuses this stream it sends an
205
+ `event: error` frame carrying a `message` (typically the long-stream quota being full — see "Known limitations";
206
+ that sentence says what to do about it). The client now **shows it on the Activity strip** ("Reconnect refused: …")
207
+ instead of discarding it. It used to just set `closed`, which made "refused → back to where it
208
+ was" indistinguishable from "the click did nothing", leaving the user to click over and over. The reason is
209
+ cleared on a successful reconnect (or when the stream is rebuilt).
210
+ The hint on the right of that header row is **small text inside a single line**, so it is deliberately short
211
+ (2026-10-08 user feedback: "verbose and stiff"): the state label on the left already says the stream is closed,
212
+ so this only says "will not recover on its own — click Reconnect". The cause, the cap and the `MaxSessions`
213
+ details live in this page, not in the UI.
204
214
  - **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
205
215
  mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
206
216
  longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
207
217
  start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" +
208
- `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; at 7–8 selected it gives a soft hint
209
- (browsers limit same-origin concurrent long connections), and above **8** the button is greyed out with a hint
210
- about the cap. Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
218
+ `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; selecting the 3rd gives a soft hint, and above
219
+ **3** the button is greyed out with a hint about the cap (**where that cap comes from is in "Known limitations" —
220
+ the browser's per-origin connection budget**; the selection bar states it too). Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
211
221
  project view (one `/logs/stream` per container, mixed by the `[service]` / container-name prefix, with filtering
212
222
  and auto-scroll), and going back exits selection mode and clears it. The merged view's **content controls are
213
223
  fully aligned with the single-container log view** (text filter + level threshold + `⬇ .log` / `⬇ .md` exporting
@@ -219,7 +229,7 @@ Inside the panel:
219
229
  refresh are pruned by id.
220
230
  - **One-click selection by condition** (in selection mode): the action bar's second row offers a row of condition
221
231
  chips (`all visible / unhealthy / needs attention / stopped`, plus `same image / same project` once something is
222
- selected), with counts **truncated to the remaining slots** — a chip reading 8 really does select 8; anything over
232
+ selected), with counts **truncated to the remaining slots** — a chip reading 3 really does select 3; anything over
223
233
  the cap is stated honestly in the title and the result hint. Conditions only apply within the
224
234
  **current filtered result** (search / filter the state first, then select in one click).
225
235
  - **Container cards**: "label + value" rows matching the reference layout (image / ID / ports / created /
@@ -228,7 +238,7 @@ Inside the panel:
228
238
 
229
239
  | Group | Buttons | Description |
230
240
  | --- | --- | --- |
231
- | View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode |
241
+ | View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode. **The Terminal button runs an interactive `docker exec -it` shell and is not gated by `allowExec`** (that switch only covers this plugin's one-shot exec channel — see "Known limitations") |
232
242
  | Mutate | start / stop, restart, remove | Ordered by increasing destructiveness; available only with `allowMutations` on, otherwise the whole group is greyed out |
233
243
 
234
244
  Remove gets two extra protections: destructive colouring plus a gap between it and "restart", and a second
@@ -536,7 +546,7 @@ return 400).
536
546
  | `announceToAgent` | true | whether to inject a capability announcement into the agent (systemPrompt section `plugin:dsh-docker`) |
537
547
  | `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 |
538
548
  | `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) |
539
- | `allowExec` | false | allows a one-shot `docker exec` (the panel's exec input and the `docker_exec` tool; while off, `/exec` returns 403) |
549
+ | `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") |
540
550
  | — (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-一条请求经过什么) |
541
551
  | `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
542
552
  | `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
@@ -598,7 +608,8 @@ and reconnect". The record list can be deleted / reset in the settings card (del
598
608
  | `docker_ps` | always registered | `target?` (**pass `*` = all targets**), `all?: boolean` | 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 |
599
609
  | `docker_attention` | always registered | `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 |
600
610
  | `docker_inspect` | always registered | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
601
- | `docker_logs` | always registered | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?` | the tail from `docker logs --tail`; `since` uses docker syntax (such as `10m`, `2026-09-09T10:00:00`); over the cap it is marked `truncated` |
611
+ | `docker_logs` | always registered | `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` |
612
+ | `docker_logs_grep` | always registered | `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). 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` |
602
613
  | `docker_stats` | always registered | `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 |
603
614
  | `docker_images` | always registered | `target?` | image list (repository:tag / size / created / short ID) |
604
615
  | `docker_events` | always registered | `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 |
@@ -627,7 +638,9 @@ and reconnect". The record list can be deleted / reset in the settings card (del
627
638
  static switches above: the switches govern registration (host-level, persistent grant), the tier gate governs
628
639
  each registered call (per call, follows the tier immediately). Mechanism:
629
640
  [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
630
- - Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs` →
641
+ - Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs`
642
+ (when the tail does not explain the failure, `docker_logs_grep` with `pattern` /
643
+ `since` / `until` to search deeper) →
631
644
  `docker_inspect` → `docker_stats`; for image problems `docker_images` →
632
645
  `docker_image_inspect`.
633
646
  - With `announceToAgent` on, the plugin injects a capability announcement into the systemPrompt (including the
@@ -760,6 +773,11 @@ read-only first:
760
773
  off, `/exec` returns 403 and the `docker_exec` tool is likewise not registered. The two switches are
761
774
  independent and must be turned on explicitly by the user in the settings card. Read-type routes (`/logs/stream`,
762
775
  `/stats/stream`, `/events/stream`, `/images/inspect`) are unaffected by either switch.
776
+ **`allowExec` only governs this plugin's *one-shot* exec channel** (the `/exec` route, the `docker_exec`
777
+ tool, the one-off command box on the container detail drawer's Overview tab) — the card's **Terminal button runs an interactive
778
+ `docker exec -it` shell hosted by tty, which passes through neither switch** and is available even in
779
+ read-only mode (see the boundary note under "Known limitations": it is not a missing gate, it is a
780
+ deliberately kept entry path).
763
781
  2. **Destructive operations restate their consequences**. `remove` maps to `docker rm` (**without `-f`**), and the
764
782
  agent announcement requires confirming the target container with the user before running; a running container
765
783
  errors with a hint that "the container is still running: stop it before removing", never a silent force-delete.
@@ -799,6 +817,18 @@ read-only first:
799
817
  without `-i` / `-t`), so it cannot run vim / top / an interactive shell, nor feed stdin for a
800
818
  dialogue. For interactive troubleshooting run `docker exec -it <container> sh` in the tty panel (this works for
801
819
  both local and SSH targets).
820
+ - **`allowExec` is not the gate that keeps you out of the container**: the card's Terminal button
821
+ (`docker exec -it '<container>' sh`) is hosted through tty's `ttyTerminal` and **does not check
822
+ `allowExec`** — it stays available in read-only mode, exactly as the panel's own hint says when exec is
823
+ off ("turn on *Allow exec*, or copy the exec command from the card into the terminal panel to enter the
824
+ container interactively"). This is deliberate: dropping into a container to look at the scene is the most
825
+ common read-only inspection path, and the gate governs **this plugin's own** one-shot channel (`/exec` and
826
+ `docker_exec`: they change local state and can be called in bulk by agent tools), while the interactive
827
+ shell belongs to tty (user-driven; tty is the PTY's owner). **The cost, stated plainly**: once tty is
828
+ installed, `allowExec` was never a boundary against **in-page scripts** (a third-party plugin's client half
829
+ / XSS) anyway — via `ttyTerminal.open` or a WS `spawn` frame they can run arbitrary commands, which is
830
+ strictly stronger than this plugin's one-shot exec. See
831
+ [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) for that layer's threat-model boundary.
802
832
  - **The event stream has a window while disconnected**: `docker events` is a stream of "from now on", so events that
803
833
  happen while the browser is disconnected and reconnecting have already been pushed by the server and are not
804
834
  resent. The client compensates with "do a full list refresh right after a successful reconnect"
@@ -815,16 +845,45 @@ read-only first:
815
845
  A long SSH stream holds that connection in the pool (busy) while other commands on the same host still reuse the
816
846
  same connection without affecting each other. **The stats stream does not end naturally**, so closing it must be
817
847
  the frontend actively aborting the `EventSource`.
848
+ - **The browser's per-origin budget is 4 live streams (this is what really sets the merge cap — D165)**:
849
+ the host speaks **HTTP/1.1** (no h2 multiplexing), a browser allows only so many TCP connections **per origin**, and
850
+ **every `EventSource` owns one connection and never releases it voluntarily**. Once that budget is used up, **every**
851
+ ordinary request from the same page (list refreshes, `/api/*`, session loading…) queues behind it — the user sees the
852
+ **whole DSH page freeze**, not just the Docker panel.
853
+ Measured (2026-10-08, real Chrome + real host; 10 in-page requests, 2s timeout):
854
+
855
+ | Live streams | In-page requests | Out-of-process request (same moment) |
856
+ |---|---|---|
857
+ | 0 | 10/10, median 27ms | 26ms |
858
+ | 4 | 8/8, median 25ms | — |
859
+ | **5** | **0/10, all timed out** | **41ms (fine)** |
860
+
861
+ That last column is the point: **the jam is in the browser, not the host**. The live-stream budget is therefore
862
+ **4**; while the panel sits on the container list the Activity strip's event stream is always open (1 of them), so
863
+ merged logs get **3** — that is where the merge cap (`PICK_MAX = 4 − 1`) comes from, and the selection bar says so.
864
+ **This is a hard constraint, not a performance tip**: going over does not mean "a bit slower", it means every request
865
+ on that page fails together. To reproduce: open `new EventSource('/api/dsh-docker/events/stream?target=<target>')`
866
+ in devtools until the 5th one, then fire an ordinary request and watch it hang; closing them restores it.
867
+ (WebSockets do **not** draw on this budget — the tty terminal's WS coexisted fine with 4 SSE streams, measured.)
818
868
  - **The SSH channel budget is shared per target (`MaxSessions`)**: a target keeps only **one** TCP connection, and
819
869
  every long stream and short command shares that connection's channels — while OpenSSH's `MaxSessions` defaults to
820
- just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel, and merged logs can
821
- take 8 at once, which uses the budget up exactly; the next "refresh list" (a short command) is then rejected by the
870
+ just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel; the next
871
+ "refresh list" (a short command) can then be rejected by the
822
872
  far end with `(SSH) Channel open failure: open failed`. The plugin therefore caps each SSH target at **8 concurrent
823
- long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect), and the merged-log selection
824
- cap on an **SSH target** drops from 8 to 6 (local targets go through subprocesses and are unaffected). Going over
873
+ long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect). Note the **merge cap is set by
874
+ the browser budget above, not by `MaxSessions`** — those are two independent accounts, and an earlier version
875
+ conflated them (it used 6, derived from `MaxSessions`, which was above the measured safe value). Going over
825
876
  the cap, and the far end refusing a channel, both produce pointed messages rather than ssh2's raw text. If your sshd
826
877
  tunes `MaxSessions` (`sshd -T | grep maxsessions`), the current cap is a compile-time constant — file an issue if it
827
878
  needs to follow.
879
+ Two more remedies attach to this (`D150`): short commands **queue per target** (at most 2 concurrent per connection,
880
+ `DSH_DOCKER_SHORT_CHANNELS` overrides, for testing / troubleshooting only), and a full budget **self-heals** by
881
+ dropping and `end()`ing the connection and retrying once on a fresh one. And (`D164`): aborting a long stream now
882
+ **settles it on the spot** instead of waiting for the far end — previously, if the far end never confirmed (the
883
+ `KILL` above being refused, or a silent channel), that stream's `busy` count never came down; after 8 such leaks
884
+ **every** long stream on that target was refused with no self-healing path at all (observed: 248 pinned at 8/8 with
885
+ the Activity strip's Reconnect appearing to do nothing). `run()` had already hit this same trap twice (`D112` /
886
+ `D161`); all three teardown paths now settle themselves.
828
887
  - **Docker CLI version differences**: parsing goes through `--format '{{json .}}'`, and fields come and go between
829
888
  versions; the parser always degrades instead of throwing (for example, a missing `State` has the state derived from
830
889
  `Status`, and health is extracted from `(healthy)` / `(unhealthy)`); with fields missing the corresponding columns
@@ -972,7 +1031,7 @@ Host half (src/index.ts)
972
1031
  ├─ image routes: /images/inspect (read-only) · /images/remove · /images/prune (allowMutations)
973
1032
  ├─ network / volume routes: /networks · /volumes and their inspect (read-only), remove / prune (allowMutations)
974
1033
  └─ agent tools: docker_targets / docker_ps / docker_inspect /
975
- docker_logs (snapshot semantics unchanged) / docker_stats / docker_events / docker_images /
1034
+ docker_logs (snapshot semantics unchanged) / docker_logs_grep (server-side search) / docker_stats / docker_events / docker_images /
976
1035
  docker_image_inspect / docker_networks / docker_volumes (always registered)
977
1036
  + docker_action / docker_image_remove / docker_image_prune / docker_image_pull
978
1037
  (allowMutations) / docker_exec (allowExec)
@@ -1023,12 +1082,14 @@ ring buffer / action labels / debounce** (pure logic through the `__events` test
1023
1082
  the entry label when the sidebar is collapsed (`data-sidebar-collapsed`).
1024
1083
  Verification that needs a real daemon follows the manual checklist below.
1025
1084
 
1026
- `test/logs-stream.test.ts` (37 cases, run by the root `pnpm test`) covers four layers of the live log stream:
1085
+ `test/logs-stream.test.ts` (case count deliberately not hardcoded — `vitest run`'s output is the single source of truth) covers four layers of the live log stream:
1027
1086
  `logsStream`'s argv construction and `assertRef` allowlist, the single-line JSON encapsulation of SSE frames
1028
1087
  (newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
1029
1088
  ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
1030
1089
  layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
1031
- disabled — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
1090
+ disabled — plus **the `busy` count must return to zero after a long stream is aborted** (D164: neither a far end that
1091
+ never confirms the channel close nor eight such leaks in a row may pin the target, and chunks arriving after teardown
1092
+ are not delivered) — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
1032
1093
  window elapses, `end` still comes after those rows) and **exact skip accounting** (D156: frames dropped while a queued
1033
1094
  `skip` has not been written yet must still be reported — a 46-frame ledger where every frame is either delivered or
1034
1095
  accounted for). The coalescer's own rules (window merge / flush on the size cap /
@@ -1104,8 +1165,8 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1104
1165
  should **name podman** and say "put podman in 'docker CLI' and it works", while **not**
1105
1166
  rewriting `dockerBin` for you (go back to the settings card — the value is still `docker`).
1106
1167
  4. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
1107
- `/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 12 read-only tools are
1108
- registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` / `images` /
1168
+ `/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 13 read-only tools are
1169
+ registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `logs_grep` / `stats` / `events` / `images` /
1109
1170
  `image_inspect` / `networks` / `volumes` — see the tool table for the authoritative list), and
1110
1171
  the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
1111
1172
  "allow mutations" these routes and tools appear immediately (no restart needed).
@@ -1233,6 +1294,25 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1233
1294
  Reverse check: switch to Target2 and then immediately back to Target1 (which still cannot connect) → the banner
1234
1295
  should describe Target1's own
1235
1296
  failure, not treat Target2's successful result as Target1's.
1297
+ 21. **`docker_logs_grep` server-side search** (the shape used for the 2026-10-09 real-machine run): start a
1298
+ container whose log content you know in advance (any image you already have locally; no network needed):
1299
+ `docker run -d --rm --name dsh-grep-probe <local image> sh -c 'echo "[boot] starting"; for i in 1 2 3 4 5; do echo "tick $i ok"; done; echo "FATAL request-id=REQ-7788"; echo "WARN disk 91%" >&2; sleep 3600'`
1300
+ Then have the agent search it with `docker_logs_grep` and **check the raw return value item by item** (do not
1301
+ accept a paraphrase):
1302
+ - search `FATAL` → 1 hit, "scanned 8 lines", the hit marked `>` with ±2 context lines;
1303
+ - `tick [0-9]+` **without** `regexp` → 0 hits; with `regexp=true` → 5 hits (proves the default is **literal**);
1304
+ - `fatal` without `ignoreCase` → 0; with `ignoreCase=true` → 1;
1305
+ - `tick` + `limit=1` → "5 hits" reported but only one window returned, plus the "hits exceed the limit" note (`limited`);
1306
+ - `since=1h` hits / `until=1h` and `since=1s` both 0 hits **and "scanned 0 lines"** (proves `--since`/`--until` really pass through);
1307
+ - search `WARN` → the hit line's stream is `[err]`;
1308
+ - search a container that does not exist → docker's own error text, **not** an empty result.
1309
+ Forcing `truncated` needs a log **>8MB** (the 8MB scan floor is hard; lowering "output limit (KB)" does not move it):
1310
+ let a container write ~300k lines with the needles at the **very head** and the **very tail** → a full scan gives
1311
+ 0 hits + the "scan output exceeded the byte limit" note for the former and a hit + the same note for the latter;
1312
+ adding `tail=5000` makes the note disappear in both (proving it reports a **real** scan-side overflow, not
1313
+ "the log is big so it is always true"). Also, **every returned line must be a complete line** (timestamp prefix):
1314
+ the byte gate cuts mid-line, and `lineAlign` drops that partial line (see the real-machine correction in ROADMAP)
1315
+ — a fragment such as `g line 174798` without a timestamp means this has regressed.
1236
1316
 
1237
1317
  ## Version / license
1238
1318
 
package/README.md CHANGED
@@ -180,11 +180,20 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
180
180
  被不断重开,既掩盖「它断了」,又白建 SSE。重连成功后补一次全量列表刷新(断线期间
181
181
  列表可能已经落后),daemon 全程没起、流从未连上过的情形同样补——否则活动条绿了而
182
182
  列表还停在错误态,看着像好了其实没好。
183
+ **重连被拒时要说原因**(`D164`):服务端主动拒绝这条流时会发一帧 `event: error`
184
+ 带 `message`(最典型的是长流配额满,见「已知限制」——那句话里写着该怎么办);
185
+ 客户端现在把它**显示在活动条上**(「重连被拒:…」),而不是丢掉。原先只置
186
+ `closed`,于是「拒绝 → 回到原位」与「点了没反应」在界面上完全不可区分,用户只能反复点。
187
+ 重连成功(或重新建流)时这条原因清除。
188
+ 断开时右侧那句提示是**折叠头里的单行小字**,所以刻意写短(2026-10-08 用户反馈
189
+ 「啰嗦又生硬」):状态行左边已经在说「事件流已断开」,这句只说「不会自动恢复,点
190
+ 「重连」」——成因、上限与 `MaxSessions` 那一串写在本页,不塞进界面。
183
191
  - **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——每张卡片
184
192
  左侧出现勾选框,点卡片本体变成**勾选 / 取消**(不再进详情;动作条暂时收起,
185
193
  免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+
186
- `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到 7~8 个时给一条软提示
187
- (浏览器同源并发长连接有限制),超过 **8 个**按钮置灰并提示上限。点 `聚合日志`
194
+ `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到第 3 个时给一条软提示,
195
+ 超过 **3 个**按钮置灰并提示上限(**上限怎么来的见「已知限制」的浏览器同源连接预算**,
196
+ 操作条上也写着那句说明)。点 `聚合日志`
188
197
  进入聚合视图:直接复用 Compose 项目视图那套聚合日志(每容器一条 `/logs/stream`,
189
198
  按 `[service]` / 容器名前缀混流,带过滤与自动滚动),返回即退出选择态并清空。
190
199
  聚合视图的内容控制与**单容器日志完全对齐**(文本过滤 + 级别门槛 + `⬇ .log` / `⬇ .md`
@@ -195,14 +204,14 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
195
204
  列表刷新后已消失的容器按 id 自动剔除。
196
205
  - **按条件一键选中**(多选态):操作条第二行给出一排条件 chip(`全部可见 / 不健康 /
197
206
  需关注 / 已停止`,以及有勾选后的 `同镜像 / 同项目`),计数**按剩余名额截断**——
198
- chip 上写 8 就真的会选中 8 个;超出上限的数量在 title 与结果提示里如实说明。
207
+ chip 上写 3 就真的会选中 3 个;超出上限的数量在 title 与结果提示里如实说明。
199
208
  条件只在**当前筛选结果**里生效(先搜索/筛状态再一键选中)。
200
209
  - **容器卡片**:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 /
201
210
  compose,值等宽、可省略)+ 一排图标操作按钮,**按「查看 / 变更」两组用竖线分隔**:
202
211
 
203
212
  | 组 | 按钮 | 说明 |
204
213
  | --- | --- | --- |
205
- | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用 |
214
+ | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用。**终端按钮跑 `docker exec -it` 交互式 shell,不受 `allowExec` 门控**(那条开关只管本插件的一次性 exec 通道,见「已知限制」) |
206
215
  | 变更 | 启停、重启、删除 | 按破坏性递增排列;只在 `allowMutations` 开启后可用,未开启时整组置灰 |
207
216
 
208
217
  删除额外做了两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。
@@ -510,7 +519,7 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
510
519
  | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
511
520
  | `allowMutations` | false | 允许**变更操作**:容器 start / stop / restart / remove、镜像删除 / dangling 清理 / 拉取(面板按钮与 `docker_action`、`docker_image_remove`、`docker_image_prune`、`docker_image_pull` 工具;关闭时 `/action`、`/images/remove`、`/images/prune`、`/images/pull/stream` 返回 403,对应工具不注册) |
512
521
  | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效;两个能力各点一次后,面板组顶部会给一个「复制全部(N 条)」(仍 pending 的命令按发起顺序换行连接),终端粘贴一次即可全部解锁(各自探测周期内先后生效,不承诺同一时刻)。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`),而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
513
- | `allowExec` | false | 允许一次性 `docker exec`(面板 exec 输入与 `docker_exec` 工具;关闭时 `/exec` 返回 403) |
522
+ | `allowExec` | false | 允许**一次性** `docker exec`(**容器详情抽屉**「概览」页签里的一次性命令框,与 `docker_exec` 工具;关闭时 `/exec` 返回 403)。**不管**卡片「终端」按钮的 `docker exec -it` 交互式 shell——那条由 tty 承载(见「已知限制」) |
514
523
  | `execTimeoutSec` | 30 | exec 默认超时秒数(1~120) |
515
524
  | `pollIntervalSec` | 5 | 面板统计刷新间隔秒数(1~60) |
516
525
  | `logTailDefault` | 200 | 日志默认尾部行数(1~5000) |
@@ -570,7 +579,8 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
570
579
  | `docker_ps` | 恒注册 | `target?`(**传 `*` = 全部目标**)、`all?: boolean` | 列容器(名称 / 状态 / 健康 / 镜像 / 端口 / compose 项目与服务 / 短 ID);默认只列运行中,`all:true` 含已停止。`target:'*'` 时按目标分组返回,**单个目标不可达不影响其他目标**(该组带 `error`)。端口是 IPv4/IPv6 双栈归并后的映射(同一次 `-p` 不再出现两遍,D130);`ports` 为空**不等于**没暴露端口——host 网络容器的端口即宿主机端口,这种情况会给 `net` 字段(D131)。排障第一步 |
571
580
  | `docker_attention` | 恒注册 | `target?`(支持 `*`)、`limit?: number` | **需关注汇总**:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 `reasons`、`exitCode`、`oomKilled`、`restartCount`。OOM 与真实退出码来自一次 `docker inspect`(ps 摘要里 137 无法区分手动 kill)。排障入口:不确定从哪台/哪个容器看起时先调它 |
572
581
  | `docker_inspect` | 恒注册 | `target?`、`id`(必填) | `docker inspect` 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
573
- | `docker_logs` | 恒注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?` | `docker logs --tail` 尾部;`since` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00`);超上限标记 `truncated` |
582
+ | `docker_logs` | 恒注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?`、`until?` | `docker logs --tail` 尾部;`since` / `until` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00`)圈时间窗;超上限标记 `truncated` |
583
+ | `docker_logs_grep` | 恒注册 | `target?`、`id`、`pattern`(必填,≤512 字符)、`regexp?`、`ignoreCase?`、`since?`、`until?`、`context?`(0~20,默认 2)、`limit?`(1~500,默认 200)、`tail?`(缺省 = 扫全部历史)、`timestamps?`(默认开) | **日志服务端检索**:宿主侧逐行过滤(不碰远端 shell、不拼 `\| grep`),返回命中行 + 前后上下文,并如实报告 `matched`(全量命中数,即使没返回)/ `scanned` / `limited` / `truncated`——「只扫到这些」与「扫完了没更多」可区分;扫描窗口**对齐到行边界**(按字节截断处的半行不当作一行返回,所以 `lines` 里每一行都是完整的行)。与 `docker_logs` 的分工:报错就在最近尾部 → `docker_logs`(便宜);埋在深处 / 特定时间窗 / 找关键词、异常码、请求 ID → 本工具。默认字面子串(grep -F 语义,零 ReDoS),`regexp: true` 才按 JS 正则 |
574
584
  | `docker_stats` | 恒注册 | `target?`、`ids?`(逗号分隔的容器名/ID) | `docker stats --no-stream` 快照:CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs;`ids` 省略 = 全部运行中容器。实时跟随是面板能力(SSE),工具保持单值快照语义 |
575
585
  | `docker_images` | 恒注册 | `target?` | 镜像列表(仓库:标签 / 大小 / 创建时间 / 短 ID) |
576
586
  | `docker_events` | 恒注册 | `target?`、`since?`(docker `--since` 语法,默认 `10m`) | 容器事件快照(`docker events --since <d> --until <now>`,同样过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,`exec_*` 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
@@ -596,7 +606,8 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
596
606
  `tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:
597
607
  静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。
598
608
  机制见 [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
599
- - 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs` →
609
+ - 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs`(尾部看不到成因就
610
+ `docker_logs_grep` 带 `pattern` / `since` / `until` 检索深处)→
600
611
  `docker_inspect` → `docker_stats`;镜像排查 `docker_images` →
601
612
  `docker_image_inspect`。
602
613
  - `announceToAgent` 开启时,插件向 systemPrompt 注入一段能力公告(含「默认
@@ -719,6 +730,10 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
719
730
  未开启时,`/exec` 返回 403,`docker_exec` 工具同样不注册。两个开关互相
720
731
  独立,必须在设置卡片由用户显式打开。读取类路由(`/logs/stream`、
721
732
  `/stats/stream`、`/events/stream`、`/images/inspect`)不受这两个开关影响。
733
+ **`allowExec` 只管本插件的「一次性」exec 通道**(`/exec` 路由、`docker_exec`
734
+ 工具、容器详情抽屉「概览」页签的一次性命令框)——卡片上的**「终端」按钮跑的是 `docker exec -it`
735
+ 交互式 shell,由 tty 承载、不经过这两个开关**,只读模式下也可用(见下面
736
+ 「已知限制」里的边界说明:它不是漏了门,是刻意留的进入通道)。
722
737
  2. **破坏性操作要复述后果**。`remove` 映射为 `docker rm`(**不带 `-f`**),
723
738
  agent 公告要求执行前向用户确认目标容器;运行中容器会报错并附
724
739
  「容器仍在运行:先停止再删除」的提示,不会静默强删。
@@ -755,6 +770,16 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
755
770
  不带 `-i` / `-t`),不能跑 vim / top / 交互式 shell,也不能喂 stdin 做
756
771
  对话。交互排障请到 tty 面板执行 `docker exec -it <容器> sh`(本机与 SSH
757
772
  目标都可以)。
773
+ - **`allowExec` 不是「进不了容器」的闸**:卡片「终端」按钮(`docker exec -it
774
+ '<容器>' sh`)经 tty 的 `ttyTerminal` 承载,**不检查 `allowExec`**,只读模式下
775
+ 也照常可用——exec 被关时面板给的提示原文就是这么说的(「打开『允许 exec』,或
776
+ 直接复制卡片上的 exec 命令到终端面板交互式进入容器」)。这是刻意的:只读巡检时
777
+ 进容器看现场是最常用的一条路径,闸门管的是**本插件自己**那条一次性通道
778
+ (`/exec` 与 `docker_exec`:改本地状态、可被 agent 工具批量调用),交互式 shell
779
+ 归 tty(用户主动操作、它自己才是 PTY 的所有者)。**代价要说清**:装上 tty 之后,
780
+ `allowExec` 对**页内脚本**(第三方插件的 client 半体 / XSS)本来就不是边界——它们
781
+ 走 `ttyTerminal.open` 或 WS `spawn` 帧就能跑任意命令,比本插件的一次性 exec 还强。
782
+ 这一层的威胁模型边界见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么)。
758
783
  - **事件流有断线窗口**:`docker events` 是「从现在开始」的推流,浏览器断线重连期间
759
784
  发生的事件服务端已经推过、不会补发。客户端用「重连成功后先做一次全量列表刷新」
760
785
  来补偿(状态对齐,不是把事件补回来);活动条里缺的那几条只能靠刷新后的最终状态
@@ -768,6 +793,26 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
768
793
  SSH 长流会占住连接池中的该连接(busy),同一主机上的其它命令复用同一条连接——
769
794
  但**不是互不影响**:通道额度是共享的,见下一条。**统计流不会自然结束**,关闭必须
770
795
  由前端主动断 `EventSource`。
796
+ - **浏览器的同源连接预算是 4 条常驻流(这是聚合上限的真正依据,D165)**:
797
+ 宿主是 **HTTP/1.1**(没有 h2 多路复用),浏览器对**每个源**只放这么几条 TCP,而
798
+ **每条 `EventSource` 独占一条连接、且不会主动释放**。吃满之后,同一个页面里
799
+ **所有**普通请求(列表刷新、`/api/*`、会话加载……)一起排队——用户看到的是
800
+ **整个 DSH 页面卡住**,不只是 docker 面板。
801
+ 实测(2026-10-08,真 Chrome + 真宿主,页内连打 10 次普通请求、2 秒超时):
802
+
803
+ | 常驻流 | 页内请求 | 进程外请求(同一时刻) |
804
+ |---|---|---|
805
+ | 0 条 | 10/10 成功,中位 27ms | 26ms |
806
+ | 4 条 | 8/8 成功,中位 25ms | — |
807
+ | **5 条** | **0/10 全部超时** | **41ms(正常)** |
808
+
809
+ 最后一列是关键:**堵在浏览器,不在宿主**。所以常驻流预算取 **4 条**;面板停在容器页时
810
+ 活动条的事件流恒开(占 1 条),聚合日志因此只能用 **3 条**——这就是聚合上限
811
+ (`PICK_MAX = 4 − 1`)的由来,操作条上写着同一句说明。
812
+ **这不是「性能建议」而是硬约束**:超了不是「慢一点」,是同页面所有请求一起失效。
813
+ 想自己复核:按上表形状在 devtools 里连开 `new EventSource('/api/dsh-docker/events/stream?target=<目标>')`
814
+ 到第 5 条,再打一次普通请求即可看到它挂住;关掉就恢复。
815
+ (WebSocket **不**占这份预算——tty 终端那条 WS 与 4 条 SSE 共存时页面仍正常,实测过。)
771
816
  - **SSH 目标上的通道额度是共享的(`MaxSessions`)**:一个目标只维持**一条** TCP 连接,
772
817
  所有长流与短命令共用这条连接上的通道,而 OpenSSH 的 `MaxSessions` 默认只有 10。
773
818
  长流(日志 / 统计 / 事件)会一直占到用户关掉面板为止,聚合日志还能一次占 8 条——
@@ -786,7 +831,13 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
786
831
  宿主日志留一行 `通道额度已满:已重建连接重试`。之所以必须重建:这条连接**不会自己
787
832
  恢复**(中止长流时发的 `signal('KILL')` 在部分 sshd 上被拒绝,而 sshd 在子进程仍活着
788
833
  时延迟释放 session 槽)。代价是该连接上正在跟随的流会断开、由面板 SSE 自动重连
789
- (日志流按 `D133` 用 `tail=0` 续尾)。**根治办法在 sshd 一侧**:把那台主机的
834
+ (日志流按 `D133` 用 `tail=0` 续尾);③ **中止长流时插件自己的账当场结清**(`D164`)
835
+ —— `AbortSignal` 触发时除了向远端 `KILL` + 关通道,还会**直接结束这条流**,不再等远端
836
+ 回话。原先只等远端的 `close` 事件:远端不确认(上面那种被拒的 KILL,或通道静默)时,
837
+ 那条流的 `busy` 计数**永远不会减**,累积到 8 之后该目标的每条长流都被配额拒绝,
838
+ 且没有任何自愈路径(现场:248 上恒满 8/8,活动条点「重连」毫无反应)。同一类坑
839
+ `run()` 在 `D112` / `D161` 已经踩过两次,现在三条收尾路径统一成「自己 settle」。
840
+ **根治办法在 sshd 一侧**:把那台主机的
790
841
  `MaxSessions` 调大 —— 例如 `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` 写一行
791
842
  `MaxSessions 50`,再 `sshd -t && systemctl reload ssh`(reload 只影响新连接)。
792
843
  - **docker CLI 版本差异**:解析走 `--format '{{json .}}'`,字段随版本增减,
@@ -928,7 +979,7 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
928
979
  ├─ 镜像路由:/images/inspect(只读)· /images/remove · /images/prune(allowMutations)
929
980
  ├─ 网络 / 卷路由:/networks · /volumes 与各自的 inspect(只读)、remove / prune(allowMutations)
930
981
  └─ agent 工具:docker_targets / docker_ps / docker_inspect /
931
- docker_logs(快照语义不变)/ docker_stats / docker_events / docker_images /
982
+ docker_logs(快照语义不变)/ docker_logs_grep(服务端检索)/ docker_stats / docker_events / docker_images /
932
983
  docker_image_inspect / docker_networks / docker_volumes(恒注册)
933
984
  + docker_action / docker_image_remove / docker_image_prune / docker_image_pull
934
985
  (allowMutations)/ docker_exec(allowExec)
@@ -982,7 +1033,7 @@ loopback 403(含三条流路由)、容器名 / 镜像引用注入尝试被
982
1033
  卡片 key 等于命名空间 `docker`、找不到宿主侧边栏时安静降级且卸载可重复调用,ttyConnbar 集成的四条路径(连接簿名命中 / host:port 命中 / 未配置主机不加按钮 / tty 未安装静默跳过),FOLLOW 的 SSE 订阅与「回到底部」交互(静态断言),**镜像详情 / 拉取流 / 删除 / prune 入口**、**统计 FOLLOW + sparkline 钩子**、**Compose 分组与聚合日志**、**「活动」条装配 + 事件环形缓冲 / 动作标签 / 防抖**(纯逻辑经 `__events` 测试缝),以及侧边栏折叠态(`data-sidebar-collapsed`)隐藏入口标签的样式规则。
983
1034
  需要真 daemon 的验证走下面的手工清单。
984
1035
 
985
- `test/logs-stream.test.ts`(37 例,随根 `pnpm test` 跑)覆盖日志实时流的四层:
1036
+ `test/logs-stream.test.ts`(例数不写死,`vitest run` 的输出是唯一真相源)覆盖日志实时流的四层:
986
1037
  `logsStream` 的 argv 构造与 `assertRef` 白名单、SSE 帧的单行 JSON 封装(换行 /
987
1038
  多字节)、本地流生命周期(假 spawn:跨 chunk 多字节、SIGTERM→SIGKILL 阶梯、
988
1039
  close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳过、以及
@@ -990,6 +1041,8 @@ close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳
990
1041
  **`tail=0` 只跟随不补历史**(D133)、**背压不重写已写过的帧**与**积压时丢最旧 + `skip` 通知、
991
1042
  不掐流**(D153)、**skip 账目精确**(D156:skip 排队期间再丢弃,两段缺口都要报——
992
1043
  46 块逐块对账「每一帧要么送达、要么被 skip 记账」)、
1044
+ **中止长流后 `busy` 必须归零**(D164:远端不确认关通道、累积 8 次都不会把目标钉死,
1045
+ 且收尾后的分片不再投递)、
993
1046
  以及**分片合帧**(D151:十个分片只出一个 `line` 帧;未到窗口就收尾时 `end` 仍排在
994
1047
  这些行之后)。合帧器本身的规则(窗口合并 / 攒满即推 / flush 幂等 / dispose 停表)在
995
1048
  `test/sse-coalesce.test.ts`(6 例)。
@@ -1059,9 +1112,9 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
1059
1112
  **点名 podman** 并说明「把『docker CLI』填成 podman 就能用」,同时**不该**替你
1060
1113
  改 `dockerBin`(回头去设置卡片看,值仍是 `docker`)。
1061
1114
  4. **只读拦截**:两个开关都关时,`/action`、`/exec`、`/images/remove`、
1062
- `/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册 12 个只读工具
1063
- (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` /
1064
- `images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
1115
+ `/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册 13 个只读工具
1116
+ (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` /
1117
+ `logs_grep` / `stats` / `events` / `images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
1065
1118
  面板的启停删 / 镜像删除 / 清理 / 拉取按钮置灰。打开「允许变更操作」后这些
1066
1119
  路由与工具立即出现(无需重启)。
1067
1120
  5. **日志 / 统计 / 镜像**:`tail` 与 `timestamps` / `since` 生效;统计显示
@@ -1154,6 +1207,23 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
1154
1207
  目标1 的容器。从总览点 目标1 那张红色计数卡进去,同样不该看到上一个目标的失败。
1155
1208
  反向验证:切到目标2 后立刻切回目标1(它仍然连不上)→ 横幅应写的是目标1 自己的
1156
1209
  失败,而不是把目标2 的成功结果当成了目标1 的。
1210
+ 21. **`docker_logs_grep` 服务端检索**(2026-10-09 真机实测的口径):起一个日志内容已知的容器
1211
+ (用本地已有镜像即可,不必联网):
1212
+ `docker run -d --rm --name dsh-grep-probe <本地镜像> sh -c 'echo "[boot] starting"; for i in 1 2 3 4 5; do echo "tick $i ok"; done; echo "FATAL request-id=REQ-7788"; echo "WARN disk 91%" >&2; sleep 3600'`
1213
+ 然后在会话里让 agent 用 `docker_logs_grep` 检索它,**逐项看原始返回值**(别接受转述):
1214
+ - 找 `FATAL` → 命中 1 行、「扫描 8 行」、命中行标 `>` 且带 ±2 行上下文;
1215
+ - `tick [0-9]+` **不传** `regexp` → 0 命中;传 `regexp=true` → 5 命中(证明默认是**字面**匹配);
1216
+ - `fatal` 不传 `ignoreCase` → 0;传 `ignoreCase=true` → 1;
1217
+ - `tick` + `limit=1` → 「命中 5 行」但只返回 1 组窗口,并出现「命中超过上限」提示(`limited`);
1218
+ - `since=1h` 命中 / `until=1h` 与 `since=1s` 各 0 命中**且「扫描 0 行」**(证明 `--since`/`--until` 真透传);
1219
+ - 找 `WARN` → 命中行的 stream 是 `[err]`;
1220
+ - 检索不存在的容器 → 报 docker 原文错误,**不是**空结果。
1221
+ `truncated` 只能用 **>8MB** 日志逼出来(扫描侧 8MB 硬下限,调小「输出上限(KB)」没用):
1222
+ 让容器打 ~30 万行、探针分别写在**最开头**与**最末尾** → 全量扫时前者 0 命中 + 「扫描输出超过
1223
+ 字节上限」提示,后者命中 + 同一提示;加 `tail=5000` 后两者都不再出现该提示(证明它报的是
1224
+ 扫描侧**真溢出**,不是「日志大就恒 true」)。另外**返回的每一行都必须是完整行**(带时间戳
1225
+ 前缀):字节闸的切口会切在行中间,`lineAlign` 负责丢掉那个半行(见 ROADMAP 的真机验收补正)
1226
+ —— 看到 `g line 174798` 这种没时间戳的碎片,就是这条回归了。
1157
1227
 
1158
1228
  ## 版本 / 许可证
1159
1229
 
@@ -1595,6 +1595,15 @@
1595
1595
  .dk_chipQuiet { border-color: var(--dk-border); color: var(--dk-label-3); }
1596
1596
  .dk_pickNotice { margin-left: auto; }
1597
1597
 
1598
+ /*
1599
+ * 上限的来由(D165):它是浏览器同源连接预算量出来的,不是随手定的数字。
1600
+ * 与 notice 共用行尾——notice 是「刚发生的事」(选择结果),排在更靠右的位置。
1601
+ */
1602
+ .dk_pickBudget { color: var(--dk-label-3); }
1603
+ /* 只有它一个时贴右,与 notice 同位置(两个都在时 notice 更靠右) */
1604
+ .dk_pickBudget:last-child { margin-left: auto; }
1605
+ .dk_pickBudget + .dk_pickNotice { margin-left: var(--dk-gap-md); }
1606
+
1598
1607
  .dk_pickCount {
1599
1608
  font-size: 12px;
1600
1609
  line-height: 18px;