opencode-docker-panel 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this plugin, by version and date. The version is the one in `package.json` at
4
4
  that commit.
5
5
 
6
+ ## 0.6.0 - 2026-10-10
7
+
8
+ - The panel follows `docker events` instead of asking on a timer, so a container appears or disappears
9
+ as soon as Docker reports it: 442 ms on a measured `compose stop`, against up to three seconds before
10
+ - Only `start`, `die`, `destroy`, `pause`, `unpause` and `rename` cause a repaint. `kill`, `stop` and
11
+ `create` would redraw a frame identical to the one already on screen, and `exec_` and `health_` events
12
+ are the container's own internals
13
+ - Polling stays as a safety net: `docker ps` every 30 seconds, and every 3 seconds while Docker is
14
+ stopped so that starting Docker Desktop from the tray shows up at once
15
+ - The event stream is reopened when its process exits, which it does loudly: about thirty seconds of
16
+ silence, then `unexpected EOF` and exit code 1. Reopening waits for `docker ps` to answer, so no
17
+ process is spawned for as long as Docker stays down
18
+ - `intervalMs` is gone. It controlled how often the panel ran `docker ps`, and events make that
19
+ meaningless; leaving it in a config is harmless
20
+ - Two container changes in quick succession no longer lose the second one, and simultaneous refreshes
21
+ can no longer apply out of order
22
+ - `Stop Docker Desktop` hides the `Up stack` line the moment it is pressed rather than after the
23
+ command finishes
24
+
6
25
  ## 0.5.1 - 2026-10-08
7
26
 
8
27
  - The repository is formatted with prettier, with a config that matches the existing style: no semicolons,
package/README.md CHANGED
@@ -35,7 +35,7 @@ whatever is already in it.
35
35
  ```jsonc
36
36
  {
37
37
  "$schema": "https://opencode.ai/v2/cli.json",
38
- "plugins": [{ "package": "opencode-docker-panel", "options": { "intervalMs": 3000 } }],
38
+ "plugins": ["opencode-docker-panel"],
39
39
  }
40
40
  ```
41
41
 
@@ -49,7 +49,7 @@ Two things trip people up here, so they are worth stating plainly:
49
49
  Restart the TUI afterwards. The host installs the package on the next start; nothing to copy and
50
50
  nothing to build.
51
51
 
52
- Pin a version when you want a known state: `{ "package": "opencode-docker-panel@0.5.0" }`.
52
+ Pin a version when you want a known state: `{ "package": "opencode-docker-panel@0.6.0" }`.
53
53
 
54
54
  ### Verification
55
55
 
@@ -71,9 +71,13 @@ If the header never appears, the plugin did not load: check that the entry is in
71
71
 
72
72
  ## Options
73
73
 
74
- | Option | Default | Notes |
75
- | ------------ | ------- | ------------------------------------- |
76
- | `intervalMs` | `3000` | Poll interval, clamped to 1000..60000 |
74
+ There are none. The panel follows Docker's own event stream, so there is nothing to configure and
75
+ nothing to tune. It still polls as a safety net every 30 seconds, and every 3 seconds while Docker is
76
+ stopped so that starting Docker Desktop from the tray shows up right away.
77
+
78
+ Before `0.6.0` there was an `intervalMs` option. It controlled how often the panel ran `docker ps`,
79
+ and it no longer does anything: events carry the updates and the poll is only a net. Leaving it in
80
+ your config is harmless.
77
81
 
78
82
  Everything else lives in [docs/features.md](docs/features.md).
79
83
 
package/README.ru.md CHANGED
@@ -33,7 +33,7 @@ https://github.com/victor-ochenin/opencodeDockerPlugin#installation
33
33
  ```jsonc
34
34
  {
35
35
  "$schema": "https://opencode.ai/v2/cli.json",
36
- "plugins": [{ "package": "opencode-docker-panel", "options": { "intervalMs": 3000 } }],
36
+ "plugins": ["opencode-docker-panel"],
37
37
  }
38
38
  ```
39
39
 
@@ -44,7 +44,7 @@ https://github.com/victor-ochenin/opencodeDockerPlugin#installation
44
44
 
45
45
  После этого перезапусти TUI: хост сам поставит пакет при следующем старте, копировать и собирать ничего не нужно.
46
46
 
47
- Если нужна зафиксированная версия, укажи её явно: `{ "package": "opencode-docker-panel@0.5.0" }`.
47
+ Если нужна зафиксированная версия, укажи её явно: `{ "package": "opencode-docker-panel@0.6.0" }`.
48
48
 
49
49
  ### Проверка
50
50
 
@@ -61,9 +61,13 @@ CLI-проверки у этого плагина нет: он рисуется
61
61
 
62
62
  ## Опции
63
63
 
64
- | Опция | По умолчанию | Примечания |
65
- | ------------ | ------------ | ------------------------------------------------------ |
66
- | `intervalMs` | `3000` | Интервал опроса, ограничивается диапазоном 1000..60000 |
64
+ Их нет. Панель следует за потоком событий самого Docker, поэтому настраивать и подкручивать нечего.
65
+ Опрос при этом остался как страховка: раз в 30 секунд и раз в 3 секунды, пока Docker выключен, чтобы
66
+ запуск Docker Desktop из трея был виден сразу.
67
+
68
+ До `0.6.0` была опция `intervalMs`. Она задавала, как часто панель запускает `docker ps`, и больше
69
+ ничего не делает: обновления приходят по событиям, а опрос только страхует. Если она осталась у тебя
70
+ в конфиге, это безвредно.
67
71
 
68
72
  Всё остальное — в [docs/features.ru.md](docs/features.ru.md).
69
73
 
package/dist/events.js ADDED
@@ -0,0 +1,103 @@
1
+ import { spawn } from "node:child_process";
2
+ const COMMAND = "docker";
3
+ const ARGS = ["events", "--filter", "type=container", "--format", "{{json .}}"];
4
+
5
+ /**
6
+ * The actions that can change what the panel draws.
7
+ *
8
+ * `kill` is left out because it arrives as a pair ahead of `stop` and `die` and changes nothing on its
9
+ * own. `create` is out because the container stays `created` for the half second before `start` and the
10
+ * panel draws no row for that state. `stop` is out because `docker ps` already reports `exited` by the
11
+ * time it arrives. `restart` is out because a measured `docker restart` sends `die` and `start` first,
12
+ * which bracket the whole visible change, so it would repeat the frame `start` just produced.
13
+ * Everything with an `exec_` or `health_` prefix is the container's internals and never reaches the panel.
14
+ */
15
+ export const INTERESTING_ACTIONS = ["start", "die", "destroy", "pause", "unpause", "rename"];
16
+ /** Returns the container name an event is about, or null when the panel has no reason to react */
17
+ export function parseEventLine(line) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed) return null;
20
+ let raw;
21
+ try {
22
+ raw = JSON.parse(trimmed);
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (typeof raw !== "object" || raw === null) return null;
27
+ const event = raw;
28
+ if (event.Type !== "container") return null;
29
+ const action = String(event.Action ?? event.status ?? "");
30
+ if (!INTERESTING_ACTIONS.includes(action)) return null;
31
+ const name = event.Actor?.Attributes?.name;
32
+ return typeof name === "string" && name.length > 0 ? name : null;
33
+ }
34
+ /**
35
+ * A long-lived `docker events` reader.
36
+ *
37
+ * The stream ends loudly rather than going quiet: stopping Docker Desktop leaves it silent for about
38
+ * thirty seconds and then writes `unexpected EOF` and exits with code 1. So the caller is told when the
39
+ * process is gone and decides on its own when reopening is worth the attempt.
40
+ */
41
+ export function createEventWatch() {
42
+ let child;
43
+ let buffer = "";
44
+ let closed = false;
45
+ let consumer;
46
+ let onDead;
47
+ const open = () => {
48
+ if (closed) return;
49
+ const spawned = spawn(COMMAND, ARGS, {
50
+ windowsHide: true,
51
+ stdio: ["ignore", "pipe", "ignore"]
52
+ });
53
+ child = spawned;
54
+ // A docker that is not on the PATH makes spawn emit an error, and an unheard one throws
55
+ spawned.on("error", () => {});
56
+ // A half-written line from the killed process would prepend itself to the new process's first
57
+ // chunk and take that first event down with it
58
+ buffer = "";
59
+ spawned.stdout?.setEncoding("utf8");
60
+ spawned.stdout?.on("data", chunk => {
61
+ // The last split piece is a partial line and has to survive to the next chunk
62
+ const lines = (buffer + chunk).split("\n");
63
+ buffer = lines.pop() ?? "";
64
+ for (const line of lines) {
65
+ if (!parseEventLine(line)) continue;
66
+ consumer?.();
67
+ }
68
+ });
69
+ // Only the process still in hand gets to speak for the stream, and killing one is asynchronous, so
70
+ // after a restart the previous child still reports in. Trusting that report would drop the live
71
+ // child's reference, orphan the process and fake a dead stream. The same check covers our own stop,
72
+ // which clears `child` before killing.
73
+ spawned.on("close", () => {
74
+ if (child !== spawned) return;
75
+ child = undefined;
76
+ onDead?.();
77
+ });
78
+ };
79
+ const stop = () => {
80
+ const current = child;
81
+ child = undefined;
82
+ current?.kill();
83
+ };
84
+ open();
85
+ return {
86
+ onEvent: onChange => {
87
+ consumer = onChange;
88
+ },
89
+ onDead: handler => {
90
+ onDead = handler;
91
+ },
92
+ restart: () => {
93
+ stop();
94
+ open();
95
+ },
96
+ close: () => {
97
+ closed = true;
98
+ consumer = undefined;
99
+ onDead = undefined;
100
+ stop();
101
+ }
102
+ };
103
+ }
package/dist/panel.js CHANGED
@@ -227,9 +227,10 @@ export function DockerPanel(props) {
227
227
  const engine = createEngineProbe();
228
228
  // The probe only has to be re-read when the poll changes its verdict: an available docker proves the
229
229
  // engine answers, and a probe on every poll would stack several four second calls on the same pipe,
230
- // while never re-reading it would leave a stale stopped engine once docker starts from the tray
230
+ // while never re-reading it would leave a stale stopped engine once docker starts from the tray, which
231
+ // poll.ts covers by keeping the poll fast while the last answer said stopped
231
232
  let lastKind = "";
232
- const polling = createDockerPolling(props.intervalMs, props.reload, next => {
233
+ const polling = createDockerPolling(props.reload, next => {
233
234
  refreshStack();
234
235
  if (next.kind === lastKind) return;
235
236
  lastKind = next.kind;
@@ -647,7 +648,7 @@ export function DockerPanel(props) {
647
648
  }), null);
648
649
  _$insert(_el$18, _$createComponent(Show, {
649
650
  get when() {
650
- return _$memo(() => !!pendingStack())() && !engineDown();
651
+ return _$memo(() => !!(pendingStack() && !engineDown()))() && !stopping();
651
652
  },
652
653
  get children() {
653
654
  var _el$42 = _$createElement("text"),
package/dist/poll.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createSignal, onCleanup } from "solid-js";
2
+ import { createEventWatch } from "./events.js";
2
3
  import { runDockerPs } from "./docker.js";
3
4
  import { runDesktopStatus } from "./runtime.js";
4
5
  const INITIAL = {
@@ -10,6 +11,22 @@ const INITIAL = {
10
11
  /** docker reports the old state for a moment after start or stop, so an action is polled twice */
11
12
  const SETTLE_MS = 1200;
12
13
 
14
+ /**
15
+ * Events drive the panel now, so this is only the net for the case where docker says nothing at all:
16
+ * a container changed outside anything we would hear about, or the stream is gone and the engine has
17
+ * come back since the last attempt.
18
+ */
19
+ export const FALLBACK_INTERVAL_MS = 30000;
20
+
21
+ /**
22
+ * Nothing can change while the engine is down, so a start from the tray is the one thing worth waiting
23
+ * on closely, and the engine probe has no timer of its own to notice it.
24
+ */
25
+ const ENGINE_DOWN_RETRY_MS = 3000;
26
+ export function fallbackDelayMs(kind) {
27
+ return kind === "stopped" ? ENGINE_DOWN_RETRY_MS : FALLBACK_INTERVAL_MS;
28
+ }
29
+
13
30
  /**
14
31
  * A Docker Desktop cold start boots a virtual machine, which takes a minute and answers `unknown` while
15
32
  * it goes. Polling faster than the container loop is what makes the panel notice it came up at all,
@@ -39,6 +56,13 @@ export function selectRows(all, pinned) {
39
56
  const running = all.filter(item => !isPinned(item) && item.state === "running");
40
57
  return [...fixed, ...running.slice(0, Math.max(0, MAX_RUNNING - fixed.length))];
41
58
  }
59
+ /**
60
+ * A stream that has closed is only worth reopening once docker answers again. Reopening while the
61
+ * engine is down would spawn a process that cannot work, every tick, for as long as the engine is off.
62
+ */
63
+ export function shouldReconnect(kind, streamAlive) {
64
+ return !streamAlive && (kind === "ok" || kind === "empty");
65
+ }
42
66
  /**
43
67
  * The engine has its own probe because `docker ps` cannot report it: on a stopped engine the ps call
44
68
  * fails to connect and says only `unavailable`, which is also what a missing CLI or a locked socket
@@ -93,17 +117,26 @@ function signature(containers) {
93
117
 
94
118
  /** A container in a crash loop changes state on every poll and each remount costs a fresh docker ps */
95
119
  const RELOAD_COOLDOWN_MS = 10000;
96
- export function createDockerPolling(intervalMs, onContentChange, onPoll) {
120
+ export function createDockerPolling(onContentChange, onPoll) {
97
121
  const [state, setState] = createSignal(INITIAL);
98
122
  let timer;
99
123
  let settle;
100
124
  let disposed = false;
101
- let last = "";
102
- let seen = false;
125
+ let shown = "";
126
+ let mounted = false;
103
127
  let reloadedAt = 0;
104
- const refresh = async () => {
105
- const next = await runDockerPs();
128
+
129
+ /**
130
+ * `shown` is what the host has been told, not what was last seen. A change that arrives inside the
131
+ * cooldown has to stay owed rather than be written off, because the signature would match on every
132
+ * later poll and nothing would ever deliver it.
133
+ */
134
+ const apply = next => {
106
135
  if (disposed) return;
136
+ if (shouldReconnect(next.kind, streamAlive)) {
137
+ streamAlive = true;
138
+ events.restart();
139
+ }
107
140
  const visible = keepContainers(state().containers, next);
108
141
  setState({
109
142
  ...next,
@@ -112,26 +145,58 @@ export function createDockerPolling(intervalMs, onContentChange, onPoll) {
112
145
  onPoll?.(next);
113
146
  if (next.kind === "unavailable" || next.kind === "stale") return;
114
147
  const current = signature(visible);
115
- if (current === last) return;
116
- last = current;
117
- if (!seen) {
118
- seen = true;
148
+ if (!mounted) {
149
+ mounted = true;
150
+ shown = current;
119
151
  return;
120
152
  }
153
+ if (current === shown) return;
121
154
  if (Date.now() - reloadedAt < RELOAD_COOLDOWN_MS) return;
155
+ shown = current;
122
156
  reloadedAt = Date.now();
123
157
  onContentChange();
124
158
  };
159
+
160
+ /**
161
+ * One `docker ps` at a time. Events arrive faster than a ps call returns, and without this the older
162
+ * result lands last and overwrites the newer one. A call that arrives while one is in flight asks for
163
+ * one more pass instead of starting a parallel one, the same guard `createEngineProbe` uses.
164
+ */
165
+ let reading = false;
166
+ let reread = false;
167
+ const refresh = async () => {
168
+ if (reading) {
169
+ reread = true;
170
+ return;
171
+ }
172
+ reading = true;
173
+ try {
174
+ do {
175
+ reread = false;
176
+ apply(await runDockerPs());
177
+ } while (reread && !disposed);
178
+ } finally {
179
+ reading = false;
180
+ }
181
+ };
182
+ const events = createEventWatch();
183
+ let streamAlive = true;
184
+ events.onEvent(() => void refresh());
185
+ events.onDead(() => {
186
+ streamAlive = false;
187
+ });
125
188
  const tick = async () => {
126
189
  await refresh();
127
190
  if (disposed) return;
128
- timer = setTimeout(tick, intervalMs);
191
+ timer = setTimeout(tick, fallbackDelayMs(state().kind));
129
192
  };
130
193
  void tick();
131
194
  onCleanup(() => {
132
195
  disposed = true;
133
196
  if (timer) clearTimeout(timer);
134
197
  if (settle) clearTimeout(settle);
198
+ // A docker events process outlives the panel otherwise and stays in the task list
199
+ events.close();
135
200
  });
136
201
  return {
137
202
  state,
package/dist/tui.js CHANGED
@@ -1,12 +1,8 @@
1
1
  import { createComponent as _$createComponent } from "@opentui/solid";
2
2
  import { DockerPanel } from "./panel.js";
3
- const MIN_INTERVAL = 1000;
4
- const MAX_INTERVAL = 60000;
5
3
  const plugin = {
6
4
  id: "docker.panel.cli",
7
5
  setup(context) {
8
- const requested = Number(context.options.intervalMs);
9
- const intervalMs = Number.isFinite(requested) ? Math.min(Math.max(requested, MIN_INTERVAL), MAX_INTERVAL) : 3000;
10
6
  const agentDir = context.location?.directory ?? process.cwd();
11
7
  const [pinned, setPinned] = context.storage.store("docker-panel.pinned", {
12
8
  initial: {
@@ -33,7 +29,6 @@ const plugin = {
33
29
  const claim = {
34
30
  append: "sidebar.content",
35
31
  render: () => _$createComponent(DockerPanel, {
36
- intervalMs: intervalMs,
37
32
  agentDir: agentDir,
38
33
  get pinned() {
39
34
  return pinned.names;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-docker-panel",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Docker container panel for the OpenCode 2 TUI sidebar",
5
5
  "keywords": [
6
6
  "opencode",